前言
现代前端对速度的追求已经进入二进制工具时代,Rust 开发成为每个人的必修课。
一般我们将常见的前端 Rust 开发分为以下几类,难度由上至下递增:
-
开发 wasm 。
-
开发 swc 插件。
-
开发代码处理工具。
我们将默认读者具备最简单的 Rust 知识,进行快速入门介绍。
正文
开发 wasm
意义
开发 wasm 的意义在于利用浏览器运行 wasm 的优势,在 wasm 中进行大量复杂的计算、音视频、图像处理等,当你有此类需求,可以优先考虑使用 Rust 开发 wasm 分发至浏览器。
初始化
我们使用 wasm-pack 构建 wasm ,参考 wasm-pack > Quickstart 得到一个模板起始项目。
实战 case
使用 tsify 支持输出结构体的 TypeScript 类型,实现一个简单的加法运算:
# Cargo.toml 确保你含有这些依赖
[dependencies]
serde = { version = "1.0.163", features = ["derive"] }
tsify = "0.4.5"
use wasm_bindgen::prelude::*;
use serde::{Serialize, Deserialize};
use tsify::Tsify;
#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Rect {
pub width: u32,
pub height: u32
}
#[wasm_bindgen]
pub fn plus(mut rect: Rect) -> Rect {
rect.width += rect.height;
rect
}
构建
# dev
wasm-pack build --verbose --out-dir pkg --out-name index --dev
# release
wasm-pack build --verbose --out-dir pkg --out-name index --release
这将在当前目录的 pkg/*
下生成 wasm 产物与 index.js
等胶水代码,导入 index.js
便即开即用,非常方便。
运行 wasm
为了支持直接导入 .wasm
文件,我们需要 webpack 5 的 asyncWebAssembly
特性支持,此处以在 Umi 4 项目中调试为例,创建一个 Umi 4 Simple 模板项目:
pnpm create umi wasm-demo
参考 FAQ > 怎么用 WebAssembly 配置开启 wasm 支持:
// .umirc.ts
export default {
chainWebpack(config) {
config.set('experiments', {
...config.get('experiments'),
asyncWebAssembly: true
})
const REG = /\.wasm$/
config.module.rule('asset').exclude.add(REG).end();
config.module
.rule('wasm')
.test(REG)
.exclude.add(/node_modules/)
.end()
.type('webassembly/async')
.end()
},
}
之后便可在项目中直接导入刚刚打包好,在 pkg/*
的 wasm 即开即用:
import * as wasm from './path/to/pkg'
const ret = wasm.plus({
width: 1,
height: 2,
})
// { width: 3, height: 2 }
console.log('ret: ', ret);
注:
-
由于 wasm 文件可能较大,当你需要优化时,可将使用
.wasm
的组件手动React.lazy(() => import('./Component'))
拆包,之后在useEffect
中懒加载await import('./path/to/pkg')
。 -
对于非 Umi 4 的 webpack 5 项目,请自行开启
experiments.asyncWebAssembly
即可一键支持 wasm 导入。
缺点
由于当下浏览器和 PC 设备性能已足够强大,更多场合下,运行 wasm 进行数据计算,传递数据花费的时间将 远远超出使用 JavaScript 进行同逻辑计算的时间 。
所以除音视频场景外,你很可能不需要 wasm ,而是优先考虑使用 Worker 等优化策略。
开发 swc 插件
意义
现代前端高效构建往往将 babel 替代为 swc 化,为了替代 babel 插件实现代码转换,开发 swc 插件成为了一门必修课。
初始化
参考 swc > Create a project ,我们用 swc 脚手架初始化得到一个插件的模板起始项目。
实战 case
我们编写一个最简单的功能,将所有的 react
导入转换为 preact
:
# Cargo.toml 确保你含有这些依赖
[dependencies]
serde = "1.0.163"
serde_json = "1.0.96"
swc_core = { version = "0.76.39", features = ["ecma_plugin_transform"] }
use swc_core::ecma::{
ast::{Program, ImportDecl, ImportSpecifier},
visit::{as_folder, FoldWith, VisitMut, VisitMutWith},
};
use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct TransformPluginConfig {
from: String,
to: String,
}
pub struct TransformVisitor {
config: TransformPluginConfig,
}
impl VisitMut for TransformVisitor {
fn visit_mut_import_decl(&mut self, n: &mut ImportDecl) {
n.visit_mut_children_with(self);
if n.specifiers.len() == 1 {
if let ImportSpecifier::Default(_) = n.specifiers[0] {
if n.src.value == self.config.from {
n.src = Box::new(self.config.to.clone().into());
}
}
}
}
}
#[plugin_transform]
pub fn process_transform(program: Program, metadata: TransformPluginProgramMetadata) -> Program {
let config = serde_json::from_str(
&metadata
.get_transform_plugin_config()
.unwrap()
)
.expect("invalid config");
program.fold_with(&mut as_folder(TransformVisitor { config }))
}
在编写过程中,以下文档可供参考:
构建
# dev
cargo build --target wasm32-wasi
# release
cargo build --target wasm32-wasi --release
通过构建,你可以在当前目录下得到 wasm 形式的 swc 插件产物。
运行 swc 插件
import { transformSync } from '@swc/core'
const transform = async () => {
const { code } = transformSync(
`
import React from 'react'
`,
{
jsc: {
experimental: {
plugins: [
[
require.resolve(
'./target/wasm32-wasi/debug/my_first_plugin.wasm'
),
{
from: 'react',
to: 'preact',
},
],
],
},
parser: {
syntax: 'typescript',
dynamicImport: true,
tsx: true,
},
target: 'es2015',
minify: {
compress: false,
mangle: false,
},
transform: {
react: {
runtime: 'automatic',
throwIfNamespace: true,
development: true,
useBuiltins: true,
},
},
},
module: {
type: 'es6',
ignoreDynamic: true,
},
minify: false,
isModule: true,
sourceMaps: false,
filename: 'index.tsx',
}
)
// import React from 'preact'
console.log('code: ', code)
}
transform()
缺点
为了避免多平台差异,我们分发了 wasm32-wasi
目标的 wasm 包,好处是只需构建一次即可全平台通用,缺点是产物较大,同时 wasm 运行速度不如 .node
;但现代前端已无需担心只在本地编译阶段使用的包大小,如 Nextjs 单包依赖已达 40 M
以上,TypeScript 20 M
,你可以无需关心产物体积问题。
至此,我们介绍了如何借助 swc 插件实现 babel 插件的替代,在下文中,我们将继续深入,真正构建多平台分发的二进制包,同时不会做过多细节介绍,推荐只学习到此处为止。
开发代码处理工具
意义
目前最主流的前端 Rust 开发即是借助 Swc 来解析 JavaScript 、TypeScript 代码,从而实现代码信息提取、转换、编译等,我们会将 Rust 编译为 Node addon .node
文件,以获得远比 wasm 更快的运行速度。
初始化
使用 napi-rs 构建 ,参考 napi > Create project 得到一个模板起始项目。
实战 case
此处同样我们实现一个:将所有 react
导入转换为 preact
的需求,所需要的依赖与模板代码如下:
# Cargo.toml 确保你含有这些依赖
[dependencies]
napi = { version = "2.12.2", default-features = false, features = ["napi4", "error_anyhow"] }
napi-derive = "2.12.2"
swc_common = { version = "0.31.12", features = ["sourcemap"] }
swc_ecmascript = { version = "0.228.27", features = ["parser", "visit", "codegen"] }
#[macro_use]
extern crate napi_derive;
use std::path::{Path, PathBuf};
use std::str;
use swc_common::comments::SingleThreadedComments;
use swc_common::{sync::Lrc, FileName, Globals, SourceMap};
use swc_ecmascript::ast;
use swc_ecmascript::codegen::text_writer::JsWriter;
use swc_ecmascript::parser::lexer::Lexer;
use swc_ecmascript::parser::{EsConfig, Parser, StringInput, Syntax, TsConfig};
use swc_ecmascript::visit::{VisitMut, VisitMutWith};
#[napi]
pub fn transform(code: String, options: ImportChange) -> String {
let is_jsx = true;
let is_typescript = true;
let filename_path_buf = PathBuf::from("filename.tsx");
let syntax = if is_typescript {
Syntax::Typescript(TsConfig {
tsx: is_jsx,
decorators: true,
..Default::default()
})
} else {
Syntax::Es(EsConfig {
jsx: is_jsx,
export_default_from: true,
..Default::default()
})
};
let source_map = Lrc::new(SourceMap::default());
let source_file = source_map.new_source_file(
FileName::Real(filename_path_buf.clone()),
code.clone().into(),
);
let comments = SingleThreadedComments::default();
let lexer = Lexer::new(
syntax,
Default::default(),
StringInput::from(&*source_file),
Some(&comments),
);
let mut parser = Parser::new_from(lexer);
let mut module = parser.parse_module().expect("failed to parse module");
swc_common::GLOBALS.set(&Globals::new(), || {
let mut visitor = options;
module.visit_mut_with(&mut visitor);
});
let (code, _map) = emit_source_code(
Lrc::clone(&source_map),
Some(comments),
&module,
None,
false,
)
.unwrap();
code
}
#[napi(object)]
pub struct ImportChange {
pub from: String,
pub to: String,
}
impl ImportChange {
pub fn new(from: String, to: String) -> Self {
Self { from, to }
}
}
impl VisitMut for ImportChange {
fn visit_mut_module_decl(&mut self, decl: &mut ast::ModuleDecl) {
if let ast::ModuleDecl::Import(import_decl) = decl {
if import_decl.src.value == self.from {
import_decl.src = Box::new(self.to.clone().into());
}
}
}
}
pub fn emit_source_code(
source_map: Lrc<SourceMap>,
comments: Option<SingleThreadedComments>,
program: &ast::Module,
root_dir: Option<&Path>,
source_maps: bool,
) -> Result<(String, Option<String>), napi::Error> {
let mut src_map_buf = Vec::new();
let mut buf = Vec::new();
{
let writer = Box::new(JsWriter::new(
Lrc::clone(&source_map),
"\n",
&mut buf,
if source_maps {
Some(&mut src_map_buf)
} else {
None
},
));
let config = swc_ecmascript::codegen::Config {
minify: false,
target: ast::EsVersion::latest(),
ascii_only: false,
omit_last_semi: false,
};
let mut emitter = swc_ecmascript::codegen::Emitter {
cfg: config,
comments: Some(&comments),
cm: Lrc::clone(&source_map),
wr: writer,
};
emitter.emit_module(program)?;
}
let mut map_buf = vec![];
let emit_source_maps = if source_maps {
let mut s = source_map.build_source_map(&src_map_buf);
if let Some(root_dir) = root_dir {
s.set_source_root(Some(root_dir.to_str().unwrap()));
}
s.to_writer(&mut map_buf).is_ok()
} else {
false
};
if emit_source_maps {
Ok((
unsafe { str::from_utf8_unchecked(&buf).to_string() },
unsafe { Some(str::from_utf8_unchecked(&map_buf).to_string()) },
))
} else {
Ok((unsafe { str::from_utf8_unchecked(&buf).to_string() }, None))
}
}
构建
# dev
napi build --platform
# release
napi build --release
一般情况下,我们通常会分发至以下 9
个平台:
"napi": {
"triples": {
"defaults": false,
"additional": [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"armv7-unknown-linux-gnueabihf"
]
}
}
通常本地只能编译自己平台的 .node
二进制文件,所以需要依赖 Github Actions CI 等云环境进行多平台的构建,并且在 CICD 中构造好 npm 包使用 Npm Token 发布,此部分内容往往是大量的模板代码与调试过程,请自行研究学习。
运行二进制包
import { transform } from './index'
console.log(
// import React from 'preact'
transform(
`import React from 'react'`,
{ from: 'react', to: 'preact' }
)
)
直接导入 napi
生成的 index.js
胶水代码即可使用 .node
二进制包。
缺点
-
通过构建
.node
分发至不同平台是目前最高运行效率、最小下载体积的方法,但相对应需要手动管理所有 Rust 代码,且多平台构建也强依赖云环境,这提出了一些较高的要求。 -
随着对
napi
/ Rust 异步、并发编程 / Swc 的理解精进,你可以写出更高运行效率的代码,得到更快的执行速度。但最简单的代码依然够用,因为现代计算机性能已经足够快,1s 还是 10s 的争论没有意义。 -
在开发过程中,你可能会遇到各种 Rust 构建相关的问题,请自行研究并解决。
总结
本文对 Rust 浅尝辄止,如希望更有所作为,你可以通过不断精进 Rust ,组织出更优雅的代码结构,实现更高的执行效率。
前端 AST 人尽皆知,如同开发 Babel 插件一样,开发 Rust Swc 插件已然成为现代前端的必修课,文本推荐只入门至 Swc 插件为止,已经能应对绝大多数场景。
另外,对于性能上无需过多追求,由于计算机的性能已经过剩,不管是 wasm 还是 .node
速度都是很快的,秒级之争没有意义。
以上。