自己动手构建 JavaScript 运行时
在这篇文章中,我们将逐步创建一个自定义的 JavaScript 运行时。我们称之为 runjs
。可以把它想象成构建一个(大大)简化版的 deno
。本文的目标是创建一个命令行工具,它可以执行本地 JavaScript 文件、读取文件、写入文件、删除文件,并具有简化的 console
API。
让我们开始吧。
更新于 2022-12-04:代码示例已更新至 deno_core
最新版本
更新于 2023-02-16:我们发布了本教程的第二部分,其中实现了类似 fetch
的 API 并添加了 TypeScript 编译功能.
更新于 2023-05-04:我们发布了本教程的第三部分,其中创建了快照以加快启动时间。
更新于 2024-09-26:代码示例已更新至 deno_core
最新版本
预备知识
本教程假定读者具备以下知识:
- Rust 基础知识
- JavaScript 事件循环基础知识
请确保您的机器上已安装 Rust(以及 cargo
),并且版本至少为 1.80.0
。访问 rust-lang.org 安装 Rust 编译器和 cargo
。
确保我们已准备就绪
$ cargo --version
cargo 1.80.1 (3f5fd8dd4 2024-08-06)
你好,Rust!
首先,让我们创建一个新的 Rust 项目,它将是一个名为 runjs
的二进制 crate
$ cargo init --bin runjs
Created binary (application) package
将您的工作目录切换到 runjs
并用编辑器打开它。确保一切设置正确
$ cd runjs
$ cargo run
Compiling runjs v0.1.0 (/Users/ib/dev/runjs)
Finished dev [unoptimized + debuginfo] target(s) in 1.76s
Running `target/debug/runjs`
Hello, world!
太棒了!现在我们开始创建自己的 JavaScript 运行时。
依赖
接下来,让我们向项目中添加 deno_core
和 tokio
依赖项
$ cargo add deno_core
Updating crates.io index
Adding deno_core v0.311.0 to dependencies.
$ cargo add tokio --features=full
Updating crates.io index
Adding tokio v1.40.0 to dependencies.
我们更新后的 Cargo.toml
文件应该如下所示
[package]
name = "runjs"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.net.cn/cargo/reference/manifest.html
[dependencies]
deno_core = "0.311"
tokio = { version = "1.40", features = ["full"] }
deno_core
是 Deno 团队开发的一个 crate,它抽象了与 V8 JavaScript 引擎的交互。V8 是一个拥有数千个 API 的复杂项目,为了简化其使用,deno_core
提供了一个 JsRuntime
结构体,它封装了一个 V8 引擎实例(称为 Isolate
)并允许与事件循环集成。
tokio
是一个异步 Rust 运行时,我们将用它作为事件循环。Tokio 负责与网络套接字或文件系统等操作系统抽象进行交互。deno_core
与 tokio
结合使用,可以轻松地将 JavaScript 的 Promise
映射到 Rust 的 Future
上。
同时拥有 JavaScript 引擎和事件循环使我们能够创建 JavaScript 运行时。
你好,runjs!
首先,我们来编写一个异步 Rust 函数,它将创建一个 JsRuntime
实例,该实例负责 JavaScript 执行。
// main.rs
use deno_core::error::AnyError;
use std::rc::Rc;
async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module =
deno_core::resolve_path(file_path, &std::env::current_dir()?)?;
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(Default::default()).await?;
result.await
}
fn main() {
println!("Hello, world!");
}
这里有很多细节需要解释。异步 run_js
函数会创建一个新的 JsRuntime
实例,它使用基于文件系统的模块加载器。之后,我们将模块加载到 js_runtime
运行时中,评估它,并运行事件循环直到完成。
这个 run_js
函数封装了我们的 JavaScript 代码将经历的整个生命周期。但在此之前,我们需要创建一个单线程的 tokio
运行时,以便能够执行我们的 run_js
函数
// main.rs
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
if let Err(error) = runtime.block_on(run_js("./example.js")) {
eprintln!("error: {}", error);
}
}
让我们尝试执行一些 JavaScript 代码!创建一个 example.js
文件,它将打印“Hello runjs!”
// example.js
Deno.core.print("Hello runjs!");
请注意,我们使用的是 Deno.core
中的 print
函数——这是一个由 deno_core
Rust crate 提供的全局可用内置对象。
现在运行它
cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/runjs`
Hello runjs!⏎
成功!仅用 33 行 Rust 代码,我们就创建了一个简单的 JavaScript 运行时,可以执行本地文件。当然,这个运行时目前还不能做太多事情(例如,console.log
还不工作——试试看!),但我们已经将 V8 JavaScript 引擎和 tokio
集成到我们的 Rust 项目中了。
console
API
添加 我们来着手处理 console
API。首先,创建 src/runtime.js
文件,它将实例化 console
对象并使其全局可用
// runtime.js
const { core } = Deno;
function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}
globalThis.console = {
log: (...args) => {
core.print(`[out]: ${argsToMessage(...args)}\n`, false);
},
error: (...args) => {
core.print(`[err]: ${argsToMessage(...args)}\n`, true);
},
};
console.log
和 console.error
函数将接受多个参数,将它们 JSON 字符串化(以便我们可以检查非原始 JS 对象),并用 log
或 error
作为每个消息的前缀。这是一个“老式”JavaScript 文件,就像我们在 ES 模块出现之前在浏览器中编写 JavaScript 那样。
现在,我们将此代码包含到我们的二进制文件中,并在每次运行时执行
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});
+ let internal_mod_id = js_runtime
+ .load_side_es_module_from_code(
+ &deno_core::ModuleSpecifier::parse("runjs:runtime.js")?,
+ include_str!("./runtime.js"),
+ )
+ .await?;
+ let internal_mod_result = js_runtime.mod_evaluate(internal_mod_id);
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(Default::default()).await?;
+ internal_mod_result.await?;
result.await
最后,让我们用新的 console
API 更新 example.js
- Deno.core.print("Hello runjs!");
+ console.log("Hello", "runjs!");
+ console.error("Boom!");
并再次运行它
cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[err]: "Boom!"
它成功了!现在让我们添加一个 API,它将允许我们与文件系统交互。
添加基本文件系统 API
我们首先更新 runtime.js
文件
};
+ globalThis.runjs = {
+ readFile: (path) => {
+ return core.ops.op_read_file(path);
+ },
+ writeFile: (path, contents) => {
+ return core.ops.op_write_file(path, contents);
+ },
+ removeFile: (path) => {
+ return core.ops.op_remove_file(path);
+ },
+ };
我们刚刚添加了一个新的全局对象 runjs
,它有三个方法:readFile
、writeFile
和 removeFile
。前两个方法是异步的,而第三个是同步的。
您可能想知道这些 core.ops.[op name]
调用是什么——它们是 deno_core
crate 中用于绑定 JavaScript 和 Rust 函数的机制。当您调用其中任何一个时,deno_core
将查找具有 #[op2]
属性且名称匹配的 Rust 函数。
通过更新 main.rs
,我们来看看它的实际应用
+ use deno_core::extension;
+ use deno_core::op2;
use deno_core::PollEventLoopOptions;
use std::rc::Rc;
+ #[op2(async)]
+ #[string]
+ async fn op_read_file(#[string] path: String) -> Result<String, AnyError> {
+ let contents = tokio::fs::read_to_string(path).await?;
+ Ok(contents)
+ }
+
+ #[op2(async)]
+ async fn op_write_file(#[string] path: String, #[string] contents: String) -> Result<(), AnyError> {
+ tokio::fs::write(path, contents).await?;
+ Ok(())
+ }
+
+ #[op2(fast)]
+ fn op_remove_file(#[string] path: String) -> Result<(), AnyError> {
+ std::fs::remove_file(path)?;
+ Ok(())
+ }
我们刚刚添加了三个可以从 JavaScript 调用的操作(ops)。但在此操作(ops)可用于我们的 JavaScript 代码之前,我们需要通过注册一个“扩展(extension)”来告知 deno_core
。
+ extension!(
+ runjs,
+ ops = [
+ op_read_file,
+ op_write_file,
+ op_remove_file,
+ ]
+ );
async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module = deno_core::resolve_path(file_path)?;
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
+ extensions: vec![runjs::init_ops()],
..Default::default()
});
扩展(Extensions)允许您配置 JsRuntime
实例,并将不同的 Rust 函数暴露给 JavaScript,还可以执行更高级的操作,例如加载额外的 JavaScript 代码。
因此,我们现在还可以通过更改 runtime.js
的声明来稍微清理一下代码
extension!(
runjs,
ops = [
op_read_file,
op_write_file,
op_remove_file,
],
+ esm_entry_point = "ext:runjs/runtime.js",
+ esm = [dir "src", "runtime.js"],
);
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
- extensions: vec![runjs::init_ops()],
+ extensions: vec![runjs::init_ops_and_esm()],
..Default::default()
});
- let internal_mod_id = js_runtime
- .load_side_es_module_from_code(
- &deno_core::ModuleSpecifier::parse("runjs:runtime.js")?,
- include_str!("./runtime.js"),
- )
- .await?;
- let internal_mod_result = js_runtime.mod_evaluate(internal_mod_id);
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(Default::default()).await?;
- internal_mod_result.await?;
result.await
宏中的 esm
指令定义了我们希望包含到扩展中的 JavaScript 文件。dir "src"
部分指定了该扩展的 JavaScript 文件位于 src
目录中。然后我们将 runtime.js
作为要包含的文件添加到其中。
esm_entry_point
指令声明了我们想要用作入口点的文件。我们来分析一下为其指定的字符串
"ext:"
:deno_core
用于引用扩展的特殊 schemerunjs
:我们尝试访问其文件的扩展名runtime.js
:我们想要用作入口点的 JavaScript 文件
现在,我们再次更新 example.js
console.log("Hello", "runjs!");
console.error("Boom!");
+
+ const path = "./log.txt";
+ try {
+ const contents = await runjs.readFile(path);
+ console.log("Read from a file", contents);
+ } catch (err) {
+ console.error("Unable to read file", path, err);
+ }
+
+ await runjs.writeFile(path, "I can write to a file.");
+ const contents = await runjs.readFile(path);
+ console.log("Read from a file", path, "contents:", contents);
+ console.log("Removing file", path);
+ runjs.removeFile(path);
+ console.log("File removed");
+
然后运行它
$ cargo run
Compiling runjs v0.1.0 (/Users/ib/dev/runjs)
Finished dev [unoptimized + debuginfo] target(s) in 0.97s
Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[err]: "Boom!"
[err]: "Unable to read file" "./log.txt" {"code":"ENOENT"}
[out]: "Read from a file" "./log.txt" "contents:" "I can write to a file."
[out]: "Removing file" "./log.txt"
[out]: "File removed"
恭喜,我们的 runjs
运行时现在可以与文件系统协同工作了!请注意,从 JavaScript 调用 Rust 只需很少的代码——deno_core
负责在 JavaScript 和 Rust 之间编组数据,因此我们无需自己进行任何转换。
总结
在这个简短的示例中,我们启动了一个 Rust 项目,它将强大的 JavaScript 引擎(V8
)与高效的事件循环实现(tokio
)进行了集成。
完整的示例可在 denoland 的 GitHub 上找到。
更新于 2023-02-16:我们发布了本教程的第二部分,其中实现了类似 fetch
的 API 并添加了 TypeScript 编译功能.