阅读 460

vue3中的编译器原理和优化策略

学习目标

  1. 编译器原理

  2. vue3编译过程剖析

  3. vue3编译优化策略

在初始化之前可能有编译的过程,最终的产物是个渲染函数,我们知道渲染函数返回的值是一个虚拟DOM(vnode),那么这个虚拟DOM在我们后续的更新过程中到底有什么作用呢?我们今天就来探讨一下。

编译器原理

1.概念

广义上的编译原理:编译器是将源代码转化成机器码的软件;所以编译的过程则是将源代码转化成机器码的过程,也就是 cpu 可执行的二进制代码。 例如使用高级语言java编写的程序需要编译成我们看不懂但计算机能看懂的的字节码。

如果了解过编译器的工作流程的同学应该知道,一个完整的编译器的工作流程会是这样:

  • 首先,parse 解析原始代码字符串,生成抽象语法树 AST。

  • 其次,transform 转化抽象语法树,让它变成更贴近目标「DSL」的结构。

  • 最后,codegen 根据转化后的抽象语法树生成目标「DSL」的可执行代码。

2.vue中的编译

在vue里也有编译的过程,我们经常写的那个HTML模版,在真正工作的时候,并不是那个HTML模版,它实际上是一个渲染函数,在这个过程中就发生了转换,也就是编译,也就是那个字符串的模版最终会变成一个JS函数,叫render函数。所以在这个过程中我们就需要引入编译器的概念。在计算机中当一种东西从一种形态到另一种形态进行转换的时候,就需要编译。**编译器:用来将模板字符串编译成为 JavaScript 渲染函数的代码 **

那么vue中的编译发生在什么时候呢?

这个时候我们就需要进一步了解vue包的不同版本的不同功能了。vue有携带编译器和不携带编译的包(对不同构建版本的解释)。

3.运行时编译

在使用携带编译器(compiler)的vue包的时候,vue编译的时刻是发生在挂载($mount)的时候。

4.运行时不编译

如果使用未携带编译器的vue包的时候,vue在运行时是不会进行编译的。那么它的编译又发生在什么时候呢?使用未携带编译器的vue包的时候,需要进行预编译,也就是基于构建工具使用,就是我们平时使用的vue-cli进行构建的项目,就是使用webpack调用vue-loader进行预编译,将所有vue文件,就是SFC,将里面的template模版部分转换成render函数。这样做的好处就是vue的包体积变小了,执行的时候速度更快了,因为不需要进行编译了。

vue编译器原理

简单来说就是:先将template模版转换成ast抽象语法树,ast再转换成渲染函数render。

那么什么是是ast抽象语法树呢?

1.ast抽象语法树

在template模版和render函数之间有一个中间产物叫做ast抽象语法树。它就是个js对象,它能够描述当前模版的结构信息,跟vnode很类似。注意,ast只是程序运行过程中编译产生的,它跟我们最终程序的运行是没有任何关系的。也就是当这个渲染函数生成之后,ast的生命周期就结束了,不再需要了,而那个虚拟DOM则伴随整个程序的生命周期。这个就是ast和虚拟DOM的本质区别。

2.为什么需要ast呢

在ast转换成render函数的过程中,需要进行特别的操作。第一次,将template转成的ast是个非常粗糙的js对象,是一次非常粗糙的转换,类似正则表达式的匹配,然后我们的template模版中还有很多表达式,指令,事件需要重新解析,经过这些具体的深加工的解析(transform)之后会得到一个终极ast,然后这个对这个终极ast进行generate,生成render函数

template => ast => transform => ast => render 复制代码

3.mini版vue编译器

下面我们来看一个mini版的vue编译器,具体代码已省略,具体代码我已经放在Github上了:mini-vue-compiler

