vue3 提速小巧思🚀,值得一提的编译优化!

5,436次阅读
没有评论

共计 6379 个字符,预计需要花费 16 分钟才能阅读完成。

Vue 卷了一代又一代,可谓是越卷越快,从 Vue2Vue3 更是完成了一个大的飞跃!而 Vue3 之所以这么快,很大一部分归功于它做的一系列 编译优化 策略。

我们今天就来看看它究竟修炼了什么秘籍来提升速度。

vue3 提速小巧思🚀,值得一提的编译优化!

在讲解编译优化之前,我们先来盘一盘 Vue 是如何把 我们写的模板变成页面上的真实 DOM 的。

下面是一个组件对应的模板:

template>
  div>
    h1>我是静态文本h1>
    h2>{{dynamicText}}h2>
  div>
template>

模板编译

Vue2 时代,这段模板会被编译器编译成类似下面这样的渲染函数:

// 渲染函数
export function render(ctx) {
  return (createElementVNode("div", null, [
    createElementVNode("h1", null, "我是静态文本"),
    // 新增一个参数 1
    createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
  ]))
}

这里的 createElementVNode 方法简单来说就是:创建并返回虚拟 DOM 的方法

function createElementVNode(tag, props, children) {
  const vnode = {
    tag,
    props,
    children,
    key: props.key
  }
  return vnode
}

运行时

接下来,如果我们的代码是在浏览器环境运行的话,上面的渲染函数会被交给浏览器;

这时候就是 Vue 运行时 的地盘了,上面的渲染函数会被执行最终生成如下的虚拟 DOM:

// 旧 vnode
const vnode = {
  tag: 'div',
  children: [
    {
      tag: 'h1',
      children: '我是静态文本'
    },
    {
      tag: 'h2',
      children: ctx.dynamicText
    }
  ]
}

Vue渲染器 (renderer) 会负责调用浏览器平台相关的 API,把 虚拟 DOM 渲染成真实的 DOM 元素

而当 dynamicText 变量对应的内容发生了改变之后,由于这是个响应式数据;它会导致这个 ** 组件的渲染函数被再次执行,生成新的虚拟 DOM **;

这时候 Vuediff 算法 开始发挥作用,通过层层比较新旧虚拟 DOM 节点,diff 算法 找到了差异部分,也就是变量 dynamicText 所在的部分。

最后,渲染器 (renderer) 再次调用浏览器相关 API 将差异的部分更新到页面上。

这么一盘,不知道细心的小伙伴们有没有发现问题所在?

在我们给出的这段模板中,实际上 只有 dynamicText 所在的部分是可能发生变化的,但是在进行 diff 算法 时,却需要层层向下进行比较;

如果模板中有大量静态的节点,只有一个动态的部分,这样逐层的比较无疑是十分浪费性能的。

Vue3 中是如何解决这个问题的呢?

Vue3 的解决思路是:给模板中动态的部分加上标识,并将它们提取出来存放在虚拟 DOM 中独立的一块区域

比如像这样:

const vnode = {
  tag: 'div',
  children: [
    {tag: 'h1', children: '我是静态文本' },
    {
      tag: 'h2',
      children: ctx.dynamicText,
      // 增加一个 patchFlag 标识
      patchFlag: 1
    }
  ],
  // 专门用于存放动态节点
  dynamicChildren: [
    {tag: 'h2', children: ctx.dynamicText, patchFlag: 1 }
  ]
}

这里的 patchFlag 是个 number 类型,不同的数值代表了不同的含义:

const patchFlags = {
  TEXT: 1, // 表示动态文本内容
  CLASS: 2, // 表示动态的 class
  STYLE: 3, // 表示动态的 style
  PROPS: 4 // 表示动态的 props
  // 省略...
}

这样一来,在渲染器进行 diff 对比新旧 vnode 时就 只需要对比存放了动态节点的 dynamicChildren 数组就可以了

同时由于动态节点上存在着 patchFlag 标识,明确指出了当前节点需要更新的部分,从而达到 靶向更新 的效果。

在具体实现上,Vue 首先在 编译模板时就会收集相关的动态节点信息,将动态节点的相关信息体现在最终生成的渲染函数上:

export function render(ctx) {
  return (createElementVNode("div", null, [
    createElementVNode("h1", null, "我是静态文本"),
    // 新增一个参数 1
    createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
  ]))
}

可以看到,新的渲染函数中给 createElementVnode 方法传递第 4 个参数:数字 1;它表示这个节点是一个有动态文本内容的节点;

