# 2-1 元件系統的特性

元件系統 (components system) 是 Vue.js 另一個重要的概念與核心功能。

在上一個章節,已經介紹了 Vue.js 的基本結構與功能,那麼從本章開始,將為各位讀者解說元件的拆解、結構,以及元件間狀態的溝通傳遞等功能。

備註

自此章節開始,程式碼內若未特別說明,皆以 Vue 3.x 版本為主。

# 什麼是元件

元件 (Component) 是 Vue 最主要也是最強大的特性之一,它提供了 HTML DOM 元素的擴充性, 也可將部分模板、程式碼封裝起來以便開發者維護以及重複使用

傳統網頁的結構,從早期的「義大利麵式程式碼」(Spaghetti code) 把所有的東西通通往 HTML 頁面塞, 到後來將 CSS、Javascript 從 HTML 結構抽離,這是表現層級上的關注點分離。

但是當專案的架構越來越大,人們開始把「關注點」從表現層移到了架構層面, 思考如何將功能、邏輯抽象化,將封裝好的 UI 模組、功能重複使用,就如同樂高積木一般。

元件化

<div id="app">
  <header-component> ... </header-component>
  <menu-component> ... </menu-component>
  <main-component> ... </main-component>
  <footer-component> ... </footer-component>
</div>
1
2
3
4
5
6

元件系統

每一個被封裝後的元件單元,都含有自己的模板、樣式,與行為邏輯,並且可以被重複使用。 而在元件之中又可以含有元件,這樣由一個個元件單元組合而成的「元件樹」,就是 Vue.js 元件系統的概念。

# 元件的分類與切分

當我們開始要把網頁轉換成模組區塊來管理的時候,首先面臨的問題,元件該怎麼拆? 從何拆起?

要是範圍切得太大,元件過於龐大,切得太細則元件數量太多。 再者,元件之間要是彼此耦合程度高,反而不容易維護,還不如不拆。 那麼,接下來就來談談幾種常見的元件分類方法。

常見的元件類型,大致可以分作這幾種類型:

# 展示型元件 (Presentation)

以負責呈現 UI 為主的類型,我們很單純地把資料傳遞進去,然後 DOM 就根據我們丟進去的資料生成出來。 這種元件的好處是可以提升 UI 的重複使用性。

# 容器型元件 (Container)

這類型的元件主要負責與資料層的 service 溝通,包含了與 server 端、資料來源做溝通的邏輯, 然後再將資料傳遞給前面所說的展示型元件。

# 互動型元件 (Interactive)

像是大家所熟知的 elementUI、bootstrap 的 UI library 都屬於此種類型。 這種類型的元件通常會包含許多的互動邏輯在裡面,但也與展示型元件同樣強調重複使用。 像是表單、燈箱等各種互動元素都算在這類型。

# 功能型元件 (Functions)

這類型的元件本身不渲染任何内容,主要負責將元件內容作為某種應用的延伸,或是某種機制的封裝。 像是我們未來會提及的 <transition><router-view> 等都屬於此類型。

# 元件的宣告與註冊

Vue.js 的元件就是個可以被重複使用的實體。 如同我們在前一章介紹過的,每一個元件都可以有自己的 datacomputedmethods,甚至是生命週期的 Hooks function。 如同 JavaScript 的變數可分為全域變數與區域變數,元件的宣告同樣可以分為全域元件區域元件

在過去 Vue 2.x 的時候,全域元件可以透過 Vue.component() 來註冊,第一個參數是元件的名稱,第二個則是它的屬性 (Options):

// for Vue 2.x, 把全域元件註冊在 Vue 上
// 子元件內多數屬性與之前介紹的用法完全一樣。 
Vue.component('my-component', {  
  template: `<div>Hello Vue!</div>`,
  data () {  
    // ...略
  },
  props: {
    // ...略
  },
  computed: {
    // ...略
  },
  methods: {
    // ...略  
  },
  // ...以及其他選項、各種 lifecycle hooks 等
});

// 新增一個「根實體」,並掛載於 #app 之上
const vm = new Vue({ }).$mount('#app');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

而自從 Vue 3.0 開始, 我們需要先透過 Vue.createApp() 建立一個新的實體,假設叫 app

// for Vue 3.x
const app = Vue.createApp({});

