跳到主要内容
Deno 2.4 已发布,带来 deno bundle、bytes/text imports、稳定化的 OTel 等新特性
了解更多
Roll your own javascript runtime, pt2.

自己动手构建 JavaScript 运行时,第二部分

这是第二部分,接续从零开始构建你的 JavaScript 运行时,第一部分。还有第三部分,我们在其中创建快照以加快启动时间。

更新 2024-09-26:代码示例已更新至最新版本的 deno_core

您可能出于多种原因希望构建自己的 JavaScript 运行时,例如使用 Rust 后端构建交互式 Web 应用程序、通过构建插件系统扩展您的平台,或者为 Minecraft 编写插件。

在这篇博文中,我们将基于第一篇博文,通过以下方式进行构建:

Deno Sessions episode 2 where Bartek walks through setting up fetch and supporting TypeScript

观看视频演示在此处查看源代码。

设置

如果您遵循了第一篇博文,您的项目应该包含三个文件

  • example.js:我们打算在自定义运行时中执行的 JavaScript 文件
  • main.rs:一个异步 Rust 函数,用于创建 JsRuntime 实例,该实例负责 JavaScript 执行
  • runtime.js:运行时接口,定义并提供了将与 main.rs 中的 JsRuntime 进行互操作的 API

让我们在自定义运行时中实现一个 HTTP 函数 fetch

实现 fetch

在我们的 runtime.js 文件中,让我们在全局对象 runjs 下添加一个新函数 fetch

// runtime.js

const { core } = Deno;
const { ops } = 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.runjs = {
  readFile: (path) => {
    return ops.op_read_file(path);
  },
  writeFile: (path, contents) => {
    return ops.op_write_file(path, contents);
  },
  removeFile: (path) => {
    return ops.op_remove_file(path);
  },
+  fetch: (url) => {
+    return ops.op_fetch(url);
+  },
};

现在,我们必须在 main.rs 中定义 op_fetch。它将是一个异步函数,接受一个 String 并返回一个 String 或一个错误。

在函数内部,我们将使用reqwest crate,这是一个方便而强大的 HTTP 客户端,并且只使用 get 函数。

// main.rs

// …

#[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(())
}

+ #[op]
+ async fn op_fetch(url: String) -> Result<String, AnyError> {
+   let body = reqwest::get(url).await?.text().await?;
+   Ok(body)
+ }

// …

为了使用 reqwest,让我们通过命令行将其添加到我们的项目

$ cargo add reqwest

接下来,我们将在 runjs 扩展中注册 op_fetch

// main.rs

// …

extension!(
  runjs,
  ops = [
    op_read_file,
    op_write_file,
    op_remove_file,
+    op_fetch,
  ],
  esm_entry_point = "ext:runjs/runtime.js",
  esm = [dir "src", "runtime.js"],
);

// …

让我们更新 example.js,以便我们可以尝试新的 fetch 函数

console.log("Hello", "runjs!");
content = await runjs.fetch(
  "https://deno.land/std@0.177.0/examples/welcome.ts",
);
console.log("Content from fetch", content);

我们可以这样运行它

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 2m 14s
     Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[out]: "Content from fetch" "// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.\n\n/** Welcome to Deno!\n *\n * @module\n */\n\nconsole.log(\"Welcome to Deno!\");\n"

成功了!我们只用了不到10行代码,就将自定义的 fetch 版本添加到了 JavaScript 运行时中。

读取命令行参数

到目前为止,我们一直硬编码要加载和执行的文件路径。每次我们只需运行 cargo run 即可运行 example.js 的内容。

让我们将其更新为读取命令行参数,并将第一个参数作为要运行的文件路径。我们可以在 main.rsmain() 函数中进行此更改

// main.rs

// ...

fn main() {
+  let args: Vec<String> = std::env::args().collect();

+  if args.is_empty() {
+    eprintln!("Usage: runjs <file>");
+    std::process::exit(1);
+  }
  let file_path = &args[1];

  let runtime = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();
+  if let Err(error) = runtime.block_on(run_js(file_path)) {
+    eprintln!("error: {error}");
+  }
}

让我们尝试运行 cargo run example.js

$ cargo run example.js
    Finished dev [unoptimized + debuginfo] target(s) in 6.99s
     Running `target/debug/runjs example.js`
[out]: "Hello" "runjs!"
[out]: "Content from fetch" "// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.\n\n/** Welcome to Deno!\n *\n * @module\n */\n\nconsole.log(\"Welcome to Deno!\");\n"

成功了!现在我们可以将文件作为命令行参数传递给运行时。

支持 TypeScript

但是如果我们还想支持 TypeScript 或 TSX 怎么办?

第一步是将 TypeScript 转译成 JavaScript。

让我们将 example.js 更新为 example.ts 并添加一些简单的 TypeScript

console.log("Hello", "runjs!");

+ interface Foo {
+   bar: string;
+   fizz: number;
+ }
+ let content: string;
content = await runjs.fetch(
  "https://deno.land/std@0.177.0/examples/welcome.ts",
);
console.log("Content from fetch", content);

接下来,我们必须更新 main.rs 中的模块加载器。我们当前的模块加载器deno_core::FsModuleLoader,它提供了从本地文件系统加载模块的功能。但是,此加载器只能加载 JavaScript 文件。

// main.rs
// …

  let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
    module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
    extensions: vec![runjs::init_ops_and_esm()],
    ..Default::default()

// …

所以让我们实现一个新的 TsModuleLoader,我们可以根据文件扩展名确定要转译哪种语言。这个新的模块加载器将实现 deno_core::ModuleLoader trait,所以我们必须实现 resolveload 函数。

