[7626] 01 組件渲染:vnode 到真實 DOM 是如何轉變的?

在 Vue.js 中,組件是一個非常重要的概念,整個應用的頁面都是通過組件渲染來實現的,但是你知道當我們編寫這些組件的時候,它的內部是如何工作的嗎?從我們編寫組件開始,到最終真實的 DOM 又是怎樣的一個轉變過程呢?這節課,我們將會學習 Vue.js 3.0 中的組件是如何渲染的,通過學習,你的這些問題將會迎刃而解。

首先,組件是一個抽象的概念,它是對一棵 DOM 樹的抽象,我們在頁面中寫一個組件節點:

<hello-world></hello-world>

這段代碼並不會在頁面上渲染一個<hello-world>標籤,而它具體渲染成什麼,取決於你怎麼編寫 HelloWorld 組件的模板。舉個例子,HelloWorld 組件內部的模板定義是這樣的:

<template>
  <div>
    <p>Hello World</p>
  </div>
</template>

可以看到,模板內部最終會在頁面上渲染一個 div,內部包含一個 p 標籤,用來顯示 Hello World 文本。

所以,從表現上來看,組件的模板決定了組件生成的 DOM 標籤,而在 Vue.js 內部,一個組件想要真正的渲染生成 DOM,還需要經歷“創建 vnode - 渲染 vnode - 生成 DOM” 這幾個步驟:

1111.png

你可能會問,什麼是 vnode,它和組件什麼關係呢?先不要着急,我們在後面會詳細說明。這裏,你只需要記住它就是一個可以描述組件信息的 JavaScript 對象即可。

接下來,我們就從應用程序的入口開始,逐步來看 Vue.js 3.0 中的組件是如何渲染的。

應用程序初始化

一個組件可以通過“模板加對象描述”的方式創建,組件創建好以後是如何被調用並初始化的呢?因爲整個組件樹是由根組件開始渲染的,爲了找到根組件的渲染入口,我們需要從應用程序的初始化過程開始分析。

在這裏,我分別給出了通過 Vue.js 2.x 和 Vue.js 3.0 來初始化應用的代碼:

// 在 Vue.js 2.x 中,初始化一個應用的方式如下
import Vue from 'vue'
import App from './App'
const app = new Vue({
  render: h => h(App)
})
app.$mount('#app')
// 在 Vue.js 3.0 中,初始化一個應用的方式如下
import { createApp } from 'vue'
import App from './app'
const app = createApp(App)
app.mount('#app')

可以看到,Vue.js 3.0 初始化應用的方式和 Vue.js 2.x 差別並不大,本質上都是把 App 組件掛載到 id 爲 app 的 DOM 節點上。

但是,在 Vue.js 3.0 中還導入了一個 createApp,其實這是個入口函數,它是 Vue.js 對外暴露的一個函數,我們來看一下它的內部實現:

const createApp = ((...args) => {
  // 創建 app 對象
  const app = ensureRenderer().createApp(...args)
  const { mount } = app
  // 重寫 mount 方法
  app.mount = (containerOrSelector) => {
    // ...
  }
  return app
})

從代碼中可以看出 createApp 主要做了兩件事情:創建 app 對象和重寫 app.mount 方法。接下來,我們就具體來分析一下它們。

1. 創建 app 對象

首先,我們使用 ensureRenderer().createApp() 來創建 app 對象 :

 const app = ensureRenderer().createApp(...args)

其中 ensureRenderer() 用來創建一個渲染器對象,它的內部代碼是這樣的:

// 渲染相關的一些配置,比如更新屬性的方法,操作 DOM 的方法
const rendererOptions = {
  patchProp,
  ...nodeOps
}
let renderer
// 延時創建渲染器,當用戶只依賴響應式包的時候,可以通過 tree-shaking 移除核心渲染邏輯相關的代碼
function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}
function createRenderer(options) {
  return baseCreateRenderer(options)
}
function baseCreateRenderer(options) {
  function render(vnode, container) {
    // 組件渲染的核心邏輯
  }

return {
render,
createApp: createAppAPI(render)
}
}
function createAppAPI(render) {
// createApp createApp 方法接受的兩個參數:根組件的對象和 prop
return function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
mount(rootContainer) {
// 創建根組件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
}
return app
}
}

