淺談vue3的編譯優化
-
編譯優化:編譯器將模版編譯為渲染函式的過程中,儘可能地提取關鍵資訊,並以此指導生成最優程式碼的過程。
-
優化的方向:儘可能地區分動態內容和靜態內容,並針對不同的內容採用不同的優化策略
1.動態節點收集與補丁標誌
1.1 傳統diff演算法的問題
比對新舊兩棵虛擬DOM樹的時候,總是要按照虛擬DOM的層級結構“一層一層”地遍歷
``` js
```
上面這段程式碼中,當響應式資料text值發生變化的時候,最高效的更新方式是直接設定p標籤的文字內容
傳統Diff演算法做不到如此高效,當text值發生變化的時候,會產生一顆新的虛擬DOM樹,對比新舊虛擬DOM過程如下: - 對比div節點,以及該節點的屬性和子節點 - 對比p節點,以及該節點的屬性和子節點 - 對比p節點的文字子節點,如果文字子節點的內容變了,則更新,否則什麼都不做
可以發現,有很多無意義的對比操作。
總結:
- 傳統diff演算法的問題: 無法利用編譯時提取到的任何關鍵資訊,導致渲染器在執行時不會去做相關的優化。
- vue3的編譯器會將編譯得到的關鍵資訊“附著”在它生成的虛擬DOM上,傳遞給渲染器,執行“快捷路徑”。
1.2 Block 與 PatchFlags
傳統Diff演算法無法避免新舊虛擬DOM樹間無用的比較操作,是因為執行時得不到足夠的關鍵資訊,從而無法區分動態內容和靜態內容。 換句話說,只要執行時能夠區分動態內容和靜態內容,就可以實現極簡的優化策略
舉個例子: ``` js
{{ bar }}
```
只有 {{ bar }}是動態的內容。理想情況下,當資料bar的值變化時,只需要更新p標籤的文字節點即可。為了實現這個目標,需要提供資訊給執行時
js
// 傳統虛擬DOM描述
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar },
]
}
js
// 編譯優化後
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 }, // 這是動態節點
]
}
可以發現,虛擬節點多了一個額外的屬性,即 patchFlag(補丁標誌),存在該屬性,就認為是動態節點
patchFlag(補丁標誌)可以理解為一系列的數字標記,含義如下
js
const PatchFlags = {
TEXT: 1, // 代表節點有動態的 textContent
CLASS: 2, // 代表元素有動態的 class 繫結
STYLE: 3
// 其他。。。
}
可以在虛擬節點的建立階段,把它的動態子節點提取出來,並存儲到該虛擬節點的 dynamicChildren 陣列中
js
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 }, // 這是動態節點
],
// 將children 中的動態節點提取到 dynamicChildren 陣列中
dynamicChildren: [
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
}
-
Block定義: 帶有 dynamicChildren 屬性的虛擬節點稱為“塊” ,即(Block)
-
一個Block本質上也是一個虛擬DOM, 比普通的虛擬節點多處一個用來儲存動態節點的 dynamicChildren屬性。(能夠收集所有的動態子代節點)
渲染器的更新操作會以Block為維度。當渲染器在更新一個Block時,會忽略虛擬節點的children陣列,直接找到dynamicChildren陣列,並只更新該陣列中的動態節點。跳過了靜態內容,只更新動態內容。同時,由於存在對應的補丁標誌,也能夠做到靶向更新。
Block節點有哪些: 模版根節點、 帶有v-for、v-if/v-else-if/v-else等指令的節點
1.3 收集動態節點
編譯器生成的渲染函式程式碼中, 不會直接包含用來描述虛擬節點的資料結構,而是包含著用來建立虛擬DOM節點的輔助函式,如下
``` js render() { return createVNode('div', { id: 'foo' }, [ createVNode('p', null, 'text') ]) }
function createVNode(tag, props, children) { const key = props && props.key props && delete props.key
// 省略部分程式碼
return { tag, props, children, key } } ```
createVNode的返回值是一個虛擬DOM節點
舉個例子: ``` js
```
上面模版生成帶有補丁標誌的渲染函式如下:
js
render() {
return createVNode('div', { id: 'foo' }, [
createVNode('p', { class: 'bar' }, text, PatchFlags.TEXT)
])
}
怎麼將根節點變成一個Block, 如何將動態子代節點收集到該Block的dynamicChildren陣列中?
可以發現,在渲染函式內,對createVNode函式的呼叫是層層巢狀結構,執行順序是 內層先執行,外層再執行
,
當外層createVNode函式執行時,內層的createVNode函式已經執行完畢了。因此,為了讓外層Block節點能夠收集到內層動態節點,需要一個棧結構
的資料來臨時儲存內層的動態節點。 程式碼實現如下:
js
// 動態節點
const dynamicChildrenStack = []
// 當前動態節點集合
let currentDynamicChildren = null
// openBlock 用來建立一個新的動態節點集合,並將該集合壓入棧中
function openBlock() {
dynamicChildrenStack.push((currentDynamicChildren = []))
}
// closeBlock 用來通過openBlock建立的動態節點集合從棧中彈出
function closeBlock() {
currentDynamicChildren = dynamicChildrenStack.pop()
}
然後調整createVNode函式
``` js function createVNode(tag, props, children, flags) { const key = props && props.key props && delete props.key
const vnode = { tag, props, children, key }
if (typeof flags !== 'undefined' && currentDynamicChildren) { // 動態節點新增到當前動態集合節點中 currentDynamicChildren.push(vnode) }
return vnode } ```
接著調整
``` js render() { // 1. 使用 createBlock 代替 createVNode 來建立 block // 2. 每當呼叫 createBlock 之前, 先呼叫 openBlock return (openBlock(), createBlock('div', null, [ createVNode('p', { class: 'foo' }, null, 1), createVNode('p', { class: 'bar' }, null), ])) // 利用逗號運算子的性質來保證渲染函式的返回值仍然是VNode物件 }
function createBlock(tag, props, children) { // block 本質上也是一個 vnode const block = createVNode(tag, props, children) // 將當前動態節點集合作為 block.dynamicChildren block.dynamicChildren = currentDynamicChildren
closeBlock() return block } ```
1.4.渲染器的執行時支援
傳統的節點更新方式如下: ``` js function patchElement(n1, n2) { const el = n2.el = n1.el const oldProps = n1.props const newProps = n2.props
for (const key in newProps) { if (newProps[key] !== oldProps[key]) { patchProps(el, key, oldProps[key], newProps[key]) } }
for (const key in oldProps) { if (!(key in newProps)) { patchProps(el, key, oldProps[key], null) } }
// 在處理 children 時,呼叫 patchChildren 函式 patchChildren(n1, n2, el) } ```
優化後的更新方式,直接對比動態節點
``` js function patchElement(n1, n2) { const el = n2.el = n1.el const oldProps = n1.props const newProps = n2.props
// 省略部分程式碼
if (n2.dynamicChildren) { // 呼叫 patchBlockChildren 函式,只更新動態節點 patchBlockChildren(n1, n2) } else { patchChildren(n1, n2, el) } }
function patchBlockChildren(n1, n2) { // 只更新動態節點 for(let i=0; i<n2.dynamicChildren.length; i++) { patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i]) } } ```
存在對應的補丁標誌,可以針對性地完成靶向更新
``` js function patchElement(n1, n2) { const el = n2.el = n1.el const oldProps = n1.props const newProps = n2.props
if (n2.patchFlags) { // 靶向更新 if (n2.patchFlags === 1) { // 只需要更新class } else if (n2.patchFlags === 2) { // 只需要更新style } else if (...) { // ... } } else { // 全量更新 for (const key in newProps) { if (newProps[key] !== oldProps[key]) { patchProps(el, key, oldProps[key], newProps[key]) } }
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
}
// 在處理 children 時,呼叫 patchChildren 函式 patchChildren(n1, n2, el) } ```
2. Block樹
除了模版的根節點是Block外, 帶有結構化指令的節點,如:v-if、v-for,也都應該是Block
2.1 帶有v-if指令的節點
``` js
{{ a }}
{{ a }}
```
假設只有最外層的div標籤會作為Block, 那麼變數foo的值為true還是false, block收集到的動態節點都是一樣的,如下:
js
const block = {
tag: 'div',
dynamicChildren: [
{
tag: 'p', children: ctx.a, patchFlags: 1 }
}
]
}
這意味著,在Diff階段不會更新。顯然,foo 不同值下,一個是 section, 一個是 div, 是不同標籤,是需要更新的。
再舉個例子: ``` js
{{ a }}
{{ a }}
```
一樣會導致更新失敗
問題在於:dynamicChildren收集的動態節點是忽略虛擬DOM樹層級的,結構化指令會導致更新前後模版的結構發生變化,即模版結構不穩定
解決方法: 讓帶有v-if/v-else-if/v-else等結構化指令的節點也作為Block即可
,如下所示
js
Block(Div)
- Block(Section v-if)
- Block(Section v-else)
js
const block = {
tag: 'div',
dynamicChildren: [
{ tag: 'section', { key: 0 /* 不同的block, key不同 */ },dynamicChildren:[...]},
]
}
在Diff過程中, 渲染器根據key值區分, 使用新的 Block 替換舊的 Block
2.2 帶有 v-for 指令的節點
帶有 v-for 指令的節點也會讓虛擬DOM樹變得不穩定
例子: ``` js
{{ item }}
{{ foo }} {{ bar }}```
list 的值 由 [1, 2] 變成 [1]
更新前後對應的 Block 樹如下:
``` js // 更新前 const prevBlock = { tag: 'div', dynamicChildren: [ { tag: 'p', children: 1, 1 / TEXT / }, { tag: 'p', children: 2, 1 / TEXT / }, { tag: 'i', children: ctx.foo, 1 / TEXT / }, { tag: 'i', children: ctx.bar, 1 / TEXT / }, ] }
// 更新後 const prevBlock = { tag: 'div', dynamicChildren: [ { tag: 'p', children: 1, 1 / TEXT / }, { tag: 'i', children: ctx.foo, 1 / TEXT / }, { tag: 'i', children: ctx.bar, 1 / TEXT / }, ] } ```
更新前後,動態節點數量不一致,無法進行 diff 操作(diff操作的前提是:操作的節點必須是同層級節點, dynamicChildren不一定是同層級的
)
解決方法: 讓 v-for指令的標籤也作為Block角色,保證虛擬DOM樹具有穩定的結構,無論 v-for 在執行時怎樣變化。如下:
js
const block = {
tag: 'div',
dynamicChildren: [
// 這是一個 Block, 有dynamicChildren
{ tag: Fragment, dynamicChildren: [/* v-for的節點 */] },
{ tag: 'i', children: ctx.foo, 1 /* TEXT */ },
{ tag: 'i', children: ctx.bar, 1 /* TEXT */ },
]
}
由於 v-for指令渲染的是一個片段,所以型別用 Fragment
2.3 Fragment的穩定性
``` js
{{ item }}
// list 的值 由 [1, 2] 變成 [1]
// 更新前 const prevBlock = { tag: Fragment, dynamicChildren: [ { tag: 'p', children: 1, 1 / TEXT / }, { tag: 'p', children: 2, 1 / TEXT / }, ] }
// 更新後 const prevBlock = { tag: Fragment, dynamicChildren: [ { tag: 'p', children: 1, 1 / TEXT / }, ] } ```
發現 Fragment 本身收集的動態節點存在結構是不穩定的情況
結構不穩定: 指更新前後一個block的dynamicChildren陣列中收集的動態節點的數量或順序不一致
這種情況無法直接進行靶向更新
解決方法: 回退到傳統虛擬DOM的Diff手段,即直接使用Fragment的children而非 dynamicChildren來進行Diff操作
Fragment 的子節點仍然可以是由 Block 組成的陣列
js
const block = {
tag: Fragment,
children: [
{ tag: 'p', children: item, dynamicChildren: [/*...*/, 1 /* TEXT */] },
{ tag: 'p', children: item, dynamicChildren: [/*...*/, 1 /* TEXT */] },
]
}
當Fragment 的子節點更新時,就可以恢復優化模式
有穩定的Fragment嗎? 如下:
``` js // v-for指令的表示式是常量
``` 穩定的Fragment, 可以使用優化模式
vue3模版中的多個根節點,也是穩定的Fragment
<template>
<div></div>
<p></p>
<i></i>
</template>
3. 靜態提升
減少更新時建立虛擬DOM帶來的效能開銷和記憶體佔用
如:
``` js
static text
{{ title }}
```
沒有靜態提升時,渲染函式是:
js
function render(){
return (openBlock(), createBlock('div', null, {
createVNode('p', null, 'static text'),
createVNode('p', null, ctx.title, 1 /* TEXT */),
}))
}
響應式資料 title 變化後,整個渲染函式會重新執行
把純靜態的節點提升到渲染函式之外 ``` js const hoist1 = createVNode('p', null, 'static text')
function render(){ return (openBlock(), createBlock('div', null, { hoist1, createVNode('p', null, ctx.title, 1 / TEXT /), })) } ```
響應式資料 title 變化後,不會重新建立靜態的虛擬節點
注: 靜態提升是以樹為單位的
包含動態繫結的節點本身不會被提升,但是該節點上的靜態屬性是可以被提升的 ``` js
{{ text }}
// 靜態提升的props物件 const hoistprop = { foo: 'bar', a: 'b'}
function render(ctx) { return (openBlock(), createBlock('div', null, [ createVNode('p', hoistprop, ctx.text) ])) } ```
可以減少建立虛擬DOM產生的開銷以及記憶體佔用
4. 預字串化
基於靜態提升,進一步採用預字串化優化。
``` js
```
採用靜態提升優化策略後
``` js const hoist1 = createVNode('p', null, null, PatchFlags.HOISTED) const hoist2 = createVNode('p', null, null, PatchFlags.HOISTED) // ...20個 hoistx 變數 const hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)
render(){ return (openBlock(), createBlock('div', null, [ hoist1, hoist2, / ...20個變數 /, hoist20 ])) } ```
採用預字串化將這些靜態節點序列化為字串, 並生成一個Static型別的VNode
``` js const hoistStatic = createStatticVNode('
...20個...')render() { return (openBlock(), createBlock('div', null, [hoistStatic])) } ```
優勢: - 大塊的靜態內容可以通過 innerHTML設定, 在效能上有一定優勢 - 減少建立虛擬節點產生的效能開銷 - 減少記憶體佔用
# 5. 快取內聯事件處理函式
js
<Com @change="a+b"/>
js
function render(ctx) {
return h(Com, {
// 內聯事件處理函式
onChange: () => (ctx.a + ctx.b)
})
}
每次重新渲染時,都會為Com元件建立一個全新的props物件。同時,props物件中onChange屬性的值也會是全新的函式。造成額外的效能開銷
js
function render(ctx, cache) { // 陣列cache來自元件例項
return h(Com, {
// 將內聯事件處理函式快取到 cache中
onChange: cache[0] || cache[0] = ($event) => (ctx.a + ctx.b)
})
}
6. v-once
v-once 可以對虛擬DOM進行快取
``` js
function render(ctx, cache) { return (openBlock(), createBlock('div', null, [ cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 / TEXT /)) ])) } ```
由於節點被快取,意味著更新前後的虛擬節點不會發生變化,因此也就不需要這些被快取的虛擬節點參與Diff操作了。編譯後的結果如下:
js
render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
setBlockTracking(-1), //阻止這段 VNode 被 Block快取
cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */)),
setBlockTracking(1), // 恢復
cache[1] // 整個表示式的值
]))
}
v-once包裹的動態節點不會被父級Block收集,因此不會參與Diff操作
v-once指令通常用於不會發生改變的動態繫結中,例如繫結一個常量
``` js
```
v-once帶來的效能提升 - 1. 避免元件更新時重新建立虛擬DOM帶來的效能開銷。因為虛擬DOM被快取了, 所以更新時無需重新建立 - 2. 避免無用的Diff開銷。因為被v-once標記的虛擬DOM樹不會被父級Block節點收集
7. 總結
1. vue3提出了 Block 的概念, 利用 Block樹及補丁標誌
2. 靜態提升:可以減少更新時建立虛擬DOM產生的效能開銷和記憶體佔用
3. 預字串化: 在靜態提升的基礎上,對靜態節點進行字串化。這樣做能夠減少建立虛擬節點產生的效能開銷以及記憶體佔用
4. 快取內聯事件處理函式:避免造成不必要的元件更新
5. v-once指令: 快取全部或部分虛擬節點,能夠避免元件更新時重新建立虛擬DOM帶來的效能開銷, 也可以避免無用的Diff操作