App下載

Vue 3.0 diff 新特性 - 靜態(tài)節(jié)點(diǎn)提升

猿友 2020-09-22 14:24:42 瀏覽數(shù) (3448)
反饋

文章來(lái)源于公眾號(hào):Code center ,作者五柳

前言

「靜態(tài)節(jié)點(diǎn)提升」是「Vue3」針對(duì) VNode 更新過(guò)程性能問(wèn)題而提出的一個(gè)優(yōu)化點(diǎn)。眾所周知,在大型應(yīng)用場(chǎng)景下,「Vue2.x」patchVNode 過(guò)程,即 diff 過(guò)程是非常緩慢的,這是一個(gè)十分令人頭疼的問(wèn)題。

雖然,對(duì)于面試常問(wèn)的 diff 過(guò)程在一定程度上是減少了對(duì) DOM 的直接操作。但是,「這個(gè)減少是有一定成本的」。因?yàn)?,如果是?fù)雜應(yīng)用,那么就會(huì)存在父子關(guān)系非常復(fù)雜的 VNode,而這也就是 diff 的痛點(diǎn),它會(huì)不斷地遞歸調(diào)用 patchVNode,不斷堆疊而成的幾毫秒,最終就會(huì)造成 VNode 更新緩慢。

也因此,這也是為什么我們所看到的大型應(yīng)用諸如阿里云之類(lèi)的采用的是基于「React」的技術(shù)棧的原因之一。所以,「Vue3」也是痛改前非,重寫(xiě)了整個(gè) Compiler 過(guò)程,提出了靜態(tài)提升、靶向更新等優(yōu)化點(diǎn),來(lái)提高 patchVNode 過(guò)程。

那么,回到今天的正題,我們從源碼角度看看在整個(gè)編譯過(guò)程「Vue3」靜態(tài)節(jié)點(diǎn)提升究竟是「何許人也」?

什么是 patchFlag

由于,在 compile 過(guò)程的 transfrom 階段會(huì)提及 AST Element 上的 patchFlag 屬性。所以,在正式認(rèn)識(shí) complie 之前,我們先搞清楚一個(gè)概念,什么是 patchFlag?

patchFlagcomplier 時(shí)的 transform 階段解析 AST Element 打上的「優(yōu)化標(biāo)識(shí)」。并且,顧名思義 patchFlag,patch 一詞表示著它會(huì)為 runtime時(shí)的 patchVNode 提供依據(jù),從而實(shí)現(xiàn)靶向更新 VNode 的效果。因此,這樣一來(lái)一往,也就是耳熟能詳?shù)?Vue3 巧妙結(jié)合 runtimecompiler 實(shí)現(xiàn)靶向更新和靜態(tài)提升。

而在源碼中 patchFlag 被定義為一個(gè)「數(shù)字枚舉類(lèi)型」,每一個(gè)枚舉值對(duì)應(yīng)的標(biāo)識(shí)意義會(huì)是這樣:

數(shù)字枚舉類(lèi)型

并且,值得一提的是整體上 patchFlag 的分為兩大類(lèi):

  • 當(dāng) patchFlag 的值「大于」 0 時(shí),代表所對(duì)應(yīng)的元素在 patchVNode 時(shí)或 render 時(shí)是可以被優(yōu)化生成或更新的。
  • 當(dāng) patchFlag 的值「小于」 0 時(shí),代表所對(duì)應(yīng)的元素在 patchVNode 時(shí),是需要被 full diff,即進(jìn)行遞歸遍歷 VNode tree 的比較更新過(guò)程。

其實(shí),還有兩類(lèi)特殊的 flagshapeFlagslogFlag,這里我就不對(duì)此展開(kāi),有興趣的同學(xué)可以自行去了解。

Compile 編譯過(guò)程

對(duì)比 Vue2.x 編譯過(guò)程

了解過(guò)「Vue2.x」源碼的同學(xué),我想應(yīng)該都知道在「Vue2.x」中的 Compile 過(guò)程會(huì)是這樣:

  • parse 編譯模板生成原始 AST。
  • optimize 優(yōu)化原始 AST,標(biāo)記 AST Element 為靜態(tài)根節(jié)點(diǎn)或靜態(tài)節(jié)點(diǎn)。
  • generate 根據(jù)優(yōu)化后的 AST,生成可執(zhí)行代碼,例如 _c、_l 之類(lèi)的。

對(duì)比 Vue2.x 編譯過(guò)程

