初探 Vue3 编译之美

·  阅读 1101
初探 Vue3 编译之美

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

前言

在之前的文章中,分析了 Vue 2.x 版本的编译三部曲:parseoptimizegenerate

Vue 编译三部曲:如何将 template 编译成 AST ?

Vue 编译三部曲:模型树优化

Vue 编译三部曲:最后一曲,render code 生成

我们再整体来回顾一下 Vue 2.x 版本的编译三部曲:

三部曲第一步parse 将开发者写的 template 模板字符串转换成抽象语法树 AST ,AST 就这里来说就是一个树状结构的 JavaScript 对象,描述了这个模板,这个对象包含了每一个元素的上下文关系。

三部曲第二步optimize 深度遍历parse流程生成的 AST 树,去检测它的每一颗子树是不是静态节点,如果是静态节点表示生成的 DOM 永远不需要改变,这对运行时对模板的更新起到极大的优化作用,提升了运行效率。

三部曲第三步generate 把优化后的 AST 树转换成可执行的代码,即生成 render code。 为后续 vnode生成提供基础。

虽然 Vue 2.x 版本已经足够优秀,但是还存在一些瑕疵,所以在 Vue 3.x 版本进行了一些相关的优化。

Vue 3 与 Vue 2 相比,在 bundle 包大小方面(tree-shaking 减少了 41% 的体积),初始渲染速度方面(快了 55%),更新速度方面(快了 133%)以及内存占用方面(减少了 54%)都有着显著的性能提升。

这一系列的提升,在于 Vue 3.x 在三个大的方向进行了优化:

  • 源码体积优化
  • 数据劫持优化
  • 编译优化

那我们今天就来重点聊聊 Vue 3.x 的编译优化。

编译优化

我们知道,在 Vue 中通过通过数据劫持和依赖收集来进行重渲染,Vue.js 2.x 的数据更新并触发重新渲染的粒度是组件级的:

这里举一个例子说明:

<template>
  <div id="content">
    <p class="text">1</p>
    <p class="text">2</p>
    <p class="text">3</p>
    <p class="text">{{message}}</p>
    <p class="text">4</p>
    <p class="text">5</p>
    <p class="text">6</p>
  </div>
</template>
复制代码

大家看上面这段模板,虽然只有一个动态节点message。但在如果 message 值发生改变,对于单个组件来说,内部需要遍历该组件的整个 vnode 树,你没有听错,Vue 2.x 内部需要遍历整个 vnode树。

但是有很多节点其实本身并不需要进行 diff 和遍历,这间接导致了导致性能跟模版大小正相关,跟动态节点的数量无关,当一些组件的整个模版内只有少量动态节点时,这些遍历都是性能的浪费。

如果你还在用 Vue 2.x ,这就告诉我们尽量把模板组件化,从而来降低更新粒度的大小提升性能。

在 Vue.js 3.x ,通过编译阶段对静态模板的分析,编译生成了 Block treeBlock tree 是一个将模版基于动态节点指令切割的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要以一个 Array 来追踪自身包含的动态节点。

借助 Block treeVue.js 将 vnode 更新性能由与模版整体大小相关提升为与动态内容的数量相关,这是一个非常大的性能突破。

除此之外,Vue.js 3.0 在编译阶段还包含了对 Slot 的编译优化、事件侦听函数的缓存优化,并且在运行时重写了 diff 算法。

那在 Vue 3.x 是如果在编译节点对静态模板进行分析并且生成 Block tree的?

又是如何将模版基于动态节点指令进行切割?

又是如何只靠一个 Array 就能追踪自身包含的动态节点?

带着这些问题,正式开启今天的探索。

编译之前

我们知道,不管是 Vue 3.x 还是 Vue 2.x 其实本身都是存在服务器编译web 编译,但是本文我只会分享关于 web 编译,并不会分析服务器编译请注意。

先看看编译入口,Vue 3.x 的编译入口在 compile$1中,函数接受两个参数:

  • template:被编译模板字符串
  • options:编译配置
// TODO: 编译入口
function compile$1(template, options = {}) {
  return baseCompile(template, extend({}, parserOptions, options, {...}));
}
复制代码

compile$1函数内部调用baseCompile函数,通过baseCompile函数来完成编译工作。

