# 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>
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 的元件就是個可以被重複使用的實體。
如同我們在前一章介紹過的,每一個元件都可以有自己的 data
、computed
、methods
,甚至是生命週期的 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');
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');
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>
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');
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');
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>
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');
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');
2
3
4
5
6
7
8
9
10
除了以上所述,區域元件與全域元件的其他部分,在使用上幾乎沒有差別。
唯一要注意的是,全域型元件註冊後即可在這個應用程式裡的所有位置使用 (app
掛載的範圍內) ,
而區域型元件就只有在這個經由 components: { ... }
屬性宣告後的元件才能使用。
# 元件與標籤的命名規則
雖然說只要是合法的 JavaScript 屬性名,都可以被當作 Vue 元件的名稱,
但由於元件在模板中是以「標籤」的形式來使用,為了避免與現有或未來的標籤名稱產生衝突,
通常會以兩個以上的單字來進行命名,如 <todo-item>
、 <base-table>
等。
另外,我們在註冊元件的時候,可以使用首字大寫駝峰式命名 (pascal-case) 來為元件命名:
// 子元件命名為 TodoItem
app.component('TodoItem', {
// 略...
});
2
3
4
或是連字號 (kebab-case) 命名:
// 子元件命名為 todo-item
app.component('todo-item', {
// 略...
});
2
3
4
需要注意的是,若直接以 HTML 當作模板的情況下,由於瀏覽器在解析 HTML 標籤的時候,並無大小寫之分,使用上必須改寫為連字號標籤:
<!-- 在 HTML 裡不可使用 <TodoItem> 必需轉為連字號標籤 -->
<todo-item></todo-item>
2
而使用在 .vue
單一元件檔的 <template>
模板中,則無此限制。
實務上仍建議一致使用連字號命名法最保險。
<template>
<!--
在 .vue 單一元件檔的模板中,
最後都會被編譯為 JavaScript,所以下列兩者皆可
-->
<todo-item />
<TodoItem />
</template>
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>
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>`
});
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>
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'
});
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>
`,
}
}
2
3
4
5
6
7
8
不過這樣寫法也會有缺點,當根節點大於一個的時候,使用 this.$el
屬性時會無法取得正確的元件 DOM,
不過別擔心,我們可以透過 $refs
來取得實際在網頁上的 DOM 節點:
<div id="app">
<h3 ref="title">Root Instance</h3>
</div>
2
3
// Vue 3.x
const vm = Vue.createApp({
mounted () {
// "Root Instance"
console.log(this.$refs.title.innerText);
}
}).mount('#app');
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
};
}
});
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>
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');
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
}
}
});
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.assign
與 this.$options.data()
重新指定元件內 data
的內容,
讓它回復到初始狀態:
methods: {
resetData() {
Object.assign(this.$data, this.$options.data());
}
}
2
3
4
5
備註
關於 JavaScript 的傳值與傳址的觀念,可以參考拙作 重新認識 JavaScript: Day 05 JavaScript 是「傳值」或「傳址」? (opens new window) 一文,有更詳細的說明。