天天看點

Vue3 Compiler 優化細節,如何手寫高性能渲染函數

Vue3 Compiler 優化細節,如何手寫高性能渲染函數

vue3 的 Compiler 與 runtime 緊密合作,充分利用編譯時資訊,使得性能得到了極大的提升。

本文的目的告訴你 vue3 的 Compiler 到底做了哪些優化,以及一些你可能希望知道的優化細節,在這個基礎上我們試着總結出一套手寫優化模式的高性能渲染函數的方法,這些知識也可以用于實作一個 Vue3 的 jsx babel 插件中,讓 jsx 也能享受優化模式的運作時收益,這裡需要澄清的是,即使在非優化模式下,理論上 Vue3 的 Diff 性能也是要優于 Vue2 的。

另外本文不包括 SSR 相關優化,希望在下篇文章總結。

篇幅較大,花費了很大的精力整理,對于對 Vue3 還沒有太多了解的同學閱讀起來也許會吃力,不妨先收藏,以後也許會用得到。

按照慣例 TOC:

Block Tree 和 PatchFlags

傳統 Diff 算法的問題

Block 配合 PatchFlags 做到靶向更新

節點不穩定 - Block Tree

v-if 的元素作為 Block

v-for 的元素作為 Block

不穩定的 Fragment

穩定的 Fragment

v-for 的表達式是常量

多個根元素

插槽出口

<template v-for>

靜态提升

提升靜态節點樹

元素不會被提升的情況

元素帶有動态的 key 綁定

使用 ref 的元素

使用自定義指令的元素

提升靜态 PROPS

預字元串化

Cache Event handler

v-once

手寫高性能渲染函數

幾個需要記住的小點

Block Tree 是靈活的

正确地使用 PatchFlags

NEED_PATCH

該使用 Block 的地方必須用

分支判斷使用 Block

清單使用 Block

使用動态 key 的元素應該是 Block

使用 Slot hint

為元件正确地使用 DYNAMIC_SLOTS

使用 $stable hint

Block Tree 和 PatchFlags

Block Tree 和 PatchFlags 是 Vue3 充分利用編譯資訊并在 Diff 階段所做的優化。尤大已經不止一次在公開場合聊過思路,我們深入細節的目的是為了更好的了解,并試圖手寫出高性能的 VNode。

傳統 Diff 算法的問題

“傳統 vdom”的 Diff 算法總歸要按照 vdom 樹的層級結構一層一層的周遊(如果你對各種傳統 diff 算法不了解,可以看我之前寫《渲染器》這套文章,裡面總結了三種傳統 Diff 方式),舉個例子如下模闆所示:

<div>
    <p class="foo">bar</p>
</div>      

對于傳統 diff 算法來說,它在 diff 這段 vnode(模闆編譯後的 vnode)時會經曆:

Div 标簽的屬性 + children

<p> 标簽的屬性(class) + children

文本節點:bar

但是很明顯,這明明就是一段靜态 vdom,它在元件更新階段是不可能發生變化的。如果能在 diff 階段跳過靜态内容,那就會避免無用的 vdom 樹的周遊和比對,這應該就是最早的優化思路來源----跳過靜态内容,隻對比動态内容。

Block 配合 PatchFlags 做到靶向更新

咱們先說 Block 再聊 Block Tree。現在思路有了,我們隻希望對比非靜态的内容,例如:

<div>
    <p>foo</p>
    <p>{{ bar }}</p>
</div>      

在這段模闆中,隻有 <p>{{ bar }}</p> 中的文本節點是動态的,是以隻需要靶向更新該文本節點即可,這在包含大量靜态内容而隻有少量動态内容的場景下,性能優勢尤其明顯。

可問題是怎麼做呢?我們需要拿到整顆 vdom 樹中動态節點的能力,其實可能沒有大家想像的複雜,來看下這段模闆對應的傳統 vdom 樹大概長什麼樣:

const vnode = {
    tag: 'div',
    children: [
        { tag: 'p', children: 'foo' },
        { tag: 'p', children: ctx.bar },  // 這是動态節點
    ]
}      

在傳統的 vdom 樹中,我們在運作時得不到任何有用資訊,但是 Vue3 的 compiler 能夠分析模闆并提取有用資訊,最終展現在 vdom 樹上。

例如它能夠清楚的知道:哪些節點是動态節點,以及為什麼它是動态的(是綁定了動态的 class?還是綁定了動态的 style?亦或是其它動态的屬性?),總之編譯器能夠提取我們想要的資訊,有了這些資訊我們就可以在建立 vnode 的過程中為動态的節點打上标記:也就是傳說中的 PatchFlags。

我們可以把 PatchFlags 簡單的了解為一個數字标記,把這些數字賦予不同含義,例如:

數字 1:代表節點有動态的 textContent(例如上面模闆中的 p 标簽)

數字 2:代表元素有動态的 class 綁定

數字 3:代表xxxxx

總之我們可以預設這些含義,最後展現在 vnode 上:

const vnode = {
    tag: 'div',
    children: [
        { tag: 'p', children: 'foo' },
        { tag: 'p', children: ctx.bar, patchFlag: 1 /* 動态的 textContent */ },
    ]
}      

有了這個資訊,我們就可以在 vnode 的建立階段把動态節點提取出來,什麼樣的節點是動态節點呢?帶有 patchFlag 的節點就是動态節點,我們将它提取出來放到一個數組中存着,例如:

const vnode = {
    tag: 'div',
    children: [
        { tag: 'p', children: 'foo' },
        { tag: 'p', children: ctx.bar, patchFlag: 1 /* 動态的 textContent */ },
    ],
    dynamicChildren: [
        { tag: 'p', children: ctx.bar, patchFlag: 1 /* 動态的 textContent */ },
    ]
}      

dynamicChildren 就是我們用來存儲一個節點下所有子代動态節點的數組,注意這裡的用詞哦:“子代”,例如:

const vnode = {
    tag: 'div',
    children: [
        { tag: 'section', children: [
            { tag: 'p', children: ctx.bar, patchFlag: 1 /* 動态的 textContent */ },
        ]},
    ],
    dynamicChildren: [
        { tag: 'p', children: ctx.bar, patchFlag: 1 /* 動态的 textContent */ },
    ]
}      

如上 vnode 所示,div 節點不僅能收集直接動态子節點,它還能收集所有子代節點中的動态節點。

為什麼 div 節點這麼厲害呢?因為它擁有一個特殊的角色:Block,沒錯這個 div 節點就是傳說中的 Block。

一個 Block 其實就是一個 VNode,隻不過它有特殊的屬性(其中之一就是 dynamicChildren)。

現在我們已經拿到了所有的動态節點,它們存儲在 dynamicChildren 中,是以在 diff 過程中就可以避免按照 vdom 樹一層一層的周遊,而是直接找到 dynamicChildren 進行更新。

除了跳過無用的層級周遊之外,由于我們早早的就為 vnode 打上了 patchFlag,是以在更新 dynamicChildren 中的節點時,可以準确的知道需要為該節點應用哪些更新動作,這基本上就實作了靶向更新。

節點不穩定 - Block Tree

一個 Block 怎麼也構不成 Block Tree,這就意味着在一顆 vdom 樹中,會有多個 vnode 節點充當 Block 的角色,進而構成一顆 Block Tree。

那麼什麼情況下一個 vnode 節點會充當 block 的角色呢?

來看下面這段模闆:

<div>
  <section v-if="foo">
    <p>{{ a }}</p>
  </section>
  <div v-else>
    <p>{{ a }}</p>
  </div>
</div>      

假設隻要最外層的 div 标簽是 Block 角色,那麼當 foo 為真時,block 收集到的動态節點為:

cosnt block = {
    tag: 'div',
    dynamicChildren: [
        { tag: 'p', children: ctx.a, patchFlag: 1 }
    ]
}      

當 foo 為假時,block 的内容如下:

cosnt block = {
    tag: 'div',
    dynamicChildren: [
        { tag: 'p', children: ctx.a, patchFlag: 1 }
    ]
}      

可以發現無論 foo 為真還是假,block 的内容是不變的,這就意味什麼在 diff 階段不會做任何更新,但是我們也看到了:v-if 的是一個 <section> 标簽,v-else 的是一個 <div> 标簽,是以這裡就出問題了。

實際上問題的本質在于 dynamicChildren 的 diff 是忽略 vdom 樹層級的,如下模闆也有同樣的問題:

<div>
  <section v-if="foo">
    <p>{{ a }}</p>
  </section>
  <section v-else> <!-- 即使這裡是 section -->
       <div> <!-- 這個 div 标簽在 diff 過程中被忽略 -->
            <p>{{ a }}</p>
        </div>
  </section >
</div>      

即使 v-else 的也是一個 <section> 标簽,但由于前後 DOM 樹的不穩定,也會導緻問題。這時我們就思考,如何讓 DOM 樹的結構變穩定呢?

v-if 的元素作為 Block

如果讓使用了 v-if/v-else-if/v-else 等指令的元素也作為 Block 會怎麼樣呢?我們拿如下模闆為例:

<div>
  <section v-if="foo">
    <p>{{ a }}</p>
  </section>
  <section v-else> <!-- 即使這裡是 section -->
       <div> <!-- 這個 div 标簽在 diff 過程中被忽略 -->
            <p>{{ a }}</p>
        </div>
  </section >
</div>      

如果我們讓這兩個 section 标簽都作為 block,那麼将構成一顆 block tree:

Block(Div)
    - Block(Section v-if)
    - Block(Section v-else)      

父級 Block 除了會收集子代動态節點之外,也會收集子 Block,是以兩個 Block(section) 将作為 Block(div) 的 dynamicChildren:

cosnt block = {
    tag: 'div',
    dynamicChildren: [
        { tag: 'section', { key: 0 }, dynamicChildren: [...]}, /* Block(Section v-if) */
        { tag: 'section', { key: 1 }, dynamicChildren: [...]}  /* Block(Section v-else) */
    ]
}      

這樣當 v-if 條件為真時,dynamicChildren 中包含的是 Block(section v-if),當條件為假時 dynamicChildren 中包含的是 Block(section v-else),在 Diff 過程中,渲染器知道這是兩個不同的 Block,是以會做完全的替換,這樣就解決了 DOM 結構不穩定引起的問題。而這就是 Block Tree。

v-for 的元素作為 Block

不僅 v-if 會讓 DOM 結構不穩定,v-for 也會,但是 v-for 的情況稍微複雜一些。思考如下模闆:

<div>
    <p v-for="item in list">{{ item }}</p>
    <i>{{ foo }}</i>
    <i>{{ bar }}</i>
</div>      

假設 list 值由 [1 ,2] 變為 [1],按照之前的思路,最外層的 <div> 标簽作為一個 Block,那麼它更新前後對應的 Block Tree 應該是:

// 前
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 nextBlock = {
    tag: 'div',
    dynamicChildren: [
        { tag: 'p', children: item, 1 /* TEXT */ },
        { tag: 'i', children: ctx.foo, 1 /* TEXT */ },
        { tag: 'i', children: ctx.bar, 1 /* TEXT */ },
    ]
}      

prevBlcok 中有四個動态節點,nextBlock 中有三個動态節點。這時候要如何進行 Diff?有的同學可能會說拿 dynamicChildren 進行傳統 Diff,這是不對的,因為傳統 Diff 的一個前置條件是同層級節點間的 Diff,但是 dynamicChildren 内的節點未必是同層級的,這一點我們之前就提到過。

實際上我們隻需要讓 v-for 的元素也作為一個 Block 就可以了。這樣無論 v-for 怎麼變化,它始終都是一個 Block,這保證了結構穩定,無論 v-for 怎麼變化,這顆 Block Tree 看上去都是:

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 */ },
    ]
}      

不穩定的 Fragment

剛剛我們使用一個 Fragment 并讓它充當 Block 的角色解決了 v-for 元素所在層級的結構穩定,但我們來看一下這個 Fragment 本身:

{ tag: Fragment, dynamicChildren: [/*.. v-for 的節點 ..*/] }      

對于如下這樣的模闆:

<p v-for="item in list">{{ item }}</p>      

在 list 由 [1, 2] 變成 [1] 的前後,Fragment 這個 Block 看上去應該是:

// 前
const prevBlock = {
    tag: Fragment,
    dynamicChildren: [
        { tag: 'p', children: item, 1 /* TEXT */ },
        { tag: 'p', children: item, 2 /* TEXT */ }
    ]
}


// 後
const prevBlock = {
    tag: Fragment,
    dynamicChildren: [
        { tag: 'p', children: item, 1 /* TEXT */ }
    ]
}      

我們發現,Fragment 這個 Block 仍然面臨結構不穩定的情況,所謂結構不穩定從結果上看指的是更新前後一個 block 的 dynamicChildren 中收集的動态節點數量或順序的不一緻。

這種不一緻會導緻我們沒有辦法直接進行靶向 Diff,怎麼辦呢?其實對于這種情況是沒有辦法的,我們隻能抛棄 dynamicChildren 的 Diff,并回退到傳統 Diff:即 Diff Fragment 的 children 而非 dynamicChildren。

但需要注意的是 Fragment 的子節點(children)仍然可以是 Block:

const block = {
    tag: Fragment,
    children: [
        { tag: 'p', children: item, dynamicChildren: [/*...*/], 1 /* TEXT */ },
        { tag: 'p', children: item, dynamicChildren: [/*...*/], 1 /* TEXT */ }
    ]
}      

這樣,對于 <p> 标簽及其子代節點的 Diff 将恢複 Block Tree 的 Diff 模式。

穩定的 Fragment

既然有不穩定的 Fragment,那就有穩定的 Fragment,什麼樣的 Fragment 是穩定的呢?

v-for 的表達式是常量

<p v-for="n in 10"></p>
<!-- 或者 -->
<p v-for="s in 'abc'"></p>      

由于 10 和 'abc' 是常量,所有這兩個 Fragment 是不會變化的,是以它是穩定的,對于穩定的 Fragment 是不需要回退到傳統 Diff 的,這在性能上會有一定的優勢。

多個根元素

Vue3 不再限制元件的模闆必須有一個根節點,對于多個根節點的模闆,例如:

<template>
    <div></div>
    <p></p>
    <i></i>
</template>      

如上,這也是一個穩定的 Fragment,有的同學或許會想如下模闆也是穩定的 Fragment 嗎:

<template>
    <div v-if="condition"></div>
    <p></p>
    <i></i>
</template>      

這其實也是穩定的,因為帶有 v-if 指令的元素本身作為 Block 存在,是以這段模闆的 Block Tree 結構總是:

Block(Fragment)
    - Block(div v-if)
    - VNode(p)
    - VNode(i)      

對應到 VNode 應該類似于:

const block = {
    tag: Fragment,
    dynamicChildren: [
        { tag: 'div', dynamicChildren: [...] },
        { tag: 'p' },
        { tag: 'i' },
    ],
    PatchFlags.STABLE_FRAGMENT
}      

無論如何,它的結構都是穩定的。需要注意的是這裡的 PatchFlags.STABLE_FRAGMENT,該标志必須存在,否則會回退傳統 Diff 模式。

插槽出口

如下模闆所示:

<Comp>
    <p v-if="ok"></p>
    <i v-else></i>
</Comp>      

元件 <Comp> 内的 children 将作為插槽内容,在經過編譯後,應該作為 Block 角色的内容自然會是 Block,已經能夠保證結構的穩定了,例如如上代碼相當于:

render(ctx) {
    return createVNode(Comp, null, {
        default: () => ([
            ctx.ok
                // 這裡已經是 Block 了
                ? (openBlock(), createBlock('p', { key: 0 }))
                : (openBlock(), createBlock('i', { key: 1 }))
        ]),
        _: 1 // 注意這裡哦
    })
}      

既然結構已經穩定了,那麼在渲染出口處 Comp.vue:

<template>
    <slot/>
</template>      

相當于:

render() {
    return (openBlock(), createBlock(Fragment, null,
        this.$slots.default() || []
    ), PatchFlags.STABLE_FRAGMENT)
}      

這自然就是 STABLE_FRAGMENT,大家注意前面代碼中 _: 1 這是一個編譯的 slot hint,當我們手寫優化模式的渲染函數時必須要使用這個标志才能讓 runtime 知道 slot 是穩定的,否則會退出非優化模式。另外還有一個 $stable hint,在文末會講解。

