# 2-2 元件之間的溝通傳遞

前面一個小節我們快速介紹了 Vue.js 元件系統的特性,以及元件內部的基本結構。 那麼在這個小節中,我們繼續對元件與元件之間各種傳遞資料的方式來做說明。

# Props

前面我們提到,Vue.js 每個元件的實體狀態、模板等作用範圍都應該要是獨立的, 這意味著我們不能(也不應該)在子元件的模組「直接」去修改父元件,甚至是另一個元件的資料,

這樣除了元件因為耦合程度過高維護不易,也可能產生難以追蹤的錯誤。

但是當我們切分元件的時候,就是希望能夠重複利用這個元件,我們希望這個元件可以根據「外部」傳入的資料來反映出不同的結果。 那麼,既然不能直接取用,那麼上下層元件之間,若需要從外部引進資料時,就需要透過 props 屬性來引用外部的狀態。

使用方式很簡單,我們只要在自訂的子元件上使用上一章介紹過的 v-bind 指令:






 


<div id="app"> 
  <!-- 這是外層元件的 msg -->
  <h3>{{ msg }}</h3>

  <!-- 這裡的 v-bind:parent-msg 可以簡寫為 :parent-msg -->
  <my-component v-bind:parent-msg="msg"></my-component>
</div>
1
2
3
4
5
6
7

Props















 









const app = Vue.createApp({
  data () {
    return {
      msg: '這是外層元件的 msg'
    }
  }
});

app.component('my-component', {
  template: `
    <div class="component">
      <div> 從 props 來的 parentMsg ==> {{ parentMsg }} </div>
      <div> 自己的 msg ==> {{ msg }} </div>
    </div>`,
  props: ["parentMsg"],
  data () {
    return {
      msg: '這是子元件的 msg'
    }
  }
});

app.mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
試一試

像這樣,我們可以在內層元件內透過 props 屬性宣告要從「外部」引用進來的屬性名稱, 並且在外層模板使用內層元件標籤時,以 v-bind 指令來將資料傳遞進來。

另外,這裡要特別注意的是, props 與子元件命名的情況一樣,若我們是以 HTML 作為模板的時候,因為 HTML 不分大小寫的關係,像 parentMsg 這樣的駝峰式寫法,在模板裡要轉換成連字號 (kebab-case) parent-msg 來使用。

在內層元件 (或稱子元件) 宣告 props 屬性,最簡單的方式就是透過「陣列」的型態,

app.component('my-component', {
  props: ['props1', 'props2', 'props3', ...],
  // 下略...
});
1
2
3
4

這樣我們就可以透過 HTML 標籤內的屬性將外層的狀態引入至對應的 props

<my-component 
  :props1="..."
  :props2="..."
  :props3="..."></my-component>
1
2
3
4

小提醒: 傳入 props 時一定要加 v-bind (`:`) 嗎?

先說結論,答案是不一定,或者更準確一點來說,看情況決定加或不加。

在前面的範例中,我們在外層模板要將資料傳進子元件時,都會透過 v-bind:XXX="...":XXX="..." 的方式來進行資料的傳遞,但如果我們在傳遞資料的時候,忘了加上 v-bind: 指令時,內層元件仍然會收到資料。

稍微修改一下前面的範例,像這樣:

<!-- 注意這裡沒有 v-bind 或 : -->
<my-component parent-msg="msg"></my-component>
1
2
試一試

此時,子元件接收到的會是 "msg" 的「純文字字串」,而不是來自外層元件的 msg 狀態內容。

實務上,除了忘記加上 v-bind 指令的情況外,通常使用在希望由後端直接渲染輸出網頁內容的時候,預先將傳入子元件的內容印在 HTML 的標籤上,這樣可以節省掉一次 request (意思是無需呼叫 API 取得內容)。

也就是後端不想出 API 的時候會用到的實用技巧

但要注意的是,像這樣沒有使用 v-bind 傳入的 props ,會一律以「純文字」的形式在子元件被接收,即便你所傳遞進來的內容是數字的資料。

# props 資料類型的驗證

如果說元件與網站的應用是由不同團隊所開發的時候 (如第三方套件),針對從外部傳入的 props 型別檢查與驗證就是很實用的功能。

Vue.js 內建能夠檢查的 type 屬性有下面幾種類型:

  • String
  • Number
  • Boolean
  • Array
  • Object
  • Date
  • Function
  • Symbol

#props 指定資料格式

如何指定 props 的資料格式來做驗證呢? 使用方式很簡單,我們稍微改寫一下 props 屬性:

