Vue3 正式釋出已經有一段時間了,前段時間寫了一篇文章(《Vue 模板編譯原理》)分析 Vue 的模板編譯原理。今天的文章打算學習下 Vue3 下的模板編譯與 Vue2 下的差異,以及 VDOM 下 Diff 演算法的優化。
編譯入口
瞭解過 Vue3 的同學肯定知道 Vue3 引入了新的組合 Api,在元件 mount
階段會呼叫 setup
方法,之後會判斷 render
方法是否存在,如果不存在會呼叫 compile
方法將 template
轉化為 render
。
// packages/runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container) => {
const instance = (
initialVNode.component = createComponentInstance(
// ...params
)
)
// 呼叫 setup
setupComponent(instance)
}
// packages/runtime-core/src/component.ts
let compile
export function registerRuntimeCompiler(_compile) {
compile = _compile
}
export function setupComponent(instance) {
const Component = instance.type
const { setup } = Component
if (setup) {
// ...呼叫 setup
}
if (compile && Component.template && !Component.render) {
// 如果沒有 render 方法
// 呼叫 compile 將 template 轉為 render 方法
Component.render = compile(Component.template, {...})
}
}
複製程式碼
這部分都是 runtime-core 中的程式碼,之前的文章有講過 Vue 分為完整版和 runtime 版本。如果使用 vue-loader
處理 .vue
檔案,一般都會將 .vue
檔案中的 template
直接處理成 render
方法。
// 需要編譯器
Vue.createApp({
template: '<div>{{ hi }}</div>'
})
// 不需要
Vue.createApp({
render() {
return Vue.h('div', {}, this.hi)
}
})
複製程式碼
完整版與 runtime 版的差異就是,完整版會引入 compile
方法,如果是 vue-cli 生成的專案就會抹去這部分程式碼,將 compile 過程都放到打包的階段,以此優化效能。runtime-dom 中提供了 registerRuntimeCompiler
方法用於注入 compile
方法。
主流程
在完整版的 index.js
中,呼叫了 registerRuntimeCompiler
將 compile
進行注入,接下來我們看看注入的 compile
方法主要做了什麼。
// packages/vue/src/index.ts
import { compile } from '@vue/compiler-dom'
// 編譯快取
const compileCache = Object.create(null)
// 注入 compile 方法
function compileToFunction(
// 模板
template: string | HTMLElement,
// 編譯配置
options?: CompilerOptions
): RenderFunction {
if (!isString(template)) {
// 如果 template 不是字串
// 則認為是一個 DOM 節點,獲取 innerHTML
if (template.nodeType) {
template = template.innerHTML
} else {
return NOOP
}
}
// 如果快取中存在,直接從快取中獲取
const key = template
const cached = compileCache[key]
if (cached) {
return cached
}
// 如果是 ID 選擇器,這獲取 DOM 元素後,取 innerHTML
if (template[0] === '#') {
const el = document.querySelector(template)
template = el ? el.innerHTML : ''
}
// 呼叫 compile 獲取 render code
const { code } = compile(
template,
options
)
// 將 render code 轉化為 function
const render = new Function(code)();
// 返回 render 方法的同時,將其放入快取
return (compileCache[key] = render)
}
// 注入 compile
registerRuntimeCompiler(compileToFunction)
複製程式碼
在講 Vue2 模板編譯的時候已經講過,compile
方法主要分為三步,Vue3 的邏輯類似:
- 模板編譯,將模板程式碼轉化為 AST;
- 優化 AST,方便後續虛擬 DOM 更新;
- 生成程式碼,將 AST 轉化為可執行的程式碼;
// packages/compiler-dom/src/index.ts
import { baseCompile, baseParse } from '@vue/compiler-core'
export function compile(template, options) {
return baseCompile(template, options)
}
// packages/compiler-core/src/compile.ts
import { baseParse } from './parse'
import { transform } from './transform'
import { transformIf } from './transforms/vIf'
import { transformFor } from './transforms/vFor'
import { transformText } from './transforms/transformText'
import { transformElement } from './transforms/transformElement'
import { transformOn } from './transforms/vOn'
import { transformBind } from './transforms/vBind'
import { transformModel } from './transforms/vModel'
export function baseCompile(template, options) {
// 解析 html,轉化為 ast
const ast = baseParse(template, options)
// 優化 ast,標記靜態節點
transform(ast, {
...options,
nodeTransforms: [
transformIf,
transformFor,
transformText,
transformElement,
// ... 省略了部分 transform
],
directiveTransforms: {
on: transformOn,
bind: transformBind,
model: transformModel
}
})
// 將 ast 轉化為可執行程式碼
return generate(ast, options)
}
複製程式碼
計算 PatchFlag
這裡大致的邏輯與之前的並沒有多大的差異,主要是 optimize
方法變成了 transform
方法,而且預設會對一些模板語法進行 transform
。這些 transform
就是後續虛擬 DOM 優化的關鍵,我們先看看 transform
的程式碼 。
// packages/compiler-core/src/transform.ts
export function transform(root, options) {
const context = createTransformContext(root, options)
traverseNode(root, context)
}
export function traverseNode(node, context) {
context.currentNode = node
const { nodeTransforms } = context
const exitFns = []
for (let i = 0; i < nodeTransforms.length; i++) {
// Transform 會返回一個退出函式,在處理完所有的子節點後再執行
const onExit = nodeTransforms[i](node, context)
if (onExit) {
if (isArray(onExit)) {
exitFns.push(...onExit)
} else {
exitFns.push(onExit)
}
}
}
traverseChildren(node, context)
context.currentNode = node
// 執行所以 Transform 的退出函式
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
複製程式碼
我們重點看一下 transformElement
的邏輯:
// packages/compiler-core/src/transforms/transformElement.ts
export const transformElement: NodeTransform = (node, context) => {
// transformElement 沒有執行任何邏輯,而是直接返回了一個退出函式
// 說明 transformElement 需要等所有的子節點處理完後才執行
return function postTransformElement() {
const { tag, props } = node
let vnodeProps
let vnodePatchFlag
const vnodeTag = node.tagType === ElementTypes.COMPONENT
? resolveComponentType(node, context)
: `"${tag}"`
let patchFlag = 0
// 檢測節點屬性
if (props.length > 0) {
// 檢測節點屬性的動態部分
const propsBuildResult = buildProps(node, context)
vnodeProps = propsBuildResult.props
patchFlag = propsBuildResult.patchFlag
}
// 檢測子節點
if (node.children.length > 0) {
if (node.children.length === 1) {
const child = node.children[0]
// 檢測子節點是否為動態文字
if (!getStaticType(child)) {
patchFlag |= PatchFlags.TEXT
}
}
}
// 格式化 patchFlag
if (patchFlag !== 0) {
vnodePatchFlag = String(patchFlag)
}
node.codegenNode = createVNodeCall(
context,
vnodeTag,
vnodeProps,
vnodeChildren,
vnodePatchFlag
)
}
}
複製程式碼
buildProps
會對節點的屬性進行一次遍歷,由於內部原始碼涉及很多其他的細節,這裡的程式碼是經過簡化之後的,只保留了 patchFlag
相關的邏輯。
export function buildProps(
node: ElementNode,
context: TransformContext,
props: ElementNode['props'] = node.props
) {
let patchFlag = 0
for (let i = 0; i < props.length; i++) {
const prop = props[i]
const [key, name] = prop.name.split(':')
if (key === 'v-bind' || key === '') {
if (name === 'class') {
// 如果包含 :class 屬性,patchFlag | CLASS
patchFlag |= PatchFlags.CLASS
} else if (name === 'style') {
// 如果包含 :style 屬性,patchFlag | STYLE
patchFlag |= PatchFlags.STYLE
}
}
}
return {
patchFlag
}
}
複製程式碼
上面的程式碼只展示了三種 patchFlag
的型別:
- 節點只有一個文字子節點,且該文字包含動態的資料(
TEXT = 1
)
<p>name: {{name}}</p>
複製程式碼
- 節點包含可變的 class 屬性(
CLASS = 1 << 1
)
<div :class="{ active: isActive }"></div>
複製程式碼
- 節點包含可變的 style 屬性(
STYLE = 1 << 2
)
<div :style="{ color: color }"></div>
複製程式碼
可以看到 PatchFlags 都是數字 1
經過 左移操作符 計算得到的。
export const enum PatchFlags {
TEXT = 1, // 1, 二進位制 0000 0001
CLASS = 1 << 1, // 2, 二進位制 0000 0010
STYLE = 1 << 2, // 4, 二進位制 0000 0100
PROPS = 1 << 3, // 8, 二進位制 0000 1000
...
}
複製程式碼
從上面的程式碼能看出來,patchFlag
的初始值為 0,每次對 patchFlag
都是執行 |
(或)操作。如果當前節點是一個只有動態文字子節點且同時具有動態 style 屬性,最後得到的 patchFlag
為 5(二進位制:0000 0101
)。
<p :style="{ color: color }">name: {{name}}</p>
複製程式碼
patchFlag = 0
patchFlag |= PatchFlags.STYLE
patchFlag |= PatchFlags.TEXT
// 或運算:兩個對應的二進位制位中只要一個是1,結果對應位就是1。
// 0000 0001
// 0000 0100
// ------------
// 0000 0101 => 十進位制 5
複製程式碼
我們將上面的程式碼放到 Vue3 中執行:
const app = Vue.createApp({
data() {
return {
color: 'red',
name: 'shenfq'
}
},
template: `<div>
<p :style="{ color: color }">name: {{name}}</p>
</div>`
})
app.mount('#app')
複製程式碼
最後生成的 render
方法如下,和我們之前的描述基本一致。
render 優化
Vue3 在虛擬 DOM Diff 時,會取出 patchFlag
和需要進行的 diff 型別進行 &
(與)操作,如果結果為 true 才進入對應的 diff。
還是拿之前的模板舉例:
<p :style="{ color: color }">name: {{name}}</p>
複製程式碼
如果此時的 name 發生了修改,p 節點進入了 diff 階段,此時會將判斷 patchFlag & PatchFlags.TEXT
,這個時候結果為真,表明 p 節點存在文字修改的情況。
patchFlag = 5
patchFlag & PatchFlags.TEXT
// 或運算:只有對應的兩個二進位都為1時,結果位才為1。
// 0000 0101
// 0000 0001
// ------------
// 0000 0001 => 十進位制 1
複製程式碼
if (patchFlag & PatchFlags.TEXT) {
if (oldNode.children !== newNode.children) {
// 修改文字
hostSetElementText(el, newNode.children)
}
}
複製程式碼
但是進行 patchFlag & PatchFlags.CLASS
判斷時,由於節點並沒有動態 Class,返回值為 0,所以就不會對該節點的 class 屬性進行 diff,以此來優化效能。
patchFlag = 5
patchFlag & PatchFlags.CLASS
// 或運算:只有對應的兩個二進位都為1時,結果位才為1。
// 0000 0101
// 0000 0010
// ------------
// 0000 0000 => 十進位制 0
複製程式碼
總結
其實 Vue3 相關的效能優化有很多,這裡只單獨將 patchFlag 的十分之一的內容拿出來講了,Vue3 還沒正式釋出的時候就有看到說 Diff 過程會通過 patchFlag 來進行效能優化,所以打算看看他的優化邏輯,總的來說還是有所收穫。