可以看到,這裏先用 ensureRenderer() 來延時創建渲染器,這樣做的好處是當用戶只依賴響應式包的時候,就不會創建渲染器,因此可以通過 tree-shaking 的方式移除核心渲染邏輯相關的代碼。

這裏涉及了渲染器的概念,它是爲跨平臺渲染做準備的,之後我會在自定義渲染器的相關內容中詳細說明。在這裏,你可以簡單地把渲染器理解爲包含平臺渲染核心邏輯的 JavaScript 對象。

我們結合上面的代碼繼續深入,在 Vue.js 3.0 內部通過 createRenderer 創建一個渲染器,這個渲染器內部會有一個 createApp 方法,它是執行 createAppAPI 方法返回的函數,接受了 rootComponent 和 rootProps 兩個參數,我們在應用層面執行 createApp(App) 方法時,會把 App 組件對象作爲根組件傳遞給 rootComponent。這樣,createApp 內部就創建了一個 app 對象,它會提供 mount 方法,這個方法是用來掛載組件的。

在整個 app 對象創建過程中,Vue.js 利用閉包和函數柯里化的技巧,很好地實現了參數保留。比如,在執行 app.mount 的時候,並不需要傳入渲染器 render,這是因爲在執行 createAppAPI 的時候渲染器 render 參數已經被保留下來了。

2. 重寫 app.mount 方法

接下來,是重寫 app.mount 方法。

根據前面的分析,我們知道 createApp 返回的 app 對象已經擁有了 mount 方法了,但在入口函數中,接下來的邏輯卻是對 app.mount 方法的重寫。先思考一下,爲什麼要重寫這個方法,而不把相關邏輯放在 app 對象的 mount 方法內部來實現呢?

這是因爲 Vue.js 不僅僅是爲 Web 平臺服務,它的目標是支持跨平臺渲染,而 createApp 函數內部的 app.mount 方法是一個標準的可跨平臺的組件渲染流程:

mount(rootContainer) {
  // 創建根組件的 vnode
  const vnode = createVNode(rootComponent, rootProps)
  // 利用渲染器渲染 vnode
  render(vnode, rootContainer)
  app._container = rootContainer
  return vnode.component.proxy
}

標準的跨平臺渲染流程是先創建 vnode,再渲染 vnode。此外參數 rootContainer 也可以是不同類型的值,比如,在 Web 平臺它是一個 DOM 對象,而在其他平臺(比如 Weex 和小程序)中可以是其他類型的值。所以這裏面的代碼不應該包含任何特定平臺相關的邏輯,也就是說這些代碼的執行邏輯都是與平臺無關的。因此我們需要在外部重寫這個方法,來完善 Web 平臺下的渲染邏輯。

接下來,我們再來看 app.mount 重寫都做了哪些事情:

app.mount = (containerOrSelector) => {
  // 標準化容器
  const container = normalizeContainer(containerOrSelector)
  if (!container)
    return
  const component = app._component
   // 如組件對象沒有定義 render 函數和 template 模板,則取容器的 innerHTML 作爲組件模板內容
  if (!isFunction(component) && !component.render && !component.template) {
    component.template = container.innerHTML
  }
  // 掛載前清空容器內容
  container.innerHTML = ''
  // 真正的掛載
  return mount(container)
}

首先是通過 normalizeContainer 標準化容器(這裏可以傳字符串選擇器或者 DOM 對象,但如果是字符串選擇器,就需要把它轉成 DOM 對象,作爲最終掛載的容器),然後做一個 if 判斷,如果組件對象沒有定義 render 函數和 template 模板,則取容器的 innerHTML 作爲組件模板內容;接着在掛載前清空容器內容,最終再調用 app.mount 的方法走標準的組件渲染流程。

在這裏,重寫的邏輯都是和 Web 平臺相關的,所以要放在外部實現。此外,這麼做的目的是既能讓用戶在使用 API 時可以更加靈活,也兼容了 Vue.js 2.x 的寫法,比如 app.mount 的第一個參數就同時支持選擇器字符串和 DOM 對象兩種類型。

從 app.mount 開始,纔算真正進入組件渲染流程,那麼接下來,我們就重點看一下核心渲染流程做的兩件事情:創建 vnode 和渲染 vnode。

核心渲染流程:創建 vnode 和渲染 vnode