props: {
  'props-number': {
    // 注意:這裡的 Number 無需用引號包成字串,而且首字要大寫
    type: Number
  } 
}
1
2
3
4
5
6

像這樣,我們就可以指定傳入的 props-number 為一個 Number 的格式。

<!-- 正確,有使用 v-bind, Vue.js 會將其轉為數字 -->
<my-component :props-number="123"></my-component>

<!-- 錯誤,傳入的會是 "123" 的字串 -->
<my-component props-number="123"></my-component>
1
2
3
4
5

若是我們嘗試傳遞一個 "123" 的字串給 props-number,則會在 console 主控台看到這樣的錯誤: [Vue warn]: Invalid prop: type check failed for prop "propsNumber". Expected Number with value 123, got String with value "123".

這段警告的意思是 propsNumber 這個 prop 狀態, Vue.js 預期它應該是個 Number 型別的資料,但傳入的卻是字串。

當然,若是我們希望允許多種不同格式的 prop ,則可以透過陣列的形式來指定:

props: {
  // 同時允許 String 與 Number 型別的資料傳入
  something: {
    type: [String, Number]
  } 
}
1
2
3
4
5
6

如果希望指定這個 props 為必要的屬性,則可以加上 required 屬性,並指定為 true

props: {
  something: {
    required: true
  }
}
1
2
3
4
5

像這樣,如果沒有傳入指定的 props 則會在 console 主控台看到 Missing required prop: "something" 的錯誤。

#props 指定預設值

當然要為某個 props 指定預設值也是沒問題,只要加上 default 屬性即可:

props: {
  something: {
    type: [String, Number],
    default: 'Hello'
  } 
}
1
2
3
4
5
6

這樣即使沒有傳入 something 這個 props,在子元件的實體中,也會自動給定 'Hello' 的字串做為預設值。

另外,像是陣列、物件的預設內容也是可以的:

something: {
  type: Array,
  default: [1, 2, 3]
} 
1
2
3
4
something: {
  type: Object,
  default: {
    msg: 'Hello Vue 3.0!'
  }
} 
1
2
3
4
5
6

透過 default 來指定預設內容,可以避免許多因 props 忘記傳遞帶來的問題。

# 自訂 props 驗證規則

如果 Vue.js 內建的幾種型別檢查還沒辦法滿足你的話,沒關係,我們可以加上 validator 屬性來自定驗證規則:

props: {
 something: {
   type: Number,
   // 注意,在 validator function 內不可存取 data / computed 屬性!
   // 驗證傳入的 something 是否大於 0
   validator: value => value > 0
 } 
}
1
2
3
4
5
6
7
8

像這樣,我們在 something 這個 props 加上了 validator 檢查, 當傳入的數值大於 0 的時候表示正確,否則 console 主控台將會出現錯誤訊息。

小提醒

注意 props 在元件初始化時的順序會更優先於 datacomputed 等屬性,所以像是在 defaultvalidator 是無法取得實體內的這些狀態 (意思是無法在裡面取得 this.xxx 的實體內容) 。

# 以物件作為 props 傳遞

由於 JavaScript 的物件是以「參考」的方式來傳遞的 (pass by reference) ,所以若是要由外層元件傳遞物件至內層子元件時,則需要特別小心。

假設外層元件的 data 有個叫 books 的陣列:

