淺談vue3的編譯優化

語言: CN / TW / HK
  • 編譯優化:編譯器將模版編譯為渲染函式的過程中,儘可能地提取關鍵資訊,並以此指導生成最優程式碼的過程。

  • 優化的方向:儘可能地區分動態內容和靜態內容,並針對不同的內容採用不同的優化策略

1.動態節點收集與補丁標誌

1.1 傳統diff演算法的問題

比對新舊兩棵虛擬DOM樹的時候,總是要按照虛擬DOM的層級結構“一層一層”地遍歷

``` js

{{ text }}

```

上面這段程式碼中,當響應式資料text值發生變化的時候,最高效的更新方式是直接設定p標籤的文字內容

傳統Diff演算法做不到如此高效,當text值發生變化的時候,會產生一顆新的虛擬DOM樹,對比新舊虛擬DOM過程如下: - 對比div節點,以及該節點的屬性和子節點 - 對比p節點,以及該節點的屬性和子節點 - 對比p節點的文字子節點,如果文字子節點的內容變了,則更新,否則什麼都不做

可以發現,有很多無意義的對比操作。

總結: - 傳統diff演算法的問題: 無法利用編譯時提取到的任何關鍵資訊,導致渲染器在執行時不會去做相關的優化。 - vue3的編譯器會將編譯得到的關鍵資訊“附著”在它生成的虛擬DOM上,傳遞給渲染器,執行“快捷路徑”。

1.2 Block 與 PatchFlags

傳統Diff演算法無法避免新舊虛擬DOM樹間無用的比較操作,是因為執行時得不到足夠的關鍵資訊,從而無法區分動態內容和靜態內容。 換句話說,只要執行時能夠區分動態內容和靜態內容,就可以實現極簡的優化策略

舉個例子: ``` js

foo

{{ 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

{{ bar }}

```

上面模版生成帶有補丁標誌的渲染函式如下: 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 }}

// 即使這裡是section
// 這個div標籤在diff過程中會被忽略

{{ 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

// ... 20個 p 標籤

```

採用靜態提升優化策略後

``` 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

{{ foo }}

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

{{ SOME_CONSTANT }}

```

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操作