1. 創建 vnode

首先,是創建 vnode 的過程。

vnode 本質上是用來描述 DOM 的 JavaScript 對象,它在 Vue.js 中可以描述不同類型的節點,比如普通元素節點、組件節點等。

什麼是普通元素節點呢?舉個例子,在 HTML 中我們使用 <button> 標籤來寫一個按鈕:

<button class="btn" style="width:100px;height:50px">click me</button>

我們可以用 vnode 這樣表示<button>標籤:

const vnode = {
  type: 'button',
  props: { 
    'class': 'btn',
    style: {
      width: '100px',
      height: '50px'
    }
  },
  children: 'click me'
}

其中,type 屬性表示 DOM 的標籤類型,props 屬性表示 DOM 的一些附加信息,比如 style 、class 等,children 屬性表示 DOM 的子節點,它也可以是一個 vnode 數組,只不過 vnode 可以用字符串表示簡單的文本 。

什麼是組件節點呢?其實, vnode 除了可以像上面那樣用於描述一個真實的 DOM,也可以用來描述組件。

我們先在模板中引入一個組件標籤 <custom-component>

<custom-component msg="test"></custom-component>

我們可以用 vnode 這樣表示 <custom-component> 組件標籤:

const CustomComponent = {
  // 在這裏定義組件對象
}
const vnode = {
  type: CustomComponent,
  props: { 
    msg: 'test'
  }
}

組件 vnode 其實是對抽象事物的描述,這是因爲我們並不會在頁面上真正渲染一個 <custom-component> 標籤,而是渲染組件內部定義的 HTML 標籤。

除了上兩種 vnode 類型外,還有純文本 vnode、註釋 vnode 等等,但鑑於我們的主線只需要研究組件 vnode 和普通元素 vnode,所以我在這裏就不贅述了。

另外,Vue.js 3.0 內部還針對 vnode 的 type,做了更詳盡的分類,包括 Suspense、Teleport 等,且把 vnode 的類型信息做了編碼,以便在後面的 patch 階段,可以根據不同的類型執行相應的處理邏輯:

const shapeFlag = isString(type)
  ? 1 /* ELEMENT */
  : isSuspense(type)
    ? 128 /* SUSPENSE */
    : isTeleport(type)
      ? 64 /* TELEPORT */
      : isObject(type)
        ? 4 /* STATEFUL_COMPONENT */
        : isFunction(type)
          ? 2 /* FUNCTIONAL_COMPONENT */
          : 0

知道什麼是 vnode 後,你可能會好奇,那麼 vnode 有什麼優勢呢?爲什麼一定要設計 vnode 這樣的數據結構呢?

首先是抽象,引入 vnode,可以把渲染過程抽象化,從而使得組件的抽象能力也得到提升。

其次是跨平臺,因爲 patch vnode 的過程不同平臺可以有自己的實現,基於 vnode 再做服務端渲染、Weex 平臺、小程序平臺的渲染都變得容易了很多。

不過這裏要特別注意,使用 vnode 並不意味着不用操作 DOM 了,很多同學會誤以爲 vnode 的性能一定比手動操作原生 DOM 好,這個其實是不一定的。

因爲,首先這種基於 vnode 實現的 MVVM 框架,在每次 render to vnode 的過程中,渲染組件會有一定的 JavaScript 耗時,特別是大組件,比如一個 1000 * 10 的 Table 組件,render to vnode 的過程會遍歷 1000 * 10 次去創建內部 cell vnode,整個耗時就會變得比較長,加上 patch vnode 的過程也會有一定的耗時,當我們去更新組件的時候,用戶會感覺到明顯的卡頓。雖然 diff 算法在減少 DOM 操作方面足夠優秀,但最終還是免不了操作 DOM,所以說性能並不是 vnode 的優勢。

那麼,Vue.js 內部是如何創建這些 vnode 的呢?

回顧 app.mount 函數的實現,內部是通過 createVNode 函數創建了根組件的 vnode :

 const vnode = createVNode(rootComponent, rootProps)

我們來看一下 createVNode 函數的大致實現:

function createVNode(type, props = null
,children = null) {
  if (props) {
    // 處理 props 相關邏輯,標準化 class 和 style
  }
  // 對 vnode 類型信息編碼
  const shapeFlag = isString(type)
    ? 1 /* ELEMENT */
    : isSuspense(type)
      ? 128 /* SUSPENSE */
      : isTeleport(type)
        ? 64 /* TELEPORT */
        : isObject(type)
          ? 4 /* STATEFUL_COMPONENT */
          : isFunction(type)
            ? 2 /* FUNCTIONAL_COMPONENT */
            : 0
  const vnode = {
    type,
    props,
    shapeFlag,
    // 一些其他屬性
  }
  // 標準化子節點,把不同數據類型的 children 轉成數組或者文本類型
  normalizeChildren(vnode, children)
  return vnode
}

通過上述代碼可以看到,其實 createVNode 做的事情很簡單,就是:對 props 做標準化處理、對 vnode 的類型信息編碼、創建 vnode 對象,標準化子節點 children 。

我們現在擁有了這個 vnode 對象,接下來要做的事情就是把它渲染到頁面中去。

2. 渲染 vnode

接下來,是渲染 vnode 的過程。

回顧 app.mount 函數的實現,內部通過執行這段代碼去渲染創建好的 vnode:

render(vnode, rootContainer)
const render = (vnode, container) => {
  if (vnode == null) {
    // 銷燬組件
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    // 創建或者更新組件
    patch(container._vnode || null, vnode, container)
  }
  // 緩存 vnode 節點,表示已經渲染
  container._vnode = vnode
}

這個渲染函數 render 的實現很簡單,如果它的第一個參數 vnode 爲空,則執行銷燬組件的邏輯,否則執行創建或者更新組件的邏輯。

接下來我們接着看一下上面渲染 vnode 的代碼中涉及的 patch 函數的實現:

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
  // 如果存在新舊節點, 且新舊節點類型不同,則銷燬舊節點
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
  const { type, shapeFlag } = n2
  switch (type) {
    case Text:
      // 處理文本節點
      break
    case Comment:
      // 處理註釋節點
      break
    case Static:
      // 處理靜態節點
      break
    case Fragment:
      // 處理 Fragment 元素
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 處理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 6 /* COMPONENT */) {
        // 處理組件
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 64 /* TELEPORT */) {
        // 處理 TELEPORT
      }
      else if (shapeFlag & 128 /* SUSPENSE */) {
        // 處理 SUSPENSE
      }
  }
}

patch 本意是打補丁的意思,這個函數有兩個功能,一個是根據 vnode 掛載 DOM,一個是根據新舊 vnode 更新 DOM。對於初次渲染,我們這裏只分析創建過程,更新過程在後面的章節分析。

在創建的過程中,patch 函數接受多個參數,這裏我們目前只重點關注前三個:

  1. 第一個參數 n1 表示舊的 vnode,當 n1 爲 null 的時候,表示是一次掛載的過程;

  2. 第二個參數 n2 表示新的 vnode 節點,後續會根據這個 vnode 類型執行不同的處理邏輯;

  3. 第三個參數 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 後,會掛載到 container 下面。

對於渲染的節點,我們這裏重點關注兩種類型節點的渲染邏輯:對組件的處理和對普通 DOM 元素的處理。

先來看對組件的處理。由於初始化渲染的是 App 組件,它是一個組件 vnode,所以我們來看一下組件的處理邏輯是怎樣的。首先是用來處理組件的 processComponent 函數的實現:

const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  if (n1 == null) {
   // 掛載組件
   mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
  else {
    // 更新組件
    updateComponent(n1, n2, parentComponent, optimized)
  }
}

該函數的邏輯很簡單,如果 n1 爲 null,則執行掛載組件的邏輯,否則執行更新組件的邏輯。

我們接着來看掛載組件的 mountComponent 函數的實現:

const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  // 創建組件實例
  const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
  // 設置組件實例
  setupComponent(instance)
  // 設置並運行帶副作用的渲染函數
  setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}

可以看到,掛載組件函數 mountComponent 主要做三件事情:創建組件實例、設置組件實例、設置並運行帶副作用的渲染函數。

首先是創建組件實例,Vue.js 3.0 雖然不像 Vue.js 2.x 那樣通過類的方式去實例化組件,但內部也通過對象的方式去創建了當前渲染的組件實例。

其次設置組件實例,instance 保留了很多組件相關的數據,維護了組件的上下文,包括對 props、插槽,以及其他實例的屬性的初始化處理。