resolve 函数很简单——我们可以直接调用 deno_core::resolve_import

// main.rs

struct TsModuleLoader;

impl deno_core::ModuleLoader for TsModuleLoader {
  fn resolve(
    &self,
    specifier: &str,
    referrer: &str,
    _kind: deno_core::ResolutionKind,
  ) -> Result<deno_core::ModuleSpecifier, deno_core::error::AnyError> {
    deno_core::resolve_import(specifier, referrer).map_err(|e| e.into())
  }
}

接下来,我们必须实现 load 函数。这比较棘手,因为将 TypeScript 转译为 JavaScript 并不容易——你需要能够解析 TypeScript 文件,创建抽象语法树,然后去除 JavaScript 无法理解的可选类型,最后将这个树折叠回文本文档。

我们不会自己做这件事(因为这可能需要几周才能实现),所以我们将使用 Deno 生态系统中的现有解决方案:deno_ast

让我们通过命令行将其添加到我们的依赖项

$ cargo add deno_ast

在我们的 Cargo.toml 中,我们还需要将 transpile 作为 deno_ast 的一个特性包含进来

// …
[dependencies]
deno_ast = { version = "0.42", features = ["transpiling"] }
// …

接下来,让我们在 main.rs 的顶部添加四个 use 声明,我们将在 load 函数中需要它们

// main.rs

use deno_ast::MediaType;
use deno_ast::ParseParams;
use deno_core::ModuleLoadResponse;
use deno_core::ModuleSourceCode;

// …

现在我们可以实现 load 函数了

// main.rs

struct TsModuleLoader;

impl deno_core::ModuleLoader for TsModuleLoader {
  // fn resolve() ...

  fn load(
    &self,
    module_specifier: &deno_core::ModuleSpecifier,
    _maybe_referrer: Option<&deno_core::ModuleSpecifier>,
    _is_dyn_import: bool,
    _requested_module_type: deno_core::RequestedModuleType,
  ) -> ModuleLoadResponse {
    let module_specifier = module_specifier.clone();
    let module_load = move || {
      let path = module_specifier.to_file_path().unwrap();

      // Determine what the MediaType is (this is done based on the file
      // extension) and whether transpiling is required.
      let media_type = MediaType::from_path(&path);
      let (module_type, should_transpile) = match MediaType::from_path(&path) {
        MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
          (deno_core::ModuleType::JavaScript, false)
        }
        MediaType::Jsx => (deno_core::ModuleType::JavaScript, true),
        MediaType::TypeScript
        | MediaType::Mts
        | MediaType::Cts
        | MediaType::Dts
        | MediaType::Dmts
        | MediaType::Dcts
        | MediaType::Tsx => (deno_core::ModuleType::JavaScript, true),
        MediaType::Json => (deno_core::ModuleType::Json, false),
        _ => panic!("Unknown extension {:?}", path.extension()),
      };

      // Read the file, transpile if necessary.
      let code = std::fs::read_to_string(&path)?;
      let code = if should_transpile {
        let parsed = deno_ast::parse_module(ParseParams {
          specifier: module_specifier.clone(),
          text: code.into(),
          media_type,
          capture_tokens: false,
          scope_analysis: false,
          maybe_syntax: None,
        })?;
        parsed
          .transpile(&Default::default(), &Default::default())?
          .into_source()
          .source
      } else {
        code.into_bytes()
      };

      // Load and return module.
      let module = deno_core::ModuleSource::new(
        module_type,
        ModuleSourceCode::Bytes(code.into_boxed_slice().into()),
        &module_specifier,
        None,
      );
      Ok(module)
    };

    ModuleLoadResponse::Sync(module_load)
  }
}

让我们详细分解一下。我们的 load 函数需要接受一个文件路径并返回一个 JavaScript 模块源。文件路径可以指向 JavaScript 或 TypeScript 文件,只要它返回一个转译后的 JavaScript 模块即可。

第一步是获取文件路径,确定其 MediaType,以及是否需要转译。接下来,函数将文件读取为字符串,并在必要时进行转译。最后,代码被转换为一个 module 并返回。

然而,在我们运行它之前,我们需要在创建 JsRuntime 的地方用我们新定义的 TsModuleLoader 替换 FsModuleLoader

// …

  let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
-   module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
+   module_loader: Some(Rc::new(TsModuleLoader)),
    extensions: vec![runjs::init_ops_and_esm()],
    ..Default::default()

// …

这应该就是我们让 TypeScript 转译工作所需的全部了。

让我们用 cargo run example.ts 运行它,它应该能正常工作!

cargo run example.ts
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/runjs example.ts`
[out]: "Hello" "runjs!"
[out]: "Content from fetch" "// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.\n\n/** Welcome to Deno!\n *\n * @module\n */\n\nconsole.log(\"Welcome to Deno!\");\n"

(请注意,“正常工作”意味着在解析 example.ts 中的 TypeScript 时没有出现错误。)

在大约154行 Rust 代码中,我们成功添加了对 TypeScript、TSX 及更多内容转译的支持。

下一步是什么?

在 Rust 中嵌入 JavaScript 和 TypeScript 是构建交互式、高性能应用程序的绝佳方式。无论是用于扩展平台功能的插件系统,还是高性能、专用运行时,Deno 都让 JavaScript、TypeScript 和 Rust 之间的互操作变得简单。

您正在构建自定义 JavaScript 运行时或在 Rust 中嵌入 JavaScript 吗?请在TwitterDiscord上告诉我们。