现在已经有了动态节点的标识 patchFlag,接下来就要考虑如何将这些动态节点收集到根节点 divvnode 下的 dynamicChildren 数组中了。

这里有一点需要注意 ——

上面渲染函数是 由内向外执行 的,也就是说 h1h2 节点的 createElementVnode 会先执行,最后才到 div 节点对应的 createElementVnode 方法;

divcreateElementVNode 执行时,动态节点 h2createElementVNode 方法已经执行完毕,那么 div 就无法将对应的节点收集到自身的 dynamicChildren 数组中。

为了解决这个问题,Vue3 使用了一个 栈的结构 配合 openBlock 方法来 临时储存内层的动态节点

// 创建一个临时栈以及 openBlock 方法
// 临时栈 用于还原渲染函数执行时的层级关系
const dynamicChildrenStack = []
// 用于储存当前的动态节点
let currentDynamicChildren = null

function openBlock() {
  const currentDynamicChildren = []
  dynamicChildrenStack.push(currentDynamicChildren)
}

同时修改 createElementVNode 方法:

// 增加第四个入参 patchFlags
function createElementVNode(tag, props, children, patchFlags) {
  const vnode = {
    tag,
    props,
    children,
    key: props.key
  }
  // 如果当前节点是个动态节点,就将其 push 进 currentDynamicChildren 中
  if (typeof patchFlags !== 'undefined' && currentDynamicChildren) {
    currentDynamicChildren.push(vnode)
  }
  return vnode
}

针对那些需要收集动态节点的 vnode,对应的创建虚拟节点的方法也需要改变:

// 增加一个 closeBlock 方法,用于将当前的动态节点集合从栈中弹出
function closeBlock() {
  currentDynamicChildren = dynamicChildrenStack.pop()
}

// 创建一个带有 dynamicChildren 的 vnode
function createElementBlock() {
  const block = createElementVnode()
  block.dynamicChildren = currentDynamicChildren
  // 最后调用 closeBlock 方法关闭当前动态节点的收集
  closeBlock()
  return block
}

最后修改渲染函数,在创建 vnode 之前先调用 openBlock 方法,并将 div 节点对应的 createElementVNode 方法修改为 createElementBlock

export function render(ctx) {
  // 先调用 openBlock 方法来创建临时栈
  // 并将原本 div 的 createElementVNode 方法修改为 createElementBlock
  return (openBlock(), createElementBlock("div", null, [
    createElementVNode("h1", null, "我是静态文本"),
    // 新增一个参数 1
    createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
  ]))
}

这样一来就能够将 h2 对应的动态节点正确的收集到根节点 divdynamicChildren 中了。

而这种 带有 dynamicChildrenvnode 则被称为块 (Block)

说完了 Vue3 是如何提取动态内容来实现靶向更新,我们再来看看 静态提升

还是原先那段模板,正常情况下它对应的渲染函数如下:

export function render(ctx) {
  return (openBlock(), createElementBlock("div", null, [
    createElementVNode("h1", null, "我是静态文本"),
    // 新增一个参数 1
    createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
  ]))
}

而这个渲染函数中,h1 标签对应的 createElementVNode 逻辑是不会发生变化的;

因此,我们可以 将这段逻辑抽离到 render 函数之外,避免由于渲染函数重新执行导致这段逻辑再次执行,从而造成浪费

// 这里将创建 vnode 的逻辑提取到 render 函数之外
const hoist1 = createElementVNode("h1", null, "我是静态文本")
export function render(ctx) {
  return (openBlock(), createElementBlock("div", null, [
    hoist1, // 这里使用 h1 创建 vnode 逻辑的引用
    createElementVNode("h2", null, ctx.dynamicText, 1 /* TEXT */)
  ]))
}

除了针对整个节点的静态提升之外,如果节点的内容是动态的,但是 属性是纯静态的,也可以进行属性的静态提升。

如下模板:

template>
  h2
    class="hello"
    style="color:red"
    id="1"
  >
   {{dynamicText}}
  h2>
template>

静态提升后的渲染函数:

// 针对属性进行静态提升
const hoist1 = {
  class: "hello",
  style: {"color":"red"},
  id: "1"
}

export function render(ctx) {
  return (openBlock(), createElementBlock("template", null, [
    // 这里的第二个参数变为静态引用
    createElementVNode("h2", hoist1, ctx.dynamicText, 1 /* TEXT */)
  ]))
}

预字符串化