而在「Vue3」中,整體的 Compile 過(guò)程仍然是三個(gè)階段,但是不同于「Vue2.x」的是,第二個(gè)階段換成了正常編譯器都會(huì)存在的階段 transform。所以,它看起來(lái)會(huì)是這樣:

對(duì)比 Vue2.x 編譯過(guò)程

在源碼中,它對(duì)應(yīng)的偽代碼會(huì)是這樣:

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  ...
  const ast = isString(template) ? baseParse(template, options) : template
  ...
  transform(
    ast,
    extend({}, options, {....})
  )


  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

那么,我想這個(gè)時(shí)候大家可能會(huì)問(wèn)為什么會(huì)是 transform?它的職責(zé)是什么?

通過(guò)簡(jiǎn)單的對(duì)比「Vue2.x」編譯過(guò)程的第二階段的 optimize,很明顯,transform并不是「無(wú)米之炊」,它仍然有著「優(yōu)化」原始 AST 的作用,而具體職責(zé)會(huì)表現(xiàn)在:

  • 對(duì)所有 AST Element 新增 codegen 屬性來(lái)幫助 generate 更準(zhǔn)確地生成「最優(yōu)」的可執(zhí)行代碼。
  • 對(duì)靜態(tài) AST Element 新增 hoists 屬性來(lái)實(shí)現(xiàn)靜態(tài)節(jié)點(diǎn)的「單獨(dú)創(chuàng)建」。
  • ...

此外,transform 還標(biāo)識(shí)了諸如 isBlock、helpers 等屬性,來(lái)生成最優(yōu)的可執(zhí)行代碼,這里我們就不細(xì)談,有興趣的同學(xué)可以自行了解。

baseParse 構(gòu)建原始抽象語(yǔ)法樹(shù)(AST)

baseParse 顧名思義起著「解析」的作用,它的表現(xiàn)和「Vue2.x」parse 相同,都是解析模板 tempalte 生成「原始 AST」

假設(shè),此時(shí)我們有一個(gè)這樣的模板 template

<div><div>hi vue3</div><div>{{msg}}</div></div>

那么,它在經(jīng)過(guò) baseParse 處理后生成的 AST 看起來(lái)會(huì)是這樣:

{
  cached: 0,
  children: [{…}],
  codegenNode: undefined,
  components: [],
  directives: [],
  helpers: [],
  hoists: [],
  imports: [],
  loc: {start: {…}, end: {…}, source: "<div><div>hi vue3</div><div>{{msg}}</div></div>"},
  temps: 0,
  type: 0
}

如果,了解過(guò)「Vue2.x」編譯過(guò)程的同學(xué)應(yīng)該對(duì)于上面這顆 AST 的大部分屬性不會(huì)陌生。AST 的本質(zhì)是通過(guò)用對(duì)象來(lái)描述「DSL」(特殊領(lǐng)域語(yǔ)言),例如:

  • children 中存放的就是最外層 div 的后代。
  • loc 則用來(lái)描述這個(gè) AST Element 在整個(gè)字符串(template)中的位置信息。
  • type 則是用于描述這個(gè)元素的類(lèi)型(例如 5 為插值、2 為文本)等等。

并且,可以看到的是不同于「Vue2.x」的 AST,這里我們多了諸如 helpers、codegenNode、hoists 等屬性。而,這些屬性會(huì)在 transform 階段進(jìn)行相應(yīng)地賦值,進(jìn)而幫助 generate 階段生成「更優(yōu)的」可執(zhí)行代碼。

transfrom 優(yōu)化原始抽象語(yǔ)法樹(shù)(AST)

對(duì)于 transform 階段,如果了解過(guò)「編譯器」的工作流程的同學(xué)應(yīng)該知道,一個(gè)完整的編譯器的工作流程會(huì)是這樣:

  • 首先,parse 解析原始代碼字符串,生成抽象語(yǔ)法樹(shù) AST。
  • 其次,transform 轉(zhuǎn)化抽象語(yǔ)法樹(shù),讓它變成更貼近目標(biāo)「DSL」的結(jié)構(gòu)。
  • 最后,codegen 根據(jù)轉(zhuǎn)化后的抽象語(yǔ)法樹(shù)生成目標(biāo)「DSL」的可執(zhí)行代碼。

而在「Vue3」采用 Monorepo 的方式管理項(xiàng)目后,compile 對(duì)應(yīng)的能力就是一個(gè)編譯器。所以,transform 也是整個(gè)編譯過(guò)程的重中之重。換句話說(shuō),如果沒(méi)有 transform 對(duì) AST 做諸多層面的轉(zhuǎn)化,「Vue」仍然會(huì)掛在 diff 這個(gè)「飽受詬病」的過(guò)程。