function tokenizer(input) { ... } function parse(template) {      const tokens = tokenizer(template)      ... } function transform(ast) { ... } function traverse(ast, context) { ... } function generate(ast) { ... } function compile(template) {   // 1.解析   const ast = parse(template)   console.log(JSON.stringify(ast, null, 2))   // 2.转换   transform(ast)   // 3.生成   const code = generate(ast)   console.log(code)   // return function render(ctx){   //   return h("h3", {},   //   ctx.title   //   ) }    return new Function(code)() } let tmpl = `   <h3>{{title}}</h3>   ` compile(tmpl) 复制代码

大概有以上操作,其中parse函数就是发生在把template转换成ast的这过程,具体是通过一些正则表达式的匹配template中的字符串。比如将

xxx 转成ast对象,那么就是通过正则表达式匹配如果是那么就设置一个开始标记,再往后面匹配到xxx内容,然后就设置一个子元素,最后匹配到那么就设置一个结束标记,以此类推。parse解析之后得到的是一个粗糙的ast对象。经过parse解析得到一个粗糙的ast对象之后,就用transform进行深加工,最后要经过generate生成代码。


Vue3编译过程剖析

挂载的时候先把template编译成render函数,在创建实例之后,直接调用组件实例的render函数创建这个组件的真实DOM,然后继续向下做递归。

1.vue2.x和vue3.x的编译对比

Vue2.x 中的 Compile 过程会是这样:

  • parse 词法分析,编译模板生成原始粗糙的 AST。

  • optimize 优化原始 AST,标记 AST Element 为静态根节点或静态节点。

  • generate 根据优化后的 AST,生成可执行代码,例如 _c_l 之类的。

在 Vue3 中,整体的 Compile 过程仍然是三个阶段,但是不同于 Vue2.x 的是,第二个阶段换成了正常编译器都会存在的阶段 transform。

  • parse 词法分析,编译模板生成原始粗糙的 AST。

  • transform 遍历 AST, 对每一个 AST element 进行转化,例如文本元素、指令元素、动态元素等等的转化

  • generate 根据优化后的 AST,生成可执行代码函数。

2.源码编译入口

我们先从一个入口来开始我们的源码阅读, packages/vue/index.ts。

// web平台特有编译函数 function compileToFunction (   template: string | HTMLElement,   options?: CompilerOptions ): RenderFunction { // 省略...   if (template[0] === '#') {     // 获取模版内容     const el = document.querySelector(template) // 省略...     template = el ? el.innerHTML : ''   }   // 编译   const { code } = compile(     template,     extend(       {         // 省略...       },       options     )   )   const render = (__GLOBAL__     ? new Function(code)()     : new Function('Vue', code)(runtimeDom)) as RenderFunction   // 省略...   return (compileCache[key] = render) } // 注册编译函数 registerRuntimeCompiler(compileToFunction) export { compileToFunction as compile } 复制代码

这个入口文件的代码比较简单,只有一个 compileToFunction 函数,但函数体内的内容却又比较关键,主要是经历以下步骤:

  1. 依赖注入编译函数至runtime registerRuntimeCompiler(compileToFunction)

  2. runtime调用编译函数 compileToFunction

  3. 调用compile函数

  4. 返回包含code的编译结果

  5. 将code作为参数传入Function的构造函数 将生成的函数赋值给render变量

  6. 将render函数作为编译结果返回

3.template获取

app.mount()获取了template  packages/runtime-dom/src/index.ts

mount-get-template.png

4.编译template

compile将传⼊template编译为render函数,packages/runtime-core/src/component.ts

compile.png

实际执⾏的是baseCompile,packages/compiler-core/src/compile.ts

parse.png

第⼀步解析-parse:解析字符串template为抽象语法树ast

第⼆步转换-transform:解析属性、样式、指令等

第三步⽣成-generate:将ast转换为渲染函数

Vue3编译器优化策略

这是一个非常典型的用内存换时间的操作

1.静态节点提升

<div>   <div>{{msg}}</div>   <p>coboy</p>   <p>coboy</p>   <p>coboy</p> </div> 复制代码

以上这个段template如果没有开启静态节点提升它编译后是这样的:

import { toDisplayString as _toDisplayString, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue" export function render(_ctx, _cache, $props, $setup, $data, $options) {   return (_openBlock(), _createBlock("div", null, [     _createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),     _createVNode("p", null, "coboy"),     _createVNode("p", null, "coboy"),     _createVNode("p", null, "coboy")   ])) } 复制代码

如果开启了静态节点提升之后它编译后则是这样的:

import { toDisplayString as _toDisplayString, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue" const _hoisted_1 = /*#__PURE__*/_createVNode("p", null, "coboy", -1 /* HOISTED */) const _hoisted_2 = /*#__PURE__*/_createVNode("p", null, "coboy", -1 /* HOISTED */) const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "coboy", -1 /* HOISTED */) export function render(_ctx, _cache, $props, $setup, $data, $options) {   return (_openBlock(), _createBlock("div", null, [     _createVNode("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),     _hoisted_1,     _hoisted_2,     _hoisted_3   ])) } 复制代码