創建和設置組件實例這兩個流程我們這裏不展開講,會在後面的章節詳細分析。

最後是運行帶副作用的渲染函數 setupRenderEffect,我們重點來看一下這個函數的實現:

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
  // 創建響應式的副作用渲染函數
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      // 渲染組件生成子樹 vnode
      const subTree = (instance.subTree = renderComponentRoot(instance))
      // 把子樹 vnode 掛載到 container 中
      patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
      // 保留渲染生成的子樹根 DOM 節點
      initialVNode.el = subTree.el
      instance.isMounted = true
    }
    else {
      // 更新組件
    }
  }, prodEffectOptions)
}

該函數利用響應式庫的 effect 函數創建了一個副作用渲染函數 componentEffect (effect 的實現我們後面講響應式章節會具體說)。副作用,這裏你可以簡單地理解爲,當組件的數據發生變化時,effect 函數包裹的內部渲染函數 componentEffect 會重新執行一遍,從而達到重新渲染組件的目的。

渲染函數內部也會判斷這是一次初始渲染還是組件更新。這裏我們只分析初始渲染流程。

初始渲染主要做兩件事情:渲染組件生成 subTree、把 subTree 掛載到 container 中。

首先,是渲染組件生成 subTree,它也是一個 vnode 對象。這裏要注意別把 subTree 和 initialVNode 弄混了(其實在 Vue.js 3.0 中,根據命名我們已經能很好地區分它們了,而在 Vue.js 2.x 中它們分別命名爲 _vnode 和 $vnode)。我來舉個例子說明,在父組件 App 中裏引入了 Hello 組件:

<template>
  <div class="app">
    <p>This is an app.</p>
    <hello></hello>
  </div>
</template>

在 Hello 組件中是 <div> 標籤包裹着一個 <p> 標籤:

<template>
  <div class="hello">
    <p>Hello, Vue 3.0!</p>
  </div>
</template>

在 App 組件中, <hello> 節點渲染生成的 vnode ,對應的就是 Hello 組件的 initialVNode ,爲了好記,你也可以把它稱作“組件 vnode”。而 Hello 組件內部整個 DOM 節點對應的 vnode 就是執行 renderComponentRoot 渲染生成對應的 subTree,我們可以把它稱作“子樹 vnode”。

我們知道每個組件都會有對應的 render 函數,即使你寫 template,也會編譯成 render 函數,而 renderComponentRoot 函數就是去執行 render 函數創建整個組件樹內部的 vnode,把這個 vnode 再經過內部一層標準化,就得到了該函數的返回結果:子樹 vnode。

渲染生成子樹 vnode 後,接下來就是繼續調用 patch 函數把子樹 vnode 掛載到 container 中了。

那麼我們又再次回到了 patch 函數,會繼續對這個子樹 vnode 類型進行判斷,對於上述例子,App 組件的根節點是 <div> 標籤,那麼對應的子樹 vnode 也是一個普通元素 vnode,那麼我們接下來看對普通 DOM 元素的處理流程。

首先我們來看一下處理普通 DOM元素的 processElement 函數的實現:

const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  isSVG = isSVG || n2.type === 'svg'
  if (n1 == null) {
    //掛載元素節點
    mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
  else {
    //更新元素節點
    patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
  }
}

該函數的邏輯很簡單,如果 n1 爲 null,走掛載元素節點的邏輯,否則走更新元素節點邏輯。

我們接着來看掛載元素的 mountElement 函數的實現:

const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let el
  const { type, props, shapeFlag } = vnode
  // 創建 DOM 元素節點
  el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
  if (props) {
    // 處理 props,比如 class、style、event 等屬性
    for (const key in props) {
      if (!isReservedProp(key)) {
        hostPatchProp(el, key, null, props[key], isSVG)
      }
    }
  }
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    // 處理子節點是純文本的情況
    hostSetElementText(el, vnode.children)
  }
  else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
    // 處理子節點是數組的情況
    mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
  }
  // 把創建的 DOM 元素節點掛載到 container 上
  hostInsert(el, container, anchor)
}

可以看到,掛載元素函數主要做四件事:創建 DOM 元素節點、處理 props、處理 children、掛載 DOM 元素到 container 上。