<template v-for>

如下模闆所示:

<template>
    <template v-for="item in list">
        <p>{{ item.name }}</P>
        <p>{{ item.age }}</P>
    </template>
</template>      

對于帶有 v-for 的 template 元素本身來說,它是一個不穩定的 Fragment,因為 list 不是常量。

除此之外,由于 <template> 元素本身不渲染任何真實 DOM,是以如果它含有多個元素節點,那麼這些元素節點也将作為 Fragment 存在,但這個 Fragment 是穩定的,因為它不會随着 list 的變化而變化。

以上内容差不多就是 Block Tree 配合 PatchFlags 是如何做到靶向更新以及一些具體的思路細節了。

靜态提升

提升靜态節點樹

Vue3 的 Compiler 如果開啟了 hoistStatic 選項則會提升靜态節點,或靜态的屬性,這可以減少建立 VNode 的消耗,如下模闆所示:

<div>
    <p>text</p>
</div>      

在沒有被提升的情況下其渲染函數相當于:

function render() {
    return (openBlock(), createBlock('div', null, [
        createVNode('p', null, 'text')
    ]))
}      

很明顯,p 标簽是靜态的,它不會改變。

但是如上渲染函數的問題也很明顯,如果元件記憶體在動态的内容,當渲染函數重新執行時,即使 p 标簽是靜态的,那麼它對應的 VNode 也會重新建立。

當開啟靜态提升後,其渲染函數如下:

const hoist1 = createVNode('p', null, 'text')


function render() {
    return (openBlock(), createBlock('div', null, [
        hoist1
    ]))
}      

這就實作了減少 VNode 建立的性能消耗。需要了解的是,靜态提升是以樹為機關的,如下模闆所示:

<div>
  <section>
    <p>
      <span>abc</span>
    </p>
  </section >
</div>      

除了根節點的 div 作為 block 不可被提升之外,整個 <section> 元素及其子代節點都會被提升,因為他們是整棵樹都是靜态的。

如果我們把上面代碼中的 abc 換成 {{ abc }},那麼整棵樹都不會被提升。再看如下代碼:

<div>
  <section>
    {{ dynamicText }}
    <p>
      <span>abc</span>
    </p>
  </section >
</div>      

由于 section 标簽内包含動态插值,是以以 section 為根節點的子樹就不會被提升,但是 p 标簽以及其子代節點都是靜态的,是可以被提升的。

元素不會被提升的情況

元素帶有動态的 key 綁定

除了剛剛講到的元素的所有子代節點必須都是靜态的才會被提升之外還有哪些情況下會阻止提升呢?

如果一個元素有動态的 key 綁定那麼它是不會被提升的,例如:

<div :key="foo"></div>      

實際上一個元素擁有任何動态綁定都不應該被提升,那麼為什麼 key 會被單獨拿出來?實際上 key 和普通的 props 相比,它對于 VNode 的意義是不一樣的,普通的 props 如果它是動态的,那麼隻需要展現在 PatchFlags 上就可以了,例如:

<div>
    <p :foo="bar"></p>
</div>      

我們可以為 p 标簽打上 PatchFlags:

render(ctx) {
    return (openBlock(), createBlock('div', null, [
        createVNode('p', { foo: ctx }, null, PatchFlags.PROPS, ['foo'])
    ]))
}      

注意到在建立 VNode 時,為其打上了 PatchFlags.PROPS,代表這個元素需要更新 PROPS,并且需要更新的 PROPS 的名字叫 foo。

h但是 key 本身具有特殊意hi義,它是 VNode(或元素) 的唯一辨別,即使兩個元素除了 key 以外一切都相同,但這兩個元素仍然是不同的元素,對于不同的元素需要做完全的替換處理才行,而 PatchFlags 用于在同一個元素上的屬性更新檔,是以 key 是不同于其它 props 的。

正因為 key 的值是動态的可變的,是以對于擁有動态 key 的元素,它始終都應該參與到 diff 中并且不能簡單的打 PatchFlags 更新檔辨別,那應該怎麼做呢?很簡單,讓擁有動态 key 的元素也作為 Block 即可,以如下模闆為例:

<div>
    <div :key="foo"></div>
</div>      

它對應的渲染函數應該是:

render(ctx) {
    return (openBlock(), createBlock('div', null, [
        (openBlock(), createBlock('div', { key: ctx.foo }))
    ]))
}      

Tips:手寫優化模式的渲染函數時,如果使用動态的 key,記得要使用 Block 哦,我們在後文還會總結。

使用 ref 的元素

如果一個元素使用了 ref,無論是否動态綁定的值,那麼這個元素都不會被靜态提升,這是因為在每一次 patch 時都需要設定 ref 的值,如下模闆所示:

<div ref="domRef"></div>      

乍一看覺得這完全就是一個靜态元素,沒錯,元素本身不會發生變化,但由于 ref 的特性,導緻我們必須在每次 Diff 的過程中重新設定 ref 的值,為什麼呢?來看一個使用 ref 的場景:

<template>
    <div>
        <p ref="domRef"></p>
    </div>
</template>
<script>
export default {
    setup() {
        const refP1 = ref(null)
        const refP2 = ref(null)
        const useP1 = ref(true)


        return {
            domRef: useP1 ? refP1 : refP2
        }
    }
}
</script>      