相比之下,「Vue2.x」的編譯階段沒(méi)有完整的 transform,只是 optimize 優(yōu)化了一下 AST,可以想象在「Vue」設(shè)計(jì)之初尤大也沒(méi)想到它以后會(huì)「這么地流行」

那么,我們來(lái)看看 transform 函數(shù)源碼中的定義:

function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // finalize meta information
  root.helpers = [...context.helpers]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = [...context.imports]
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
}

可以說(shuō),transform 函數(shù)做了什么,在它的定義中是「一覽無(wú)余」。這里我們提一下它對(duì)靜態(tài)提升其決定性作用的兩件事:

  • 將原始 AST 中的靜態(tài)節(jié)點(diǎn)對(duì)應(yīng)的 AST Element 賦值給根 AST 的 hoists 屬性。
  • 獲取原始 AST 需要的 helpers 對(duì)應(yīng)的鍵名,用于 generate 階段的生成可執(zhí)行代碼的獲取對(duì)應(yīng)函數(shù),例如 createTextVNodecreateStaticVNode、renderList 等等。

并且,在 traverseNode 函數(shù)中會(huì)對(duì) AST Element 應(yīng)用具體的 transform 函數(shù),大致可以分為兩類(lèi):

  • 靜態(tài)節(jié)點(diǎn) transform 應(yīng)用,即節(jié)點(diǎn)不含有插值、指令、props、動(dòng)態(tài)樣式的綁定等。
  • 動(dòng)態(tài)節(jié)點(diǎn) transform 應(yīng)用,即節(jié)點(diǎn)含有插值、指令、props、動(dòng)態(tài)樣式的綁定等。

那么,我們就來(lái)看看對(duì)于靜態(tài)節(jié)點(diǎn) transform 是如何應(yīng)用的?

靜態(tài)節(jié)點(diǎn) transform 應(yīng)用

這里,對(duì)于上面我們說(shuō)到的這個(gè)栗子,靜態(tài)節(jié)點(diǎn)就是這個(gè)部分:

<div>hi vue3</div>

而它在沒(méi)有進(jìn)行 transform 應(yīng)用之前,它對(duì)應(yīng)的 AST 會(huì)是這樣:

{
  children: [{
    content: "hi vue3"
    loc: {start: {…}, end: {…}, source: "hi vue3"}
    type: 2
  }],
  codegenNode: undefined,
  isSelfClosing: false,
  loc: {start: {…}, end: {…}, source: "<div>hi vue3</div>"},
  ns: 0,
  props: [],
  tag: "div",
  tagType: 0,
  type: 1
}

可以看出,此時(shí)它的 codegenNodeundefined。而在源碼中各類(lèi) transform函數(shù)被定義為 plugin,它會(huì)根據(jù) baseParse 生成的 AST 「遞歸應(yīng)用」對(duì)應(yīng)的 plugin。然后,創(chuàng)建對(duì)應(yīng) AST Element 的 codegen 對(duì)象。

所以,此時(shí)我們會(huì)命中 transformElementtransformText 兩個(gè) plugin的邏輯。

「transformText」

transformText 顧名思義,它和「文本」相關(guān)。很顯然,此時(shí)的 AST Element 所屬的類(lèi)型就是 Text。那么,我們先來(lái)看一下 transformText 函數(shù)對(duì)應(yīng)的偽代碼:

export const transformText: NodeTransform = (node, context) => {
  if (
    node.type === NodeTypes.ROOT ||
    node.type === NodeTypes.ELEMENT ||
    node.type === NodeTypes.FOR ||
    node.type === NodeTypes.IF_BRANCH
  ) {
    return () => {
      const children = node.children
      let currentContainer: CompoundExpressionNode | undefined = undefined
      let hasText = false


      for (let i = 0; i < children.length; i++) { // {1}
        const child = children[i]
        if (isText(child)) {
          hasText = true
          ...
        }
      }
      if (
        !hasText ||
        (children.length === 1 &&
          (node.type === NodeTypes.ROOT ||
            (node.type === NodeTypes.ELEMENT &&
              node.tagType === ElementTypes.ELEMENT)))
      ) { // {2}
        return
      }
      ...
    }
  }
}