function baseCompile(template, options = {}) {
  ...
  // TODO: 解析 template 生成 AST
  const ast = isString(template) ? baseParse(template, options) : template;
  ...
  // TODO: AST 转换
  transform(ast, extend({}, options, {
    prefixIdentifiers,
    nodeTransforms: [
      ...nodeTransforms,
      ...(options.nodeTransforms || []) // user transforms
    ],
    directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {} // user transforms
                               )
  }));
  // TODO: 生成代码
  return generate(ast, extend({}, options, {
    prefixIdentifiers
  }));
}
复制代码

baseCompile函数做三件事:

  • 模板字符串 template 的解析,将 template 解析成 AST
  • AST 转换
  • 代码生成

baseCompile是整个底层核心编译系统的入口,虽然内部逻辑相对比较复杂,但是功能很明确,就是将输入的模板编译成运行时产物render code。在通过执行render code生成vnode

这里其实大家有没有一个疑惑,为什么 Vue 不直接将 template 转换为 vnode?而是先生成 render code 函数在通过 render code 函数来生成 vnode

其实原因很简单,原因在于 Vue 中当状态发生改变之后,需要重渲染视图,而 vnode 是无法获取到最新的状态。所以需要一个运行时的执行器,来保证重渲染视图时,vnode 每次能拿到最新的状态。而 render code 函数本质上是一个可以执行的函数,能满足动态性,获取到最新的状态。

这里用一段模板来举个例子:

<div> 
  <!-- 这是一段注释 --> 
  {{msg}}
  <div>hello, {{msg}}.</div> 
  this is text.
</div> 
复制代码

在 Vue 2.x 版本会生成这样一段 render code。

with (this) {
  return _c('div', [
    _e(' 这是一段注释 '),
    _v(' \n        ' + _s(msg) + '\n        '),
    _c('div', [_v('hello, ' + _s(msg) + '.')]),
    _v(' \n        this is text.\n      '),
  ]);
}
复制代码

在 Vue 3.x 版本生成的是这样一段 render code。

const _Vue = Vue
const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode } = _Vue

const _hoisted_1 = /*#__PURE__*/_createTextVNode(" this is text. ")