如上代碼所示,p 标簽使用了一個非動态的 ref 屬性,值為字元串 domRef,同時我們注意到 setupContext(我們把 setup 函數傳回的對象叫做 setupContext) 中也包含了同名的 domRef 屬性,這不是偶然,他們之間會建立聯系,最終結果就是:

當 useP1 為真時,refP1.value 引用 p 元素

當 useP1 為假時,refP2.value 引用 p 元素

是以,即使 ref 是靜态的,但很顯然在更新的過程中由于 useP1 的變化,我們不得不更新 domRef,是以隻要一個元素使用了 ref,它就不會被靜态提升,并且這個元素對應的 VNode 也會被收集到父 Block 的 dynamicChildren 中。

但由于 p 标簽除了需要更新 ref 之外,并不需要更新其他 props,是以在真實的渲染函數中,會為它打上一個特殊的 PatchFlag,叫做:PatchFlags.NEED_PATCH:

render() {
    return (openBlock(), createBlock('div', null, [
        createVNode('p', { ref: 'domRef' }, null, PatchFlags.NEED_PATCH)
    ]))
}      

使用自定義指令的元素

實際上一個元素如果使用除 v-pre/v-cloak 之外的所有 Vue 原生提供的指令,都不會被提升,使用自定義指令也不會被提升,例如:

<p v-custom></p>      

和使用 key 一樣,會為這段模闆對應的 VNode 打上 NEED_PATCH 标志。順便講一下手寫渲染函數時如何應用自定義指令,自定義指令是一種運作時指令,與元件的生命周期類似,一個 VNode 對象也有它自己生命周期:

beforeMount

mounted

beforeUpdate

updated

beforeUnmount

unmounted

編寫一個自定義指令:

const myDir: Directive = {
  beforeMount(el, binds) {
    console.log(el)
    console.log(binds.value)
    console.log(binds.oldValue)
    console.log(binds.arg)
    console.log(binds.modifiers)
    console.log(binds.instance)
  }
}      

使用該指令:

const App = {
  setup() {
    return () => {
      return h('div', [
        // 調用 withDirectives 函數
        withDirectives(h('h1', 'hahah'), [
          // 四個參數分别是:指令、值、參數、修飾符
          [myDir, 10, 'arg', { foo: true }]
        ])
      ])
    }
  }
}      

一個元素可以綁定多個指令:

const App = {
  setup() {
    return () => {
      return h('div', [
        // 調用 withDirectives 函數
        withDirectives(h('h1', 'hahah'), [
          // 四個參數分别是:指令、值、參數、修飾符
          [myDir, 10, 'arg', { foo: true }],
          [myDir2, 10, 'arg', { foo: true }],
          [myDir3, 10, 'arg', { foo: true }]
        ])
      ])
    }
  }
}      

提升靜态 PROPS

前面說過,靜态節點的提升以樹為機關,如果一個 VNode 存在非靜态的子代節點,那麼該 VNode 就不是靜态的,也就不會被提升。

但這個 VNode 的 props 卻可能是靜态的,這使我們可以将它的 props 進行提升,這同樣可以節約 VNode 對象的建立開銷,記憶體占用等,例如:

<div>
    <p foo="bar" a=b>{{ text }}</p>
</div>      

在這段模闆中 p 标簽有動态的文本内容,是以不可以被提升,但 p 标簽的所有屬性都是靜态的,是以可以提升它的屬性,經過提升後其渲染函數如下:

const hoistProp = { foo: 'bar', a: 'b' }


render(ctx) {
    return (openBlock(), createBlock('div', null, [
        createVNode('p', hoistProp, ctx.text)
    ]))
}      

即使動态綁定的屬性值,但如果值是常量,那麼也會被提升:

<p :foo="10" :bar="'abc' + 'def'">{{ text }}</p>      

'abc' + 'def' 是常量,可以被提升。

預字元串化

靜态提升的 VNode 節點或節點樹本身是靜态的,那麼能否将其預先字元串化呢?如下模闆所示:

<div>
    <p></p>
    <p></p>
    ...20 個 p 标簽
    <p></p>
</div>      

假設如上模闆中有大量連續的靜态的 p 标簽,當采用了 hoist 優化時,結果如下:

cosnt hoist1 = createVNode('p', null, null, PatchFlags.HOISTED)
cosnt hoist2 = createVNode('p', null, null, PatchFlags.HOISTED)
... 20 個 hoistx 變量
cosnt hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)


render() {
    return (openBlock(), createBlock('div', null, [
        hoist1, hoist2, ...20 個變量, hoist20
    ]))
}      

預字元串化會将這些靜态節點序列化為字元串并生成一個 Static 類型的 VNode:

const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20個...<p></p>')


render() {
    return (openBlock(), createBlock('div', null, [
       hoistStatic
    ]))
}      

這有幾個明顯的優勢:

  • 生成代碼的體積減少
  • 減少建立 VNode 的開銷
  • 減少記憶體占用

靜态節點在運作時會通過 innerhtml 來建立真實節點,是以并非所有靜态節點都是可以預字元串化的,可以預字元串化的靜态節點需要滿足以下條件:

非表格類标簽:caption 、thead、tr、th、tbody、td、tfoot、colgroup、col

标簽的屬性必須是:

标準 html attribute:https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes

或>當一個節點滿足這些條件時代表這個節點是可以預字元串化的,但是如果隻有一個節點,那麼并不會将其字元串化,可字元串化的節點必須連續且達到一定數量才行:

如果節點沒有屬性,那麼必須有連續 20 個及以上的靜态節點存在才行,例如:

<div>
    <p></p>
    <p></p>
    ... 20 個 p 标簽
    <p></p>
