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

blank

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

Vue3Compilerruntime緊密合作,充分利用編譯時訊息,使得性能得到了極大的提升。本文的目的告訴你Vue3Compiler到底做了哪些優化,以及一些你可能希望知道的優化細節,在這個基礎上我們試著總結出一套手寫優化模式的高性能渲染函數的方法,這些知識也可以用於實現一個Vue3jsx babel插件中,讓jsx也能享受優化模式的運行時收益,這裡需要澄清的是,即使在非優化模式下,理論上Vue3Diff性能也是要優於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 TreePatchFlagsVue3充分利用編譯訊息並在Diff階段所做的優化。尤大已經不止一次在公開場合聊過思路,我們深入細節的目的是為了更好的理解,並試圖手寫出高性能的VNode

傳統Diff算法的問題

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

<div><pclass="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樹大概長什麼樣:

constvnode={tag:'div',children:[{tag:'p',children:'foo'},{tag:'p',children:ctx.bar},// 这是动态节点
]}

在傳統的vdom樹中,我們在運行時得不到任何有用訊息,但是Vue3compiler能夠分析模板並提取有用訊息,最終體現在vdom樹上。例如它能夠清楚的知道:哪些節點是動態節點,以及為什麼它是動態的(是綁定了動態的class ?還是綁定了動態的style ?亦或是其它動態的屬性?),總之編譯器能夠提取我們想要的訊息,有了這些訊息我們就可以在創建vnode的過程中為動態的節點打上標記:也就是傳說中的PatchFlags

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

  • 數字1:代表節點有動態的textContent (例如上面模板中的p標籤)
  • 數字2:代表元素有動態的class綁定
  • 數字3:代表xxxxx

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

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

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