return function render(_ctx, _cache) {
  with (_ctx) {
    const { createCommentVNode: _createCommentVNode, toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, createTextVNode: _createTextVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    return (_openBlock(), _createElementBlock("div", null, [
      _createCommentVNode(" 这是一段注释 "),
      _createTextVNode(_toDisplayString(msg) + " ", 1 /* TEXT */),
      _createElementVNode("div", null, "hello, " + _toDisplayString(msg) + ".", 1 /* TEXT */),
      _hoisted_1
    ]))
  }
}
复制代码

虽然看起来有很多差异,但是其实本质是一样的,生成的渲染函数字符串都是用with包裹的字符串。with的好处有两个:

  • with 作用域和模板的作用域正好契合
  • 编译生成with(xxx)可以在某种程度上实现对于作用域的动态注入

通过这两个好处,可以实现上文说的动态性。with 作用域和模板作用域契合 + 动态注入,模板中状态的变化可以直接映射到 render code中,在通过每次 render code 执行生成新的 vnode ,通过前后 vnode 的对比,实现动态更新。

好,回到主题,接着往下。

解析 template 生成 AST

不管是 Vue 2 还是 Vue 3 parse 阶段就是对 template 做解析,生成 AST 抽象语法树。只是在一些细节存在差异。

Vue 2.0 AST VS Vue 3.0 AST

用一段简单的模板来看看在 Vue 2 和 Vue 3 生成的 AST 有什么不一样,模板如下:

<div> 
  <!-- 这是一段注释 --> 
  {{msg}}
  <div>hello, {{msg}}.</div> 
  this is text.
</div> 
复制代码

Vue 2.x 生成的 AST

Vue 3.x 生成的 AST

我们发现尽管 Vue 2.x 和 Vue 3.x 版本在 AST 描述上有一些差异,但是 Vue 2.x 版本生成的 AST 还是 Vue 3.x 版本生成的 AST 本质上都是一样的, 都是一棵层级嵌套的 template 描述对象。AST 中的节点是可以完整地描述它在模板中映射的节点信息。

这里有一个注意点,Vue 3.x 版本的 AST 对象根节点其实是一个虚拟节点,它并不会映射到一个具体节点,另外它还包含了其他的一些属性,这些属性在后续的 AST 转换的过程中会赋值,并在生成代码阶段用到。

这个虚拟节点是干什么用的了?为什么要设计一个虚拟节点呢?

因为 Vue.js 3.x 和 Vue.js 2.x 有一个很大的不同,Vue.js 3.x 版本支持 Fragment 语法,即组件可以有多个根节点,比如:

<p>无用节点测试</p>
<div> 
  <!-- 这是一段注释 --> 
  {{msg}}
  <div>hello, {{msg}}.</div> 
  this is text.
</div> 
复制代码

这种写法在 Vue.js 2.x 中会报错,提示模板只能有一个根节点。

而 Vue.js 3.0 允许了这种写法。但是对于 AST 来说必须有一个根节点,所以虚拟节点在这种场景下就非常有用了,它可以作为 AST 的根节点。

那么接下来我们看一下如何根据模板字符串来构建这个 AST 对象吧。

baseParse

解析 template 的入口就是 baseParse函数。

我们来看看 baseParse函数做了什么?

const ast = isString(template) ? baseParse(template, options) : template;

function baseParse(content, options = {}) {
  // 创建解析上下文
  const context = createParserContext(content, options);
  const start = getCursor(context);
  // 解析 template,并创建 AST
  return createRoot(parseChildren(context, 0 /* DATA */, []), getSelection(context, start));
}
复制代码

从实现来看,主要做三件事:

createParserContext:创建解析上下文

通过解析上下文,创建一个保存初始化信息的对象:

  • options:解析相关的配置
  • column:当前代码的列号
  • line:当前代码的行号
  • offset:当前代码相对原始代码的偏移量
  • originalSource:表示最初的原始代码
  • source:当前代码
  • inPre:代码是否在 pre 标签内
  • inVPre:代码是否在 v-pre 指令下
  • onWarn:warn 函数
function createParserContext(content, rawOptions) {
        const options = extend({}, defaultParserOptions);
        let key;
        for (key in rawOptions) {
            // @ts-ignore
            options[key] =
                rawOptions[key] === undefined
                    ? defaultParserOptions[key]
                    : rawOptions[key];
        }
        return {
            // 解析相关的配置
            options,
            // 当前代码的列号
            column: 1,
            // 当前代码的行号
            line: 1,
            // 当前代码相对原始代码的偏移量
            offset: 0,
            // 表示最初的原始代码
            originalSource: content,
            // 当前代码
            source: content,
            // 代码是否在 pre 标签内
            inPre: false,
            // 代码是否在 v-pre 指令下
            inVPre: false,
            // warn 函数
            onWarn: options.onWarn
        };
    }
复制代码

上下文的作用就是在后续的解析过程中,对上下文的信息进行更新,用来表示当前解析的状态。

创建好上下文之后,就开始解析节点。

parseChildren:解析节点

parseChildren 函数会将 template 字符串进行逐一解析,将解析出来的 node放入到一个数组nodes中。然后会遍历生成的nodes中每一个节点的信息对空白符进行处理。处理空白符的一个目的是提升编译效率。

function parseChildren(context, mode, ancestors) {
    ...
    const nodes = [];
    while (!isEnd(context, mode, ancestors)) {
      ...
    }
    ...
    let removedWhitespace = false;
    if (mode !== 2 /* RAWTEXT */ && mode !== 1 /* RCDATA */) {
      ...
      for (let i = 0; i < nodes.length; i++) {
         ...
      }
    }
    return removedWhitespace ? nodes.filter(Boolean) : nodes;
 }
复制代码

createRoot:创建根节点

节点解析完成之后,baseParse 过程就剩之后一步,创建根节点。创建根节点的目的就是添加一个虚拟节点,原因在上文中有讲到,这里就不多赘述了。

function createRoot(children, loc = locStub) {
        return {
            type: 0 /* ROOT */,
            children,
            helpers: [],
            components: [],
            directives: [],
            hoists: [],
            imports: [],
            cached: 0,
            temps: 0,
            codegenNode: undefined,
            loc
        };
    }
复制代码

唯一需要注意的就是根节点的 type 类型是 0 。在 Vue 2.x 中,节点类型就三种:

  • type = 1基础元素节点
  • type = 2含有expressiontokens文本节点
  • type = 3纯文本节点或者是注释节点

在 Vue 3.x 版本,将节点类型分的比较细,type 类型也不止三种了。在后面会提到。

小结

由于编译这部分内容比较多,所以今天内容就先到这。下一篇文章,我们分析 Vue 3.x 中 template 生成 AST 的背后实现原理。Vue 3.x 是如果做 template 的解析?又和 Vue 2.x 中 template 生成 AST 有什么不同?带着疑问期待下一篇文章。

参考

分类:
前端
收藏成功!
已添加到「」, 点击更改