// 將過去的 Vue.component 改為 app.component
// 將元件註冊在 app 身上
app.component('my-component', {
  template: `<div>Hello Vue 3.x!</div>`,
  // 內部其餘選項與過去幾乎一樣
  data () {  
    // ...略
  },
  props: {
    // ...略
  },
  computed: {
    // ...略
  },
  methods: {
    // ...略  
  }
});

// 新增一個「根實體」,並掛載於 #app 之上
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
24

這時我們就可以透過 app.component() 將新建立的元件註冊在 app 這個根實體 (或稱根元件,root instance) 裡頭, 除此之外的其餘選項與過去幾乎一樣。

<div id="app">
  <h3>Root Instance</h3>
  
  <!-- 使用自訂元件 my-component -->
  <my-component></my-component>
  <my-component></my-component>
</div>
1
2
3
4
5
6
7
試一試

另外,除了全域元件從過去 Vue 2.x 的 Vue.components(...) 到了 Vue 3.0 改為 app.components(...) 之外, 同樣改變寫法的還有:

// Vue 2.x
Vue.use(/* 略 */);
Vue.mixin(/* 略 */);
Vue.component(/* 略 */);
Vue.directive(/* 略 */);

const app = new Vue({
  // 略
});

app.$mount('#app');
1
2
3
4
5
6
7
8
9
10
11
// Vue 3.x
const app = Vue.createApp({
  // 略
});

app.use(/* 略 */);
app.mixin(/* 略 */);
app.component(/* 略 */);
app.directive(/* 略 */);

app.mount('#app');
1
2
3
4
5
6
7
8
9
10
11

Vue 3.0 這樣的改變,最主要是為了不把所有的特性與行為全部綁在 Vue 上, 將全域的概念,從原本的整個 Vue 移到「根實體」,這樣即使在同個頁面上同時宣告了多個根實體,也不會因此而互相污染。


而區域型的元件則是在上層元件的 components 屬性 (記得加上 s) 裡面定義:

<div id="app">
  <h3>Root Instance</h3>
  
  <!-- 在根實體使用自訂元件 -->
  <my-component></my-component>
</div>
1
2
3
4
5
6
// for Vue 3.0
// 新增一個「根實體」,並在 components 選項內註冊子元件 my-component:
const app = Vue.createApp({
  components: {
    'my-component': {
      // 子元件的 options
      template: `<div>Hello Vue!</div>`,
    }
  }
});

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

另外,若環境支援 ES Module 也可透過 import 的方式將外部檔案引入子元件:

// for Vue 3.0
import myComponent from './components/my-component.js'; 

const app = Vue.createApp({
  components: {
    myComponent
  }
});

app.mount('#app');
1
2
3
4
5
6
7
8
9
10

除了以上所述,區域元件與全域元件的其他部分,在使用上幾乎沒有差別。

唯一要注意的是,全域型元件註冊後即可在這個應用程式裡的所有位置使用 (app 掛載的範圍內) , 而區域型元件就只有在這個經由 components: { ... } 屬性宣告後的元件才能使用。


# 元件與標籤的命名規則

雖然說只要是合法的 JavaScript 屬性名,都可以被當作 Vue 元件的名稱, 但由於元件在模板中是以「標籤」的形式來使用,為了避免與現有或未來的標籤名稱產生衝突, 通常會以兩個以上的單字來進行命名,如 <todo-item><base-table> 等。

另外,我們在註冊元件的時候,可以使用首字大寫駝峰式命名 (pascal-case) 來為元件命名:

// 子元件命名為 TodoItem 
app.component('TodoItem', {
  // 略...
});
1
2
3
4

或是連字號 (kebab-case) 命名:

// 子元件命名為 todo-item 
app.component('todo-item', {
  // 略...
});
1
2
3
4

需要注意的是,若直接以 HTML 當作模板的情況下,由於瀏覽器在解析 HTML 標籤的時候,並無大小寫之分,使用上必須改寫為連字號標籤:

<!-- 在 HTML 裡不可使用 <TodoItem> 必需轉為連字號標籤 -->
<todo-item></todo-item>
1
2

而使用在 .vue 單一元件檔的 <template> 模板中,則無此限制。 實務上仍建議一致使用連字號命名法最保險。

