跳至主要内容
Deno 2 终于发布了 🎉️
了解更多
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:创建 JsRuntime 实例的异步 Rust 函数,它负责 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/[email protected]/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.rs 中的 main() 函数中进行更改

// 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/[email protected]/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 特性,因此我们必须实现 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 并返回。

但是,在我们运行它之前,我们需要用我们新定义的 TsModuleLoader 替换 FsModuleLoader,我们在创建 JsRuntime 时使用它

// …

  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 上告诉我们。