跳过主内容
Deno 2 终于发布了 🎉️
了解更多

自己动手构建 JavaScript 运行时

在这篇文章中,我们将逐步创建一个自定义 JavaScript 运行时。让我们称之为 runjs。可以把它看作是构建一个(非常)简化的 deno 版本。这篇文章的目标是创建一个可以执行本地 JavaScript 文件、读取文件、写入文件、删除文件并拥有简化的 console API 的 CLI。

让我们开始吧。

更新时间 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 的二进制包

$ 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_coretokio 依赖项添加到我们的项目中

$ 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 团队的一个包,它抽象了与 V8 JavaScript 引擎的交互。V8 是一个复杂的项目,拥有数千个 API,因此为了简化使用它们,deno_core 提供了一个 JsRuntime 结构体,该结构体封装了一个 V8 引擎实例(称为 Isolate)并允许与事件循环集成。

tokio 是一个异步 Rust 运行时,我们将用它作为事件循环。Tokio 负责与 OS 抽象(如网络套接字或文件系统)进行交互。deno_coretokio 结合使用,可以轻松地将 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.coreprint 函数 - 这是一个全局可用的内置对象,由 deno_core Rust 包提供。

现在运行它

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.logconsole.error 将接受多个参数,并将它们作为 JSON 字符串化(以便我们可以检查非原始 JS 对象),并在每个消息前面加上 logerror。这是一个 “普通” 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,它有三个方法:readFilewriteFileremoveFile。前两个方法是异步的,而第三个是同步的。

您可能想知道这些 core.ops.[op name] 调用是什么 - 它们是 deno_core 包中用于绑定 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 中调用的操作。但在这些操作能够被我们的 JavaScript 代码访问之前,我们需要告诉 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()
  });

扩展允许您配置 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 用于引用扩展的特殊模式
  • runjs:我们要访问其文件的扩展名称
  • 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 编译.