原文:devongovett.me/blog/static...,parcel 作者 Devon 的博客
最近我一直在把Parcel的更多部分移植到Rust上。基于Rust的工具面临的一大挑战就是如何支持插件。很多常用工具已经有了Rust版本的平替:比如处理JavaScript的SWC和OXC,CSS的Lightning CSS,SVG的oxvg等等。但像React Compiler、Less和Sass这些热门工具还在用JavaScript编写,所以我们得想办法在Rust工具里运行它们。
一种方案是通过napi把Rust核心嵌入Node。这种模式下,程序的入口是JavaScript,它会调用Rust代码。当Rust需要调用JS插件时,再回调到JavaScript引擎。Lightning CSS的JS插件就是这么实现的。不过这会带来一些性能损耗:我开发Lightning CSS时测过,用JS插件比不用慢了大概7倍。
另一种类似的方案是跨进程通信。这种模式下入口是Rust,需要运行插件时就启动Node子进程。这同样会有不小的性能开销。
静态Hermes
Hermes是Facebook为React Native打造的定制JavaScript引擎。最新版本的静态Hermes(Static Hermes)采用了全新思路:它不再在运行时使用JIT(即时)编译器,而是提前把JavaScript编译成字节码或原生二进制文件。这样就省去了运行时编译和优化代码的启动时间,这在手机上可是个不小的提升。
静态Hermes的工作原理是把JavaScript编译成C代码,再通过LLVM编译成机器码。最终生成的是完全独立的二进制文件,不需要JavaScript虚拟机就能运行。编译出的C代码会用到Hermes提供的一些辅助函数,这些函数会像Rust等语言的标准库一样被静态链接到二进制文件中。这不仅能利用LLVM的高级优化提升性能,还能让JavaScript轻松嵌入到Rust这类能和C交互的语言编写的程序中。
把Less.js编译成C
我打算给Parcel做个Less插件,能从Rust调用的那种。借助静态Hermes,我成功把它编译成了C库,然后就能从Rust调用了。
第一步是把less这个npm模块打包成一个没有外部依赖的单文件JavaScript。Hermes不支持Node模块,所以一切都得自给自足,也不能依赖fs或path这些Node内置模块。当然,我用Parcel来做这件事。
javascript
// 使用Less的环境无关版本,并模拟PluginLoader
const less = require('less/lib/less').default({}, {})
less.PluginLoader = function() {}
// 暴露一个全局函数,把Less代码字符串编译成CSS
function compile(input) {
let result
less.render(input, (err, res) => {
result = res.css
})
return result
}
globalThis.compile = compile
用Parcel编译:
bash
parcel build less.js --no-optimize
这样就生成了dist/less.js,一个完全独立的文件,暴露了全局的compile函数。
接下来把它编译成C库。首先得自己编译静态Hermes,按照官方文档操作就行。
bash
./build_release/bin/shermes -O -c -exported-unit=less dist/less.js
-O生成优化过的构建-c编译成原生目标文件,之后可以链接到更大的程序中-exported-unit=less告诉Hermes不要生成main函数,而是导出一个叫less的编译单元
这会生成less.o目标文件。(如果想看看编译出的C源代码,可以把-c换成-emit-c。)
然后需要一个小的C包装器来调用这个JavaScript函数。
c
// compile.c
#include <stdlib.h>
#include <hermes/VM/static_h.h>
#include <hermes/hermes.h>
// 声明静态Hermes生成的`less`单元
// 这个会来自`less.o`
extern \"C\" SHUnit sh_export_less;
extern \"C\" char* compile_less(char *in) {
// 初始化Hermes运行时
static SHRuntime *s_shRuntime = nullptr;
static facebook::hermes::HermesRuntime *s_hermes = nullptr;
if (s_shRuntime == nullptr) {
s_shRuntime = _sh_init(0, nullptr);
s_hermes = _sh_get_hermes_runtime(s_shRuntime);
if (!_sh_initialize_units(s_shRuntime, 1, &sh_export_less)) {
abort();
}
}
// 获取全局的`compile`函数并调用
std::string res = s_hermes->global()
.getPropertyAsFunction(*s_hermes, \"compile\")
.call(*s_hermes, std::string(in))
.getString(*s_hermes)
.utf8(*s_hermes);
// 把C++字符串转成能返回的C字符串
char* result = new char[res.size() + 1];
strcpy(result, res.c_str());
return result;
}
用clang++把这个编译成另一个目标文件:
bash
clang++ -c -O3 -std=c++17 -IAPI -IAPI/jsi -Iinclude -Ipublic -Ibuild_release/lib/config compile.c
-c生成目标文件-O3生成优化过的构建-std=c++17启用C++17特性-I添加Hermes的头文件路径
这会生成compile.o目标文件。
最后就是从Rust调用这个compile_less函数了。
rust
// main.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
// 声明要调用的C函数
extern \"C\" {
fn compile_less(input: *const c_char) -> *const c_char;
}
fn main() {
// 创建C字符串
let input = CString::new(
r#\"// 变量
@link-color: #428bca;
@link-color-hover: darken(@link-color, 10%);
a,
.link {
color: @link-color;
}
a:hover {
color: @link-color-hover;
}
.widget {
color: #fff;
background: @link-color;
}
\"#,
)
.unwrap();
// 调用C函数并转成Rust字符串
let res = unsafe {
let ptr = compile_less(input.as_ptr());
CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
// 打印结果
println!(\"OUTPUT: {}\", res);
}
用rustc编译并链接所有东西:
bash
rustc main.rs -O -C link-arg=less.o -C link-arg=compile.o -Lbuild_release/lib -Lbuild_release/jsi -Lbuild_release/tools/shermes -lshermes_console_a -lhermesvm_a -ljsi -lc++ -Lbuild_release/external/boost/boost_1_86_0/libs/context/ -lboost_context -l framework=Foundation
-O生成优化过的构建-C link-arg=less.o -C link-arg=compile.o链接之前创建的C库-L ...添加库搜索路径-l ...链接Hermes库和依赖-l framework=Foundation是macOS特有的,用来链接Foundation框架
现在运行程序,就能看到它通过Rust编译Less了!🪄
bash
./main
总结
这只是个简单的初步示例,但展示了原生工具整合预编译JS插件的潜力,而且不需要嵌入解释器。另一个潜在用例是基于Babel的React Compiler------对很多人来说,这可能是他们构建流程中仅存的JS工具了。我简单试了下,但遇到了一些问题,可能是目前还缺少某些特性