</div>      

或者在這些連續的節點中有 5 個及以上的節點是有屬性綁定的節點:

<div>
    <p id="a"></p>
    <p id="b"></p>
    <p id="c"></p>
    <p id="d"></p>
    <p id="e"></p>
</div>      

這段節點的數量雖然沒有達到 20 個,但是滿足 5 個節點有屬性綁定。

這些節點不一定是兄弟關系,父子關系也是可以的,隻要門檻值滿足條件即可,例如:

<div>
    <p id="a">
        <p id="b">
            <p id="c">
                <p id="d">
                    <p id="e"></p>
                </p>
            </p>
        </p>
    </p>
</div>      

預字元串化會在編譯時計算屬性的值,例如:

<div>
    <p :id="'id-' + 1">
        <p :id="'id-' + 2">
            <p :id="'id-' + 3">
                <p :id="'id-' + 4">
                    <p :id="'id-' + 5"></p>
                </p>
            </p>
        </p>
    </p>
</div>      

在與字元串化之後:

const hoistStatic = createStaticVNode('<p id="id-1"></p><p id="id-2"></p>.....<p id="id-5"></p>')      

可見 id 屬性值時計算後的。

Cache Event handler

如下元件的模闆所示:

<Comp @change="a + b" />      

這段模闆如果手寫渲染函數的話相當于:

render(ctx) {
    return h(Comp, {
        onChange: () => (ctx.a + ctx.b)
    })
}      

很顯然,每次 render 函數執行的時候,Comp 元件的 props 都是新的對象,onChange 也會是全新的函數。這會導緻觸發 Comp 元件的更新。

當 Vue3 Compiler 開啟 prefixIdentifiers 以及 cacheHandlers 時,這段模闆會被編譯為:

render(ctx, cache) {
    return h(Comp, {
        onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
    })
}      

這樣即使多次調用渲染函數也不會觸發 Comp 元件的更新,因為 Vue 在 patch 階段比對 props 時就會發現 onChange 的引用沒變。

如上代碼中 render 函數的 cache 對象是 Vue 内部在調用渲染函數時注入的一個數組,像下面這種:

render.call(ctx, ctx, [])      

實際上,我們即使不依賴編譯也能手寫出具備 cache 能力的代碼:

const Comp = {
    setup() {
        // 在 setup 中定義 handler
        const handleChange = () => {/* ... */}
        return () => {
            return h(AnthorComp, {
                onChange: handleChange  // 引用不變
            })
        }
    }
}      

是以我們最好不要寫出如下這樣的代碼:

const Comp = {
    setup() {
        return () => {
            return h(AnthorComp, {
                onChang(){/*...*/}  // 每次渲染函數執行,都是全新的函數
            })
        }
    }
}      

v-once

這是 Vue2 就支援的功能,v-once 是一個“很指令”的指令,因為它就是給編譯器看的,當編譯器遇到 v-once 時,會利用我們剛剛講過的 cache 來緩存全部或者一部分渲染函數的執行結果,例如如下模闆:

<div>
    <div v-once>{{ foo }}</div>
</div>      

會被編譯為:

render(ctx, cache) {
    return (openBlock(), createBlock('div', null, [
        cache[1] || (cache[1] = h("div", null, ctx.foo, 1 /* TEXT */))
    ]))
}      

這樣就緩存了這段 vnode。既然 vnode 已經被緩存了,後續的更新就都會讀取緩存的内容,而不會重新建立 vnode 對象了,同時在 Diff 的過程中也就不需要這段 vnode 參與了,是以你通常會看到編譯後的代碼更接近如下内容:

render(ctx, cache) {
    return (openBlock(), createBlock('div', null, [
        cache[1] || (
            setBlockTracking(-1), // 阻止這段 VNode 被 Block 收集
            cache[1] = h("div", null, ctx.foo, 1 /* TEXT */),
            setBlockTracking(1), // 恢複
            cache[1] // 整個表達式的值
        )
    ]))
}      

稍微解釋一下這段代碼,我們已經講解過何為 “Block Tree”,而 openBlock() 和 createBlock() 函數用來建立一個 Block。

而 setBlockTracking(-1) 則用來暫停收集的動作,是以在 v-once 編譯生成的代碼中你會看到它,這樣使用 v-once 包裹的内容就不會被收集到父 Block 中,也就不參與 Diff 了。

是以,v-once 帶來的性能提升來自兩方面:

1、VNode 的建立開銷

2、無用的 Diff 開銷

但其實我們不通過模闆編譯,一樣可以通過緩存 VNode 來減少 VNode 的建立開銷:

const Comp = {
    setup() {
        // 緩存 content
        const content = h('div', 'xxxx')
        return () => {
            return h('section', content)
        }
    }
}      

但這樣避免不了無用的 Diff 開銷,因為我們沒有使用 Block Tree 優化模式。

這裡有必要提及的一點是:在 Vue2.5.18+ 以及 Vue3 中 VNode 是可重用的,例如我們可以在不同的地方多次使用同一個 VNode 節點:

const Comp = {
    setup() {
        const content = h('div', 'xxxx')
        return () => {
            // 多次渲染 content
            return h('section', [content, content, content])
        }
    }
}      

手寫高性能渲染函數

接下來我們将進入重頭戲環節,我們嘗試手寫優化模式的渲染函數。

幾個需要記住的小點:

一個 Block 就是一個特殊的 VNode,可以了解為它隻是比普通 VNode 多了一個 dynamicChildren 屬性