data () {
  return {
    books: [
      {
        id: 'a00001',
        name: '0 陷阱!0 誤解!8 天重新認識 JavaScript!',
        author: 'Kuro Hsu',
        publishedAt: '2019/09'
      },
      {
        id: 'a00002',
        name: '重新認識 Vue.js',
        author: 'Kuro Hsu',
        publishedAt: '2021/02'
      },
    ]
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

接著我們透過 v-for 將書籍資訊列出來:

<ul v-for="book in books" class="book">
  <li>{{ book.name }}</li>
  <li>{{ book.author }}</li>
  <li>{{ book.publishedAt }}</li>
</ul>
1
2
3
4
5

然後加入一個子元件,同樣用 v-for 來渲染 books,並將 book 當作 prop 傳入:

<!-- 
  注意,若直接使用 HTML 作為模板的情況下,因 HTML 不分大小寫的特性,
  v-bind 的屬性不可使用駝峰式 (:bookInfo) ,需要使用連字號 (:book-info) 才能正確解析。

  寫在 JavaScript 實體物件或是 .vue 的單一元件檔的 template 模板則不受此限制。
-->
<my-component v-for="book in books" :key="book.id" :book-info="book" />
1
2
3
4
5
6
7

傳給子元件的 bookInfo prop 屬性,我們將其設定為物件的資料,並在模板中使用 v-model 指令:

app.component('my-component', {
  props: {
    bookInfo: {
      type: Object
    }
  },
  template: `
    <div class="child-app">
      <div>書名: <input type="text" v-model="bookInfo.name"></div>
      <div>作者: <input type="text" v-model="bookInfo.author"></div>
      <div>出版日: <input type="text" v-model="bookInfo.publishedAt"></div>
    </div>`,
}); 
1
2
3
4
5
6
7
8
9
10
11
12
13
試一試

乍看之下感覺沒什麼問題,但是此時若我們嘗試在子元件對 input 進行修改,就會發現外層的資料也被變動了!

父子元件耦合

很遺憾地我必須要跟各位說,這肯定不是 feature,這是 bug ,而且是我們產生的 bug,絕對禁止!

這裡要與各位讀者強調一個觀念,在 Vue 的每個實體 (或者元件) 它們的狀態都應該要是彼此獨立的, 如果說今天子元件可以透過 props 自由地修改外層元件的狀態,那麼要是有「兩個以上」的元件同時引用同一個狀態作為 prpos 呢?

這時就可能由於某個子元件的修改,卻造成另一個子元件的 props 狀態污染,產生難以追蹤且不可預期的錯誤了。

所以,想要傳遞物件類型的 props 屬性時,應該先將物件屬性解構成原始型別 (Primitive) 後再將資料傳遞出去:

<my-component
  v-for="book in books"
  :name="book.name"
  :author="book.author"
  :published-at="book.published-at"></my-component>
1
2
3
4
5
app.component('my-component', {
  template: `
  <div class="child-app">
    <div>書名: <input type="text" v-model="name"></div>
    <div>作者: <input type="text" v-model="author"></div>
    <div>出版日: <input type="text" v-model="publishedAt"></div>
  </div>`,
  props: ['name', 'author', 'published-at'],
});
1
2
3
4
5
6
7
8
9
試一試

像這樣,將傳入的 props 解構成純值的作法,更新時就不會改寫到外層的資料了。

如果覺得把所有屬性一個一個打散來寫太囉唆的話,也可以透過 v-bind 指令,改寫成 v-bind="book",這樣在傳入 propsmy-component 元件時,會自動將 book 物件解構。

<!-- v-bind="book" 會將物件自動解構 -->
<my-component
  v-for="book in books"
  v-bind="book"
></my-component>

<!-- 分開寫的結果跟上面的寫法一模一樣 -->
<my-component
  v-for="book in books"
  :name="book.name"
  :author="book.author"
  :published-at="book.published-at"
></my-component>
1
2
3
4
5
6
7
8
9
10
11
12
13

# 非 prop 的屬性傳遞

前面有說到,我們可以透過網頁模版標籤上的屬性與 props 來做到子元件的資料傳遞,那麼假如我們在子元件忘了加上 props,又會發生什麼事呢?

<div id="app">
  <!-- 這裏透過 v-bind 傳入 className -->
  <my-component :class="className"></my-component>
</div>
1
2
3
4









 
 
 



const app = Vue.createApp({
  data() {
    return {
      className: 'block'
    }
  }
});

// 注意,子元件並未含有 props 屬性
app.component('my-component', {
  template: `<div class="child-app"></div>`,
});

app.mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
試一試

有趣的是,這個時候不但不會出現錯誤,而且子元件 <my-component> 的 HTML 渲染結果會是:

<!-- 直接將外層 class 內容交給實際的 DOM 身上 -->
<div class="child-app block"></div>
1
2

而除了 props 之外,事件也有一樣的特性:




 


<div id="app">
  
  <!-- 透過 v-on 訂閱 DOM 原生 click 事件 -->
  <my-component :class="className" @click="greeting"></my-component>
</div>
1
2
3
4
5















 




const app = Vue.createApp({
  data() {
    return {
      className: 'block'
    }
  },
  methods: {
    greeting() {
      alert('Hello Vue!');
    }
  }
});

// 注意,子元件身上並未有 $emit 觸發事件的行為
app.component('my-component', {
  template: `<div class="child-app"></div>`,
});

app.mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
試一試

小提醒

屬性的傳遞與繼承只有在子元件是「唯一根節點」時有效,若子元件擁有多個根節點時,Vue.js 不知道該將屬性交給哪一個 DOM 節點,就會出現警告訊息。

// 多個根節點,若嘗試指定非 prop 的屬性傳遞,會出現警告訊息
app.component('custom-layout', {
  template: `
    <header>...</header>
    <main>...</main>
    <footer>...</footer>
  `
});
1
2
3
4
5
6
7
8

但如果我們在某個指定的標籤上加入了 v-bind="$attrs" 後,便可正常執行。





 




// 加入 v-bind="$attrs" 至指定的節點 (不一定要是根節點) 後可正常執行
app.component('custom-layout', {
  template: `
    <header>...</header>
    <main v-bind="$attrs">...</main>
    <footer>...</footer>
  `
});
1
2
3
4
5
6
7
8

另外,若不希望屬性被子元件所繼承,則可在子元件加入 inheritAttrs: false


 




app.component('custom-layout', {
  inheritAttrs: false,
  // 略
  template: `...`
});
1
2
3
4
5

這樣非 Props 的屬性就不會被傳入子元件了。

# 雙向綁定? 單向資料流?

到這裏,我們已經可以將父元件的 data 與傳遞給子元件的 props 順利解耦了。 但是,這時如果我們試著修改子元件的任何一個透過 v-model 綁定 props 資料的輸入框內容時,你應該會發現 console 主控台跳出警告:

[Vue warn]: Attempting to mutate prop "XXXXX". Props are readonly. (XXXXX 是你修改的欄位名稱)

的錯誤訊息,這是為什麼呢?

在解釋原因前,我們先談談 Vue.js 資料的「雙向綁定」 與 「單向資料流」。

在前一章介紹指令的部分,我們提到了 v-model 會針對 Vue 實體內的狀態 (data) 與畫面上表單元素 (如 input 等) 進行綁定, 當表單元素的 value 被更新的時候,Vue.js 就會直接反映至實體對應的狀態。 這樣的作用,我們通常稱它叫資料的「雙向綁定」 (Two-way Data Binding)。

雙向綁定

然而你在 Vue.js 的官方文件或是某些文章當中,可能會看到 Vue.js 其實是採用「單向資料流」 (One-way Data Flow) 的方式來管理狀態的。

單向資料流

那麼 Vue.js 究竟是雙向綁定或是單向資料流呢? 其實兩者都是對的,要看你用什麼角度解釋它。

假設我們從狀態 (Data) 到畫面 (View) 的角度來看,那麼 Vue.js 確實能做到 UI 的雙向綁定。 但若是以「元件對元件」的狀態管理來看,每一個元件都應該有屬於自己的狀態,自己的狀態自己改, 所以當我們嘗試將 props 傳入的屬性透過 v-model 來更新狀態時, Vue.js 就會跳出錯誤訊息提醒。

所以,如果我們希望能排除錯誤,則可以將 props 傳入的狀態,在元件實體內使用 data 來承接:





 
 
 





 
 
 



app.component('my-component', {
  props: ['name', 'author', 'publishedAt'],
  data () {
    return {
      bookName: this.name,
      bookAuthor: this.author,
      bookPublishedAt: this.publishedAt,
    }
  },
  // v-model 綁定的是 data 回傳的資料,而不是 props
  template: `
    <div class="child-app">
      <div>書名: <input type="text" v-model="bookName"></div>
      <div>作者: <input type="text" v-model="bookAuthor"></div>
      <div>出版日: <input type="text" v-model="bookPublishedAt"></div>
    </div>`,
}); 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

這樣就可以將 props 傳入的狀態複製一份由子元件來管理了。

# Props 與遞迴元件

前面說過,在 Vue.js 的元件系統當中,元件裡頭可以再包覆另一個元件作為子元件。 但是你知道嗎,元件也可以將「自己」當成「子元件」,而這類元件通常我們稱它叫「遞迴元件」 (Recursive Component)。

而使用「遞迴元件」的限制只有一個,就是它必須要有 name 屬性。

讓我們以實務上很常見的樹狀選單為例,假設我們今天有個像這樣的階層式選單資訊:

const menuData = {
  name: '好書推薦',
  childNodes: [{
      name: 'Git',
      childNodes: [{
        name: '為你自己學 Git',
        url: 'https://www.tenlong.com.tw/products/9789864342662'
      }]
    },
    {
      name: '前端開發',
      childNodes: [{
          name: '金魚都能懂的 CSS 選取器',
          url: 'https://www.tenlong.com.tw/products/9789864344994'
        },
        {
          name: '0 陷阱!0 誤解!8 天重新認識 JavaScript!',
          url: 'https://www.tenlong.com.tw/products/9789864344130'
        },
        {
          name: '讓 TypeScript 成為你全端開發的 ACE!',
          url: 'https://www.tenlong.com.tw/products/9789864344895'
        },
      ]
    },
    {
      name: 'IoT',
      childNodes: [{
        name: 'IoT沒那麼難!新手用 JavaScript 入門做自己的玩具!',
        url: 'https://www.tenlong.com.tw/products/9789864345328'
      }]
    },
    {
      name: 'Chatbot',
      childNodes: [{
        name: '人人可作卡米狗:從零打造自己的 LINE 聊天機器人',
        url: 'https://www.tenlong.com.tw/products/9789864342938'
      }]      
    }
  ]
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

我們需要依照資料的階層來渲染樹狀選單,這時可以怎麼做呢? 很簡單,只需要一個子元件即可完成:

<div id="app">

  <!-- Magic! -->
  <menu-component 
    :title="menuData.name"
    :child="menuData.childNodes"></menu-component>
</div>
1
2
3
4
5
6
7
試一試

雖然看起來很神奇,但這並不是施展了什麼魔法,而是我們將 <menu-component> 當作子元件來利用:










 
 
 
 
 
 
 







<!-- <menu-component> 的模板結構 -->
<ul>
  <li>    
    <template v-if="child.length > 0">
      <h2 class="has-child"
        :class="{ 'is-open': isOpen }"
        @click="isOpen = !isOpen">{{ title }}</h2>

      <!-- 把自己當成子元件利用,並把下層資料透過 Props 傳遞進去 -->
      <menu-component 
        v-show="isOpen"
        v-for="c in child"
        :key="c.name"
        :title="c.name"
        :child="c.childNodes"
        :url="c.url"></menu-component>
    </template>

    <!-- 下層已經沒有 childNodes 了,表示是最後一層,直接渲染連結 -->
    <a v-else :href="url" target="_blank">{{ title }}</a>
  </li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22


 















// 注意,「遞迴元件」必須要有 `name` 屬性,這樣在 template 內才會認得
app.component('menu-component', {
  name: `menu-component`,
  props: {
    title: String,
    url: String,
    child: {
      type: Array,
      default: []
    }
  },
  data() {
    return {
      isOpen: false
    }
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

像這樣,我們就可以透過列表元件 <menu-component> 進行包裝,樹狀選單就可以利用階層式物件搭配 Props 進行渲染了。

# 元件與自訂事件

然而從父元件傳遞 props 給子元件之後,有時可能會需要將處理過的狀態送回給外層的父元件, 但我們又不能直接修改外層的父元件的狀態,這時該怎麼處理呢?

在 Vue.js 裡面,父子元件之間的溝通方式有個流傳已久的口訣:「Props in, Event out」。

資料的傳遞

父層資料透過 props 傳入子層,而子層透過 event 來觸發父層狀態的更新。

props 傳入的部分我們前面看過了,現在我們來看看事件的部分。

在元件內部處理事件與 DOM 監聽事件一樣,我們可以透過 v-on 指令來處理:







 


<!-- 直接將 v-for 的 book 物件作為 props 傳遞 -->
<!-- 並監聽自訂的 update 事件 -->
<my-component
  v-for="(book, idx) in books"
  :key="idx"      
  v-bind="book"
  @update="updateInfo"      
></my-component>
1
2
3
4
5
6
7
8

然後外層元件加上對應的 methods 作為事件處理器:




















 
 
 
 
 
 
 
 



const app = Vue.createApp({
  data() {
    return {
      books: [{
          id: '0001',
          name: '0 陷阱!0 誤解!8 天重新認識 JavaScript!',
          author: 'Kuro Hsu',
          publishedAt: '2019/09'
        },
        {
          id: '0002',
          name: '重新認識 Vue.js',
          author: 'Kuro Hsu',
          publishedAt: '2021/02'
        },
      ]
    }
  },
  methods: {
    updateInfo(val) {
      // 註:如果是 Vue 2.x 要透過 this.$set 來更新
      // 如: this.$set(this.books, idx, val);

      // Vue 3.x 則無此限制
      const idx = this.books.findIndex(d => d.id === val.id);
      this.books[idx] = val;
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

那麼子元件的 <my-component> 則是將 props 接收的狀態,在 data 複製一份後回傳一個新物件 bookInfo ,並且加上 watch 屬性來偵測更新:



















 
 
 
 
 
 
 
 
 
 


app.component('my-component', {
  template: `
    <div class="child-app">
      <div>書名: <input type="text" v-model="bookInfo.name"></div>
      <div>作者: <input type="text" v-model="bookInfo.author"></div>
      <div>出版日: <input type="text" v-model="bookInfo.publishedAt"></div>
    </div>`,
  props: ['id', 'name', 'author', 'publishedAt'],
  data() {
    return {
      bookInfo: {
        id: this.id,
        name: this.name,
        author: this.author,
        publishedAt: this.publishedAt
      }
    };
  },
  watch: {
    bookInfo: {
      // 注意! 由於 bookInfo 物件必須加上 deep: true 
      // 才能做物件的深層更新偵測 
      deep: true,
      handler(val) {
        this.$emit('update', val);
      },
    },
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

當資料被更新時,我們就可以透過 this.$emit('事件名', 參數) 的方式來觸發事件。

bookInfo 被更新時,透過 this.$emit 觸發在 @update="updateInfo" 訂閱的自訂 update 事件,通知外層父元件的 updateInfo 來更新外層的狀態,而不是由子元件來直接更新。

自訂事件

試一試

像這樣,自己的資料自己改,才是 Vue.js 元件維護資料最安全的方式!

小提醒: 元件的父與子

雖然說不建議在父/子元件修改彼此的狀態,但實務上可能會因為需求的關係, 需要在子元件取得父層元件的內容,可以透過 this.$parent 來存取它的父層元件。

而父層元件則可以透過 this.$refs 來取得子元件, 在使用前子元件必須先加上 ref 屬性作為別名:

<my-component ref="child" />
<my-component ref="child2" />
1
2

這樣就可以在父層透過 this.$refs.childthis.$refs.child2 來存取對應的子元件了。

注意

Vue.js 自從 3.0 版本起,已經取消了 $on$off,以及 $once 的用法,事件只能由 v-on 所指定,請讀者使用時多加留意小心。

# v-model 與元件的雙向綁定 (Vue 3.x 新增)

雖說 Vue.js 規定父子元件狀態的管理,是遵循 Props in, Event out 的方式來管理傳遞, 但從 Vue 3.0 起,它允許我們在自訂的子元件加上 v-model 指令來做到「雙向綁定」的效果。

快速複習一下,上一章介紹的 v-model 指令,我們通常會拿來使元件內 data 與網頁「表單元素」進行雙向綁定:

data () {
  return {
    msg: 'Hello Vue!'
  }
}
1
2
3
4
5
<input v-model="msg">
1

這個時候, Vue 會將 msg 的值,也就是 "Hello Vue!" 放在 <input>value 屬性中。 當使用這進行修改的時候,會將更新後的 <input> 內容回存至 datamsg

換句話說,上面的程式碼意義等同於:

<input :value="msg" @input="msg = $event.target.value" />
1

那麼,本小節介紹的 v-model 又是怎麼應用在子元件上呢?






 


<div id="app">

  <h1>{{ message }}</h1>

  <!-- 透過 v-model 來做到父子元件間的「雙向綁定」 -->
  <custom-input v-model="message"></custom-input>
</div>
1
2
3
4
5
6
7











 
 
 




const app = Vue.createApp({
  data () {
    return {
      message: "Hello Vue!"
    };
  }
});

// 子元件 <custom-input>
app.component("custom-input", {
  props: ["modelValue"],
  template: `<input 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)">`
});

app.mount("#app");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
試一試

看起來很神奇對吧!

不過這只是一種語法糖,像這樣將 v-model 直接應用在元件的做法:

<custom-input v-model="message"></custom-input>

<!-- 或者 -->
<custom-input v-model:message="message"></custom-input>
1
2
3
4

實際上是 Vue.js 會在背後將它解開成 v-bindv-on 的組合:

<custom-input :message="message" @uptade:message="message = $event" /></custom-input>
1

依然是透過觸發事件來通知父層元件更新狀態。

另外,若我們要綁定兩個以上的 v-model 到元件上,可以像這樣把變數傳遞到元件裡:

<user-name 
  v-model:first-name="firstName" 
  v-model:last-name="lastName" />
1
2
3

在元件內同樣透過 $emit('update:lastName', lastName) 的方式發送事件通知上層更新即可。

注意

像這種父子元件間「雙向綁定」的方式,在 Vue 2.x 是透過 .sync 修飾子來處理,此修飾子在 Vue 3.x 已不適用。

# 跨越層級的傳遞方式

前面我們介紹了父子層級的元件資料是由 propsevent 來做溝通傳遞,那麼如果遇到了跨層級的狀態溝通該怎麼處理呢?

對於這類型的需求, Vue.js 提供的常見解決方案有這些:

# provideinject

前面說過,父層的元件資料通常會透過 Props 來傳遞給子層元件,那麼假設我們有更深一層的資料要進行傳遞,例如根元件傳給最底部的元件:

跨層級的資料傳遞

又該怎麼處理呢? 這時候就要透過 Vue.js 提供的 provideinject 機制了。

假設我們的元件結構如下:

app
 ├─── list-component
 │    ├── list-item 1
 │    ├── list-item 2
 │    └── 下略...
 │
 ├── XXX-component
 └── 下略...
1
2
3
4
5
6
7
8

這時候,若我們希望從 app 傳遞資料給 <list-item> 時,用傳統的 props 一層一層傳遞,肯定是很麻煩的一件事,而且還會增加元件之間的耦合程度。

provideinject 機制的使用方式非常簡單。

首先,我們在根元件也就是 app 的層級,把要傳遞出去的資料定義在 provide 中:







 
 
 
 
 
 


const app = Vue.createApp({
	data () {
  	return {
    	msg: 'Hello App!'
    }
  },
  provide () {
    // 將 this.msg 透過 provide 傳遞出去
  	return {
      provideMsg: this.msg
    };
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13

再來,在子或孫元件中 (總之不管隔幾層都可以) 需要取得頂層元件 provide 狀態的元件 (這裡以 list-item 為例) , 加上 inject 屬性:






 



 
 
 
 
 



app.component('list-component', {
	template: `
    <ul>
      <li v-for="i in 3">
        {{ i }}
        <list-item />
      </li>
    </ul>`,
  components: {
  	'list-item': {
    	// 在子、孫元件中,加上 inject 屬性即可取得 provideMsg
      inject: ['provideMsg'],
      template: `<div>{{ provideMsg }}!</div>`
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

另外要注意的是,透過 provide 輸出的資料並不會隨著父層資料的更新而有所改變, 如果希望子層 inject 取回來的資料能與上層資料連動,則需要透過 Vue.computed() 進行包裝:

// 將 provide 透過 Vue.computed 包裝
provide() {
  return {
    provideMsg: this.msg,
    provideMsg2: Vue.computed(() => this.msg)
  };
}
1
2
3
4
5
6
7

包裝後的物件,在子層元件的 inject 使用時,需要加上 .value 方可正常運作:

'list-item': {
  // 由於傳入的是透過 Vue.computed 包裝後的物件,所以要加上 .value 
  // 有關 .value 的用法在本書最後一章 Composition API 會有更詳細的說明
  inject: ['provideMsg', 'provideMsg2'],
  template: `
    <div>provideMsg: {{ provideMsg }}!</div>
    <div>provideMsg2: {{ provideMsg2.value }}!</div>
  `
}
1
2
3
4
5
6
7
8
9
試一試

像這樣,若遇到跨層級的 Props 資料,就無需一層一層引入,可以直接透過指定的 provide 輸出,再由 inject 取回來了。

# EventBus (Vue 3.x 起已不建議使用)

假設我們遇到了跨元件的事件傳遞,

跨元件的事件傳遞

由於自 Vue 3.0 開始移除了 $on, $off 的用法, 所以若想使用 EventBus 來當作元件間的橋樑,需要改用 mitt ( https://github.com/developit/mitt ) 來代替原本的 Vue 2.x 以前的 EventBus 物件實體。

eventbus

Mitt 版 EventBus 的使用很簡單,如果是使用 npm 管理的朋友,透過 npm 安裝 mitt:

$ npm install --save mitt
1

或是透過 CDN 的方式將它引入至網頁裡:

<script src="https://unpkg.com/mitt/dist/mitt.umd.js"></script>
1

首先,新增一個新的 mitt() 實體,並將其指定到 bus 變數:

const bus = mitt();
1

接著我們在外層的實體新增兩組 <button-counter> 元件,並在自訂事件 add-sum 觸發時執行 plus 這個方法。

<div id="app">
  <h1>Total: {{ sum }}</h1>
  
  <button-counter @add-sum="plus"></button-counter>
  <button-counter @add-sum="plus"></button-counter>
  <button-reset></button-reset>
</div>
1
2
3
4
5
6
7

同時,我們在外層元件的 created 階段,針對一開始宣告的 EventBus 綁定一個 reset 的自訂事件:

// 根元件
const app = Vue.createApp({
	data () {
  	return {
    	sum: 0
    }
  },
  methods: {
    plus () {
      this.sum++;
    },
    reset () {
      this.sum = 0;
    }
  },
  created () {
    // 實體建立時,在 bus 身上註冊 reset 事件
    // 觸發事件時呼叫 this.reset 方法
    bus.on('reset', this.reset);
  } 
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

另外, 在 <button-counter> 這個元件也同樣在 created 階段加上 reset 事件與對應方法 :

// 元件 <button-counter>
app.component('button-counter', {
  template: `<button @click="plus">You clicked me {{ count }} times.</button>`,
  data () {
    return {
      count: 0
    };
  },
  methods: {
    plus () {
      // 自己的 count 加一
      this.count++;      
      // 觸發在 v-on 註冊的 add-sum 事件
      this.$emit('add-sum');
    },
    reset () {
      this.count = 0;
    },
  },
  created () {
    // 訂閱 bus 的 reset 事件
    // 觸發事件時呼叫 this.reset 方法歸零
    bus.on('reset', this.reset);
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

最後, <button-reset> 元件就單純多了,在點擊內部的 button 之後會執行 reset 方法, 裡面就只對 EventBus 觸發 reset 事件:

// 元件 <button-reset>
app.component('button-reset', {
  template: `<button @click="reset">reset</button>`,
  methods: {
    reset () {
      // 觸發 bus 的 reset 事件
      bus.emit('reset');
    },
  },
});
1
2
3
4
5
6
7
8
9
10

eventbus

像這樣,當 EventBusreset 事件被觸發後,那些曾經向 EventBus 訂閱 reset 事件元件們就會執行對應的方法。

試一試

不過 EventBus 並非萬靈丹,由於我們將各種事件都往 EventBus 上註冊, 那些原本 Vue.js 會在元件銷毀時自動解除事件的動作就必須由開發者自行來處理, 甚至還要當心訂閱事件名稱重複所引發的各種問題,這些都是在使用 EventBus 需要特別注意的地方。

而且除了 EventBus 還有其他更好的做法,這個後續會在相關章節為讀者們解說。

# Vuex

前面說的 EventBus 到 Vue.js 3.x 已經不建議使用後,現今最主流的跨元件狀態維護就是透過 Vuex 來管理了

Vuex-store

過去我們想要存放多個狀態的時候,常常一言不合就往全域物件丟,但是丟 window 一時爽,一直丟 window ...時間一長專案長大後,恐怕維護起來就不太爽了。

而 Vuex 的核心就是 store,我們可以將 Vuex 的 store 想像成一個「受規範限制」的全域物件, 每個元件都可以向這個中央倉庫去存取狀態,但又必須要遵守 Vuex 的規定,不致於無法控管資料的流向。

像是 store 只能透過 Mutations 進行存取,而且只能執行「同步」的操作,而非同步的動作需要透過 Actions 來進行,這樣才能確保資料更新的動向得以追蹤。

Vuex

當我們的應用程式成長到一定規模後, Vuex 就是一個很有效且安全的狀態共享管理機制。 關於 Vuex 的詳細內容,本書的後續還有一整個專門章節來為各位做說明。

# Vue Composition API

除了 Vuex 之外,Vue 3.0 起新增的 Vue Composition API 也可以用來處理跨元件的資料與程式邏輯共享。

這裏我們將上一個範例用 Composition API 來改寫,首先抽取出共用的邏輯與方法:

// 共用邏輯
const sum = ref(0);
const plus = () => sum.value++;
const reset = () => sum.value = 0;
1
2
3
4

再來,定義父子元件內容,新增的 setup 函式是用來建立與啟動我們的元件,並將模板 <template> 會用到的東西 return 出去:

// 父層、根元件
const app = createApp({
  setup() {

    // 將模板用到的 sum, plus 回傳出去
    return {
      sum,
      plus
    };
  }
});

// <button-counter>
app.component('button-counter', {
  template: `<button @click="plus">You clicked me {{ count }} times.</button>`,
  setup(props, {emit}) {
    const count = ref(0);
    
    // 透過 emit 傳遞自定義事件
    const plus = () => {
      count.value++      
      emit('add-sum');
    };

    // 觀察 sum 的變化,若 sum 為 0 代表要 reset count 的內容
    watch(sum, v => count.value = v === 0 ? 0 : count.value);

    // 將模板用到的 count, plus 回傳出去
    return {
      count,
      plus
    }
  }
});


// <button-reset> 
app.component('button-reset', {
  template: `<button @click="reset">reset</button>`,
  setup() {

    // 將模板用到的 reset 回傳出去
    return {
      reset
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

改寫後的完整範例如下:

試一試

像這樣,雖然實際執行的結果沒什麼不同,但由於我們將相同的狀態、邏輯都抽取出來,透過 Composition API 改寫後的程式,看起來會比過去的寫法變得更加簡潔。 有關 Composition API 的詳細內容,在本書的最後一章會有整個章節來為讀者詳細解說,這裡就先簡單劇透一下。

Last Updated: 11/26/2021, 12:15:10 PM