创建一个自定义 JavaScript 运行时

本文将教大家创建一个自定义的 JavaScript 运行时—— runjs ,我们可以把这个过程看作是搭建 Deno 的极简版本。本文的目标之一是创建一个可以执行本地 JavaScript 文件的 CLI,读取文件,写入文件,删除文件,并有简化的 console API.

我们开始吧!

前期准备

建议大家在参考本教程前掌握:

  • Rust 的基本知识

  • JavaScript 事件循环的基本知识

另外,确保你的设备上已经安装了 Rust (以及 cargo ),而且是 1.62.0 以上版本。请访问 rust-lang.org 安装 Rust 编译器和 cargo

做好以下准备:

$ cargo --version
cargo 1.62.0 (a748cf5a3 2022-06-08)

你好,Rust!

首先,创建一个新的 Rust 项目,作为 unjs 的二进制箱:

$ 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.142.0 to dependencies.
$ cargo add tokio --features=full
    Updating crates.io index
      Adding tokio v1.19.2 to dependencies.

更新后的 Cargo.toml 文件如下:

[package]
name = "runjs"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
deno_core = "0.142.0"
tokio = { version = "1.19.2", features = ["full"] }

deno_core 是 Deno 团队的一个 crate,它抽象出了与 V8 JavaScript 引擎的交互关 系。V8 是一个复杂的项目,有数以千计的 API,所以为了简化 V8 的使用步骤, deno_core 提供了一个 JsRuntime 结构,封装了 V8 引擎实例(所谓的 Isolate ),并允许与事件循环集成。

tokio 是一个异步的 Rust 运行时,我们将把它作为一个事件循环。Tokio 负责与 OS抽象(如网络套接字或文件系统)交互。Deno_coretokio 一起可以把 JavaScript 的 Promise 轻松映射到 Rust 的Future 上。

只要同时拥有 JavaScript 引擎和事件循环,我们就可以创建 JavaScript 运行时。

你好,runjs!

首先,写一个异步的 Rust 函数,这个函数会创建一个 JsRuntime 的实例,负责 JavaScript 的执行。

// main.rs
use std::rc::Rc;
use deno_core::error::AnyError;

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)),
      ..Default::default()
  });

  let mod_id = js_runtime.load_main_module(&main_module, None).await?;
  let result = js_runtime.mod_evaluate(mod_id);
  js_runtime.run_event_loop(false).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 代码! 创建一个打印 “Hello runjs!” 的 example.js 文件。

// example.js
Deno.core.print("Hello runjs!");

注意,我们使用的是 Deno.coreprint 函数—— 这是一个全局可用的内置对象,由 Deno_core Rust crate 提供,运行它:

cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/runjs`
Hello runjs!⏎

成功啦! 我们只用了 25 行 Rust 代码就创建了一个简单的 JavaScript 运行时,它可以执行本地文件。当然,这个运行时目前还不能做什么(例如, console.log 还不能工作,大家可以试一下!),但我们已经在 Rust 项目中集成了 V8 JavaScript 引擎和 tokio

添加 console API

我们来研究一下 console API。首先,创建 src/runtime.js 文件,该文件将实例化并使console 对象全局可用:

// runtime.js
((globalThis) => {
  const core = Deno.core;

  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);
    },
  };
})(globalThis);

函数 console.logconsole.error 将接受多个参数,把它们串成 JSON (所以我们可以检查非原始的 JS 对象),并在每个消息前加上 logerror 。这是一个普通的 JavaScript 文件,与我们在 ES 模块之前的浏览器中编写 JavaScript 一样。

为了不污染全局范围,我们在 IIFE 中执行这段代码。如果不这样做,argsToMessage 辅助函数将在我们的运行时中全局可用。

现在,把这段代码纳入我们的二进制文件,并在每次运行时执行:

let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
  module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
  ..Default::default()
});
+ js_runtime.execute_script("[runjs:runtime.js]",  include_str!("./runtime.js")).unwrap();

最后,用新的 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.opAsync("op_read_file", path);
+   },
+   writeFile: (path, contents) => {
+     return core.opAsync("op_write_file", path, contents);
+   },
+   removeFile: (path) => {
+     return core.opSync("op_remove_file", path);
+   },
+ };
})(globalThis);

我们刚刚添加了一个新的全局对象,名为 runjs ,它有三个方法: readFilewriteFileremoveFile 。前两个方法是异步的,第三个是同步的。

大家可能想知道这些 core.opAsynccore.opSync 调用是什么,它们是 deno_core crate 中绑定 JavaScript 和 Rust 函数的机制。当调用这两个函数时, Deno_core 会寻找一个具有 #[op] 属性和匹配名称的 Rust 函数。

我们通过更新 main.rs 来查看这个动作:

+ use deno_core::op;
+ use deno_core::Extension;
use deno_core::error::AnyError;
use std::rc::Rc;

+ #[op]
+ async fn op_read_file(path: String) -> Result<String, AnyError> {
+     let contents = tokio::fs::read_to_string(path).await?;
+     Ok(contents)
+ }
+
+ #[op]
+ async fn op_write_file(path: String, contents: String) -> Result<(), AnyError> {
+     tokio::fs::write(path, contents).await?;
+     Ok(())
+ }
+
+ #[op]
+ fn op_remove_file(path: String) -> Result<(), AnyError> {
+     std::fs::remove_file(path)?;
+     Ok(())
+ }

我们刚刚添加了三个可以从 JavaScript 中调用的操作。但在这些操作对我们的 JavaScript 代码可用之前,我们需要通过注册一个 “扩展” 把它们告诉 Deno_core:

async fn run_js(file_path: &str) -> Result<(), AnyError> {
    let main_module = deno_core::resolve_path(file_path)?;
+    let runjs_extension = Extension::builder()
+        .ops(vec![
+            op_read_file::decl(),
+            op_write_file::decl(),
+            op_remove_file::decl(),
+        ])
+        .build();
    let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
        module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
+        extensions: vec![runjs_extension],
        ..Default::default()
    });

通过扩展可以配置我们的 JsRuntime 实例,并将不同的 Rust 函数暴露给 JavaScript, 同时执行更高级的东西,比如加载额外的 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’s GitHub 查看完整的工作实例。

原文作者:Bartek Iwańczuk
原文链接:Roll your own JavaScript runtime

推荐阅读
相关专栏
开发者实践
182 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。