Vue3 源碼分析 - 從 createApp 開始的首次渲染
前言
之前一直都是 React 用的比較多,對 vue 沒有深入的學習瞭解過,所以想從源碼的角度深入瞭解一下。
createApp 入口
在 vue3 中,是通過createApp
的方式進行創建新的 Vue 實例,所以我們可以直接順着createApp
往下看。
// 從 createApp 開始
// vue3.0 中初始化應用
import { createApp } from 'vue'
const app = {
template: '<div>hello world</div>'
}
const App = createApp(app)
// 把 app 組件掛載到 id 爲 app 的 DOM 節點上
App.mount('#app')
createApp
的內部比較清晰,先是創建了 app
對象,之後是改寫了 mount
方法, 最後返回了這個 app
實例。
// runtime-dom/src/index.ts
const createApp = ((...args) => {
// 創建傳入的 app 組件對象
const app = ensureRenderer().createApp(...args)
// ...
const { mount } = app
// 重寫 mount 方法
app.mount = (containerOrSelector) => {
// ...
}
return app
})
創建 app 對象
在這裏可以發現,真正的 createApp
方法是在渲染器屬性上的。爲什麼要有一個 ensureRender
方法呢?通過字面意思可以猜到是爲了確保需要是需要渲染器的, 這裏是一個優化點。在 vue3 中使用 monorepo
的方式對很多模塊做了細粒度的包拆分,比如核心的響應式部分放在了 packages/reactivity
中,創建渲染器的 createRenderer
方法放在了 packages/runtime-core
中。所以如果沒有調用 createApp
這個方法,也就不會調用 createRenderer
方法,那麼當前的 runtime-dom
這個包內是可以通過 tree shaking
去避免打包的時候把沒有用到的 packages/runtime-core
也打進去。
// runtime-dom/src/index.ts
// 渲染時使用的一些配置方法,如果在瀏覽器環境就是會傳入很多 DOM API
let rendererOptions = {
patchProp,
forcePatchProp,
insert,
remove,
createElement,
cloneNode,
...
}
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer
function ensureRenderer () {
return renderer || (renderer = createRenderer(rendererOptions))
}
接下來進入 createRenderer
方法之後,會發現還有一個 baseCreateRenderer
方法。這裏是爲了跨平臺做準備的,比如我現在是瀏覽器環境,那麼上面的 renderOptions
內的 insert
、createElement
等方法傳入的就是 DOM API
,如果以後需要完全可以根據平臺的不同傳入不同的 renderOptions
去生成不同的渲染器。在最後 baseCreateRenderer
會返回一個 render
方法和最終的 createApp
(也就是 createAppAPI
) 方法。
// runtime-core/src/renderer.ts
export function createRenderer(options) {
return baseCreateRenderer(options)
}
function baseCreateRenderer(options) {
// 組件渲染核心邏輯
// ...
retutn {
render,
createApp: createAppAPI(render)
}
}
這個 createAppAPI
纔是我們最終在應用層時調用的。首先他是返回了一個函數,這樣做的好處是通過閉包把 render
方法保留下來供內部來使用。最後他創建傳入的 app
實例,然後返回,我們可以看到這裏有一個 mount
方法,但是這個 mount
方法還不能使用,vue 會在之後對這個 mount
方法進行改寫,之後纔會進入真正的 mount
。
// runtime-code/src/apiCreateApp.ts
export function createAppAPI(render) {
// 這裏返回了一個函數,使用閉包可以在下面 mount 的使用調用 render 方法
return function createApp(rootComponent, rootProps = null) {
const context = createAppContext()
let isMounted = false
const app = {
_component: rootComponent,
_props: rootProps,
_container: null,
_context: context,
mount (rootContainer) {
if (!isMounted) {
// 創建 root vnode
const vnode = createVNode(rootComponent, rootProps)
// 緩存 context,首次掛載時設置
vnode.appContext = context
isMounted = true
// 緩存 rootContainer
app._container = rootContainer
rootContainer.__vue_app__= app
return vnode.component.proxy
}
}
// ...
}
return app
}
}
重寫 app.mount 方法, 進入真正的 mount
到這裏進入改寫 mount
方法的邏輯,這裏的重寫其實也是與平臺相關的,在瀏覽器環境下,會先去獲取正確的 DOM
容器節點,判斷一切都合法之後,纔會調用 mount
方法進入真正的渲染流程中。
// 返回掛載的DOM節點
app.mount = (containerOrSelector) => {
// 獲取 DOM 容器節點
const container = normalizeContainer(containerOrSelector)
// 不是合法的 DOM 節點 return
if (!container) return
// 獲取定義的 Vue app 對象, 之前的 rootComponent
const component = app._component
// 如果不是函數、沒有 render 方法、沒有 template 使用 DOM 元素內的 innerHTML 作爲內容
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML
}
// clear content before mounting
container.innerHTML = ''
// 真正的掛載
const proxy = mount(container)
// ...
return proxy
}
mount
方法內部的流程也比較清晰,首先是創建 vnode
,之後是渲染 vnode
,並將其掛載到 rootContainer
上。
mount (rootContainer) {
if (!isMounted) {
// 創建 root vnode
const vnode = createVNode(rootComponent, rootProps)
// 緩存 context,首次掛載時設置
vnode.appContext = context
render(vnode, rootContainer)
isMounted = true
// 緩存 rootContainer
app._container = rootContainer
rootContainer.__vue_app__= app
return vnode.component.proxy
}
}
到目前爲止可以做一個小的總結:
vnode
和 渲染 vnode
這兩件事去看看到底做了些什麼。
創建 vnode
vnode
本質上是 JavaScript
對象,用來描述各個節點的信息。
<template>
<div class="hello" style="color: red">
<p>hello world</p>
</div>
</template>
例如針對以上的信息會生成這樣一組 關於 div
標籤的 vnode
,其中有幾個關鍵屬性,type
、props
、children``type
是當前 DOM
節點的類型props
裏存放着當前 DOM
節點的屬性,比如 class
、style
等children
表示 DOM
元素的子節點信息,是由 vnode
組成的數組createVNode
方法,它就是創建一個對象並初始化了各種屬性,比如類型編碼、type
、props
等
// runtime-core/src/vnode.ts
function createVNode(type, props, children) {
// ...
// class & style normalization
if (props) {
let { class: klass, style }= props
props.class = normalizeClass(klass)
props.style = normalizeStyle(style)
}
// 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
const vnode: VNode = {
__v_isVNode: true,
[ReactiveFlags.SKIP]: true,
type,
props,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
scopeId: currentScopeId,
children: null,
// ...
}
// 處理 children 子節點
normalizeChildren(vnode, children)
}
注意上面這裏最後會調用 normalizeChildren
方法去處理 vnode
的子節點
// runtime-core/src/vnode.ts
function normalizeChildren(vnode, children) {
let type = 0
const { shapeFlag } = vnode
if (children == null) {
children = null
} else if (isArray(children)) {
type = ShapeFlags.ARRAY_CHILDREN
} else if (isString(children)) {
children = String(children)
type = ShapeFlags.TEXT_CHILDREN
}
// ...
vnode.children = children
vnode.shapeFlag |= type
}
在 normalizeChildren
方法中,會使用位或運算來改寫 vnode.shapeFlag
,這在之後渲染時有很大用處。
// shared/src/shapeFlags.ts
const enum ShapeFlags {
ELEMENT = 1,
FUNCTIONAL_COMPONENT = 1 << 1,
STATEFUL_COMPONENT = 1 << 2,
TEXT_CHILDREN = 1 << 3,
ARRAY_CHILDREN = 1 << 4,
SLOTS_CHILDREN = 1 << 5,
TELEPORT = 1 << 6,
SUSPENSE = 1 << 7,
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}
上面是 vue3
中定義的所有 shapeFlags 類型,我們拿一個<p>hello world</p>
這樣的節點來舉例說明,這個節點用 vnode
來描述是這樣子的,當前這個 vnode
是還沒有對 children
和 shapeFlag
進行操作normalizeChildren
之後,children
是一個純文本,而 shapeFlag
變成了 9,下面在渲染vnode
時會用這個 shapeFlag
來對子節點進行判斷
渲染 vnode
可以看到,如果 vnode
是空執行的是銷燬組件邏輯,否則則是使用 patch
進行創建或更新。
// runtime-core/src/renderer.ts
function baseCreateRenderer(options) {
// ...
const render = (vnode, container) => {
// vnode 是空,銷燬組件
if (vnode === null) {
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
// 創建 or 更新
} else {
// 第一個參數是之前緩存的舊節點,第二個參數是當前新生成的新節點
patch(container._vnode || null, vnode, container)
}
flushPostFlushCbs()
container._vnode = vnode
}
// ...
return {
render,
createApp: createAppAPI(render)
}
}
patch 方法
從上面可以看到,在最開始首次渲染時組件的掛載和後續的組件更新都使用了 patch
方法,下面我們來主要看一下 patch
方法。
// runtime-core/src/renderer.ts
function baseCreateRenderer(options) {
// ...
// n1 舊節點 n2 新節點
const patch = (n1, n2, ...args) => {
// patching & not same type, unmount old tree
if (n1 && !isSameVNodeType(n1, n2)) {
// ...
}
switch (type) {
// ...
default:
// 普通 DOM 元素
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, ...args)
// 自定義的 Vue 組件
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container, ...args)
// 傳送門 TELEPORT
} else if (shapeFlag & ShapeFlags.TELEPORT) {
type.process(n1, n2, container, ...args)
// 異步組件 SUSPENSE
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
type.process(n1, n2, container, ...args)
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
}
// ...
return {
render,
createApp: createAppAPI(render)
}
因爲這篇文章着重點在於首次渲染,所以我們的關注點在於處理 DOM
元素和處理 Vue
組件的情況,也就是 processElement
和 processComponent
這兩個函數。
processElement
// runtime-core/src/renderer.ts
const processElement = (n1, n2, container, ...args) => {
// 如果舊節點是空,進行首次渲染的掛載
if (n1 == null) {
mountElement(n2, container, ...args)
// 如果不爲空,進行 diff 更新
} else {
patchElement(n1, n2, ...args)
}
}
由於我們這裏關心的是首次渲染,所以只看 mountElement
// runtime-core/src/renderer.ts
const mountElement = (vnode, container) => {
let el
let vnodeHook
const { type, props, shapeFlag, transition, scopeId, patchFlag, dirs } = vnode
// 這裏判斷 patchFlag 爲靜態節點, 不進行創建而是 clone,提升性能
// 是對靜態節點的處理,首次渲染時不會遇到,先忽略
if (
!__DEV__ &&
vnode.el && // 存在 el
hostCloneNode !== undefined && //
patchFlag === PatchFlags.HOISTED
) {
el = vnode.el = hostCloneNode(vnode.el)
} else {
el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
}
簡單分析一下,首先會先判斷當前節點 vnode
是否是已經存在的靜態節點(即沒有綁定任何動態屬性,所以也就不需要重新渲染的 vnode
節點),如果是則直接拿來複用,調用 hostCloneNode
,顯然在首次渲染的時候不會進行這個過程,所以我們這裏主要還是來看 hostCreateElement
方法。
// runtime-core/src/renderer.ts
function baseCreateRenderer(options) {
const { createElement: hostCreateElement } = options
// ...
}
這個 hostCreateElement
方法是在創建渲染器的時候傳進去的平臺相關的 rendererOptions
裏,可以很清楚的看到如果是在瀏覽器環境下最終創建元素使用的就是 DOM API
,即 `document.createElement ``
// runtime-dom/src/index.ts
const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)
// runtime-dom/src/nodeOps.ts
const doc = typeof document !== 'undefined' ? document : null
const nodeOps = {
// ...
createElement: (tag, isSvg, is) =>
isSvg
? doc.createElementNs(svgNS, tag)
: doc.createElement(tag, is ? { is } : undefined)
// ...
}
// runtime-core/src/renderer.ts
const {
setElementText: hostSetElementText
} = options
const mountElement = (vnode, container) => {
// ...
// 如果子節點是文本節點 執行 hostSetElementText 實際上就是 setElementText,直接填入文本
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, vnode.children as string)
} else if (shapeFlag & ShapeFlag``s.ARRAY_CHILDREN) {
mountChildren(
vnode.children as VNodeArrayChildren,
el,
null,
parentComponent,
parentSuspense,
isSVG && type !== 'foreignObject',
optimized || !!vnode.dynamicChildren
)
}
// ...
}
通過之前創建 vnode
時 normalizeChildren
方法,可以通過 shapeFlag
判斷子節點的類型,如果 children
是一個文本節點,執行 hostSetElementText
,在瀏覽器環境下實際上就是 setElementText
,直接填入文本
// runtime-dom/src/nodeOps.ts
const doc = typeof document !== 'undefined' ? document : null
const nodeOps = {
// ...
setElementText: (el, text) => {
el.textContent = text
},
// ...
}
如果 children
是一個數組,會調用 mountChildren
,實際上就是對 children
數組進行深度優先遍歷,遞歸的調用 patch
方法依次將子節點 child
掛載到父節點上
// runtime-core/src/renderer.ts
const mountChildren = (children, container, ...args) => {
for (let i = start; i < children.length; i++) {
const child = (children[i] = optimized
? cloneIfMounted(children[i])
: normalizeVNode(children[i]))
patch(null, child, container, ...args)
}
}
接着往下執行,對 props
進行處理,因爲我們當前是瀏覽器環境,所以對 props
的處理主要是 class
、style
、和一些綁定的事件,現在我們不難猜到,如果是處理 class
,最後也是使用原生的 DOM
操作 el.setAttribute('class', value)
// runtime-core/src/renderer.ts
const mountElement = (vnode, container) => {
// ...
if (props) {
for (const key in props) {
if (!isReservedProp(key)) {
hostPatchProp(el, key, ...args)
}
}
}
// ...
}
// runtime-dom/src/patchProp.ts
const patchProp = (el, key) => {
switch(key) {
case 'class':
patchClass(el, nextValue)
break;
case 'style':
patchStyle(el, preValue, nextValue)
default:
if (isOn(key)) {
patchEvent(el, key, prevValue, nextValue, parentComponent)
}
// ...
}
}
經過漫長的操作,我們終於是把當前的 DOM
節點通過 vnode
創建出來了,現在就是要真正的將這個 DOM
節點真正的掛載到 container
上,這裏最終執行的依然是原生的 DOM API
el.insertBefore
// runtime-core/src/renderer.ts
const mountElement = (vnode, container) => {
// ...
hostInsert(el, container)
// ...
}
// runtime-dom/src/index.ts
const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)
// runtime-dom/src/nodeOps.ts
const nodeOps = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
// ..
}
好了!以上是對一個 element
類型的 vnode
節點首次渲染掛載的全部流程!萬里長征還有最後一點,接下來我們慢慢看對於 component
類型的 vnode
節點又有什麼不一樣
processComponent
在沒有看之前,我們不妨先先想一下,element
類型和 component
類型有什麼不一樣。我們編寫的一個個 component
,在 HTML
中是不存在這個標籤的,我們根據相關需求,進行了一層抽象,本質上渲染到頁面上的其實是你書寫的模板。所以這也就意味着我們拿到了一個 component
類型的 vnode
,並不能和 element
類型的 vnode
一樣去直接使用原生 DOM API
進行渲染掛載。有了這點印象之後我們再去看組件 vnode
的渲染流程。因爲我們關心的是首次渲染的流程,所以先不去看 update
的邏輯,n1 === null
的第一個分支是 keep-alive
相關,我們也先不去管。最終的重點關注對象是 mountComponent
方法
// runtime-core/src/renderer.ts
// n1 舊節點,n2 新節點
const processComponent = (n1, n2, container, ...args) => {
if (n1 === null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// ...
} else {
mountComponent(n2, container, ...args)
}
} else {
updateComponent(n1, n2)
}
}
剛纔上面說到,組件是一層抽象,他實際上不存在和 HTML
元素一樣的父子、兄弟節點的關係,所以 vue
需要去爲每一個組件創建一個實例,並去構建組件間的關係。那麼拿到一個 component vnode
掛載時,首先就是要創建當前組件的實例。通過 createComponentInstance
方法創建了當前渲染的組件實例,instance
對象中有很多屬性,後面用到的時候會慢慢介紹。在這裏着重瞭解每個組件會創建一個實例即可。
// runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
const instance = initialVNode.component = createComponentInstance(
initialVNode,
parentComponent
)
// ...
}
// runtime-core/src/component.ts
let uid = 0
function createComponentInstance(vnode, parent) {
const type = vnode.type
const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext
const instance = {
uid: uid++,
vnode,
type,
parent,
appContext,
root: null!,
next: null,
subTree: null!,
update: null!,
render: null,
proxy: null,
// ...
// 生命週期相關也是在這裏定義的
bc: null,
c: null,
bm: null,
m: null
// ...
}
instance.root = parent ? parent.root : instance
return instance
}
創建完組件的實例後,繼續往下執行會對剛纔創建好的實例進行設置,調用 setupComponent
方法後會對如 props
、slots
、生命週期函數等進行初始化
// runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
// ...
setupComponent(instance)
// ...
}
// runtime-core/src/component.ts
function setupComponent(instance) {
const { props, children, shapeFlag } = instance.vnode
const isStateful = shapeFlag & ShapeFlags.STATEFUL_COMPONENT
initProps(instance, props, isStateful)
initSlots(instance, children)
setupStatefulComponent(instance)
}
創建好了組件實例,並對組件實例的各個屬性進行了初始化後,是運行副作用 setupRenderEffect
函數,我們知道 vue3
的一大特色是新增了 Composition API
,並且 Vue3.0
中將所有的響應式部分都獨立到了 packages/reactivity
中。觀察這個函數名稱,可以猜到是要建立渲染的副作用函數,首先他是在組件實例的 update
上調用響應式部分的 effect
函數,當數據發生變化時,響應式函數 effect
會自動的去執行內部包裹的 componentEffect
方法,也就是去自動的重新 render
,重新渲染。
// runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
// ...
setupRenderEffect(instance, initialVNode, container, ...args )
}
import { effect, stop, ReactiveEffectOptions, isRef } from '@vue/reactivity'
const setupRenderEffect = (instance, initialVNode, container, ...args) => {
// create reactive effect for rendering
instance.update = effect(function componentEffect() {
// ...
})
}
進入到 componentEffect
函數內部去看一下,首先我們關注第一個 if
分支,即首次渲染的時候,else
分支表示的是更新組件暫時先不去管他。這裏的 initialVNode
其實就是之前的 vue
組件的 vnode
,這裏叫 initialVNode
是爲了區別抽象與現實,因爲我們最終渲染的還是組件內真實的 DOM
的。渲染組件 vnode
和渲染 DOM vnode
不一樣的地方在於,DOM vnode
可以直接渲染,但是組件 vnode
有定義的一系列方法,比如生命週期函數,所以在開頭從 instance
內取出了 bm
(beforeMount
)、m
(mount
) 生命週期函數調用。下面重點部分要來了,生成的 subTree
纔是最終組件模板裏的真實 DOM vnode
。
// runtime-core/src/renderer.ts
function componentEffect() {
if (!instance.isMounted) {
// create component
let vnodeHook
const { el, props } = initialVNode
// 取出 mount 相關的生命週期函數
const { bm, m, parent } = instance
if (bm) {
invokeArrayFns(bm)
}
// ...
const subTree = (instance.subTree = renderComponentRoot(instance))
} else {
// update component
// ...
}
}
通過官網可以瞭解到,所有的 template
模板最終都會被編譯成渲染函數。
而 renderComponentRoot
所做的工作就是去執行編譯後的渲染函數,最終得到的 subTree
。以下面這個組件來舉例,initialVNode
是 Foo
組件生成的組件 vnode
,subTree
是 Foo
組件內部的 DOM
節點嵌套的 DOM vnode
subTree
已經是一個 DOM vnode
了,所以在接下來 patch
的時候直接將 subTree
掛載到 container
即可,之後流程就由上面的 processElement
進行接管。
// runtime-core/src/renderer.ts
function componentEffect() {
// ...
patch(null, subTree, container, ...args)
// ...
}
在創建 vnode
和渲染 vnode
這部分主要的流程在這裏,初次看的話可能記不太住,在這裏做了各個主要函數使用的流程圖,大家可以對照着多看幾次
最後
恭喜🎉你成功看完了 vue3 的首次渲染流程,給自己呱唧呱唧~後面會陸續分析關於 vue 工作的種種流程~希望你可以點個關注~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/0svgn5mSLFy9bFE3-OtQVw