我们可以看到template里存在大量的不会变的p标签,所以当这个组件重新渲染的时候,这些静态的不会变的标签就不应该再次创建了。所以vue3就把这些静态的不会变的标签的VNode放在了render函数作用域的外面,在下次render函数再次执行的时候,那些静态标签的VNode已经在内存里了,不需要重新创建了。相当于占用当前机器的内存,避免重复创建VNode,用内存来换时间。 大家仔细斟酌一番静态提升的字眼,静态二字我们可以不看,但是提升二字,直抒本意地表达出它(静态节点)被提高了。

2.补丁标记和动态属性记录

<div>   <div :title="title">coboy</div> </div> 复制代码

意思就是在编译的过程中,像人眼一样对模版进行扫描看哪些东西是动态的,然后提前把这些动态的东西提前保存起来,作个标记和记录,等下次更新的时候,只更新这些保存起来的动态的记录。比如上面模版的title是动态的,提前做个标记和记录,更新的时候就只更新title部分的内容。

import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue" export function render(_ctx, _cache, $props, $setup, $data, $options) {   return (_openBlock(), _createBlock("div", null, [     _createVNode("div", { title: _ctx.title }, "coboy", 8 /* PROPS */, ["title"])   ])) } 复制代码

<div>   <div :title="title">{{text}}</div> </div> 复制代码

