本文主要是講述 Vue.js 3.0 中一個組件是如何轉變為頁面中真實 DOM 節點的。對于任何一個基于 Vue.js 的應用來說,一切的故事都要從應用初始化「根組件(通常會命名為 APP)掛載到 HTML 頁面 DOM 節點(根組件容器)上」說起。所以,我們可以從應用的根組件為切入點。
主線思路:聚焦于一個組件是如何轉變為 DOM 的。
輔助思路:
- 涉及到源代碼的地方,需要明確標記源碼所在文件,同時將 TS 簡化為 JS 以便于直觀理解
- 思路每前進一步要能夠得出結論
- 盡量總結歸納出流程圖
應用初始化
在 Vue.js 3.0 中,初始化一個應用的方式和 Vue.js 2.x 有差別但是差別不大(本質上都是把 App 組件掛載到 id 為 app 的 DOM 節點上),在 Vue.js 3.0 中用法如下:
1
2
3
4
5
6
|
import { createApp } from 'vue' import App from './app' const app = createApp(App) app.mount( '#app' ) |
createApp 簡化版源碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// packages/runtime-dom/src/index.ts // 創建應用 const createApp = ((...args) => { // 1. 創建 app 對象 const app = ensureRenderer().createApp(...args) const { mount } = app // 2. 重寫 mount 方法 app.mount = (containerOrSelector) => { // ... } return app }) |
createApp 方法中主要做了兩件事:
- 創建 app 對象
- 重寫 app.mount 方法
接下來會分別看一下這兩個過程都做了什么事情。
創建 app 對象
從 ensureRenderer() 著手。在 Vue.js 3.0 中有一個「渲染器」的概念,我們先對渲染器有一個初步的印象:**渲染器可以用于跨平臺渲染,是一個包含了平臺渲染核心邏輯的 JavaScript 對象。**接下來,我們通過簡化版源碼來驗證這個結論:
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
|
// packages/runtime-dom/src/index.ts // 定義渲染器變量 let renderer // 創建一個渲染器對象 // 惰性創建渲染器(當用戶只依賴響應式包的時候可以通過 tree-shaking 的方式移除核心渲染邏輯相關的代碼) function ensureRenderer() { return renderer || (renderer = createRenderer(rendererOptions)) } // packages/runtime-core/src/renderer.ts export function createRenderer(options) { return baseCreateRenderer(options) } // 創建不同平臺渲染器的函數,在其內部都會調用 baseCreateRenderer function baseCreateRenderer(options, createHydrationFns) { // 一系列內部函數 const render = (vnode, container) => { // 組件渲染的核心邏輯 } // 返回渲染器對象 return { render, hydrate, createApp: createAppAPI(render, hydrate) } } |
可以看出渲染器最終由 baseCreateRenderer 函數生成,是一個包含 render 和createApp 函數的 JS 對象。其中 createApp 函數是由 createAppAPI 函數返回的。那 createApp 接收的參數有哪些呢?為了尋求答案,我們需要看一下 createAppAPI 做了什么事情。
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
|
// packages/runtime-core/src/apiCreateApp.ts // 接收一個渲染器 render 作為參數,接收一個可選參數 hydrate,返回一個用于創建 app 的函數 export function createAppAPI(render, hydrate) { // createApp 接收兩個參數:根組件對象和根組件的prop return function createApp(rootComponent, rootProps = null ) { const context = createAppContext() const app: App = (context.app = { _uid: uid++, _component: rootComponent, _props: rootProps, _container: null , _context: context, version, get config() {}, set config(v) {}, use(plugin: Plugin, ...options: any[]) {}, mixin(mixin: ComponentOptions) {}, component(name: string, component?: Component): any {}, directive(name: string, directive?: Directive) {}, mount(rootContainer: HostElement, isHydrate?: boolean): any { // 創建根組件的 vnode const vnode = createVNode(rootComponent, rootProps) // 利用函數參數傳入的渲染器渲染 vnode render(vnode, rootContainer) app._container = rootContainer return vnode.component.proxy }, unmount() {}, provide(key, value) {} } return app } } |
渲染器對象的 createApp 方法接收兩個參數:根組件對象和根組件的prop。這和應用初始化 demo 中 createApp(App) 的使用方式是吻合的。還可以看到的是:createApp 返回的 app 對象在最初定義時包含了 _uid 、 use 、 mixin 、 component 、mount 等屬性。
此時,我們可以得出結論:在應用層調用的 createApp 方法內部,首先會生成一個渲染器,然后調用渲染器的 createApp 方法創建 app 對象。app 對象中具有一系列我們在日常開發應用時已經很熟悉的屬性。
在應用層調用的 createApp 方法內部創建好 app 對象后,接下來便是對 app.mount 方法重寫。
重寫 app.mount 方法
先看一下簡化版的 app.mount 源碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// packages/runtime-dom/src/index.ts const { mount } = app app.mount = (containerOrSelector): any => { // 1. 標準化容器(將傳入的 DOM 對象或者節點選擇器統一為 DOM 對象) const container = normalizeContainer(containerOrSelector) if (!container) return const component = app._component // 2. 標準化組件(如果根組件不是函數,并且沒有 render 函數和 template 模板,則把根組件 innerHTML 作為 template) if (!isFunction(component) && !component.render && !component.template) { component.template = container.innerHTML } // 3. 掛載前清空容器的內容 container.innerHTML = '' // 4. 執行渲染器創建 app 對象時定義的 mount 方法(在后文中稱之為「標準 mount 函數」)來渲染根組件 const proxy = mount(container) return proxy } |
瀏覽器平臺 app.mount 方法重寫主要做了 4 件事情:
- 標準化容器
- 標準化組件
- 掛載前清空容器的內容
- 執行標準 mount 函數渲染組件
此時可能會有人思考一個問題:為什么要重寫app.mount 呢?答案是因為 Vue.js 需要支持跨平臺渲染。
支持跨平臺渲染的思路:不同的平臺具有不同的渲染器,不同的渲染器中會調用標準的 baseCreateRenderer 來保證核心(標準)的渲染流程是一致的。
以瀏覽器端和服務端渲染的代碼實現為例:
createApp 流程圖
在分別了解了 創建 app 對象和重寫 app.mount 過程后,我們來以整體的視角看一下 createApp 函數的實現:
目前為止,只是對應用的初始化有了一個初步的印象,但是還沒有涉及到具體的組件渲染過程。可以看到根組件的渲染是在標準 mount 函數中進行的。所以接下來需要去深入了解標準 mount 函數。
標準 mount 函數
簡化版源碼
1
2
3
4
5
6
7
8
9
10
11
12
|
// packages/runtime-core/src/apiCreateApp.ts // createAppAPI 函數內部返回的 createApp 函數中定義了 app 對象,mount 函數是 app 對象的方法之一 mount(rootContainer, isHydrate) { // 1. 創建根組件的 vnode const vnode = createVNode(rootComponent, rootProps) // 2. 利用函數參數傳入的渲染器渲染 vnode render(vnode, rootContainer) app._container = rootContainer return vnode.component.proxy }, |
createVNode 方法做了兩件事:
- 基于根組件「創建 vnode」
- 在根組件容器中「渲染 vnode」
vnode 大致可以理解為 Virtual DOM(虛擬 DOM)概念的一個具體實現,是用普通的 JS 對象來描述 DOM 對象。因為不是真實的 DOM 對象,所以叫做 Virtual DOM。
我們來一起看一下創建 vnode 和渲染 vnode 的具體過程。
創建 vnode:createVNode(rootComponent, rootProps)
簡化版源碼(已經把分支邏輯拿掉)
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
|
// packages/runtime-core/src/vnode.ts function _createVNode(type, props, children, patchFlag, dynamicProps, isBlockNode = false ) { // 1. 對 VNodeTypes 或 ClassComponent 類型的 type 進行各種標準化處理:規范化 vnode、規范化 component、規范化 CSS 類和樣式 // 2. 將 vnode 類型信息編碼為位圖 const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : __FEATURE_SUSPENSE__ && isSuspense(type) ? ShapeFlags.SUSPENSE : isTeleport(type) ? ShapeFlags.TELEPORT : isObject(type) ? ShapeFlags.STATEFUL_COMPONENT : isFunction(type) ? ShapeFlags.FUNCTIONAL_COMPONENT : 0 // 3. 創建 vnode 對象 const vnode = { __v_isVNode: true , [ReactiveFlags.SKIP]: true , type, // 把函數入參 type 賦值給 vnode props, children: null , component: null , staticCount: 0, shapeFlag, // 把 vnode 類型信息賦值給 vnode // 還有很多屬性 } // 4. 標準化子節點 children normalizeChildren(vnode, children) return vnode } |
createVNode 做了 4 件事
- 對 VNodeTypes 或 ClassComponent 類型的 type 進行各種標準化處理
- 將 vnode 類型信息編碼為位圖
- 創建 vnode 對象
- 標準化子節點 children
細心的同學會發現:在標準 mount 函數中執行 createVNode(rootComponent, rootProps) 時,參數是根組件 rootComponent 和根組件屬性 rootProps,但是在 _createVNode 在定義時函數簽名的前兩個參數確實 type 和 props。rootComponent 與 type 的關系是什么呢?函數名為什么差了一個 _ 呢?
首先函數名的差異,是由于在定義函數時,基于代碼運行環境做了一個判斷:
1
2
3
|
export const createVNode = (__DEV__ ? createVNodeWithArgsTransform : _createVNode) as typeof _createVNode |
其次,rootComponent 與 type 的關系我們可以從 type 的類型定義中得到答案:
1
2
3
4
|
function _createVNode( type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT, props: (Data & VNodeProps) | null = null ): VNode { } |
當 createVNode把這 4 件事情做好后,會返回已經創建好 vnode,接下來做的事情是渲染 vnode。
渲染 vnode:render(vnode, rootContainer)
即使不看具體源碼實現,我們其實大致可以用一句話總結出渲染 vnode 過程做了什么事情:把 vnode 轉化為真實 DOM。
前文我們提過,**渲染器是一個包含了平臺渲染核心邏輯的 JavaScript 對象。**渲染 vnode 正是通過調用渲染器的 render 方法做的。
1
2
3
4
5
6
|
// 返回渲染器對象 return { render, hydrate, createApp: createAppAPI(render, hydrate) } |
我們來看一下 render 函數的定義(簡化版源碼):**
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// packages/runtime-core/src/renderer.ts const render = (vnode, container) => { if (vnode == null ) { // 如果 vnode 為 null,但是容器中有 vnode,則銷毀組件 if (container._vnode) { unmount(container._vnode, null , null , true ) } } else { // 創建或更新組件 patch(container._vnode || null , vnode, container) } // packages/runtime-core/src/scheduler.ts flushPostFlushCbs() // 緩存 vnode 節點(標識該 vnode 已經完成渲染) container._vnode = vnode } |
抽象來看, render 做的事情是:如果傳入的 vnode 為空,則銷毀組件,否則就創建或者更新組件。其中有兩個關鍵函數:patch 和 unmount(patch、unmount 和 render 都是在baseCreateRenderer函數內部的方法)。
可以從 patch 著手,看一下是如何將 vnode 轉化為 DOM 的。
patch
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
48
|
// packages/runtime-core/src/renderer.ts const patch = ( n1, n2, container, anchor = null , parentComponent = null , parentSuspense = null , isSVG = false , optimized = false ) => { // 1. 如果是更新 vnode 并且新舊 vnode 類型不一致,則銷毀舊的 vnode if (n1 && !isSameVNodeType(n1, n2)) { anchor = getNextHostNode(n1) unmount(n1, parentComponent, parentSuspense, true ) n1 = null } // 2. 處理不同類型節點的渲染 const { type, ref, shapeFlag } = n2 switch (type) { case Text: // 處理文本節點 processText(n1, n2, container, anchor) break case Comment: // 處理注釋節點 break case Static: // 處理靜態節點 break case Fragment: // 處理 Fragment 元素(https://v3.vuejs.org/guide/migration/fragments.html#fragments) break default : if (shapeFlag & ShapeFlags.ELEMENT) { // 處理普通 DOM 元素 } else if (shapeFlag & ShapeFlags.COMPONENT) { // 處理組件 } else if (shapeFlag & ShapeFlags.TELEPORT) { // 處理 TELEPORT } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // 處理 SUSPENSE } else if (__DEV__) { warn( 'Invalid VNode type:' , type, `(${ typeof type})`) } } } |
patch 函數做了 2 件事情:
- 如果是更新 vnode 并且新舊 vnode 類型不一致,則銷毀舊的 vnode
- 處理不同類型節點的渲染
在 patch 函數的多個參數中,我們優先關注前 3 個參數:
- n1 表示舊的 vnode,當 n1 為 null 的時候,表示是一次新建(掛載)的過程
- n2 表示新的 vnode 節點,后續會根據這個 vnode 類型執行不同的處理邏輯
- container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,會掛載到 container 下面
以新建文本 DOM 節點為例,此時 n1 為 null,n2 類型為 Text,所以會走分支邏輯:processText(n1, n2, container, anchor)。processText 內部會去調用 hostCreateText 和 hostSetText。
hostCreateText 和 hostSetText 是從 baseCreateRenderer 函數入參 options 中解析出來的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// packages/runtime-core/src/renderer.ts const { insert: hostInsert, remove: hostRemove, patchProp: hostPatchProp, forcePatchProp: hostForcePatchProp, createElement: hostCreateElement, createText: hostCreateText, createComment: hostCreateComment, setText: hostSetText, setElementText: hostSetElementText, parentNode: hostParentNode, nextSibling: hostNextSibling, setScopeId: hostSetScopeId = NOOP, cloneNode: hostCloneNode, insertStaticContent: hostInsertStaticContent } = options |
來看看 options 是怎么來的:
1
2
3
|
// packages/runtime-core/src/renderer.ts // 在調用 baseCreateRenderer 時,傳入了渲染參數 function baseCreateRenderer(options: RendererOptions) { } |
還記得前文提到的我們在哪里調用了 baseCreateRenderer 嗎?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// packages/runtime-dom/src/index.ts // 創建應用 const createApp = ((...args) => { // 1. 創建 app 對象 const app = ensureRenderer().createApp(...args) return app }) // packages/runtime-dom/src/index.ts const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps) function ensureRenderer() { return renderer || (renderer = createRenderer<Node, Element>(rendererOptions)) } // packages/runtime-core/src/renderer.ts export function createRenderer< HostNode = RendererNode, HostElement = RendererElement >(options: RendererOptions<HostNode, HostElement>) { return baseCreateRenderer<HostNode, HostElement>(options) } |
可以看到在創建渲染器時,我們調用了 baseCreateRenderer 并傳入了 rendererOptions。rendererOptions 的值為extend({ patchProp, forcePatchProp }, nodeOps)。
我們如果知道了 nodeOps 中的 createText、setText 等方法做了什么事情,就清楚了某一個確定類型的 vnode 是如何轉變為 DOM 的。先看一下 nodeOps 的定義:
1
2
3
4
5
6
|
// packages/runtime-dom/src/nodeOps.ts export const nodeOps = { createText: text => doc.createTextNode(text), setText: (node, text) => {}, // 其他方法 } |
此時已經非常接近問題的答案了,關鍵是看一下 doc 變量是什么:
1
|
const doc = ( typeof document !== 'undefined' ? document : null ) as Document |
至此,我們知道了答案:先把組件轉化為 vnode,針對特定類型的 vnode 執行不同的渲染邏輯,最終調用 document 上的方法將 vnode 渲染成 DOM。**抽象一下,從組件到渲染生成 DOM 需要經歷 3 個過程:創建 vnode - 渲染 vnode - 生成 DOM。
在渲染 vnode 部分,我們以一個簡單的 Text 類型的 vnode 為例來找到了答案。其實在 baseCreateRenderer 中有 30+ 個函數來處理不同類型的 vnode 的渲染。 比如:用來處理組件類型的 processComponent 函數、用來處理普通 DOM 元素類型的processElement 函數等。由于 vnode 是一個樹形數據結構,在處理過程中還應用到了遞歸思想。建議感興趣的同學自行查看。
總結
最后,我們來做個總結:
- 在 Vue.js 中, vnode 是對抽象事物的描述。
- 從組件到渲染生成 DOM 需要經歷 3 個過程:創建 vnode - 渲染 vnode - 生成 DOM。
- 組件是如何轉變為 DOM 的:先把組件轉化為 vnode,針對特定類型的 vnode 執行不同的渲染邏輯,最終調用 document 上的方法將 vnode 渲染成 DOM。
- 渲染器是一個包含了平臺渲染核心邏輯的 JavaScript 對象,可以用于跨平臺渲染。
- 渲染器對象中的 createApp 方法,創建了一個具有 mount 方法的 app 實例。app.mount 方法中先是用根組件創建了 vnode,然后調用渲染器對象中的 render 方法去渲染 vnode,最終通過 DOM API 將 vnode 轉化為 DOM。
附錄
Vue.js 中使用了哪些 DOM 的方法:
- createElement
- createElementNS
- createTextNode
- createComment
- querySelector
- insertBefore
- insert
- removeChild
- setAttribute
- cloneNode
到此這篇關于詳解Vue.js3.0 組件是如何渲染為DOM的 的文章就介紹到這了,更多相關Vue.js3.0 組件渲染為DOM 內容請搜索服務器之家以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持服務器之家!
原文鏈接:https://juejin.im/post/6893144723721355272