Vue 3.x 相对于 Vue 2.x 做了那些额外的性能优化?

1,344 阅读3分钟

场景一:Vue 3.x 相对于 Vue 2.x 做了那些额外的性能优化?

要理解 Vue 3 的性能优化的核心,就需要了解 Vuejs 的核心设计理念。我们知道 Vuejs 官网上有一句话总结的特别到位:渐进式 JavaScript 框架,易学易用,性能出色,适用于场景丰富的 Web 框架。 其实我们的答案就蕴藏在这句话里。

首先,我们知道当我们浏览 Web 网页时,有两类场景会制约 Web 网页的性能

  1. 网络传输的瓶颈
  2. CPU的瓶颈

所以要回答这个问题,就可以直接从这两方面入手。

网络传输的瓶颈优化

对于前端框架而言,制约网络传输的因素最大的就是代码体积,代码体积越大,传输效率越慢。尤其对于 SPA 单页应用的 CSR(客户端渲染) 而言。一个大体积的框架资源,就意味着用户需要等待白屏的时间越长。而 Vue 3 在减少源码体积方面做的最多的就是通过精细化的 Tree-Shacking 机制来构建 渐进式 代码。

1. /*#__PURE__*/ 标记

我们知道 Tree-Shaking 可以删除一些 DC(dead code) 代码。但是对于一些有副作用的函数代码,却是无法进行很好的识别和删除,举个例子:

foo()

function foo(obj) {
  obj?.a
}

上述代码中,foo 函数本身是没有任何意义的,仅仅是对对象 obj 进行了属性 a 的读取操作,但是 Tree-Shaking 是无法删除该函数的,因为上述的属性读取操作可能会产生副作用,因为 obj 可能是一个响应式对象,我们可能对 obj 定了一个 gettergetter 中触发了很多不可预期的操作。

如果我们确认 foo 函数是一个不会有副作用的纯净的函数,那么这个时候 /*#__PURE__*/ 就派上用场了,其作用就是告诉打包器,对于 foo 函数的调用不会产生副作用,你可以放心地对其进行 Tree-Shaking

另外,值得一提的是,在 Vue 3 源码中,包含了大量的 /*#__PURE__*/ 标识符,可见 Vue 3 对源码体积的控制是多么的用心!

2. 特性开关

Vue 3 源码中的 rollup.config.mjs 中有这样一段代码:

{
  __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
}

其中 __FEATURE_OPTIONS_API__ 是一个构建时的环境变量,我们知道 Vue 3 在某些 API 方面是兼容 Vue 3 写法的,比如 Options API。但是如果我们在项目中仅仅使用 Compositon API 而不想使用 Options API 那么我们就可以在项目构建时关闭这个选项,从而减少代码体积。我们看看这个变量在 Vue 3 源码中是如何使用的:

// 兼容 2.x 选项式 API
if (__FEATURE_OPTIONS_API__) {
  currentInstance = instance
  pauseTracking()
  applyOptions(instance, Component)
  resetTracking()
  currentInstance = null
}

用户可以通过设置 __VUE_OPTIONS_API__ 预定义常量的值来控制是否要包含这段代码。通常用户可以使用 webpack.DefinePlugin 插件来实现:

// webpack.DefinePlugin 插件配置
new webpack.DefinePlugin({
  __VUE_OPTIONS_API__: JSON.stringify(true) // 开启特性
})

除此之外,类似的开发环境会通过 __DEV__ 来输出告警规则,而在生产环境剔除这些告警降低构建后的包体积都是类似的手段:

if (__DEV__) {
  console.warn(`value cannot be made reactive: ${String(target)}`)
}

CPU 瓶颈优化

当项目变得庞大、组件数量繁多时,就容易遇到CPU的瓶颈。主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。

我们知道,JS可以操作DOM,GUI渲染线程JS线程是互斥的。所以JS脚本执行浏览器布局、绘制不能同时执行。

在每16.6ms时间内,需要完成如下工作:

JS脚本执行 -----  样式布局 ----- 样式绘制

当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局样式绘制了,也就出现了丢帧的情况,会发生卡顿。

为了解决庞大元素组件渲染、更新卡顿的问题,Vue 的策略是一方面采用了组件级的细粒度更新,控制更新的影响面:Vue 3 中,每个组件都会生成一个渲染函数,这些渲染函数执行时会进行数据访问,此时这些渲染函数被收集进入副作用函数中,建立数据 -> 副作用的映射关系。当数据变更时,再触发副作用函数的重新执行,即重新渲染。

image.png

另一方面则在编译器中做了大量的静态优化,得益于这些优化,才让我们可以 易学易用的写出性能出色的 Vue 项目。 下面简单介绍几种编译时优化策略:

1. 靶向更新

假设有以下模板:

<template>
  <p>hello world</p>
  <p>{{ msg }}</p>
</template>>

其中一个 p 标签的节点是一个静态的节点,第二个 p 标签的节点是一个动态的节点,如果当 msg 的值发生了变化,那么理论上肉眼可见最优的更新方案应该是只做第二个动态节点的 diff 而无需进行第一个 p 标签节点的 diff

上述模版转成 vnode 后的结果大致为:

const vnode = {
  type: Symbol(Fragment),
  children: [
    { type: 'p', children: 'hello world' },
    { type: 'p', children: ctx.msg, patchFlag: 1 /* 动态的 text */ },
  ],
  dynamicChildren: [
    { type: 'p', children: ctx.msg, patchFlag: 1 /* 动态的 text */ },
  ]
}

此时组件内存在了一个静态的节点 <p>hello world</p>,在传统的 diff 算法里,还是需要对该静态节点进行不必要的 diff

Vue3 则是先通过 patchFlag 来标记动态节点 <p>{{ msg }}</p> 然后配合 dynamicChildren 将动态节点进行收集,从而完成在 diff 阶段只做靶向更新的目的。

2. 静态提升

接下来,我们再来说一下,为什么要做静态提升呢? 如下模板所示:

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

在没有被提升的情况下其渲染函数相当于:

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", null, "text")
  ]))
}

很明显,p 标签是静态的,它不会改变。但是如上渲染函数的问题也很明显,如果组件内存在动态的内容,当渲染函数重新执行时,即使 p 标签是静态的,那么它对应的 VNode 也会重新创建。

所谓的 “静态提升”,就是将一些静态的节点或属性提升到渲染函数之外。如下面的代码所示:

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "text", -1 /* HOISTED */)
const _hoisted_2 = [
  _hoisted_1
]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, _hoisted_2))
}

这就实现了减少 VNode 创建的性能消耗。

而这里的静态提升步骤生成的 hoists,会在 codegenNode 会在生成代码阶段帮助我们生成静态提升的相关代码。

预字符串化

Vue 3 在编译时会进行静态提升节点的 预字符串化。什么是预字符串化呢?一起来看个示例:

<template>
  <p></p>
  ... 共 20+ 节点
  <p></p>
</template>

对于这样有大量静态提升的模版场景,如果不考虑 预字符串化 那么生成的渲染函数将会包含大量的 createElementVNode 函数:假设如上模板中有大量连续的静态的 p 标签,此时渲染函数生成的结果如下:

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, null, -1 /* HOISTED */)
// ...
const _hoisted_20 = /*#__PURE__*/_createElementVNode("p", null, null, -1 /* HOISTED */)
const _hoisted_21 = [
  _hoisted_1,
  // ...
  _hoisted_20,
]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, _hoisted_21))
}

createElementVNode 大量连续性创建 vnode 也是挺影响性能的,所以可以通过 预字符串化 来一次性创建这些静态节点,采用 与字符串化 后,生成的渲染函数如下:

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<p></p>...<p></p>", 20)
const _hoisted_21 = [
  _hoisted_1
]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, _hoisted_21))
}

这样一方面降低了 createElementVNode 连续创建带来的性能损耗,也侧面减少了代码体积。