现代前端编译技术

185 阅读11分钟

编译流程概述

编译流程包括前置编译后置编译两个阶段,前置编译(和平台框架无关)和后置编译(和平台相关)。

就拿js来说:

前置编译:包含词法分析语法分析,而这一过程与平台无关,因为它最终生成的AST语法树遵循 estree 规范。

后置编译:在前置编译的基础上进一步处理和优化代码,代码转化依赖解析特定平台优化代码压缩等,生成最终的可执行文件。

......

这部分细节比较枯燥,就不展开说了嗷。下面讲点运用层面的;

以下是针对一些涉及到编译时的处理,并且个人感觉比较有意思的插件或工具的部分原理分析,仅代表个人见解,感兴趣的同学也可再去进一步了解;

编译技术运用

unplugin-vue-components

能力介绍:

该工具是vue生态的一个插件,用于自动导入vue组件(auto-import),达到下图这种效果:

image-20240605192325776.png

原理概述:

unplugin-vue-components插件统一用unplugin暴露出来的插件工厂编写对应构建工具的插件逻辑,调用的时候只会实例化对应构建工具的工厂函数(懒加载);

unplugin本质是一个针对构建工具的统一插件系统。提供这些构建工具的统一插件服务。目前支持的构建工具有esbuild、rollup、vite、rolldown、webpack、rspack、farm;

vue-cli + uvc 场景

uvc = unplugin-vue-components

就先拿webpack构建场景来举个例子,先看这个插件的使用: carbon11.png

再看uvc这个库导出的本质上是createUnplugin这个方法,这个方法就是unplugin库暴露出来的插件工厂,这个工厂暴露出来了各个构建工具的插件服务: carbon12.png

下图右侧就是getWebpackPlugin函数,这个函数可以看到返回就是经典的webpack插件项,左侧是uvc传给unplugin的工厂函数配置项; image-20240612105438039.png

getWebpackPlugin主要逻辑:

1.该插件会将工厂函数配置项(factory参数) 作为插件上下文保证其能够被正确注入到编译过程的每个阶段

2.Rollup 和其他构建工具都使用的一些生命周期钩子来扩展和定制构建流程。这里抽象出这些钩子函数,并在 Webpack 插件中实现相应的逻辑,这样在使用层面可以在 Webpack 中实现类似于 Rollup 的插件机制

3.执行工厂函数配置项的自定义的插件逻辑; image-20240606192702462.png

在uvc的场景下,只会命中plugin.transfrom和plugin.webpack的逻辑;

plugin.transfrom(对应第二步)

transfrom其实就是webpack的loader实现的,流程如下: image-20240607182433851.png

转换前后的code(vue2):

image-20240609104511332.png

因为篇幅原因,上述流程贴了关键部分的代码截图,其余部分用文字描述基本带过了,感兴趣也可以下载源码自行过一遍流程,这个插件的整体设计还是很有趣的,想法和思路都值得借鉴学习。

vue components组件的类型声明文件也是自动生成的,其中还包括像RouterView和RouterLink这种全局组件的类型声明;

plugin.webpack(对应第三步)

通过监听文件的变化(使用 chokidar),默认监听的文件夹是'src/components',在文件增加或删除时通知 Webpack 进行依赖关系更新并重新编译。确保了在开发过程中,文件的动态变化能够被即时捕捉并反映在 Webpack 的编译结果中。 image-20240609100541656.png

小结

项目引入unplugin-vue-components 本质是:

1.利用unplugin提供整合起来的通用的各个构建工具的统一插件能力;

2.利用unplugin 处理好webpack上下文并暴露出来能力,编写transfrom (webpack loader)将对应的vue相关文件进行处理,编译时 替换渲染函数对应组件名,import进对应组件,生成类型声明文件;

3.watch模式下更新文件依赖关系;

上面介绍的是webpack构建工具的处理方式,其他构建工具也是类似的处理方式,像vite本身具有更切合的生命周期钩子,整体功能实现应该更容易理解,这里暂不分析了;

比较流行的Unocss和下面要介绍的Vue-Macros同样也是利用了unplugin构建的webpack插件能力;

Vue - Reactivity Transform 提案

背景