createBlock() 函數和 createVNode() 函數的調用簽名幾乎相同,實際上 createBlock() 函數内部就是封裝了 createVNode(),這再次證明 Block 就是 VNode。

在調用 createBlock() 建立 Block 前要先調用 openBlock() 函數,通常這兩個函數配合逗号運算符一同出現:

render() {
    return (openBlock(), createBlock('div'))
}      

Block Tree 是靈活的:

在之前的介紹中根節點以 Block 的角色存在的,但是根節點并不必須是 Block,我們可以在任意節點開啟 Block:

setup() {
    return () => {
        return h('div', [
            (openBlock(), createBlock('p', null, [/*...*/]))
        ])
    }
}      

這也是可以的,因為渲染器在 Diff 的過程中如果 VNode 帶有 dynamicChildren 屬性,會自動進入優化模式。但是我們通常會讓根節點充當 Block 角色。

正确地使用 PatchFlags:

PatchFlags 用來标記一個元素需要更新的内容,例如當元素有動态的 class 綁定時,我們需要使用 PatchFlags.CLASS 标記:

const App = {
  setup() {
    const refOk = ref(true)


    return () => {
      return (openBlock(), createBlock('div', null, [
        createVNode('p', { class: { foo: refOk.value } }, 'hello', PatchFlags.CLASS) // 使用 CLASS 标記
      ]))
    }
  }
}      

如果使用了錯誤的标記則可能導緻更新失敗,下面列出詳細的标記使用方式:

PatchFlags.CLASS - 當有動态的 class 綁定時使用

PatchFlags.STYLE - 當有動态的 style 綁定時使用,例如:

createVNode('p', { style: { color: refColor.value } }, 'hello', PatchFlags.STYLE)      

PatchFlags.TEXT - 當有動态的文本節點是使用,例如:

createVNode('p', null, refText.value, PatchFlags.TEXT)      

PatchFlags.PROPS - 當有除了 class 和 style 之外的其他動态綁定屬性時,例如:

createVNode('p', { foo: refVal.value }, 'hello', PatchFlags.PROPS, ['foo'])      

這裡需要注意的是,除了要使用 PatchFlags.PROPS 之外,還要提供第五個參數,一個數組,包含了動态屬性的名字。

PatchFlags.FULL_PROPS - 當有動态 name 的 props 時使用,例如:

createVNode('p', { [refKey.value]: 'val' }, 'hello', PatchFlags.FULL_PROPS)      

實際上使用 FULL_PROPS 等價于對 props 的 Diff 與傳統 Diff 一樣。其實,如果覺得心智負擔大,我們大可以全部使用 FULL_PROPS,這麼做的好處是:

避免誤用 PatchFlags 導緻的 bug

減少心智負擔的同時,雖然失去了 props diff 的性能優勢,但是仍然可以享受 Block Tree 的優勢。

當同時存在多種更新,需要将 PatchFlags 進行按位或運算,例如:PatchFlags.CLASS | PatchFlags.STYLE 。

NEED_PATCH 辨別

為什麼單獨把這個标志拿出來講呢,它比較特殊,需要我們額外注意。當我們使用 ref 或 onVNodeXXX 等 hook 時(包括自定義指令),需要使用該标志,以至于它可以被父級 Block 收集,詳細原因我們在靜态提升一節裡面講解過了:

const App = {
  setup() {
    const refDom = ref(null)
    return () => {
      return (openBlock(), createBlock('div', null,[
        createVNode('p',
          {
            ref: refDom,
            onVnodeBeforeMount() {/* ... */}
          },
          null,
          PatchFlags.NEED_PATCH
        )
      ]))
    }
  }
}      

該使用 Block 的地方必須用

在最開始的時候,我們講解了有些指令會導緻 DOM 結構不穩定,進而必須使用 Block 來解決問題。手寫渲染函數也是一樣:

分支判斷使用 Block:

const App = {
  setup() {
    const refOk = ref(true)
    return () => {
      return (openBlock(), createBlock('div', null, [
        refOk.value
          // 這裡使用 Block
          ? (openBlock(), createBlock('div', { key: 0 }, [/* ... */]))
          : (openBlock(), createBlock('div', { key: 1 }, [/* ... */]))
      ]))
    }
  }
}      

這裡使用 Block 的原因我們在前文已經講解過了,但這裡需要強調的是,除了分支判斷要使用 Block 之外,還需要為 Block 指定不同的 key 才行。

清單使用 Block:

當我們渲染清單時,我們常常寫出如下代碼:

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })


    return () => {
      return (openBlock(), createBlock('div', null,
        // 渲染清單
        obj.list.map(item => {
          return createVNode('p', null, item.val, PatchFlags.TEXT)
        })
      ))
    }
  }
}      

這麼寫在非優化模式下是沒問題的,但我們現在使用了 Block,前文已經講過為什麼 v-for 需要使用 Block 的原因,試想當我們執行如下語句修改資料:

obj.list.splice(0, 1)      

這就會導緻 Block 中收集的動态節點不一緻,最終 Diff 出現問題。解決方案就是讓整個清單作為一個 Block,這時我們需要使用 Fragment:

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })


    return () => {
      return (openBlock(), createBlock('div', null, [
        // 建立一個 Fragment,并作為 Block 角色
        (openBlock(true), createBlock(Fragment, null,
          // 在這裡渲染清單
          obj.list.map(item => {
            return createVNode('p', null, item.val, PatchFlags.TEXT)
          }),
          // 記得要指定正确的 PatchFlags
          PatchFlags.UNKEYED_FRAGMENT
        ))
      ]))
    }
  }
}      