constvnode={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就是我們用來存儲一個節點下所有子代動態節點的數組,注意這裡的用詞哦:“子代”,例如:

constvnode={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><sectionv-if="foo"><p>{{ a }}</p></section><divv-else><p>{{ a }}</p></div></div>

假設只要最外層的div標籤是Block角色,那麼當foo為真時, block收集到的動態節點為:

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

foo為假時, block的內容如下:

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

可以發現無論foo為真還是假, block的內容是不變的,這就意味什麼在diff階段不會做任何更新,但是我們也看到了: v-if的是一個<section>標籤, v-else的是一個<div>標籤,所以這裡就出問題了。實際上問題的本質在於dynamicChildrendiff是忽略vdom樹層級的,如下模板也有同樣的問題:

<div><sectionv-if="foo"><p>{{ a }}</p></section><sectionv-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><sectionv-if="foo"><p>{{ a }}</p></section><sectionv-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

cosntblock={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><pv-for="item in list">{{ item }}</p><i>{{ foo }}</i><i>{{ bar }}</i></div>

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

//前constprevBlock={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 */},]}//後constnextBlock={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 的节点 ..*/]}

對於如下這樣的模板:

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

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

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

我們發現, Fragment這個Block仍然面臨結構不穩定的情況,所謂結構不穩定從結果上看指的是更新前後一個blockdynamicChildren中收集的動態節點數量或順序的不一致。這種不一致會導致我們沒有辦法直接進行靶向Diff ,怎麼辦呢?其實對於這種情況是沒有辦法的,我們只能拋棄dynamicChildrenDiff ,並回退到傳統Diff :即Diff Fragmentchildren而非dynamicChildren

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

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

這樣,對於<p>標籤及其子代節點的Diff將恢復Block TreeDiff模式。

穩定的Fragment

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

  • v-for的表達式是常量
<pv-for="n in 10"></p><!-- 或者 --><pv-for="s in 'abc'"></p>

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

  • 多個根元素

Vue3不再限制組件的模板必須有一個根節點,對於多個根節點的模板,例如:

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

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

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

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

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

對應到VNode應該類似於:

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

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

  • 插槽出口

如下模板所示:

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

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

render(ctx){returncreateVNode(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><templatev-for="item in list"><p>{{ item.name }}</P><p>{{ item.age }}</P></template></template>

對於帶有v-fortemplate元素本身來說,它是一個不穩定的Fragment ,因為list不是常量。除此之外,由於<template>元素本身不渲染任何真實DOM ,因此如果它含有多個元素節點,那麼這些元素節點也將作為Fragment存在,但這個Fragment是穩定的,因為它不會隨著list的變化而變化。

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

靜態提升

提升靜態節點樹

Vue3Compiler如果開啟了hoistStatic選項則會提升靜態節點,或靜態的屬性,這可以減少創建VNode的消耗,如下模板所示:

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

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

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

很明顯, p標籤是靜態的,它不會改變。但是如上渲染函數的問題也很明顯,如果組件內存在動態的內容,當渲染函數重新執行時,即使p標籤是靜態的,那麼它對應的VNode也會重新創建。當開啟靜態提升後,其渲染函數如下:

consthoist1=createVNode('p',null,'text')functionrender(){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的值,如下模板所示:

<divref="domRef"></div>

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

<template><div><pref="domRef"></p></div></template><script>exportdefault{setup(){constrefP1=ref(null)constrefP2=ref(null)constuseP1=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也會被收集到父BlockdynamicChildren中。

但由於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原生提供的指令,都不會被提升,使用自定義指令也不會被提升,例如:

<pv-custom></p>

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

  • beforeMount
  • mounted
  • beforeUpdate
  • updated
  • beforeUnmount
  • unmounted

編寫一個自定義指令:

constmyDir: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)}}

使用該指令:

constApp={setup(){return()=>{returnh('div',[// 调用 withDirectives 函数
withDirectives(h('h1','hahah'),[// 四个参数分别是:指令、值、参数、修饰符
[myDir,10,'arg',{foo:true}]])])}}}

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

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

提升靜態PROPS

前面說過,靜態節點的提升以樹為單位,如果一個VNode存在非靜態的子代節點,那麼該VNode就不是靜態的,也就不會被提升。但這個VNodeprops卻可能是靜態的,這使我們可以將它的props進行提升,這同樣可以節約VNode對象的創建開銷,內存佔用等,例如:

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

在這段模板中p標籤有動態的文本內容,因此不可以被提升,但p標籤的所有屬性都是靜態的,因此可以提升它的屬性,經過提升後其渲染函數如下:

consthoistProp={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優化時,結果如下:

cosnthoist1=createVNode('p',null,null,PatchFlags.HOISTED)cosnthoist2=createVNode('p',null,null,PatchFlags.HOISTED)...20hoistx變量cosnthoist20=createVNode('p',null,null,PatchFlags.HOISTED)render(){return(openBlock(),createBlock('div',null,[hoist1,hoist2,...20個變量,hoist20]))}

預字符串化會將這些靜態節點序列化為字符串並生成一個Static類型的VNode

consthoistStatic=createStaticVNode('<p></p><p></p><p></p>...20个...<p></p>')render(){return(openBlock(),createBlock('div',null,[hoistStatic]))}

這有幾個明顯的優勢:

  • 生成代碼的體積減少
  • 減少創建VNode 的開銷
  • 減少內存佔用

靜態節點在運行時會通過innerHTML來創建真實節點,因此並非所有靜態節點都是可以預字符串化的,可以預字符串化的靜態節點需要滿足以下條件:

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

  • 如果節點沒有屬性,那麼必須有連續20個及以上的靜態節點存在才行,例如:
< div > < p ></ p > < p ></ p > ... 20 个p 标签< p ></ p > </ div >

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

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

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

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

<div><pid="a"><pid="b"><pid="c"><pid="d"><pid="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){returnh(Comp,{onChange:()=>(ctx.a+ctx.b)})}

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

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

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

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

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

render.call(ctx,ctx,[])

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

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

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

constComp={setup(){return()=>{returnh(AnthorComp,{onChang(){/*...*/}// 每次渲染函数执行,都是全新的函数
})}}}

v-once

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

<div><divv-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 ]) } } }

手寫高性能渲染函數

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

幾個需要記住的小點:

  1. 一個Block就是一個特殊的VNode ,可以理解為它只是比普通VNode多了一個dynamicChildren屬性
  2. createBlock()函數和createVNode()函數的調用簽名幾乎相同,實際上createBlock()函數內部就是封裝了createVNode() ,這再次證明Block就是VNode
  3. 在調用createBlock()創建Block前要先調用openBlock()函數,通常這兩個函數配合逗號運算符一同出現:
render(){return(openBlock(),createBlock('div'))}

Block Tree是靈活的:

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

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

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

正確地使用PatchFlags:

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

constApp={setup(){constrefOk=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 -當有除了classstyle之外的其他動態綁定屬性時,例如:
createVNode('p',{foo:refVal.value},'hello',PatchFlags.PROPS,['foo'])

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

  • PatchFlags.FULL_PROPS -當有動態nameprops時使用,例如:
createVNode('p',{[refKey.value]:'val'},'hello',PatchFlags.FULL_PROPS)

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

    • 避免誤用PatchFlags導致的bug
    • 減少心智負擔的同時,雖然失去了props diff的性能優勢,但是仍然可以享受Block Tree的優勢。

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

NEED_PATCH標識

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

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

該使用Block的地方必須用

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

  • 分支判斷使用Block:
constApp={setup(){constrefOk=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:

當我們渲染列表時,我們常常寫出如下代碼:

constApp={setup(){constobj=reactive({list:[{val:1},{val:2}]})return()=>{return(openBlock(),createBlock('div',null,//渲染列表obj.list.map(item=>{returncreateVNode('p',null,item.val,PatchFlags.TEXT)})))}}}

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

obj.list.splice(0,1)

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

constApp={setup(){constobj=reactive({list:[{val:1},{val:2}]})return()=>{return(openBlock(),createBlock('div',null,[//創建一個Fragment,並作為Block角色(openBlock(true),createBlock(Fragment,null,//在這裡渲染列表obj.list.map(item=>{returncreateVNode('p',null,item.val,PatchFlags.TEXT)}),//記得要指定正確的PatchFlagsPatchFlags.UNKEYED_FRAGMENT))]))}}}

總結一下:

  • 對於列表我們應該始終使用Fragment ,並作為Block的角色
  • 如果Fragmentchildren沒有指定key ,那麼應該為Fragment打上PatchFlags.UNKEYED_FRAGMENT 。相應的,如果指定了key就應該打上PatchFlags.KEYED_FRAGMENT
  • 注意到在調用openBlock(true)時,傳遞了參數true ,這代表這個Block不會收集dynamicChildren ,因為無論是KEYED還是UNKEYEDFragment ,在Diff它的children時都會回退傳統Diff模式,因此不需要收集dynamicChildren

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

constApp={setup(){constobj=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

constApp={setup(){constobj=reactive({list:[{val:1},{val:2}]})return()=>{return(openBlock(),createBlock('div',null,[//調用openBlock()不要傳參(openBlock(),createBlock(Fragment,null,obj.list.map(item=>{//列表中的任何節點都不需要是Block角色returncreateVNode('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 })) ])) } } }

這實際上是必須的,詳情查看 github.com/vuejs/vue-ne

使用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 演示):

exportconstApp=defineComponent({name:'App',setup(){constrefVal=ref(true)return()=>{refVal.valuereturn(<Hello>{{default:()=>[<p>hello</p>] }}</Hello>)}}})

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

exportconstApp=defineComponent({name:'App',setup(){constrefVal=ref(true)return()=>{refVal.valuereturn(<Hello>{{default:()=>[<p>hello</p>], $stable: true } //修改了这里}</Hello>)}}})

為組件正確地使用DYNAMIC_SLOTS

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

render(){//使用當前組件作用域的變量constslots={}//常見的場景//情況一:條件判斷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_SLOTScreateVNode(Comp,null,slots,PatchFlags.DYNAMIC_SLOTS)]))}

如上註釋所述。

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

What do you think?

Written by marketer

blank

小程序長列表渲染優化另一種解決方案

blank

深入理解Angular 編譯器