渲染器主要負責將虛擬 DOM 渲染為真實 DOM,我們只需要使用虛擬 DOM 來描述最終呈現的內容即可。但當我們編寫比較復雜的頁面時,用來描述頁面結構的虛擬 DOM 的代碼量會變得越來越多,或者說頁面模板會變得越來越大。這時,我們就需要組件化的能力。有了組件,我們就可以將一個大的頁面拆分為多個部分,每一個部分都可以作為單獨的組件,這些組件共同組成完整的頁面。組件化的實現同樣需要渲染器的支持,從現在開始,我們將詳細討論 Vue.js 中的組件化。
1、渲染組件
從用戶的角度來看,一個有狀態組件就是一個選項對象,如下面的代碼所示:
01 // MyComponent 是一個組件,它的值是一個選項對象
02 const MyComponent = {
03 name: 'MyComponent',
04 data() {
05 return { foo: 1 }
06 }
07 }
但是,如果從渲染器的內部實現來看,一個組件則是一個特殊類型的虛擬 DOM 節點。例如,為了描述普通標簽,我們用虛擬節點的 vnode.type 屬性來存儲標簽名稱,如下面的代碼所示:
01 // 該 vnode 用來描述普通標簽
02 const vnode = {
03 type: 'div'
04 // ...
05 }
為了描述片段,我們讓虛擬節點的 vnode.type 屬性的值為Fragment,例如:
01 // 該 vnode 用來描述片段
02 const vnode = {
03 type: Fragment
04 // ...
05 }
為了描述文本,我們讓虛擬節點的 vnode.type 屬性的值為Text,例如:
01 // 該 vnode 用來描述文本節點
02 const vnode = {
03 type: Text
04 // ...
05 }
渲染器的 patch 函數證明了上述內容,如下是我們實現的 patch 函數的代碼:
01 function patch(n1, n2, container, anchor) {
02 if (n1 && n1.type !== n2.type) {
03 unmount(n1)
04 n1 = null
05 }
06
07 const { type } = n2
08
09 if (typeof type === 'string') {
10 // 作為普通元素處理
11 } else if (type === Text) {
12 // 作為文本節點處理
13 } else if (type === Fragment) {
14 // 作為片段處理
15 }
16 }
可以看到,渲染器會使用虛擬節點的 type 屬性來區分其類型。對于不同類型的節點,需要采用不同的處理方法來完成掛載和更新。
實際上,對于組件來說也是一樣的。為了使用虛擬節點來描述組件,我們可以用虛擬節點的 vnode.type 屬性來存儲組件的選項對象,例如:
01 // 該 vnode 用來描述組件,type 屬性存儲組件的選項對象
02 const vnode = {
03 type: MyComponent
04 // ...
05 }
為了讓渲染器能夠處理組件類型的虛擬節點,我們還需要在patch 函數中對組件類型的虛擬節點進行處理,如下面的代碼所示:
01 function patch(n1, n2, container, anchor) {
02 if (n1 && n1.type !== n2.type) {
03 unmount(n1)
04 n1 = null
05 }
06
07 const { type } = n2
08
09 if (typeof type === 'string') {
10 // 作為普通元素處理
11 } else if (type === Text) {
12 // 作為文本節點處理
13 } else if (type === Fragment) {
14 // 作為片段處理
15 } else if (typeof type === 'object') {
16 // vnode.type 的值是選項對象,作為組件來處理
17 if (!n1) {
18 // 掛載組件
19 mountComponent(n2, container, anchor)
20 } else {
21 // 更新組件
22 patchComponent(n1, n2, anchor)
23 }
24 }
25 }
在上面這段代碼中,我們新增了一個 else if 分支,用來處理虛擬節點的 vnode.type 屬性值為對象的情況,即將該虛擬節點作為組件的描述來看待,并調用 mountComponent 和patchComponent 函數來完成組件的掛載和更新。
渲染器有能力處理組件后,下一步我們要做的是,設計組件在用戶層面的接口。這包括:用戶應該如何編寫組件?組件的選項對象必須包含哪些內容?以及組件擁有哪些能力?等等。實際上,組件本身是對頁面內容的封裝,它用來描述頁面內容的一部分。因此,一個組件必須包含一個渲染函數,即 render 函數,并且渲染函數的返回值應該是虛擬 DOM。換句話說,組件的渲染函數就是用來描述組件所渲染內容的接口,如下面的代碼所示:
01 const MyComponent = {
02 // 組件名稱,可選
03 name: 'MyComponent',
04 // 組件的渲染函數,其返回值必須為虛擬 DOM
05 render() {
06 // 返回虛擬 DOM
07 return {
08 type: 'div',
09 children: `我是文本內容`
10 }
11 }
12 }
這是一個最簡單的組件示例。有了基本的組件結構之后,渲染器就可以完成組件的渲染,如下面的代碼所示:
01 // 用來描述組件的 VNode 對象,type 屬性值為組件的選項對象
02 const CompVNode = {
03 type: MyComponent
04 }
05 // 調用渲染器來渲染組件
06 renderer.render(CompVNode, document.querySelector('#app'))
渲染器中真正完成組件渲染任務的是 mountComponent 函數,其具體實現如下所示:
01 function mountComponent(vnode, container, anchor) {
02 // 通過 vnode 獲取組件的選項對象,即 vnode.type
03 const componentOptions = vnode.type
04 // 獲取組件的渲染函數 render
05 const { render } = componentOptions
06 // 執行渲染函數,獲取組件要渲染的內容,即 render 函數返回的虛擬 DOM
07 const subTree = render()
08 // 最后調用 patch 函數來掛載組件所描述的內容,即 subTree
09 patch(null, subTree, container, anchor)
10 }
這樣,我們就實現了最基本的組件化方案。
2、組件狀態與自更新
在上一節中,我們完成了組件的初始渲染。接下來,我們嘗試為組件設計自身的狀態,如下面的代碼所示:
01 const MyComponent = {
02 name: 'MyComponent',
03 // 用 data 函數來定義組件自身的狀態
04 data() {
05 return {
06 foo: 'hello world'
07 }
08 },
09 render() {
10 return {
11 type: 'div',
12 children: `foo 的值是: ${this.foo}` // 在渲染函數內使用組件狀態
13 }
14 }
15 }
在上面這段代碼中,我們約定用戶必須使用 data 函數來定義組件自身的狀態,同時可以在渲染函數中通過 this 訪問由 data 函數返回的狀態數據。
下面的代碼實現了組件自身狀態的初始化:
01 function mountComponent(vnode, container, anchor) {
02 const componentOptions = vnode.type
03 const { render, data } = componentOptions
04
05 // 調用 data 函數得到原始數據,并調用 reactive 函數將其包裝為響應式數據
06 const state = reactive(data())
07 // 調用 render 函數時,將其 this 設置為 state,
08 // 從而 render 函數內部可以通過 this 訪問組件自身狀態數據
09 const subTree = render.call(state, state)
10 patch(null, subTree, container, anchor)
11 }
如上面的代碼所示,實現組件自身狀態的初始化需要兩個步驟:
- 通過組件的選項對象取得 data 函數并執行,然后調用reactive 函數將 data 函數返回的狀態包裝為響應式數據;
- 在調用 render 函數時,將其 this 的指向設置為響應式數據state,同時將 state 作為 render 函數的第一個參數傳遞。
經過上述兩步工作后,我們就實現了對組件自身狀態的支持,以及在渲染函數內訪問組件自身狀態的能力。