import { toDisplayString as _toDisplayString, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue" export function render(_ctx, _cache, $props, $setup, $data, $options) {   return (_openBlock(), _createBlock("div", null, [     _createVNode("div", { title: _ctx.title }, _toDisplayString(_ctx.text), 9 /* TEXT, PROPS */, ["title"])   ])) } 复制代码

我们可以观察到在_createVNode函数的第四个参数是个9,后面是一个注释:/* TEXT, PROPS */,这个是表示在当前的节点里面有两个东西是动态的,一个是内部的文本,一个是属性,然后具体是哪个属性,在第五个参数的数组里面则记录了下来["title"],有个title的属性是动态的。

在将来进行patch更新的时候,就可以根据当前记录的信息,进行更新,缩减更新过程和操作,可以非常精确地只进行title和文本的更新。

如果div标签里是静态文本的话,_createVNode函数的第四个参数则变成了8,后面的注释变成了:/* PROPS */,后面的第五个参数数据不变。

_createVNode函数的第四个参数的数字其实是一个二进制数字转成十进制的数字。

8的二进制是1000,9的二进制是1001,很容易可以看出二进制的每一位的数字都代表着特殊的含义。这些数字就是patchFlag,那么什么是patchFlag呢?

什么是 patchFlag

patchFlagcomplier 时的 transform 阶段解析 AST Element 打上的补丁标记。它会为 runtime 时的 patchVNode 提供依据,从而实现靶向更新 VNode 和静态提升的效果。

patchFlag 被定义为一个数字枚举类型,它的每一个枚举值对应的标识意义是:

  1. TEXT = 1  动态文本的元素

  2. CLASS = 2  动态绑定 class 的元素

  3. STYLE = 4  动态绑定 style 的元素

  4. PROPS = 8  动态 props 的元素,且不含有 classstyle 绑定

  5. FULL_PROPS = 16  动态 props 和带有 key 值绑定的元素

  6. HYDRATE_EVENTS = 32  事件监听的元素

  7. STABLE_FRAGMENT = 64  子元素的订阅不会改变的 Fragment 元素

  8. KEYED_FRAGMENT = 128  自己或子元素带有 key 值绑定的 Fragment 元素

  9. UNKEYED_FRAGMENT = 256  没有 key 值绑定的 Fragment 元素

  10. NEED_PATCH=512  带有 ref指令的元素

  11. DYNAMIC_SLOTS = 1024  动态 slot 的组件元素

  12. HOISTED = -1  静态的元素

  13. BAIL = -2  不是 render 函数生成的一些元素,例如 renderSlot

整体上 patchFlag 的分为两大类:

  • 当 patchFlag 的值大于 0 时,代表所对应的元素在 patchVNode 时或 render 时是可以被优化生成或更新的

  • 当 patchFlag 的值小于 0 时,代表所对应的元素在 patchVNode 时,是需要被 full diff,即进行递归遍历 VNode tree 的比较更新过程。

以上就是vue3的一个非常高效的优化策略叫补丁标记和动态属性记录。

3.缓存事件处理程序

<div>   <div @click="onClick">coboy</div> </div> 复制代码

将来框架会像react那样把@click="onClick"变成@click="() => onClick()",最后可能是这样的一个箭头函数。那就意味着每次onClick的函数都是一个全新的函数,那就会造成这个回调函数明明没有变,都会被认为变了,那就必须进行一系列的更新,那么如果能把这个回调函数缓存起来,更新的时候,就不要再创建了。

未进行缓存事件处理程序之前的编译

import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue" export function render(_ctx, _cache, $props, $setup, $data, $options) {   return (_openBlock(), _createBlock("div", null, [     _createVNode("div", { onClick: _ctx.onClick }, "coboy", 8 /* PROPS */, ["onClick"])   ])) } 复制代码

进行缓存事件处理程序之后的编译

import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue" export function render(_ctx, _cache, $props, $setup, $data, $options) {   return (_openBlock(), _createBlock("div", null, [     _createVNode("div", {       onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.onClick(...args)))     }, "coboy")   ])) } 复制代码

4.块block

这是什么意思呢?根据尤雨溪本人的解析,他说,根据他的统计那个动态的部分最多只有三分之一,基本上都是静态部分,所以在编译的过程中,能不能发现那个比较小的动态部分,把它放到比较靠上的等级模块上,那么就可以称那个比较靠上的模块为block,那么意味着将来这个模块重新更新的时候,就不会再向下进行一层一层递归了,只做这个模块的动态部分的更新。大家都知道patch的过程,最耗时间的就是那个递归的过程,现在把这个递归的过程省略了大部分,则性能大大提高了。

我们观察上面生成的编译代码,在render函数里面,先进行了一个**_openBlock**(),意思是先打开一个块,再**_createBlock创建一个块,把那些动态的部分保存在一个叫dynamicChildren的属性里,将来这个模块更新的时候,只做dynamicChildren**里更新,其他不再处理,非常的高效。

了解过何为靶向更新的同学应该知道,它的实现离不开 VNode Tree 上的 dynamicChildren 属性,dynamicChildren 则是用来承接整个 VNode Tree 中的所有动态节点, 而标记动态节点的过程又是在 compile 编译的 transform 阶段,可以说是环环相扣,所以,这也是我们常说的 Vue3 的 Runtime 和 Compile 的巧妙结合。

显然在 Vue2.x 是不具备构建 VNode 的 dynamicChildren 属性的条件。

靶向更新的本质是为了从一颗存在动态、静态节点的 VNode Tree 中筛选出动态的节点形成 Block Tree,即 dynamicChildren,然后在 patch 时实现精准、快速的更新。所以,显然 v-for 形成的 VNode Tree 它不需要靶向更新

以上的就是vue3利用编译器做的优化,非常的高效,特别厉害,这点我觉得比react做得更好,vue3在这一块非常的优秀,值得点个赞。


作者:coboy
链接:https://juejin.cn/post/7017064780263325727

文章分类
后端
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