Vue3 的模版编译优化

Vue3 的模版编译优化

我们知道,通过数据劫持和依赖收集,Vue.js 2.x 的数据更新并触发重新渲染的粒度是组件级的: 虽然 Vue.js 能保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vnode 树,举个例子,比如我们要更新这个组件:

html 复制代码
<template>
  <div id="content">
    <p class="text">static text</p>
    <p class="text">static text</p>
    <p class="text">{{message}}</p>
    <p class="text">static text</p>
    <p class="text">static text</p>
  </div>
</template>

整个 diff 过程如图所示: 可以看到,因为这段代码中只有一个动态节点,所以这里有很多 diff 和遍历其实都是不需要的。 这就会导致 vnode 的性能跟模版大小正相关,跟动态节点的数量无关,当一些组件的整个模版内只有少量动态节点时,这些遍历都是性能的浪费。

而对于上述例子,理想状态是只需要 diff 这一个绑定 message 动态节点的 p 标签即可。 Vue.js 3.0 做到了这一点,它通过编译阶段对静态模板的分析,编译生成了 Block Tree。Block Tree 是一个将模版基于动态节点指令切割的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要以一个 Array 来追踪自身包含的动态节点。借助 Block Tree,Vue.js 将 vnode 更新性能由与模版整体大小相关提升为与动态内容的数量相关,这是一个非常大的性能突破。

编译生成的 Block Tree

那么,Vue.js 在编译阶段会把哪些节点编译生成 Block Tree 呢?

看一个示例模版:

html 复制代码
<div class="app">
  <hello v-if="flag"></hello>
  <div v-else>
    <p>hello {{ msg + test }}</p>
    <p>static</p>
    <p>static</p>
  </div>
</div>

编译上述模板的结果如下:

js 复制代码
import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString } from "vue"
​
const _hoisted_1 = { class: "app" }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
​
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_hello = _resolveComponent("hello")
​
  return (_openBlock(), _createBlock("div", _hoisted_1, [
    (_ctx.flag)
      ? (_openBlock(), _createBlock(_component_hello, { key: 0 }))
      : (_openBlock(), _createBlock("div", _hoisted_2, [
          _createVNode("p", null, "hello " + _toDisplayString(_ctx.msg + _ctx.test), 1 /* TEXT */),
          _hoisted_3,
          _hoisted_4
        ]))
  ]))
}

可以看到:

  • 根节点创建了一个 Block。这很好理解,因为整个组件至少需要构建一个 Block。
  • v-if 节点在不同的分支创建了 Block。 这是因为同一时间,v-if 只会命中一个分支,而不同分支下面的动态的节点肯定是不同的,所以需要分别创建 Block 维护。

运行时构造的 Block Tree

接下来看看运行时构造的 Block Tree

openBlock 的实现

我们来看 openBlock 的实现

js 复制代码
const blockStack = []
let currentBlock = null
function openBlock(disableTracking = false) {
  blockStack.push((currentBlock = disableTracking ? null : []));
}

openBlock 的实现很简单,就是往当前 blockStack 中 push 一个新的 Block,作为 currentBlock。

  • blockStack 表示一个 Block Tree 数组
  • currentBlock 表示当前的 Block,因为要考虑嵌套 Block 的情况。currentBlock 就是一个包含动态节点的数组

为什么要设计 openBlock 和 createBlock 两个函数呢?比如下面这个函数。

js 复制代码
function render() {
  return (openBlock(),createBlock('div', null, [/*...*/]))
}

并且需要让 openBlock 先执行,为什么不把 openBlock 和 createBlock 放在一个函数中执行?

因为 createBlock 函数的第三个参数是 children,这些 children 中的元素也是经过 createVNode 创建的,显然一个函数的调用需要先去执行参数的计算,也就是优先去创建子节点的 vnode,然后才会执行父节点的 createBlock。 所以,在父节点的 createBlock 函数执行前,子节点就已经通过 createVNode 创建了对应的 vnode ,但如果此时父节点的 openBlock 还未执行,那么其就没法将子节点作为动态节点添加到父 Block 中,就无法形成嵌套结构。 所以,每个节点在执行 createBlock 之前,都需要先执行 openBlock,这样才能正确收集每个节点的动态子节点。