基于静态提升 Vue3 还做了更进一步的优化 —— 预字符串化。

所谓预字符串化就是:将静态提升后的节点预先序列化为字符串

比如下面这段模板:

template>
  h1>texth1>
  // ... 中间省略 8 个 h1>texth1>
  h1>texth1>
template>

当上面这样的 连续的静态内容超过了一定数量(10 个),那么 Vue3 就会将这些节点合并为一个静态 vnode 调用

// 将 

text

合并成一个静态节点的创造
const hoist1 = /*#__PURE__*/createStaticVNode("

text

//...

text

"
, 10)
const hoist11 = [ hoist1 ] export function render(_ctx) { return (openBlock(), createElementBlock("template", null, hoist11)) }

这样一来就可以 避免创建虚拟 DOM 带来的开销,同时这部分静态内容可以通过 innerHTML 直接设置,对性能也有一定的提升

除了对静态节点的提升,Vue3 还对于 内联事件 做了缓存。


  

上面这段模板,在没有对内联事件进行缓存时,编译后的渲染函数如下:

export function render(ctx) {
  return (openBlock(), createElementBlock("template", null, [
    createElementVNode("button", {
      // 创建一个内联函数
      onClick: $event => (ctx.num++)
    }, "点我", 8 /* PROPS */, ["onClick"])
  ]))
}

很显然,每次 render 函数重新执行都会重新创建一个 onClick 函数;

这样一来,由于 onClick 函数前后的指针不一致,渲染器会认为 button 的属性被更新了,从而去更新 button 按钮,造成额外的开销

因此 Vue3针对内联事件进行了缓存

具体做法就是:将这些内联事件缓存在一个数组 cache 中,并渲染函数执行时优先从缓存中读取事件处理函数

// 给渲染函数新增一个 cache 参数
export function render(ctx, cache) {
  return (openBlock(), createElementBlock("template", null, [
    createElementVNode("button", {
      // 优先从缓存中读取事件处理函数
      onClick: cache[0] || (cache[0] = $event => (ctx.num++))
    }, "点我")
  ]))

这里传给 render 函数的第二个参数 cache 就是用于缓存内联事件的数组,它与 ctx 一样被挂在对应的 组件实例 上。

如果模板中使用了 v-once 指令,Vue3 还可以实现对虚拟 DOM 的缓存。

在 Vue 中 v-once 是用于 将元素或组件标记为只渲染一次的静态内容

假设有如下模板,在模板上使用了 v-once 指令:

template>
  div v-once>{{text}}div>
template>

上面的模板编译成渲染函数后:

export function render(ctx, cache) {
  return (openBlock(), createElementBlock("template", null, [
    cache[0] || (
      // 阻止 Block 追踪
      setBlockTracking(-1),
      // 创建对应虚拟节点并缓存
      cache[0] = createElementVNode("div", null, [
        createTextVNode(ctx.text, 1 /* TEXT */)
      ]),
      // 恢复 Block 追踪
      setBlockTracking(1),
      // 从缓存中读取对应虚拟节点并返回
      cache[0]
    )
  ]))
}

从上面的代码中可以看出,虚拟 DOM 的缓存思路与内联事件的类似 —— 都是 优先从缓存中读取结果,没有读取到的情况下再进行重新创建

而值得一提的是:由于这段虚拟节点已经被缓存了是不会变化的,因此在父节点进行 块(Block) 收集时 它不应该被收集到 dynamicChildren

所以可以看到,代码中使用了一个 setBlockTracking(-1) 方法来阻止它被收集,并在其创建虚拟 DOM 的逻辑完成后,使用 setBlockTracking(1) 方法来恢复收集。

以上就是 Vue3 针对编译优化所做的全部内容。

各位小伙伴还可以戳这里来帮助理解编译优化:vue3 模板在线编译结果

这个网站可以很直观的看到经过静态提升、内联事件缓存等优化后的编译结果。

That’s all 🌋🌋🌋

    正文完
     0
    Yojack
    版权声明:本篇文章由 Yojack 于2024-09-23发表,共计6379字。
    转载说明:
    1 本网站名称:优杰开发笔记
    2 本站永久网址:https://yojack.cn
    3 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长进行删除处理。
    4 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
    5 本站所有内容均可转载及分享, 但请注明出处
    6 我们始终尊重原创作者的版权,所有文章在发布时,均尽可能注明出处与作者。
    7 站长邮箱:laylwenl@gmail.com
    评论(没有评论)