在开发vue项目时,因为要给基础数据类型增加响应式所以api的设计会将其封装成引用数据类型,然后用proxy将其代理然后做响应式的设计处理,但是这样也导致了我们需要使用.value去取值,有时候我在使用vue开发项目的时候也会想为什么.value的操作不能在框架的编译时层面帮用户处理,在script里也能和template中使用一样去做自动化.value的操作,后面发现这个idea有人提出来并且已经研究出可行性了。:P

起源于vue-rfcs(用来讨论vue的提案,未来可能接入生产版本中),讨论引入编译时的宏来改善vue的使用体验;

提案目的:
ref 宏:$ref

现状:要使用 .value 属性, Vue 可以拦截其 get/set 操作,以便执行依赖跟踪和效果触发。

解决痛点:目的是开发者无需再用 .value取值,因为获取 .value经常会被遗漏;

预期效果: image-20240528153555798.png

$ref() 函数是一个编译时宏,用于创建响应式变量。它充当编译器的提示:每当编译器遇到 count 变量时,它会自动为我们追加 .value;

同时每个返回值为ref类型的响应式的api都会有一个以 $ 为前缀的等效宏:

  • ref -> $ref
  • computed -> $computed
  • shallowRef -> $shallowRef
  • customRef -> $customRef
  • toRef -> $toRef

同时提供解构refs对象的能力:$() 宏

image-20240528173633277.png

props 宏

现状:

1.与 .value 类似,我们需要始终以 props.x 方式访问 props 以保证props的响应式不丢失。(或者需要用toRefs配合解构保证响应式)

2.withDefaults 来声明props的默认值比较笨拙;

解决痛点:defineProps内置响应式解构逻辑;

image-20240528190836292.png

提案可行性:

可行但是不适合接入生产版本。

vue最终放弃了在生产版本接入这些宏语法,有以下几点考虑:

1.丢失 .value 使得更难判断正在跟踪什么以及哪条线正在触发响应式效果。(因为.value是响应式所必要的成本)

这点理由我个人理解就像最近很火的signals提案,其实也是需要用get和set去操作响应式数据的,所以从这个角度出发去理解.value也应该是响应式必要的成本,而这个提案正式发布之后肯定是会统一现在前端框架的响应式数据的处理的,vue3也会由现在的proxy转到signals处理响应式数据。(感觉signals响应式数据使用方法跟vue的ref用法也很相似🤣)

2.仅在 SFC 内使用reactivity transfrom,这会造成不同心智模型之间上下文转换的不一致和成本。SFC外不用宏而SFC中使用会损害项目的可维护性。(因为vue官方只支持SFC中使用该宏,其他部分的支持交给社区orz)举个例子:

image-20240528201909018.png

3.reactivity transfrom需要一种不同的心理模型,这种模型会扭曲 JavaScript 语义。

上述讨论的Reactivity Transform这些特性会迁移到Vue Macros库中,如果想使用这些宏可以搭配Macros使用;

Vue Macros

可以理解为vue生态的先行者仓库,目的是探索 Vue 的更多宏和语法糖。

所以这些宏仍然能够使用,只是不会将其接入到vue的正式版本之中;

项目接入:

image-20240611111123389.png

这个提案主要也是利用编译时的处理,不是运行时的API,所以不需要导入ref `import { ref } from 'vue/macros',本质是将语法糖转换成编译器能正常解析的目标代码,达到响应式宏`的目的;

下面就以 $ref语法糖(宏) 为例,我们大概看一下macros怎么利用ast在编译时将对应的语法糖转换成目标代码的:

先看个macros源码里的 **ref的测试用例(label:vare=ref宏**的测试用例(label: var e = ref() 转换后为 label: var e = _ref(),不属于无关代码,这里标注一下): image-20240529163235549.png

根据测试用用例可以看到宏语法都会被转换成正常vue项目中使用的语法,未使用宏语法的无关代码不做改动,并且收集使用的宏依赖和宏变量;也能看到这些宏依赖会在编译时自动导入;下面放一张代码转换前后的代码图做对比,可以更直观的看出编译时的转换结果:

代码转换前后对比:

image-20240529165552996.png

然后我们回到上文,可以看到transfrom是核心的代码转换逻辑,其中使用babel/parse将代码解析成ast语法树,下面我们看下这个针对 $ref宏的整体处理流程:

VariableDeclarator:变量声明器;

CallExpression:函数调用表达式;

Identifier:标识符(表示变量名、函数名等);

1.首先我们利用babel/parse能拿到这段代码的ast

2.ast的顶级节点传入walkScope函数处理,这里我们**只分析利用ref变量声明语句的处理(也就是letfoo=ref 变量声明语句**的处理(也就是let foo = ref());

3.node.body的第一项节点类型let foo = $ref()就是VariableDeclaration(变量声明),这里拿到其对应的ast节点传入walkVariableDeclaration进行处理;

4.使用ref宏的话肯定是通过函数调用表达式的方式去使用,所以walkVariableDeclaration方法中判断如果存在对应的函数调用表达式(CallExpression)就拿到被调用函数callee的name属性,这里也就是$ref(这里还会判断callee的name是包含在咱们的ref类型的api白名单里);

5.然后调用processRefDeclaration方法处理语法宏,这里会利用helper给函数调用表达式做替换处理,将$ref替换成_ref,并且

importedHelpers记录了对应的依赖集,importedHelpers会在自动导入宏依赖的时候使用,这里暂不深入分析; image-20240603145039915.png

到这里就完成了对宏语法的转换,原理就是针对ast的处理拿到对应的node type进行语法转换;当然还有涉及当使用ref变量的时候自动添加.value的处理:

a.利用estree-walker深度遍历ast节点树,如果当前节点是变量标识Identifier 并且在当前作用域被引用(被收集在rootRefs中),沿着作用域链,给对应的变量做替换: x --> x.value;

这里简单讲一下,就不展开分析了,还有针对$()和$$的处理,感兴趣的同学可以自行查阅源码(github.com/vue-macros/…);

编译工具ast-grep

这里也推荐一个编译的工具,可以用于更直观的方式操作语法树的工具。由rust编写,性能强悍;

支持多语言

@ast-grep/napi 支持 htmljsjsxtstsx

特性ast-grepBabelTypeScript Compiler API
解析速度高效偏慢偏慢
修改代码能力强大,提供直观的操作API强大,支持广泛转译和生成强大,但需低级 API 操作
多语言支持支持多种语言(JS, TS, JSX, TSX)支持 JS、TS支持 JS 和 TS
社区和生态新兴工具,生态正在发展中成熟、丰富、有大量插件和文档成熟,用于大型 TypeScript 项目
学习曲线相对平缓,匹配语法直观较为陡峭,需要了解复杂的配置和插件较为陡峭,需深入理解 TypeScript 结构和 API
适用场景代码匹配和修改,高性能解析广泛的代码转换任务,适用于现代前端开发高精度类型分析,高级 TypeScript 项目

ast-grep demo:

import { js } from '@ast-grep/napi';
​
const code = `
function example() {
  console.log('hello world');
}
`;
​
const root = js.parse(code).root();
const nodes = root.findAll('console.log($MATCH)');
for (const node of nodes) {
  console.log(node.text());  // Outputs: console.log('hello world')
}

更加语义化的api,操作相对简单;

ast-grep playground: ast-grep.github.io/playground.…

babel demo:

const babel = require('@babel/core');
​
const code = `
function example() {
  console.log('hello world');
}
`;
​
babel.transform(code, {
  plugins: [
    function customPlugin() {
      return {
        visitor: {
          CallExpression(path) {
            if (path.get('callee').isMemberExpression() && path.get('callee.object').isIdentifier({ name: 'console' }) && path.get('callee.property').isIdentifier({ name: 'log' })) {
              console.log(path.toString());  // Outputs: console.log('hello world')
            }
          }
        }
      };
    }
  ]
});

api相对复杂一些,需要对ast的节点类型较为熟悉;

vue/macros refactor分支中也是使用了ast-grep尝鲜;

react-compiler

简介

React Compiler 实验性编译器,是一个仅构建时的工具,可以自动优化 React 应用程序。

react-compiler-playground

使用babel插件配合不同的构建工具一起使用;(babel-plugin-react-compiler)

使用时搭配对应的eslint插件,因为对代码层面也有一定的规范要求;

react-compiler基于AST的层面又抽象出了HIR(高级中间表示)、SSA(单一统计赋值)等等;

HIR可以携带更多的编译时所需要的信息,删除编译时不需要的信息,也就是基于AST的抽象+优化:

// 源代码
const sum = a + b;
​
// AST
Program
 ├── VariableDeclaration
      ├── VariableDeclarator
           ├── Identifier: sum
           ├── BinaryExpression
                ├── Identifier: a
                ├── Operator: +
                ├── Identifier: b// HIR
// 更抽象和优化的表示
Assignment
 ├── Target: sum (type: const)
 ├── Expression
      ├── BinaryOperation (type: int)
           ├── LeftOperand: a (type: int)
           ├── Operator: +
           ├── RightOperand: b (type: int)

SSA可以达到 降低运行时的计算消除死代码 的目的;

GNv5bAGasAAUi4K.png

但是最终在框架使用者的视角来看可以最直接的意义用于优化react目标代码,包括但不限于auto-memo的特性:

这样即使父组件发生变化只要组件纯函数依赖的输入没有变化,子组件就不会重新渲染了;

image-20240610152542167.png

不仅auto-memo,react team未来还规划auto-effect-deps,这在用法层面看起来确实是很vue了🤣;类似于下图右边这种用法:

image.png

vue-vapor

vue-vapor可以理解为vue的另一种模式,这个模式舍去了虚拟dom;而是借鉴solid这个新秀框架,在运行时就不需要再进行 Virtual DOM 的比对,而是直接对 DOM 进行操作,有着更细粒度的更新机制和在某些场景下更强的性能(diff成本更高的时候);

最直接的对比可以看下面的这个例子:

image-20240611184721873.png

点击父组件按钮之后的表现如下:

vue

640.png

solid

641.png

这是因为React 和 Vue 都是使用了 Virtual DOM,必须在每次状态更新时重新「render」一组全新的 Virtual DOM Tree 用来比较所导致的,如果要避免多余的渲染,需要额外透过 React.memo 或 computed 来协助。

而 Solid.js 因为是直接将状态更新编译为独立的 DOM 操作,所以可以让状态响应的单位降低至数据级别。

当然这个例子其实还有关于react的更新:

不过react的更新粒度更粗,孙子组件也会生成新的随机数;接入react compiler这种情况可能会好一些,孙子组件就会auto-memo;同时这种更新粒度也导致大型项目的diff情况下很可能在16ms更新不完,从而造成丢帧,所以react也是使用react fiber做时间切片优化,分散任务执行;

而vue的更新粒度是组件级别的,相对来说好一点,但是在大型项目里也可能会在16ms处理不完更新任务,但是vue没有选择fiber这种切片的方式,而是尽量的优化做到能在这个时间内更新,配套的也是在编译时做了静态提升,预字符串化,缓存内联处理函数和创建block tree等操作;block tree 收集的动态children和对应的patch flag在后续运行时可以根据这些信息在diff算法层面只比对动态节点做对应的更新即可;

传统功夫,点到为止=)

vapor

我们可以直接在vapor playground上来看下编译出来的render函数的对比,简单了解下其中的变化:

image-20240611193853848.png

可以看到针对<h1>{{ msg }}</h1> 的编译结果,vapor不再是用createElementVNode生成虚拟dom,而是使用 _setText直接更新dom元素,并且使用renderEffect注册msg响应式变量的依赖回调函数;

image-20240611195025774.png

其中renderEffect和setText都是从vue/vapor中暴露出来的api,同时Vapor 模式将仅支持composition API,并且仅支持 <script setup >

🐑社区开源的vue编译器,vue-compiler, rust实现的vue-compiler,为了打造极致性能;

了解语言/框架编译原理的好处: :p

1.能够配合框架层面的处理做特定场景的优化(eg:vue的v-once,配合vue框架block收集动态节点的原理将确定后续不需要更改的动态节点,该节点后续就不参与diff,从而降低了diff的成本);

2.es6 => es5语法场景: a?.b?.c会被编译成较长的语法句,增加bundle size,而a && a.b && a.b.c则不会,但是a.b.c会增加get属性拦截的操作,这时候就可以权衡合适的语法场景;

Nuxt 的自动导入功能

核心能力由unjs/unimport 库提供;

参考文章

Virtual DOM(虚拟DOM) 的地位再一次被挑战 !!!

The Future of Vue: Vapor Mode