动态 vnode 节点是什么时候被收集的?

设计 Block 的目的主要就是:收集动态的 vnode 的节点 这样才能在 patch 阶段只比对这些动态 vnode 节点,避免不必要的静态节点的比对,优化了性能。

而动态 vnode 节点是在 createVNode 阶段被收集的

js 复制代码
function createVNode(type, props = null,children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
  // 处理 props 相关逻辑,标准化 class 和 style
  // 对 vnode 类型信息编码 
  // 创建 vnode 对象
  // 标准化子节点,把不同数据类型的 children 转成数组或者文本类型。
  // 添加动态 vnode 节点到 currentBlock 中
  if (shouldTrack > 0 &&
    !isBlockNode &&
    currentBlock &&
    patchFlag !== 32 /* HYDRATE_EVENTS */ &&
    (patchFlag > 0 ||
      shapeFlag & 128 /* SUSPENSE */ ||
      shapeFlag & 64 /* TELEPORT */ ||
      shapeFlag & 4 /* STATEFUL_COMPONENT */ ||
      shapeFlag & 2 /* FUNCTIONAL_COMPONENT */)) {
    currentBlock.push(vnode);
  }
  return vnode
}

在 createVNode 函数的最后,会判断 vnode 是不是一个动态节点,如果是则把它添加到 currentBlock 中,这就是动态 vnode 节点的收集过程。

createBlock 的实现

js 复制代码
function createBlock(type, props, children, patchFlag, dynamicProps) {
  // 创建 vnode
  const vnode = createVNode(type, props, children, patchFlag, dynamicProps, true /* isBlock: 阻止这个 block 收集自身 */)
  // 在 vnode 上保留当前 Block 收集的动态子节点
  vnode.dynamicChildren = currentBlock || EMPTY_ARR
  closeBlock()
  // 节点本身作为父 Block 收集的子节点
  if (shouldTrack > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}
​
function closeBlock() {
  // 当前 Block 恢复到父 Block
  blockStack.pop()
  currentBlock = blockStack[blockStack.length - 1] || null
}

所以,blockStack 中先 push 父节点的 currentBlock,然后再 push 子节点 currentBlock,子节点的 currentBlock 收集完毕后,将子节点 vnode 直接添加到父节点的 currentBlock 中,这样就实现了嵌套的动态节点的收集。

patch 阶段的性能优化

在 patch 阶段,就可以根据编译优化中的 Block Tree 来做性能优化了。 patch 阶段更新节点元素的时候,会执行 patchElement 函数

js 复制代码
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, optimized) => {
  const el = (n2.el = n1.el)
  const oldProps = (n1 && n1.props) || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  // 更新 props
  patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
  const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
  // 更新子节点
  if (n2.dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren, currentContainer, parentComponent, parentSuspense, isSVG);
  }
  else if (!optimized) {
    patchChildren(n1, n2, currentContainer, currentAnchor, parentComponent, parentSuspense, isSVG);
  }
}

如果这个新 vnode 是一个 Block vnode,那么在优化的场景下,我们更新它的子节点不用通过 patchChildren 全量比对,只需要通过 patchBlockChildren 去比对并更新 Block 中的动态子节点即可。

patchBlockChildren 的实现

patchBlockChildren 的实现很简单:

  1. 遍历新的动态子节点数组,拿到对应的新旧动态子节点
  2. 根据新旧动态子节点执行 patch 更新子节点即可
js 复制代码
const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    // 确定待更新节点的容器
    const container =
      // 对于 Fragment,我们需要提供正确的父容器
      oldVNode.type === Fragment ||
      // 在不同节点的情况下,将有一个替换节点,我们也需要正确的父容器
      !isSameVNodeType(oldVNode, newVNode) ||
      // 组件的情况,我们也需要提供一个父容器
      oldVNode.shapeFlag & 6 /* COMPONENT */
        ? hostParentNode(oldVNode.el)
        :
        // 在其他情况下,父容器实际上并没有被使用,所以这里只传递 Block 元素即可
        fallbackContainer
    patch(oldVNode, newVNode, container, null, parentComponent, parentSuspense, isSVG, true)
  }
}

