硬解析优化_从一个 babel 插件探索 react 的预编译优化

本文探讨如何在React中通过Babel插件实现类似Vue的v-for指令,以此为基础深入讨论React的预编译优化。通过分析AST并编写Babel插件,将r-for转换为map方法,甚至进一步优化为预编译静态节点,以提高性能。
摘要由CSDN通过智能技术生成

fa3588916d5836b245a65544ed3d5e69.png

相信大家对 react 和 vue 两个前端框架已经非常熟悉,vue 的模版指令深入人心,react 的 jsx 又非常灵活,那我们先来看一个问题:可以在 react 中实现 vue 的模版指令吗,比如 v-for?

这是一个非常有意思的话题,前端框架的天然壁垒使得语法上互不兼容,但透过表象看本质我们发现,框架形式变幻莫测,都是受底层设计思想的支配,打破壁垒往往只需要你敢于尝试。

今天我们要探讨的问题其实也不单单是在 react 中实现一个 v-for 这样的模版指令,而是基于模版指令的实现过程,探讨在 react 中开发者能做的预编译优化。

我们知道在 react 中通过一个数组生成一堆平行节点常用的就是在 jsx 中调用 map 方法:

1ed312b0485be997fa2172d7617e8b72.png

而如果支持类似 v-for 指令(react 中就叫 r-for 吧):

07648b4a8299a3cf338388dce37286bb.png

且不说代码量是否更加简单,光是模版指令的语法看起来就十分优雅,我们不妨试一试实现这个需求。

大家对于实现方案应该有了自己的答案,避免不了就是要基于 babel 编译的 AST 来实现,最终以插件的形式引入我们的项目中,编译 r-for 指令。

我们先回顾一下 ast,ast 是抽象语法树,是源代码语法结构的一种抽象表示。代码中的每一个代码节点都会被解析成一个包装对象。比如

const a = 3

这一句简单的代码,被解析成 ast 为:

fb4a7542d41a03c778dd0a6b3d679d6c.png

其中 VariableDeclarator 就是变量包装对象,等号两边的变量名和变量初始值也会被解析成相应的包装对象。了解 ast 之后我们再回顾一下 babel 编译 react 代码的过程:

b4c879584f3025463b2d0e981a585ea2.png

源代码解析 ast 的 parse 过程在 babel 官网已经给出了流程,分别由 @babel/parser、@babel/traverse、@babel/generator 执行,parser 会把源代码编译成原始的 ast,而我们配置的 presets 和插件就是在第二部分执行,将 原始 ast 进一步编译成最终的 ast。

而关于 babel 插件的实现,可参考官方众多插件的实现方式,它返回一个 visitor 对象,该对象中定义了许多勾子函数,函数名和 ast 中的包装对象名一一对应。babel 会对原始 ast 做深度遍历,同时插件中如果有同名的勾子函数就会执行。基于这个原理,我们可以通过 babel 插件实现 r-for 指令。

对于下面的 react 代码,我们可以分析一下解析出的 ast 可能是什么结构

1cd85bfbce75c4d9f1b8b2185e20bb73.png

首先定义了 App 这样一个常量,常量值是一个箭头函数,函数体中定义了一个叫 arr 的数组变量,返回了一段 jsx 代码,那么借助下面这个工具,我们看看编译后的 ast 是什么样的:

https://astexplorer.net/​astexplorer.net

d134e5d4c6a6d6966f108c6ed18cc438.png

18b87e17222fc7df819cc3bd3291ddca.png

红色标记部分就是我们前面分析的包装对象,而 r-for 指令就在一个叫 JSXAttribute 的包装对象中,顾名思义就是节点属性。那么按照 babel 插件的实现原理,我们可以解析 JSXAttribute、JSXOpeningElement 或者 JSXElement 三个对象,以解析 JSXELement 为例来实现这个插件:

首先要解析这个 jsx 节点的属性,我们简化实现,对于 r-for 指令,保存 ast 中的对象,而其他属性则解析出来具体值。

6037dc3699f39f9746457cec07fcc7d7.png

第二步进行词法解析,我们知道 React 代码会被编译成一堆 React.createElement 的嵌套调用

0f1ee0aa546a66f4ca1c071ca475653a.png

那么我们进行词法解析,是否可以从 ast 中解析出上面这句代码中的每一个关键词,从而「拼凑」出一段可以执行的源码。

29b92cbab1187a50d0ab303bf8b016b8.png

这里通过 ast 进行词法解析,得到了上面代码中的各个元素,最终「拼凑出 createElement 」代码,通过 replaceWithSourceString 替换到 ast 树中,而他会被当作源码执行,需要注意的是要删掉 r-for 属性,否则插件会循环执行导致栈溢出。插件完成,我们看看编译结果:

5f7ab9cbeacc15d923f00b5bbdcd392d.png

最终确实生成了正常的 map 函数,插件是可以工作的。但是我们也会思考,这样一个插件似乎对于 react 的预编译并没有什么帮助,反而会增加很多心智负担显得不伦不类。那么是否可以通过 babel 插件来优化 react 的预编译呢?

我们再回到 react 的编译过程:

185f0dc5c10bd8c09603b1587c0f9c7a.png

关于预编译优化,似乎开发者只能接触到 jsx 到 React.createElement 这个过程,那么是否可以实现编译模式的转化,或者在这里埋下种子,优化延伸到后续的 dom diff 呢。随着思考的深入,我们对插件做一个改版。

