[vue3] 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組件掛載到了#appDOM節點上。
本文主要關注
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))
初次渲染流程總結


浙公網安備 33010602011771號