<template>
  <!-- 
    在 .vue 單一元件檔的模板中,
    最後都會被編譯為 JavaScript,所以下列兩者皆可    
  -->
  <todo-item />

  <TodoItem />
</template>
1
2
3
4
5
6
7
8
9

# 單一元件檔 (Single File Components, SFC)

前面提到,我們可以將某個元件以 .vue 檔案的方式包裝起來,再透過 import 的方式將這個檔案引入作為子元件, 這個 .vue 檔案通常我們將它稱為 Vue 的單一元件檔 (Single File Components, 以下稱 SFC)。

這個 SFC 通常會包含三個部分,分別是作為 HTML 模板的 <template> 、用來定義元件結構與邏輯的 <script> 以及 CSS 樣式的 <style> 標籤:

<template>
  <div class="app">
    <div>Hello Vue!</div>
  </div>
</template>

<script>
export default {
  name: 'myComponent'
};
</script>

 <style scoped>
.app {
  color: red;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

一個元件就是一個 .vue 檔案。 在這個元件中,它的模板、樣式以及邏輯應該要是高度耦合的,這樣可以讓元件變得更易於管理,並提高可維護性。

這裡需要小心的是,由於 SFC 的 .vue 檔案並非網頁標準, 在使用時通常需透過 vue-loader@vue/compiler-sfc 搭配 webpack 等工具先將 SFC 編譯成瀏覽器看得懂的 JavaScript 程式碼後才能夠被執行。

SFC 的部分各位讀者這裏先有個概念即可,之後在講解 Vue CLI 章節時還會有更深入的說明。

# 將網頁片段封裝為元件模板

完成了元件的宣告與註冊後,既然元件的目標是要重複使用,接著我們來看如何將網頁片段封裝到元件的模板裡頭。

# 透過 template 屬性封裝

首先第一種方式是透過大家都很熟悉的 template 屬性:

app.component('media-block', { 
  template: `
    <div class="media">
      <img class="mr-3" src="..." alt="Generic placeholder image">
      <div class="media-body">
        <h5 class="mt-0">Media heading</h5>
        <div>
          Cras sit amet nibh libero,
          in gravida nulla. Nulla vel metus scelerisque ante sollicitudin.
        </div>
      </div>
    </div>`
}); 
1
2
3
4
5
6
7
8
9
10
11
12
13

像這樣,我們將整個 <div class="media"> ... </div> 區塊封裝成一個名叫 media-block 的元件, 在使用的時候,只要先將它引入 (無論是全域或區域) 後,在模板對應的位置插入 <media-block></media-block> 的標籤就可以了。

# 透過 x-template 封裝模板

然而,隨著專案規模的擴增,我們的 HTML 模板結構可能會變得越來越大,光是用 template 屬性直接掛上 HTML 字串時,可能你的程式架構就會變得不是那麼好閱讀、管理。 這時候,我們可以把整個 HTML 模板區塊透過 <script id="xxx" type="text/x-template"> ... </script> 這樣的方式來封裝我們的 HTML 模板,這種方式通常被稱為「X-Templates」:

<script type="text/x-template" id="media-block">
  <div class="media">
    <img class="mr-3" src="..." alt="Generic placeholder image">
    <div class="media-body">
      <h5 class="mt-0">Media heading</h5>
      <div>
      Cras sit amet nibh libero,
      in gravida nulla. Nulla vel metus scelerisque ante sollicitudin.
      </div>
    </div>
  </div>
</script>
1
2
3
4
5
6
7
8
9
10
11
12

像這樣,我們將實際的 HTML 字串放置在 script 標籤內,並且加上 type="text/x-template" 的屬性, 而子元件註冊的時候,我們就在原本的 template 屬性加上對應的 selector:

app.component('media-block', {
  template: '#media-block'
});
1
2
3

執行結果與直接放在 template 屬性是一樣的。

# SFC 單一元件檔的 <template> 標籤

另外,還有前面提到過的單一元件檔 (SFC),由於整個元件都被封裝成一個檔案,所以直接將模板內容放置在 <template> 標籤即可。

注意

在 Vue.js 2.x (及更早前) 的版本,子元件模板的根元素只能有一個,否則在 console 主控台會出現 Component template should contain exactly one root element. If you are using v-if on multiple elements, use v-else-if to chain them instead. 的錯誤。

不過這個限制在 Vue.js 3.0 引進了 Fragment 的概念後就不存在了,自 Vue 3.0 開始的元件 template 甚至可以是這樣:

components: {
  'my-component': {
    template: `
      <div>Hello Vue!</div>
      <div>Hello Vue!</div>
    `,
  } 
}
1
2
3
4
5
6
7
8

不過這樣寫法也會有缺點,當根節點大於一個的時候,使用 this.$el 屬性時會無法取得正確的元件 DOM, 不過別擔心,我們可以透過 $refs 來取得實際在網頁上的 DOM 節點:

<div id="app">
  <h3 ref="title">Root Instance</h3>
</div>
1
2
3
// Vue 3.x
const vm = Vue.createApp({
  mounted () {
    // "Root Instance"
    console.log(this.$refs.title.innerText);
  }
}).mount('#app');
1
2
3
4
5
6
7

# 子元件的 data 必須是函數

前面我們曾說過,每一個被封裝後的元件單元,都含有自己的模板、樣式,與行為邏輯,當然它們內部的狀態也是一樣。

在前一章介紹 Vue 實體時,我們的 data 屬性總是以一個物件的形式來表示。

但是在子元件的 data 屬性,則必須是以函數回傳物件的方式來表示:

// 錯誤
app.component('my-component', {
  data: {
    count: 0
  }
});

// 正確, 子元件的 data 必須是以 function 的形式回傳物件
app.component('my-component', {
  data () {
    return {
      count: 0
    };
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

為什麼有這樣的規定呢?

這是由於 JavaScript 的物件型別是以「傳址」(Pass by reference) 的方式來進行資料傳遞, 若是沒有透過 function 來回傳另一個新物件,則這些子元件的 data 就會共用同一個狀態:

<div id="app">
  <my-component></my-component>
  <my-component></my-component>
  <my-component></my-component>
  <my-component></my-component>
</div>
1
2
3
4
5
6
const app = Vue.createApp({ });

// 共用的 data
const $data = {
  count: 0
};

app.component('my-component', {
  template: `
    <div class="data-block">
      <div>Count: {{ count }}</div>
      <button @click="count++">Plus</button>
    </div>`,
  data () {
    // 共用 $data 物件
    return $data;
  }
});

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

像這樣,每個子元件應該要有各自的狀態,但由於它們共用了同一份 data,此時只要其中一個子元件的狀態被更新,便會影響到其他的子元件。

所以為了避免子元件之間的資料互相污染,Vue.js 強制規定子元件內的 data 屬性必須是以 function 的形式來輸出新的物件。

app.component('my-component', {
  template: `
    <div class="data-block">
      <div>Count: {{ count }}</div>
      <button @click="count++">Plus</button>
    </div>`,
  // 如果沒有用到 this 則可以放心使用箭頭函式回傳一個全新的物件
  data: () => {{ count: 0 })
});


// 或是採用傳統 function 寫法:
app.component('my-component', {
  template: `
    <div class="data-block">
      <div>Count: {{ count }}</div>
      <button @click="count++">Plus</button>
    </div>`,
  data: function () {
    return { 
      count: 0 
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
試一試

像這樣,雖然 my-component 子元件只宣告一次,但每個子元件的狀態各自獨立,就不會出現互相污染的狀況了。

小提醒

在 Vue 2.x 裡,根元件的 data 允許直接使用物件 { ... } 的形式來存取資料,只有子元件強制透過 function 進行回傳, 而自 Vue 3.0 起,無論是根元件或子元件,一律規定強制透過 function 進行回傳資料

小提醒

若是想保留 data 屬性的初始狀態,又不希望引用全域變數造成 data 共用的污染, 則可以透過 Object.assignthis.$options.data() 重新指定元件內 data 的內容, 讓它回復到初始狀態:

methods: {
  resetData() {
    Object.assign(this.$data, this.$options.data());
  }
}
1
2
3
4
5

備註

關於 JavaScript 的傳值與傳址的觀念,可以參考拙作 重新認識 JavaScript: Day 05 JavaScript 是「傳值」或「傳址」? (opens new window) 一文,有更詳細的說明。

Last Updated: 12/28/2020, 7:34:16 PM