前端AST与Rust在前端工程化中的应用,本文转自知乎https://zhuanlan.zhihu.com/p/436090437
前端同学们对于编译原理都存在着复杂的看法,大部分人都觉得自己写业务也用不到这么高深的理论知识,况且编译原理晦涩难懂,并不能提升自己在前端领域内的专业知识。我不觉得这种想法有什么错,况且我之前也是这么认为的。而在前端领域内,和编译原理强相关的框架与工具类库主要有这么几种:
1.以 Babel 为代表,主要做 ECMAScript 的语法支持,比如 ?.
与 ??
对应的 babel-plugin-optional-chaining 与 babel-plugin-nullish-coalescing-operator,这一类工具还有 ESBuild 、swc 等。类似的,还有 Scss、Less 这一类最终编译到 CSS 的“超集”。这一类工具的特点是转换前的代码与转换产物实际上是同一层级的,它们的目标是得到标准环境能够运行的产物。
2.以 Vue、Svelte 还有刚诞生不久的 Astro 为代表,主要做其他自定义文件到 JavaScript(或其他产物) 的编译转化,如 .vue
.svelte
.astro
这一类特殊的语法。这一类工具的特点是,转换后的代码可能会有多种产物,如 Vue 的 SFC 最终会构建出 HTML、CSS、JavaScript
3.典型的 DSL 实现,其没有编译产物,而是由独一的编译引擎消费, 如 GraphQL (.graphql
)、Prisma (.prisma
) 这一类工具库(还有更熟悉一些的,如 HTML、SQL、Lex、XML 等),其不需要被编译为 JavaScript,如 .graphql
文件直接由 GraphQL 各个语言自己实现的 Engine 来消费。
4.语言层面的转换,TypeScript、Flow、CoffeeScript 等,以及使用者不再一定是狭义上前端开发者的语言,如张宏波老师的 ReScript(原 BuckleScript)、Dart 等。
FB 的 jscodeshift,相对于 Babel 的 Visitor API,jscodeshift 提供了命令式 + 链式调用的 API,更符合前端同学的认知模式(因为就像 Lodash、RxJS 这样)
GoGoCode 是一个基于 AST 的 JavaScript/Typescript/HTML 代码转换工具,但相较于同类,它提供了更符合直觉的 API
javascript代码
const a = 1;
const b = 2;
gogoCode转换
const $ = require('gogocode');
const script = $(source);
// 按照你的意图,用 $_$ 当通配符能匹配任意位置的 AST 节点
const aAssignment = script.find('const a = $_$');
// 获得我们匹配的 AST 节点的 value
const aValue = aAssignment.match?.[0]?.[0]?.value;
// 就像替换字符串一样去替换代码
// 但可以忽略空格、缩进或者换行的影响
script.replace('const b = $_$', `const b = ${aValue}`);
// 把 ast 节点输出成字符串
const outCode = script.generate();
转换后代码
const a = 1;
const b = 1;
相关项目
项目 | 描述 |
---|---|
gogocode-plugin-vue | 通过这个 gogocode 插件可以把 vue2 语法的项目转换成 vue3 的 |
gogocode-cli | gogocode 的命令行工具 |
gogocode-playground | 可以在浏览器里尝试 gogocode 转换 |
gogocode-vscode | 在 vscode 中通过此插件用 gogocode 重构你的代码 |
安装
npm install -g jscodeshift
使用
const {run: jscodeshift} = require('jscodeshift/src/Runner')
const path = require('node:path');
const transformPath = path.resolve('transform.js')
const paths = ['foo.js', 'bar']
const options = {
dry: true,
print: true,
verbose: 1,
// ...
}
const res = await jscodeshift(transformPath, paths, options)
console.log(res)
/*
{
stats: {},
timeElapsed: '0.001',
error: 0,
ok: 0,
nochange: 0,
skip: 0
}
*/
import { Project } from "ts-morph";
const s = new Project().createSourceFile("./func.ts", "");
s.addFunction({
isExported: true,
name: "factorial",
returnType: "number",
parameters: [
{
name: "n",
isReadonly: true,
type: "number",
},
],
statements: (writer) => {
writer.write(`
if (n <=1) {
return 1;
}
return n * factorial(n - 1);
`);
},
}).addStatements([]);
s.saveSync();
console.log(s.getText());
Tern
采用了C/S
架构。采用这种架构是由它的目标所决定的,因为Tern
是为了给各种代码编辑器提供增强服务,Tern
只是提供服务就好了,它制定协议,让代码编辑器根据协议与服务进行通信即可。
Tern
提供的主要特征如下:
javascript语法分析器
安装
npm install -g tern
安装完成后,有个tern
命令可以使用,执行该命令就会启动Tern Server
。
tern
命令的实现者没有提供-h
或者--help
参数来让我们看它的使用方法
tern [option]...
option
有下面这些
option | 说明 |
---|---|
--host HOST | 指定监听的主机,默认是127.0.0.1 |
--port NUMBER | 指定监听的端口,默认是62098 |
--verbose | 打印通信协议的具体内容,默认是不打印的 |
--persistent | 默认的,Tern Server 超过5分钟没有任何活动,就会自动关闭,如果不想让他自动关闭,使用该参数即可 |
--ignore-stdin | 默认的,Tern Server 的标准输入一旦被关闭了,就会自动关闭,如果不想让他自动关闭,使用该参数即可 |
.tern_project
文件是JSON格式。
.tern_project
文件Tern Server
的配置文件。
.tern_project
文件在Tern Server
启动的时候进行查找,如果找到,就读取它。
Tern Server
查找.tern_project
文件的过程如下:
1、在被编辑的.js
文件所在的目录中,看是否存在.tern_project
文件;如果存在,就读取;如果不存在,则实行第二步;
2、在被编辑的.js
文件的父目录中,看是否存在.tern_project
文件;如果存在,就读取;如果不存在,则实行第三步;
3、在被编辑的.js
文件的父目录的父目录中,看是否存在.tern_project
文件;如果存在,就读取;如果不存在,则继续从它的父目录往上找...
4、如果能找到就使用找到的配置,如果没有找到,就使用默认的配置, 默认的配置只支持JavaScript的基本语法,不支持其他的第三方库和Node.js语法。
~/.tern_config
文件与.tern_project
文件的格式完全一样。
~/.tern_config
文件就是在查找.tern_project
文件过程中,因为没有找到,而由用户指定的默认配置。
swc 是基于 Rust 开发的一系列编译、打包、压缩等工具,并且被广泛应用于更多更上层的 JS 基建,大大推动了 Rust 在 JS 基建的影响力,所以要第一个介绍。
swc 提供了一系列原子能力,涵盖构建与运行时:
@swc/cli
可以同时构建 js 与 ts 文件:
const a = 1
npm i -D @swc/cli
npx swc ./main.ts
# output:
# Successfully compiled 1 file with swc.
# var a = 1;
具体功能与 babel 类似,都可以让浏览器支持先进语法或者 ts,只是 @swc/cli
比 babel 快了至少 20 倍。可以通过 .swcrc
文件做自定义配置
你可以利用 @swc/core
制作更上层的构建工具,所以它是 @swc/cli
的开发者调用版本。基本 API 来自官网开发者文档:
const swc = require("@swc/core");
swc
.transform("source code", {
// Some options cannot be specified in .swcrc
filename: "input.js",
sourceMaps: true,
// Input files are treated as module by default.
isModule: false,
// All options below can be configured via .swcrc
jsc: {
parser: {
syntax: "ecmascript",
},
transform: {},
},
})
.then((output) => {
output.code; // transformed code
output.map; // source map (in string)
});
其实就是把 cli 调用改成了 node 调用。
@swc/jest
提供了 Rust 版本的 jest 实现,让 jest 跑得更快。使用方式也很简单,
npm i @swc/jest
然后在 jest.config.js
配置文件中,将 ts 文件 compile 指向 @swc/jest
即可
module.exports = {
transform: {
"^.+\\.(t|j)sx?$": ["@swc/jest"],
},
};
swc-loader
是针对 webpack 的 loader 插件,代替 babel-loader
:
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
// `.swcrc` can be used to configure swc
loader: "swc-loader"
}
}
];
}
@swc/wasm-web
可以在浏览器运行时调用 wsm 版的 swc,以得到更好的性能。
import { useEffect, useState } from "react";
import initSwc, { transformSync } from "@swc/wasm-web";
export default function App() {
const [initialized, setInitialized] = useState(false);
useEffect(() => {
async function importAndRunSwcOnMount() {
await initSwc();
setInitialized(true);
}
importAndRunSwcOnMount();
}, []);
function compile() {
if (!initialized) {
return;
}
const result = transformSync(`console.log('hello')`, {});
console.log(result);
}
return (
<div className="App">
<button onClick={compile}>Compile</button>
</div>
);
}
增强了多文件 bundle 成一个文件的功能,基本可以认为是 swc 版本的 webpack,当然性能也会比 swc-loader
方案有进一步提升。
截至目前,该功能还在测试阶段,只要安装了 @swc/cli
就可使用,通过创建 spack.config.js
后执行 npx spack
即可运行,和 webpack 的使用方式一样。
Deno 的 linter、code formatter、文档生成器采用 swc 构建,因此也算属于 Rust 阵营。
Deno 是一种新的 js/ts 运行时,所以我们总喜欢与 node 进行类比。quickjs 也一样,这三个都是一种对 js 语言的运行器,作为开发者,需求永远是更好的性能、兼容性与生态,三者几乎缺一不可,所以当下虽然不能完全代替 Nodejs,但作为高性能替代方案是很香的,可以基于他们做一些跨端跨平台的解析器,比如 kraken 就是基于 quickjs + flutter 实现的一种高性能 web 渲染引擎,是 web 浏览器的替代方案,作为一种跨端方案。
esbuild 是较早被广泛使用的新一代 JS 基建,是 JS 打包与压缩工具。虽然采用 Go 编写,但性能与 Rust 不相上下,可以与 Rust 风潮放在一起看。
esbuild 目前有两个功能:编译和压缩,理论上分别可代替 babel 与 terser。
编译功能的基本用法:
require('esbuild').transformSync('let x: number = 1', {
loader: 'ts',
})
// 'let x = 1;\n'
压缩功能的基本用法:
require('esbuild').transformSync('fn = obj => { return obj.x }', {
minify: true,
})
// 'fn=n=>n.x;\n'
压缩功能比较稳定,适合用在生产环境,而编译功能要考虑兼容 webpack 的地方太多,在成熟稳定后才考虑能在生产环境使用,目前其实已经有不少新项目已经在生产环境使用 esbuild 的编译功能了。
编译功能与 @swc
类似,但因为 Rust 支持编译到 wsm,所以 @swc
提供了 web 运行时编译能力,而 esbuild 目前还没有看到这种特性。
Rome 是 Babel 作者做的基于 Nodejs 的前端基建全家桶,包含但不限于 Babel, ESLint, webpack, Prettier, Jest。目前 计划使用 Rust 重构,虽然还没有实现,但我们姑且可以把 Rome 当作 Rust 的一员。
rome
是个全家桶 API,所以你只需要 yarn add rome
就完成了所有环境准备工作。
rome bundle
打包项目。rome compile
编译单个文件。rome develop
调试项目。rome parse
解析文件抽象语法树。rome analyzeDependencies
分析依赖。Rome 还将文件格式化与 Lint 合并为了 rome check
命令,并提供了友好 UI 终端提示。
其实我并不太看好 Rome,因为它负担太重了,测试、编译、Lint、格式化、压缩、打包的琐碎事情太多,把每一块交给社区可能会做得更好,这不现在还在重构中,牵一发而动全身。
NAPI-RS 提供了高性能的 Rust 到 Node 的衔接层,可以将 Rust 代码编译后成为 Node 可调用文件。下面是官网的例子:
#[js_function(1)]
fn fibonacci(ctx: CallContext) -> Result<JsNumber> {
let n = ctx.get::<JsNumber>(0)?.try_into()?;
ctx.env.create_int64(fibonacci_native(n))
}
上面写了一个斐波那契数列函数,直接调用了 fibonacci_native
函数实现。为了让这个方法被 Node 调用,首先安装 CLI:npm i @napi-rs/cli
。
由于环境比较麻烦,因此需要利用这个脚手架初始化一个工作台,我们在里面写 Rust,然后再利用固定的脚本发布 npm 包。执行 napi new
创建一个项目,我们发现入口文件肯定是个 js,毕竟要被 node 引用,大概长这样(我创建了一个 myLib
包):
const { loadBinding } = require('@node-rs/helper')
/**
* __dirname means load native addon from current dir
* 'myLib' is the name of native addon
* the second arguments was decided by `napi.name` field in `package.json`
* the third arguments was decided by `name` field in `package.json`
* `loadBinding` helper will load `myLib.[PLATFORM].node` from `__dirname` first
* If failed to load addon, it will fallback to load from `myLib-[PLATFORM]`
*/
module.exports = loadBinding(__dirname, 'myLib', 'myLib')
所以 loadBinding 才是入口,同时项目文件夹下存在三个系统环境包,分别供不同系统环境调用:
@cool/core-darwin-x64
macOS x64 平台。@cool/core-win32-x64
Windows x64 平台。@cool/core-linux-arm64-gnu
Linux aarch64 平台。@node-rs/helper
这个包的作用是引导 node 执行预编译的二进制文件,loadBinding
函数会尝试加载当前平台识别的二进制包。
将 src/lib.rs
的代码改成上面斐波那契数列的代码后,执行 npm run build
编译。注意在编译前需要安装 rust 开发环境,只要一行脚本即可安装,具体看 rustup.rs。然后把当前项目整体当作 node 包发布即可。
发布后,就可以在 node 代码中引用啦:
import { fibonacci } from 'myLib'
function hello() {
let result = fibonacci(10000)
console.log(result)
return result
}
NAPI-RS 作为 Rust 与 Node 的桥梁,很好的解决了 Rust 渐进式替换现有 JS 工具链的问题。
Rust + WebAssembly 说明 Rust 具备编译到 wsm 的能力,虽然编译后代码性能会变得稍慢,但还是比 js 快很多,同时由于 wsm 的可移植性,让 Rust 也变得可移植了。
其实 Rust 支持编译到 WebAssembly 也不奇怪,因为本来 WebAssembly 的定位之一就是作为其他语言的目标编译产物,然后它本身支持跨平台,这样它就很好的完成了传播的使命。
WebAssembly 是一个基于栈的虚拟机 (stack machine),所以跨平台能力一流。
想要将 Rust 编译为 wsm,除了安装 Rust 开发环境外,还要安装 wasm-pack。
安装后编译只需执行 wasm-pack build
即可。更多用法可以查看 API 文档。
dprint 是用 rust 编写的 js/ts 格式化工具,并提供了 dprint-node 版本,可以直接作为 node 包,通过 npm 安装使用,从 源码 可以看到,使用 NAPI-RS 实现。
dprint-node
可以直接在 Node 中使用:
const dprint = require('dprint-node');
dprint.format(filePath, code, options);
Parcel 严格来说算是上一代 JS 基建,它出现在 Webpack 之后,Rust 风潮之前。不过由于它已经采用 SWC 重写,所以姑且算是跟上了时髦。
前端全家桶已经有了一整套 Rust 实现,只是对于存量项目的编译准确性需要大量验证,我们还需要时间等待这些库的成熟度。
但毫无疑问的是,Rust 语言对 JS 基建支持已经较为完备了,剩下的只是工具层逻辑覆盖率的问题,都可以随时间而解决。而用 Rust 语言重写后的逻辑带来的巨幅性能提升将为社区注入巨大活力,就像原文说的,前端社区可以为了巨大性能提升而引入 Rust 语言,即便这可能导致为社区贡献门槛的提高。