这样一来,更新的复杂度就变成和动态节点的数量正相关,而不与模板大小正相关,如果一个模板的动静比越低,那么性能优化的效果就越明显。

问题

问题描述

Block 数组是一维的,但是动态的子节点可能有嵌套关系,patchBlockChildren 内部也是递归执行了 patch 函数,那么在整个更新的过程中,会出现子节点重复更新的情况吗,为什么?

出现问题的原因

首先,是因为动态节点有两种

  • 嵌套的 Block vnode。只有根节点v-forv-if/v-else-if/v-else 的节点是 Block 节点
  • 普通的动态 vnode。<div>{{ str }}</div>

而在 patchBlockChildren 的时候,会遍历所有的动态节点执行 patch,所以这两种动态节点都可能会重复被 patch 到

问题答案

不会重复更新

  1. 如果是普通的动态 vnode,再次执行 patch 的时候还会执行到 patchElement,这个时候 vnode.dynamicChildren 为 null,并且由于 optimize 为 true,所以压根不会执行 patchChildren 去更新子节点。言下之意,在这种优化的场景下,普通的动态 vnode 执行 patchElement 只会更新自身的 props,而不会更新它的子节点,所以即使动态 vnode 出现嵌套也没有关系。
js 复制代码
if (dynamicChildren) {
  patchBlockChildren(
    n1.dynamicChildren!,
    dynamicChildren,
    el,
    parentComponent,
    parentSuspense,
    areChildrenSVG,
    slotScopeIds
  )
  if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
    traverseStaticChildren(n1, n2)
  }
} else if (!optimized) {
  // full diff
  // 更新子节点
  patchChildren(
    n1,
    n2,
    el,
    null,
    parentComponent,
    parentSuspense,
    areChildrenSVG,
    slotScopeIds,
    false
  )
}

// 普通动态 vnode 场景,只会更新自身的 props,而不会更新它的子节点
if (patchFlag > 0) {
  // the presence of a patchFlag means this element's render code was
  // generated by the compiler and can take the fast path.
  // in this path old node and new node are guaranteed to have the same shape
  // (i.e. at the exact same position in the source template)
  if (patchFlag & PatchFlags.FULL_PROPS) {
    // element props contain dynamic keys, full diff needed
    // 更新 props
    patchProps(
      el,
      n2,
      oldProps,
      newProps,
      parentComponent,
      parentSuspense,
      isSVG
    )
  } else {
    ...
  }
} else if (!optimized && dynamicChildren == null) {
  // unoptimized, full diff
  patchProps(
    el,
    n2,
    oldProps,
    newProps,
    parentComponent,
    parentSuspense,
    isSVG
  )
}

会走 patchFlag > 0 的情况,只更新 Props,不更新子节点。 :::danger 因为动态子节点会统一收集在 Block vnode 的 dynamicChildren(currentBlock) 中 遍历 dynamicChildren 的时候就会完成他们的更新。 :::

  1. 如果更新的节点是一个 Block vnode 的话,那么很简单,Block vnode 是有 dynamicChildren 的,递归执行 patchBlockChildren 即可,通过递归的方式,就可以完成组件下所有动态节点的更新了。

问题考察点

  1. 从编译到运行时阶段全方位彻底了解 Block Tree 的实现原理
  2. 搞清楚 patch 过程在优化场景非优化场景的异同

编译过程中的优化点总结

最后我们总结一下 Vue3 中的编译优化点。

动态节点的收集与 patchFlag

传统 Diff 算法无法避免新旧虚拟 DOM 树间无用的比较操作,是因为运行时得不到足够的关键信息,从而无法区分动态内容和静态内容。 换句话说,只要运行时能够区分动态内容和静态内容,就可以实现极简的优化策略

Vue3 就在编译阶段会对动态节点作标记,并且会通过 patchFlag 的类型来具体标明这个动态节点的哪部分是动态的。我们上文提到的 Block 节点就是动态节点,Block 节点一般包括模版根节点、 带有 v-for、v-if/v-else-if/v-else 等指令的节点。