首先是創建 DOM 元素節點,通過 hostCreateElement 方法創建,這是一個平臺相關的方法,我們來看一下它在 Web 環境下的定義:

function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}

它調用了底層的 DOM API document.createElement 創建元素,所以本質上 Vue.js 強調不去操作 DOM ,只是希望用戶不直接碰觸 DOM,它並沒有什麼神奇的魔法,底層還是會操作 DOM。

另外,如果是其他平臺比如 Weex,hostCreateElement 方法就不再是操作 DOM ,而是平臺相關的 API 了,這些平臺相關的方法是在創建渲染器階段作爲參數傳入的。

創建完 DOM 節點後,接下來要做的是判斷如果有 props 的話,給這個 DOM 節點添加相關的 class、style、event 等屬性,並做相關的處理,這些邏輯都是在 hostPatchProp 函數內部做的,這裏就不展開講了。

接下來是對子節點的處理,我們知道 DOM 是一棵樹,vnode 同樣也是一棵樹,並且它和 DOM 結構是一一映射的。

如果子節點是純文本,則執行 hostSetElementText 方法,它在 Web 環境下通過設置 DOM 元素的 textContent 屬性設置文本:

function setElementText(el, text) {
  el.textContent = text
}

如果子節點是數組,則執行 mountChildren 方法:

const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
  for (let i = start; i < children.length; i++) {
    // 預處理 child
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i])
      : normalizeVNode(children[i]))
    // 遞歸 patch 掛載 child
    patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
}

子節點的掛載邏輯同樣很簡單,遍歷 children 獲取到每一個 child,然後遞歸執行 patch 方法掛載每一個 child 。注意,這裏有對 child 做預處理的情況(後面編譯優化的章節會詳細分析)。

可以看到,mountChildren 函數的第二個參數是 container,而我們調用 mountChildren 方法傳入的第二個參數是在 mountElement 時創建的 DOM 節點,這就很好地建立了父子關係。

另外,通過遞歸 patch 這種深度優先遍歷樹的方式,我們就可以構造完整的 DOM 樹,完成組件的渲染。

處理完所有子節點後,最後通過 hostInsert 方法把創建的 DOM 元素節點掛載到 container 上,它在 Web 環境下這樣定義:

function insert(child, parent, anchor) {
  if (anchor) {
    parent.insertBefore(child, anchor)
  }
  else {
    parent.appendChild(child)
  }
}

這裏會做一個 if 判斷,如果有參考元素 anchor,就執行 parent.insertBefore ,否則執行 parent.appendChild 來把 child 添加到 parent 下,完成節點的掛載。

因爲 insert 的執行是在處理子節點後,所以掛載的順序是先子節點,後父節點,最終掛載到最外層的容器上。

知識延伸:嵌套組件
細心的你可能會發現,在 mountChildren 的時候遞歸執行的是 patch 函數,而不是 mountElement 函數,這是因爲子節點可能有其他類型的 vnode,比如組件 vnode。

在真實開發場景中,嵌套組件場景是再正常不過的了,前面我們舉的 App 和 Hello 組件的例子就是嵌套組件的場景。組件 vnode 主要維護着組件的定義對象,組件上的各種 props,而組件本身是一個抽象節點,它自身的渲染其實是通過執行組件定義的 render 函數渲染生成的子樹 vnode 來完成,然後再 patch 。通過這種遞歸的方式,無論組件的嵌套層級多深,都可以完成整個組件樹的渲染。

總結

OK,到這裏我們這一節的學習也要結束啦,這節課我們主要分析了組件的渲染流程,從入口開始,一層層分析組件渲染。

你可能發現了,文中提到的很多技術點我會放在後面的章節去講,這樣做是爲了讓我們不跑題,重點放在理解組件的渲染流程上。下節課我將會帶你具體分析一下組件的更新過程。

這裏,我用一張圖來帶你更加直觀地感受下整個組件渲染流程:

122.png

最後,給你留一道思考題目,我們平時開發頁面就是把頁面拆成一個個組件,那麼組件的拆分粒度是越細越好嗎?爲什麼呢?歡迎你在留言區與我分享。

本節課的相關代碼在源代碼中的位置如下:
packages/runtime-dom/src/index.ts
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/vnode.ts
packages/runtime-core/src/renderer.ts
packages/runtime-dom/src/nodeOps.ts


精選評論

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章