自己动手打造 JavaScript 运行时,第二部分
这是第二部分,接着 自己动手打造 JavaScript 运行时,第一部分。还有一个 第三部分,我们将创建快照以加快启动时间。
更新 2024-09-26:代码示例已更新到最新版本的 deno_core
您可能需要自己动手打造 JavaScript 运行时的原因有很多,例如使用 Rust 后端构建交互式 Web 应用程序、通过构建插件系统扩展平台或为 Minecraft 编写插件。
在本博文中,我们将基于 第一篇博文,通过以下方式进行构建:
安装设置
如果您遵循了 第一篇博文,您的项目应该有三个文件
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
特性,因此我们必须实现 resolve
和 load
函数。
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?请在 Twitter 或 Discord 上告诉我们。