總結一下:

對于清單我們應該始終使用 Fragment,并作為 Block 的角色

如果 Fragment 的 children 沒有指定 key,那麼應該為 Fragment 打上 PatchFlags.UNKEYED_FRAGMENT。相應的,如果指定了 key 就應該打上 PatchFlags.KEYED_FRAGMENT

注意到在調用 openBlock(true) 時,傳遞了參數 true,這代表這個 Block 不會收集 dynamicChildren,因為無論是 KEYED 還是 UNKEYED 的 Fragment,在 Diff 它的 children 時都會回退傳統 Diff 模式,是以不需要收集 dynamicChildren。

這裡還有一點需要注意,在 Diff Fragment 時,由于回退了傳統 Diff,我們希望盡快恢複優化模式,同時保證後續收集的可控性,是以通常會讓 Fragment 的每一個子節點都作為 Block 的角色:

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })


    return () => {
      return (openBlock(), createBlock('div', null, [
        (openBlock(true), createBlock(Fragment, null,
          obj.list.map(item => {
            // 修改了這裡
            return (openBlock(), createBlock('p', null, item.val, PatchFlags.TEXT))
          }),
          PatchFlags.UNKEYED_FRAGMENT
        ))
      ]))
    }
  }
}      

最後再說一下穩定的 Fragment,如果你能确定清單永遠不會變化,例如你能确定 obj.list 是不會變化的,那麼你應該使用:PatchFlags.STABLE_FRAGMENT 标志,并且調用 openBlcok() 去掉參數,代表收集 dynamicChildren:

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })


    return () => {
      return (openBlock(), createBlock('div', null, [
        // 調用 openBlock() 不要傳參
        (openBlock(), createBlock(Fragment, null,
          obj.list.map(item => {
            // 清單中的任何節點都不需要是 Block 角色
            return createVNode('p', null, item.val, PatchFlags.TEXT)
          }),
          // 穩定的片段
          PatchFlags.STABLE_FRAGMENT
        ))
      ]))
    }
  }
}      

如上注釋所述。

使用動态 key 的元素應該是 Block

正如在靜态提升一節中所講的,當元素使用動态 key 的時候,即使兩個元素的其他方面完全一樣,那也是兩個不同的元素,需要做替換處理,在 Block Tree 中應該以 Block 的角色存在,是以如果一個元素使用了動态 key,它應該是一個 Block:

const App = {
  setup() {
    const refKey = ref('foo')


    return () => {
      return (openBlock(), createBlock('div', null,[
        // 這裡應該是 Block
        (openBlock(), createBlock('p', { key: refKey.value }))
      ]))
    }
  }
}      

使用 Slot hint

我們在“穩定的 Fragment”一節中提到了 slot hint,當我們為元件編寫插槽内容時,為了告訴 runtime:“我們已經能夠保證插槽内容的結構穩定”,則需要使用 slot hint:

render() {
    return (openBlock(), createBlock(Comp, null, {
        default: () => [
            refVal.value
               ? (openBlock(), createBlock('p', ...)) 
               ? (openBlock(), createBlock('div', ...)) 
        ],
        // slot hint
        _: 1
    }))
}      

當然如果你不能保證這一點,或者覺得心智負擔大,那麼就不要寫 hint 了。

使用 $stable hint

$stable hint 和之前講的優化政策不同,前文中的政策都是假設渲染器在優化模式下工作的,而 $stable 用于非優化模式,也就是我們平時寫的渲染函數。那麼它有什麼用呢?如下代碼所示(使用 tsx 示範):

export const App = defineComponent({
  name: 'App',
  setup() {
    const refVal = ref(true)


    return () => {
      refVal.value


      return (
        <Hello>
          {
            { default: () => [<p>hello</p>] }
          }
        </Hello>
      )
    }
  }
})      

如上代碼所示,渲染函數中讀取了 refVal.value 的值,建立了依賴收集關系,當修改 refVal 的值時,會觸發 <Hello> 元件的更新,但是我們發現 Hello 元件一來沒有 props 變化,二來它的插槽内容是靜态的,是以不應該更新才對,這時我們可以使用 $stable hint:

export const App = defineComponent({
  name: 'App',
  setup() {
    const refVal = ref(true)


    return () => {
      refVal.value


      return (
        <Hello>
          {
            { default: () => [<p>hello</p>], $stable: true } // 修改了這裡
          }
        </Hello>
      )
    }
  }
})      

為元件正确地使用 DYNAMIC_SLOTS

當我們動态建構 slots 時,需要為元件的 VNode 指定 PatchFlags.DYNAMIC_SLOTS,否則将導緻更新失敗。什麼是動态建構 slots 呢?通常情況下是指:依賴目前 scope 變量建構的 slots,例如:

render() {
    // 使用目前元件作用域的變量
    const slots ={}
    // 常見的場景
    // 情況一:條件判斷
    if (refVal.value) {
        slots.header = () => [h('p', 'hello')]
    }
    // 情況二:循環
    refList.value.forEach(item => {
        slots[item.name] = () => [...]
    })
    // 情況三:動态 slot 名稱,情況二包含情況三
    slots[refName.value] = () => [...]


    return (openBlock(), createBlock('div', null, [
        // 這裡要使用 PatchFlags.DYNAMIC_SLOTS
        createVNode(Comp, null, slots, PatchFlags.DYNAMIC_SLOTS)
    ]))
}      

如上注釋所述。

以上,不知道到達這裡的同學有多少,Don't stop learning...

學習更多技能

請點選下方公衆号