有了对应的 patchFlag 标志,在 diff 算法期间就可以针对性地完成靶向更新。

hoistStatic 静态提升

静态提升可以减少更新时创建虚拟 DOM 带来的性能开销和内存占用。 静态提升是以树为单位的。包含动态绑定的节点本身不会被提升,但是该节点上的静态属性是可以被提升的。

js 复制代码
<div>
   <p foo="bar" a=b>{{ text }}</p>
</div>

// 静态提升的props对象
const hoistprop = { foo: 'bar', a: 'b'}
function render(ctx) {
  return (openBlock(), createBlock('div', null, [
    createVNode('p', hoistprop, ctx.text)
  ]))
}

预字符串化

基于静态提升,Vue3 进一步采用预字符串化进行优化。 采用预字符串化可以将多个静态节点序列化为字符串, 并生成一个 Static 类型的 VNode。 例如:

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

经过预字符串化后:

js 复制代码
const hoistStatic = createStatticVNode('<p></p><p></p>...20个...<p></p>')

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

带来的优势:

  • 大块的静态内容可以通过 innerHTML 设置, 在性能上有一定优势
  • 减少创建虚拟节点产生的性能开销
  • 减少内存占用

cacheHandler 缓存内联事件处理函数

内联函数在每次重新渲染时,都会为组件创建一个全新的 props 对象。同时,props 对象中的 onChange 等属性的值也会是全新的函数。这样会造成额外的性能开销。

例如:

html 复制代码
<Com @change="a+b"/>

在未经优化前的 render 函数:

js 复制代码
function render(ctx) {
  return h(Com, {
   // 内联事件处理函数
   onChange: () => (ctx.a + ctx.b)
  })
}

缓存优化后:

js 复制代码
function render(ctx, cache) { // 数组 cache 来自组件实例
  return h(Com, {
    // 将内联事件处理函数缓存到 cache 中
    onChange: cache[0] || cache[0] = ($event) => (ctx.a + ctx.b)
  })
}

v-once, v-memo

v-once 用来缓存全部或部分虚拟 DOM 节点,能够避免组件更新时重新创建虚拟 DOM 带来的性能开销, 也可以避免无用的 Diff 操作

示例:

html 复制代码
<section>
  <div v-once>{{ foo }}</div>
</section>

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 指令通常用于不会发生改变的动态绑定中,例如绑定一个常量。

html 复制代码
<div v-once>{{ SOME_CONSTANT }}</div>

:::info v-memo 后续文章会提到 :::

SSR 优化

在 SSR 渲染中,静态节点会直接输出字符串,绕过了虚拟 DOM 的创建。但动态节点还是需要动态渲染的。

辅助函数的 tree-shaking

编译时,会根据不同的情况,引入不同的 API。上文中的辅助函数,会根据模版的写法,用到了哪些指令,插值等,来动态的引入 createVNode 等需要的 API

相关推荐
Sam90292 分钟前
【Webpack--011】配置开发和生产模式的webpack.config.js
前端·javascript·webpack
喵喵酱仔__2 分钟前
css设置overflow:hiden行内元素会发生偏移的现象
前端·javascript·css
茶卡盐佑星_3 分钟前
vue中webpack的主要功能
前端·vue.js·webpack
今天不加班*5 分钟前
生动好看的css卡片样式
前端·javascript·css
黑客大佬5 分钟前
利用shuji还原webpack打包源码
服务器·前端·javascript·网络·数据库·webpack·node.js
八了个戒41 分钟前
前端项目代码开发规范及工具配置
前端·javascript·面试·html
Fanstay9851 小时前
Vue.js与Flask/Django后端配合:构建高效Web应用
vue.js
一粟1021 小时前
Vue.js: 构建动态用户界面的现代框架
javascript·vue.js·ui
前端李易安2 小时前
JavaScript 原型与原型链的特点,使用场景及案例
开发语言·javascript·原型模式
全栈学姐2 小时前
springbootKPL比赛网上售票系统
java·vue.js·spring boot·后端