上面插件使得 r-for 指令最终编译成 map 函数,而由于数组是一个静态数组,它的 map 结果是可以预知的,那么是否可以在插件阶段生成 map 后的结果而达到预编译优化的目的呢?

6964273c9eed1d5f956d8e083ffe826a.png

从这里可以看出,预编译优化的结果正式 AOT 编译模式的最好体现。基于此,我们对插件做一个改造:

  1. 递归找到所引用的数组实体:

76cd30ee0a0b81aed63f447cd7ea193d.png

428adb59f29089dfcfb71b468d335bb2.png

插件第一个版本中,我们通过词法分析找到变量名,在「拼凑」了一段可执行的源代码,而这里我们希望通过变量名追溯到数组的初始化数据,实现方式不细讲,就是一个广度遍历,需要注意的是这里只考虑数组定义在当前一对大括号包裹的代码块中,如果是外部变量形成闭包,这里简化模型暂时不考虑。

2. 解析变量引用路径,这里考虑的情况是数组元素是引用对象时:

const arr = [
  { info: { id: 123 }}
]

9de50fa4890331d7ee3e00647e738dc7.png

这里也是一个常规的深度遍历,最终找到变量引用路径。

3. 最后一步是复制节点,原始 ast 中只提供了一个有 r-for 属性的节点,而现在我们拿到了原始数组,就可以根据数组长度复制节点生成节点群,替换 ast 中原始节点。

af122e49591a35976b15ce960bc89b62.png

c88ebb022a86456111d416f84f8a23be.png

最终我们通过 replaceWithMultiple api 将这些节点替换进去,就生成了新的 ast,基于新的 ast 编译结果为:

cb5d66280e50de09973b1f4511c062ca.png

到此我们实现了基于 babel 插件的预编译优化,对 AOT 编译模式有了一个不错的诠释。那么同时我们也陷入了思考,是否可以在预编译阶段干预后续的 dom diff 呢?

时年 2020 年五月,我看到了尤大一场直播的录像,介绍了 Vue3 关于 ssr 的预编译优化:

b7ed5f79efc687763951117f8fc8858b.png

从图中可以看到,模版中的静态节点最终被编译成一段字符串,由 createStaticNode 调用,而这些静态节点是不参与 dom diff 的,最终通过 innerHTML 塞到节点中由浏览器处理,diff 效率与之前已经不在一个数量级了。借鉴 Vue3 的预编译优化思想,我们是否可以在 react 中实现这种优化呢?可以尝试一下。

在第二个版本插件中我们根据数组长度复制了n个节点,对于每个节点我们知道了标签名称,属性,innerText,那么可以尝试硬编码拼凑出节点字符串,最终生成静态节点导出。

7bfb40dcac7121cd9829f10700336c6d.png

5ebc7948a1da95b2a36ad76f2a1cbefa.png

最终编译结果:

ef51e1bbd332c127627356be90384213.png

仿佛一切尽在掌握之中,但是最大的问题出现了

React 根本没有这个 api 啊... ??

一切都是我杜撰的,vue 框架层面的预编译优化并不能照搬过来,于是曲线救国,我们用另一种方案,生成的节点字符串当作富文本渲染:

2415c5458a2ffaf8695e77c491f911bc.png

编译结果:

364d1567083bb256f8b2eee77450a1b3.png

这里可以看到节点数量明显减少,对于 dom diff 是有利无害的,但是同样会冒出新的问题:

  1. 涉及到富文本就要考虑 XSS 攻击
  2. 多生成了一个容器节点,对于平行静态节点如何处理?
  3. 对于 dom diff 的效率提升是否真的有帮助?

以上内容我们通过一个 babel 插件在 react 实现了 r-for 的模版指令,也在 react 的预编译方向有了一些探索,这不禁让我们思考,为何 vue react 在预编译阶段会有这么大的差异呢。

vue 的响应式系统中,数据都是进行拦截代理的,所以在编译阶段会解析 template,构建 view 和 model 层的双向绑定,而正是基于对模版的解析,使得 vue 一开始就知道哪些是静态节点,哪些是动态节点,而加上对数据的依赖追踪,动静结合的 dom diff 便可以轻松实现。

44a5e71bbe3f2c89714c0dfee39d6fdc.png

如果说 vue 是基于原子级别的数据侦听,那 react 便是基于组件级别的重新渲染。react 对数据毫无感知,只是暴露一个 setState 供开发者调用,而只有对前后状态比较之后才知道哪些数据改变了。相信你对下面的代码不会陌生:

92a84b15ecf0d559c1b69d5b19087b06.png

5554b006543d759b4d49780b27769d73.png

尽管 jsx 中没有用到 state,但是每次都会执行 render,根本原因就是 react 并没有做模版解析,不知道视图依赖了哪些数据,这就导致我们在介入预编译时可供优化的信息不足,所以从工程化的角度实现动静结合的 dom diff 就不那么容易。当然,从前面插件的进化过程,我们似乎也找到了优化 dom diff 的一个思考方向。

react 在演进过程中,也在寻求预编译优化的方向,其中 prepack 结合 react 就是一个很好的例子。关于 prepack 的介绍这里不再赘述,可参考

https://github.com/facebook/prepack​github.com Facebook 开源 JavaScript 代码优化工具 Prepack​juejin.im

或者官网介绍。

react 预编译优化之路,还有许多可以探索的地方。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值