可以看到,這里我們會(huì)命中 「{2}」 的邏輯,即如果對(duì)于「節(jié)點(diǎn)含有單一文本」 transformText 并不需要進(jìn)行額外的處理,即該節(jié)點(diǎn)仍然在這里仍然保留和「Vue2.x」版本一樣的處理方式。

transfromText 真正發(fā)揮作用的場(chǎng)景是當(dāng)模板中存在這樣的情況:

<div>ab {a} </div>

此時(shí) transformText 需要將兩者放在一個(gè)「單獨(dú)的」 AST Element 下,在源碼中它被稱為「Compound Expression」,即「組合的表達(dá)式」。這種組合的目的是為了 patchVNode 這類(lèi) VNode 時(shí)做到「更好地定位和實(shí)現(xiàn) DOM 的更新」。反之,如果是一個(gè)文本節(jié)點(diǎn)和插值動(dòng)態(tài)節(jié)點(diǎn)的話,在 patchVNode 階段同樣的操作需要進(jìn)行兩次,例如對(duì)于同一個(gè) DOM 節(jié)點(diǎn)操作兩次。

「transformElement」

transformElement 是一個(gè)所有 AST Element 都會(huì)被執(zhí)行的一個(gè) plugin,它的核心是為 AST Element 生成最基礎(chǔ)的 codegen 屬性。例如標(biāo)識(shí)出對(duì)應(yīng) patchFlag,從而為生成 VNode 提供依據(jù),例如 dynamicChildren

而對(duì)于靜態(tài)節(jié)點(diǎn),同樣是起到一個(gè)初始化它的 codegenNode 屬性的作用。并且,從上面介紹的 patchFlag 的類(lèi)型,我們可以知道它的 patchFlag 為默認(rèn)值 0。所以,它的 codegenNode 屬性值看起來(lái)會(huì)是這樣:

{
  children: {
    content: "hi vue3"
    loc: {start: {…}, end: {…}, source: "hi vue3"}
    type: 2
  },
  directives: undefined,
  disableTracking: false,
  dynamicProps: undefined,
  isBlock: false,
  loc: {start: {…}, end: {…}, source: "<div>hi vue3</div>"},
  patchFlag: undefined,
  props: undefined,
  tag: ""div"",
  type: 13
}

generate 生成可執(zhí)行代碼

generatecompile 階段的最后一步,它的作用是將 transform 轉(zhuǎn)換后的 AST 生成對(duì)應(yīng)的「可執(zhí)行代碼」,從而在之后 Runtime 的 Render 階段時(shí),就可以通過(guò)可執(zhí)行代碼生成對(duì)應(yīng)的 VNode Tree,然后最終映射為真實(shí)的 DOM Tree 在頁(yè)面上。

同樣地,這一階段在「Vue2.x」也是由 generate 函數(shù)完成,它會(huì)生成是諸如 _l、_c 之類(lèi)的函數(shù),這本質(zhì)上是對(duì) _createElement 函數(shù)的封裝。而相比較「Vue2.x」版本的 generate,「Vue3」改變了很多,其 generate 函數(shù)對(duì)應(yīng)的偽代碼會(huì)是這樣:

export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {
  const context = createCodegenContext(ast, options)
  if (options.onContextCreated) options.onContextCreated(context)
  const {
    mode,
    push,
    prefixIdentifiers,
    indent,
    deindent,
    newline,
    scopeId,
    ssr
  } = context
  ...
  genFunctionPreamble(ast, context)
  ...


  if (!ssr) {
    ...
    push(`function render(_ctx, _cache${optimizeSources}) {`)
  }
  ....


  return {
    ast,
    code: context.code,
    // SourceMapGenerator does have toJSON() method but it's not in the types
    map: context.map ? (context.map as any).toJSON() : undefined
  }
}

所以,接下來(lái),我們就來(lái)「一睹」帶有靜態(tài)節(jié)點(diǎn)對(duì)應(yīng)的 AST 生成的可執(zhí)行代碼的過(guò)程會(huì)是怎樣。

CodegenContext 代碼生成上下文

從上面 generate 函數(shù)的偽代碼可以看到,在函數(shù)的開(kāi)始調(diào)用了 createCodegenContext 為當(dāng)前 AST 生成了一個(gè) context。在整個(gè) generate 函數(shù)的執(zhí)行過(guò)程「都依托」于一個(gè) CodegenContext 「生成代碼上下文」(對(duì)象)的能力,它是通過(guò) createCodegenContext 函數(shù)生成。而 CodegenContext 的接口定義會(huì)是這樣:

interface CodegenContext
  extends Omit {
  source: string
  code: string
  line: number
  column: number
  offset: number
  indentLevel: number
  pure: boolean
  map?: SourceMapGenerator
  helper(key: symbol): string
  push(code: string, node?: CodegenNode): void
  indent(): void
  deindent(withoutNewLine?: boolean): void
  newline(): void
}

可以看到 CodegenContext 對(duì)象中有諸如 push、indentnewline 之類(lèi)的方法。而它們的作用是在根據(jù) AST 來(lái)生成代碼時(shí)用來(lái)「實(shí)現(xiàn)換行」「添加代碼」、「縮進(jìn)」等功能。從而,最終形成一個(gè)個(gè)可執(zhí)行代碼,即我們所認(rèn)知的 render 函數(shù),并且,它會(huì)作為 CodegenContextcode 屬性的值返回。

下面,我們就來(lái)看下靜態(tài)節(jié)點(diǎn)的可執(zhí)行代碼生成的核心,它被稱為 Preamble 前導(dǎo)。

genFunctionPreamble 生成前準(zhǔn)備

整個(gè)靜態(tài)提升的可執(zhí)行代碼生成就是在 genFunctionPreamble 函數(shù)部分完成的。并且,大家仔細(xì)「斟酌」一番靜態(tài)提升的字眼,靜態(tài)二字我們可以不看,但是「提升二字」,直抒本意地表達(dá)出它(靜態(tài)節(jié)點(diǎn))被「提高了」。

為什么說(shuō)是提高了?因?yàn)樵谠创a中的體現(xiàn),確實(shí)是被提高了。在前面的 generate 函數(shù),我們可以看到 genFunctionPreamble 是先于 render 函數(shù)加入context.code 中,所以,在 Runtime 時(shí)的 Render 階段,它會(huì)先于 render 函數(shù)執(zhí)行。

geneFunctionPreamble 函數(shù)(偽代碼):

function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
  const {
    ssr,
    prefixIdentifiers,
    push,
    newline,
    runtimeModuleName,
    runtimeGlobalName
  } = context
  ...
  const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`
  if (ast.helpers.length > 0) {
    ...
    if (ast.hoists.length) {
      const staticHelpers = [
        CREATE_VNODE,
        CREATE_COMMENT,
        CREATE_TEXT,
        CREATE_STATIC
       ]
        .filter(helper => ast.helpers.includes(helper))
        .map(aliasHelper)
        .join(', ')
      push(`const { ${staticHelpers} } = _Vue\n`)
    }
  }
  ...
  genHoists(ast.hoists, context)
  newline()
  push(`return `)
}

可以看到,這里會(huì)對(duì)前面我們?cè)?transform 函數(shù)提及的 hoists 屬性的長(zhǎng)度進(jìn)行判斷。顯然,對(duì)于前面說(shuō)的這個(gè)栗子,它的 ast.hoists.length 長(zhǎng)度是大于 0 的。所以,這里就會(huì)根據(jù) hoists 中的 AST 生成對(duì)應(yīng)的可執(zhí)行代碼。因此,到這里,生成的可執(zhí)行代碼會(huì)是這樣:

const _Vue = Vue
const { createVNode: _createVNode } = _Vue
// 靜態(tài)提升部分
const _hoisted_1 = _createVNode("div", null, "hi vue3", -1 /* HOISTED */)
// render 函數(shù)會(huì)在這下面

小結(jié)

靜態(tài)節(jié)點(diǎn)提升在整個(gè) compile 編譯階段體現(xiàn),從最初的 baseCompiletransform 轉(zhuǎn)化原始 AST、再到 generate 的優(yōu)先 render 函數(shù)處理生成可執(zhí)行代碼,最后交給 Runtime 時(shí)的 Render 執(zhí)行,這種設(shè)計(jì)可以說(shuō)是非常精妙!所以,這樣一來(lái),就完成了我們經(jīng)??吹皆谝恍┪恼绿峒暗摹竀ue3」對(duì)于靜態(tài)節(jié)點(diǎn)在整個(gè)生命周期中它只會(huì)執(zhí)行「一次創(chuàng)建」的源碼實(shí)現(xiàn),這在一定程度上降低了性能上的開(kāi)銷(xiāo)。

以上就是W3Cschool編程獅關(guān)于Vue 3.0 diff 新特性 - 靜態(tài)節(jié)點(diǎn)提升的相關(guān)介紹了,希望對(duì)大家有所幫助。

0 人點(diǎn)贊