本文解析了 Vue3 組件初次渲染的流程,涵蓋應用程式初始化、核心渲染步驟,以及 vnode 的創建和渲染,探討了 Vue3 內部機制及其跨平臺實現的關鍵細節。 ...
組件初次渲染流程
組件是對DOM
樹的抽象,組件的外觀由template
定義,模板在編譯階段會被轉化為一個渲染函數,用於在運行時生成vnode
。即組件在運行時的渲染步驟是:
vnode
是一個用於描述視圖的結構和屬性的JavaScript
對象。vnode
是對真實DOM
的一層抽象。
使用
vnode
的優點:
- 相比於直接操作
DOM
,在需要頻繁更新視圖的場景下,可以將多次操作應用在vnode
上,再一次性地生成真實DOM
,可以避免頻繁重排重繪導致的性能問題;vnode
是抽象的視圖層,具有平臺無關性,上層代碼可移植性強。
應用程式初始化
對於一個vue-app
來說,整個組件樹由根組件開始渲染。為了找到根組件的渲染入口,從應用程式的初始化過程開始分析。
在Vue2
中,初始化應用的代碼:
import Vue from 'vue';
import App from './App';
const app = new Vue({
render: h=>h(App)
});
app.$mount('#app');
在Vue3
中,初始化應用的代碼:
import { createApp } from 'vue';
import App from './App';
const app = createApp(App);
app.mount('#app');
對比二者的代碼可以看出,本質都是把App
組件掛載到了#app
DOM節點上。
本文主要關註
Vue3
。
Vue3
的createApp
的實現大致如下:
首先,createApp
函數由createAppAPI
根據對應的render
對象構建得到。
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
hydrate?: RootHydrateFunction,
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
//...
}
}
源碼位置:core/packages/runtime-core/src/apiCreateApp.ts at main · vuejs/core (github.com)
render
對象由baseCreateRenderer
函數創建,根據不同的環境創建不同的render
對象(常見的是瀏覽器環境下用來渲染DOM
)。
並由render
對象來決定createApp
函數的實現:
// baseCreateRenderer函數的返回值
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate),
}
這種根據不同環境構建不同render
對象的操作是為了實現跨平臺。
接下來回到createApp
內部。
createApp
應用工廠模式,在內部創建app
對象,實現了mount
方法,mount
方法就是用來掛載組件的。
function createApp(rootComponent, rootProps = null){
// ...
const app: App = {
// ...
mount(
rootContainer: HostElement,
isHydrate?: boolean,
namespace?: boolean | ElementNamespace,
): any{
// mount的具體實現,這裡省略了很多代碼...
// 1. 創建vnode
const vnode = createVNode(rootComponent, rootProps)
// 2. 渲染vnode
render(vnode, rootContainer, namespace)
}
// ...
}
return app;
}
在整個app
對象創建過程中,Vue3
通過閉包和函數柯里化等技巧實現了參數保留。
例如上面的mount
方法內部實際上會使用render
函數將vnode
掛載到container
上。而render
由createAppAPI
調用時傳入。這就是閉包的應用。
上面提到的app
對象中對mount
的實現位於packages/runtime-core
,也就是說是與平臺無關的,內部都是對抽象的vnode
、rootContainer
進行操作,不一定是DOM
節點。
Vue3
將瀏覽器相關的DOM
的實現移到了packages/runtime-dom
中,在index.ts
中可以看到ensureRenderer
函數就調用了runtime-core
中上述提到的createRenderer
方法,傳入了DOM
相關的配置,用於獲取一個專門用於瀏覽器環境的renderer
。
源碼位置:core/packages/runtime-dom/src/index.ts at main · vuejs/core (github.com)
在runtime-dom
的index.ts
中,我們從createApp
函數入手,觀察到它調用了ensureRenderer
來獲取一個適配瀏覽器環境的renderer
,並調用其對應的createApp
函數。
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
// ......
const { mount } = app
// 重寫mount方法
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
// 標準化容器:將字元串選擇器轉換為DOM對象
const container = normalizeContainer(containerOrSelector)
if (!container) return
const component = app._component
// 如果組件對象沒有定義render函數和template模板,則取容器的innerHTML作為模板內容
if (!isFunction(component) && !component.render && !component.template) {
// 使用innerHTML需要註意安全性問題
component.template = container.innerHTML
// ......
}
// 掛載前刪除容器的內容
container.innerHTML = ''
// 走runtime-core中實現的標準流程進行掛載
const proxy = mount(container, false, resolveRootNamespace(container))
// ......
return proxy
}
return app
}) as CreateAppFunction<Element>
階段性總結:
-
重寫
mount
的原因:runtime-core
中的mount
:實現標準化掛載流程;runtime-dom
中的mount
:實現DOM
節點相關的預處理,然後調用runtime-core
中的mount
進行掛載;
-
runtime-dom
中mount
的流程:-
標準化容器:如果傳入字元串選擇器,那麼調用
document.querySelector
將其轉換為DOM
對象; -
檢查組件是否存在
render
函數和template
對象,如果沒有則使用容器的innerHTML
作為模板;使用
innerHTML
需要註意安全性問題。 -
刪除容器原先的
innerHTML
內容; -
調用
runtime-core
中實現的mount
方法走標準化流程掛載組件到DOM
節點上。
-
從app.mount
方法調用後,才真正開始組件的渲染流程。
接下來,回到runtime-core
中關註渲染流程。
核心渲染流程
這一流程中主要做了兩件事:創建vnode和渲染vnode。
vnode
是用來描述DOM
的JavaScript
對象,在Vue
中既可以描述普通DOM
節點,也可以描述組件節點,除此之外還有純文本vnode
和註釋vnode
。
可以在runtime-core
的vnode.ts
文件中找到vnode
的類型定義:core/packages/runtime-core/src/vnode.ts at main · vuejs/core (github.com)
內容較多,這裡不做展示,比較核心的屬性有比如:
type
:組件的標簽類型;props
:附加信息;children
:子節點,vnode
數組;
除此之外,Vue3
還為vnode
打上了各種flag
來做標記,在patch
階段根據不同的類型執行相應的處理邏輯。
創建vnode
在mount
方法的實現中,通過調用createVNode
函數創建根組件的vnode
:
const vnode = createVNode(rootComponent, rootProps);
在vnode.ts
中可以找到createVNode
函數的實現:core/packages/runtime-core/src/vnode.ts at main · vuejs/core (github.com)
大致思路如下:
function _createVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag: number = 0,
dynamicProps: string[] | null = null,
isBlockNode = false,
): VNode{
// ...
// 標準化class和style這些樣式屬性
if(props){
// ...
}
// 對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
// 調用工廠函數構建vnode對象
return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
isBlockNode,
true,
)
}
接下來看一下createBaseVNode
的大致實現(這個函數也位於vnode.ts
文件內):
function createBaseVNode(
// vnode部分屬性的值
){
const vnode = {
type,
props,
// ...很多屬性
} as VNode
// 標準化children:討論數組或者文本類型
if (needFullChildrenNormalization) {
normalizeChildren(vnode, children)
}
return vnode
}
渲染vnode
創建好vnode
之後就是渲染的過程,在mount
中使用render
函數渲染創建好的vnode
。
render
的標準化流程的實現位於runtime-core
的renderer.ts
中:
源碼位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
const render: RootRenderFunction = (vnode, container, namespace) => {
if (vnode == null) {
// 銷毀組件
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
// 創建或者更新組件
patch(
container._vnode || null,
vnode,
container,
null,
null,
null,
namespace,
)
}
if (!isFlushing) {
isFlushing = true
flushPreFlushCbs()
flushPostFlushCbs()
isFlushing = false
}
// 緩存vnode節點,表示已經渲染
container._vnode = vnode
}
- 如果
vnode
不存在,則調用unmount
銷毀組件; - 如果
vnode
存在,那麼調用patch
創建或者更新組件; - 將
vnode
緩存到容器對象上,表示已渲染。
patch
函數的前兩個參數分別是舊vnode
和新vnode
。
- 初次調用,則
container._vnode
屬性返回undefined
,短路運算符傳入null
,則patch
內部走創建邏輯;調用過後會將創建的vnode
緩存到container._vnode
; - 後續調用的
container._vnode
表示上一次創建的vnode
,不為null
,傳入patch
後走更新邏輯。
patch的實現
patch
本意是打補丁,這個函數有兩個功能:
- 根據
vnode
掛載DOM
; - 比較新舊
vnode
更新DOM
。
這裡只討論初始化流程,故只記錄如何掛載
DOM
,更新流程這裡不做介紹。
源碼位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
namespace = undefined,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
) => {
// 二者相同,不需要更新
if (n1 === n2) {
return
}
// vnode類型不同,直接卸載舊節點
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// ......
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
// 處理文位元組點
break
case Comment:
// 處理註釋節點
break
case Static:
// 靜態節點
break
case Fragment:
// Fragment節點
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__) {
// 報錯:vnode類型不在可識別範圍內
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
}
這裡只關註前三個函數參數:
n1
:舊vnode
,為null
則表示初次掛載;n2
:新vnode
;container
:掛載的目標容器。
patch
在其內部調用了processXXX
處理不同類型的vnode
,這裡只關註組件類型和普通DOM
節點類型。
對組件的處理
處理組件調用的是processComponent
函數:
processComponent
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
// ... 其它參數
) => {
if (n1 == null) {
// 掛載組件
mountComponent(n2, container, /*...other args*/)
} else {
// 更新組件
updateComponent(n1, n2, optimized)
}
}
// 這裡還有很多其它參數省略了,函數體內還處理了`keep-alive`的情況,具體可以自己看源碼。
- 掛載組件使用
mountComponent
函數; - 更新組件使用
updateComponent
函數。
mountComponent
源碼位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
這個函數處理了較多邊界情況,這裡只展示主要的步驟:
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
namespace: ElementNamespace,
optimized,
) => {
// 創建組件實例
const instance: ComponentInternalInstance =
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense,
))
// 設置組件實例
setupComponent(instance, false, optimized)
// 設置並運行帶副作用的渲染函數
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace,
optimized,
)
}
- 創建組件實例:工廠模式創建組件實例對象;
- 設置組件實例:
instance
記錄了許多組件相關的數據,setupComponent
這一步主要是對props
、slots
等屬性進行初始化。
接下來重點看一下setupRenderEffect
函數的實現。
setupRenderEffect
setupRenderEffect
函數的主要工作是設置一個響應式效果 (ReactiveEffect
),並創建一個調度任務 (SchedulerJob
) 來管理組件的渲染和更新。首次渲染和後續更新的邏輯都封裝在 componentUpdateFn
中。
簡化後的代碼:
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace: ElementNamespace,
optimized,
) => {
// 組件更新函數
const componentUpdateFn = () => {
if (!instance.isMounted) {
// 首次掛載邏輯
instance.subTree = renderComponentRoot(instance)
patch(null, instance.subTree, container, anchor, instance, parentSuspense, namespace)
instance.isMounted = true
} else {
// 後續更新邏輯
const nextTree = renderComponentRoot(instance)
patch(instance.subTree, nextTree, container, anchor, instance, parentSuspense, namespace)
instance.subTree = nextTree
}
}
// 創建響應式效果
const effect = (instance.effect = new ReactiveEffect(componentUpdateFn, NOOP))
// 創建調度任務
const update: SchedulerJob = (instance.update = () => {
if (effect.dirty) {
effect.run()
}
})
// 立即執行更新函數
update()
}
setupRenderEffect
內部主要包含了3個函數:
componentUpdateFn
的主要作用是在組件首次掛載和後續更新時執行相應的渲染邏輯,確保組件的虛擬 DOM 樹與實際的 DOM 樹保持同步,並執行相關的生命周期鉤子函數。effect
封裝了組件的渲染邏輯,負責在響應式依賴變化時觸發重新渲染。update
是調度任務,負責在適當的時機檢查和觸發effect
,確保組件的渲染邏輯能夠正確執行。
也就是說它們依次為前者的進一步封裝。
componentUpdateFn
中的初始掛載邏輯:
- 渲染組件生成
subTree
;(遞歸調用patch
) - 將
subTree
通過patch
掛載到container
上。
這裡的patch
就是一個遞歸過程。事實上patch
對於組件只有渲染過程,沒有掛載的操作,因為組件是抽象的,並不能通過DOM API
插入到頁面上。
也就是說patch
只對DOM
類型元素進行mount
掛載,對於組件類型元素的處理只做遞歸操作。換個角度描述就是:組件樹的葉子節點一定都是DOM
類型元素,只有這樣才能渲染並掛載到頁面上。
接下來開始研究patch
對DOM
類型元素的處理過程。(可以返回上文看一下patch
的實現)。
對DOM的處理
processElement
patch
函數使用processElement
函數處理新舊DOM
元素,當n1
為null
時,走掛載流程;否則走更新流程。
源碼地址:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
const processElement = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
// ...other args...
) => {
if (n1 == null) {
// 掛載
mountElement(n2, container, /* ...other args... */)
} else {
// 更新
patchElement(n1, n2, parentComponent, /* ...other args... */)
}
}
mountElement
源碼位置:core/packages/runtime-core/src/renderer.ts at main · vuejs/core (github.com)
這裡省略了很多代碼,只保留大致流程:
-
創建DOM元素;
-
掛載子節點;
-
如果子節點只是文字,則設置DOM節點的
textContent
; -
如果子節點是數組,則使用
for
迴圈 + 遞歸調用patch
函數渲染子元素;這裡遞歸使用的是
patch
而不是mountElement
是因為子元素可能不是DOM
元素,而是其它類型的元素。因此還是要用到patch
中的switch - case
走類型判斷的邏輯。
-
-
設置
DOM
元素的屬性; -
插入DOM元素。
const mountElement = (
vnode: VNode,
container: RendererElement,
/* ...other args... */
) => {
const { props, shapeFlag, transition, dirs } = vnode
// 創建DOM元素
const el = vnode.el = hostCreateElement(vnode.type as string, namespace, props && props.is, props)
// 掛載子節點
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, vnode.children as string)
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(vnode.children as VNodeArrayChildren, el, null, parentComponent, parentSuspense, resolveChildrenNamespace(vnode, namespace), slotScopeIds, optimized)
}
// 設置屬性
if (props) {
for (const key in props) {
if (key !== 'value' && !isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], namespace, parentComponent)
}
}
// 特殊處理 value 屬性
if ('value' in props) {
hostPatchProp(el, 'value', null, props.value, namespace)
}
}
// 插入元素
hostInsert(el, container, anchor)
}
其中的hostCreateElement
、hostSetElementText
、hostPatchProp
、hostInsert
函數都由runtime-dom
中在創建renderer
的時候傳入對應的實現。
在
runtime-dom
模塊的nodeOps.ts
和patchProp.ts
文件可以找到這些DOM
相關操作的具體實現。
nodeOps.ts
源碼位置:core/packages/runtime-dom/src/nodeOps.ts at e26fd7b1d15cb3335a4c2230cc49b1008daddca1 · vuejs/core (github.com)
patchProp.ts
源碼位置:core/packages/runtime-dom/src/patchProp.ts at e26fd7b1d15cb3335a4c2230cc49b1008daddca1 · vuejs/core (github.com)
上述hostXXX
對應的DOM
方法分別是:
hostCreateElement
:document.createElement
;hostSetElementText
:el.textContent = ...
;hostPatchProp
:直接修改DOM
對象上的鍵值,會對特殊的key
做處理;hostInsert
:[Node.insertBefore
](Node.insertBefore() - Web API | MDN (mozilla.org))