V8 桥梁:Deno 的扩展系统如何将 Rust 与 JavaScript 连接起来
前置知识
- ›第 1 篇:架构与 Crate 全景图
- ›V8 基础概念(isolate、context、handle、snapshot)
- ›了解 Rust proc macro 的基本原理
V8 桥梁:Deno 的扩展系统如何将 Rust 与 JavaScript 连接起来
在第 1 篇中,我们了解了 cli/ 如何分发命令,以及 CliFactory 如何按需延迟组装服务。但有一个最核心的问题我们还没有触及:Rust 代码究竟是如何变得可以从 JavaScript 调用的?答案就是 deno_core——这个超过 5000 行的基础 crate 封装了 V8,并提供了将 Rust 函数接入 JavaScript 运行时的扩展系统。本文将沿着这座桥梁,从 Extension 结构体出发,依次讲解 #[op2] proc macro、一个真实的文件系统示例、V8 快照优化,以及为榨取每一毫秒启动时间而设计的 UnconfiguredRuntime 模式。
deno_core:引擎室
libs/core/lib.rs 本质上是一份 re-export 清单,直接揭示了这个 crate 的对外接口。其中最核心的抽象有以下几个:
JsRuntime— 在 V8 isolate 之上封装了事件循环、模块加载器和 op 调度Extension— 将 op 和 JavaScript 源文件打包成可注册的单元OpState— 线程本地的状态容器,op 从中读取权限、文件系统等信息ModuleLoadertrait — 定义 ES 模块的解析与加载方式
classDiagram
class JsRuntime {
+execute_script()
+load_main_es_module()
+run_event_loop()
+op_state() OpState
+lazy_init_extensions()
}
class Extension {
+name: &str
+deps: &[&str]
+ops: Cow~[OpDecl]~
+esm_files: Cow~[ExtensionFileSource]~
+lazy_loaded_esm_files
+enabled: bool
}
class OpState {
+put~T~(value)
+borrow~T~() &T
+borrow_mut~T~() &mut T
}
class OpDecl {
+name: &str
+is_async: bool
+slow_fn: OpFnRef
+fast_fn: Option~CFunction~
}
JsRuntime --> Extension : registers
JsRuntime --> OpState : owns
Extension --> OpDecl : contains
runtime/mod.rs 将运行时拆分为若干职责单一的子模块:jsruntime(JsRuntime 主结构体)、jsrealm(V8 context 管理)、snapshot(快照的创建与加载)、bindings(V8 函数回调的绑定)以及 op_driver(异步 op 的调度)。
提示:
deno_core专为嵌入场景而设计——你可以完全独立于 Deno 使用它,构建自己的 JavaScript 运行时。这也是它放在libs/下、而不是与cli/或runtime/紧耦合的原因。
Extension 抽象
Extension 将向运行时添加某项能力所需的一切都打包在一起:
pub struct Extension {
pub name: &'static str,
pub deps: &'static [&'static str],
pub js_files: Cow<'static, [ExtensionFileSource]>,
pub esm_files: Cow<'static, [ExtensionFileSource]>,
pub lazy_loaded_esm_files: Cow<'static, [ExtensionFileSource]>,
pub ops: Cow<'static, [OpDecl]>,
pub objects: Cow<'static, [OpMethodDecl]>,
pub external_references: Cow<'static, [v8::ExternalReference]>,
pub global_template_middleware: Option<GlobalTemplateMiddlewareFn>,
pub global_object_middleware: Option<GlobalObjectMiddlewareFn>,
pub op_state_fn: Option<Box<OpStateFn>>,
pub needs_lazy_init: bool,
pub enabled: bool,
}
deps 字段声明了加载顺序的依赖关系——例如 deno_fs 依赖 deno_web。扩展可以通过三种方式提供 JavaScript 源码:普通 JS 文件、ES 模块,以及只在首次导入时才会执行的懒加载 ES 模块。op_state_fn 回调则允许扩展在初始化时向 OpState 注入状态。
extension!() 宏(由 deno_ops 生成)负责生成声明扩展所需的样板代码。以下是 deno_fs 在 ext/fs/lib.rs 中的用法:
deno_core::extension!(deno_fs,
deps = [ deno_web ],
ops = [
op_fs_open_sync, op_fs_open_async,
op_fs_mkdir_sync, op_fs_mkdir_async,
op_fs_chmod_sync, op_fs_chmod_async,
// ... ~60 more ops
],
esm = [ "30_fs.js" ],
options = { ... },
state = |state, options| { ... },
);
这个宏会生成 deno_fs::init() 和 deno_fs::lazy_init() 两个函数,均返回完整配置好的 Extension 实例。lazy_init() 变体会将 needs_lazy_init 设为 true,推迟执行 op_state_fn 回调——这对于我们稍后要介绍的 UnconfiguredRuntime 模式至关重要。
#[op2] Proc Macro
每个 op 都从一个被 #[op2] 标注的普通 Rust 函数开始。libs/ops/lib.rs 中的 proc macro 入口看起来极为简单:
#[proc_macro_attribute]
pub fn op2(attr: TokenStream, item: TokenStream) -> TokenStream {
op2_macro(attr, item)
}
但这背后,op2 模块实际上生成了大量代码。对于一个同步 op,它会生成:
- 慢路径(slow path)—— 一个 V8 函数回调,从
v8::FunctionCallbackInfo中提取参数,通过serde_v8或自定义转换器进行类型转换,调用 Rust 函数,再将返回值转换回 V8 类型 - 快路径(fast path)—— 利用 V8 的 Fast API(
CFunction),对于参数类型简单的情况,绕过FunctionCallbackInfo带来的开销 - 指标包装器(metrics wrapper)—— 在开启 op tracing 时,记录耗时和成功/失败状态
flowchart TD
JS["JavaScript call:<br/>Deno.readFileSync(path)"]
V8["V8 engine"]
FAST{Fast API<br/>eligible?}
FASTPATH["Fast CFunction call<br/>Direct type mapping"]
SLOWPATH["Slow FunctionCallback<br/>Extract from FunctionCallbackInfo"]
CONVERT["Type conversion<br/>(serde_v8 / custom)"]
RUST["Rust op function<br/>op_fs_read_file_sync()"]
RESULT["Convert result to V8"]
JS --> V8
V8 --> FAST
FAST -->|Yes| FASTPATH
FAST -->|No| SLOWPATH
FASTPATH --> RUST
SLOWPATH --> CONVERT
CONVERT --> RUST
RUST --> RESULT
RESULT --> V8
#[op2] 宏通过属性参数支持多种调用约定:#[op2(async)] 用于返回 future 的异步 op,#[op2(fast)] 强制生成快路径,#[op2(reentrant)] 用于可能回调 JavaScript 的 op。异步 op 返回 impl Future,由 op_driver 模块的事件循环驱动执行。
生成的 OpDecl 结构体同时持有慢路径和快路径的函数指针:
pub struct OpDecl {
pub name: &'static str,
pub is_async: bool,
pub arg_count: u8,
pub(crate) slow_fn: OpFnRef,
pub(crate) slow_fn_with_metrics: OpFnRef,
pub(crate) fast_fn: Option<CFunction>,
pub(crate) fast_fn_with_metrics: Option<CFunction>,
// ...
}
提示: Op 的命名遵循固定约定:
op_fs_read_file_sync是同步变体,op_fs_read_file_async是异步变体。30_fs.js中的 JavaScript 包装层会调用相应的版本,通常还会提供更友好的 API(比如接受字符串并自动转换为路径)。
真实案例解析:deno_fs
让我们端到端地追踪一次 Deno.readFileSync("/tmp/hello.txt") 调用,看看这些组件是如何协作的。
ext/fs/lib.rs 的扩展声明在约 60 个 op 中注册了 op_fs_read_file_sync。30_fs.js 中的 JavaScript 层从 ext:core/ops 导入这个 op,并在其外层包装权限检查和参数校验逻辑。
sequenceDiagram
participant JS as JavaScript (30_fs.js)
participant Core as ext:core/ops
participant V8 as V8 Engine
participant Op as op_fs_read_file_sync
participant FS as FileSystem trait
participant Disk as std::fs
JS->>Core: Deno.readFileSync(path)
Core->>V8: Function call dispatch
V8->>Op: slow_fn / fast_fn callback
Op->>Op: Extract OpState
Op->>Op: Check permissions
Op->>FS: fs.read_file_sync(path)
FS->>Disk: std::fs::read(path)
Disk-->>FS: Vec<u8>
FS-->>Op: Result<Vec<u8>>
Op-->>V8: v8::Uint8Array
V8-->>JS: Uint8Array
ext/fs/interface.rs 中的 FileSystem trait 是一层抽象——CLI 使用基于 std::fs 的 RealFs,而测试和独立二进制则可以提供不同的实现。这种基于 trait 的系统访问模式在 Deno 的扩展系统中随处可见。
op 函数本身接收 &mut OpState 作为第一个参数(由宏注入),并从中借用 FileSystem 实现和 PermissionsContainer。权限检查发生在 Rust 层,而非 JavaScript 层——op 是 Deno 安全模型的执行边界。
V8 快照:构建时序列化
Deno 的启动时间优化在很大程度上依赖 V8 快照。在构建阶段,runtime/snapshot.rs 会创建一个序列化的 V8 堆,其中包含所有扩展的 JavaScript 代码——这些代码已经过解析和编译:
pub fn create_runtime_snapshot(
snapshot_path: PathBuf,
snapshot_options: SnapshotOptions,
custom_extensions: Vec<Extension>,
) {
let mut extensions: Vec<Extension> = vec![
deno_telemetry::deno_telemetry::lazy_init(),
deno_webidl::deno_webidl::lazy_init(),
deno_web::deno_web::lazy_init(),
// ... ~30 extensions in specific order
];
extensions.extend(custom_extensions);
let output = create_snapshot(CreateSnapshotOptions {
extensions,
startup_snapshot: None,
// ...
}, None).unwrap();
let mut snapshot = std::fs::File::create(snapshot_path).unwrap();
snapshot.write_all(&output.output).unwrap();
}
在运行时,这份快照作为 JsRuntime::new() 的 startup_snapshot 参数被加载,V8 堆状态得以瞬间恢复。所有扩展的 JavaScript 模块都已完成解析、编译和部分执行——只有懒加载扩展的 JavaScript 求值会推迟进行。
sequenceDiagram
participant Build as Build Time
participant Snap as create_runtime_snapshot()
participant V8B as V8 (build)
participant File as snapshot.bin
participant Runtime as Runtime (startup)
participant V8R as V8 (runtime)
Build->>Snap: Register ~30 extensions
Snap->>V8B: Execute all JS sources
V8B->>V8B: Parse, compile, evaluate
V8B->>File: Serialize V8 heap
Note over File: ~10MB snapshot blob
Runtime->>File: Load snapshot bytes
File->>V8R: Deserialize V8 heap
Note over V8R: All JS modules ready
V8R->>Runtime: JsRuntime ready in ~5ms
快照包含每个扩展的 JavaScript 部分,但不包含 Rust op 绑定——因为函数指针无法在序列化中保留,需要在运行时重新注册。这就是 skip_op_registration 选项存在的原因:从快照加载时,op 函数会重新绑定到已存在的 V8 函数对象上。
扩展注册与顺序
common_extensions() 函数按照严格顺序注册约 30 个扩展:
fn common_extensions<...>(has_snapshot: bool, unconfigured_runtime: bool) -> Vec<Extension> {
// NOTE(bartlomieju): ordering is important here, keep it in sync with
// `runtime/worker.rs`, `runtime/web_worker.rs`, `runtime/snapshot_info.rs`
// and `runtime/snapshot.rs`!
vec![
deno_telemetry::deno_telemetry::init(),
deno_webidl::deno_webidl::init(),
deno_web::deno_web::lazy_init(),
deno_webgpu::deno_webgpu::init(),
deno_image::deno_image::init(),
deno_fetch::deno_fetch::lazy_init(),
// ... 20+ more in specific order
deno_node::deno_node::lazy_init::<...>(),
ops::bootstrap::deno_bootstrap::init(...),
runtime::init(),
ops::web_worker::deno_web_worker::init().disable(),
]
}
函数顶部的注释是一条警告:这个顺序必须与四个文件保持同步——worker.rs、web_worker.rs、snapshot_info.rs 和 snapshot.rs。如果快照构建时的扩展顺序为 A,而运行时注册顺序为 B,op ID 就会错位,并且会以静默方式引发错误。
注意最后一条:deno_web_worker::init().disable()。.disable() 调用会将所有 op 函数替换为空操作(noop)。这样做的目的是让 op 正常注册,使其对应的 JavaScript import 语句不会报错,但在非 worker 上下文中调用它们会触发 panic。这是一个优雅的变通方案,解决了"所有 JavaScript import 必须可解析,即使某些代码路径实际上不可达"这一约束。
graph LR
subgraph "Extension Registration Order"
T[telemetry] --> WI[webidl] --> W[web] --> WG[webgpu]
WG --> IMG[image] --> F[fetch] --> CA[cache]
CA --> WS[websocket] --> WST[webstorage] --> CR[crypto]
CR --> FFI[ffi] --> N[net] --> TLS[tls]
TLS --> KV[kv] --> CRON[cron] --> NAPI[napi]
NAPI --> HTTP[http] --> IO[io] --> FS[fs]
FS --> OS[os] --> PROC[process] --> NC[node_crypto]
NC --> NS[node_sqlite] --> NODE[node]
NODE --> RT[runtime ops] --> BS[bootstrap]
end
UnconfiguredRuntime 优化
UnconfiguredRuntime 模式将 JsRuntime 的创建拆分为两个阶段:V8 初始化(可以提前进行,甚至在 flag 完全解析之前)和配置(需要模块加载器、权限及其他服务就绪)。
在 MainWorker::from_options() 中,如果有可用的 UnconfiguredRuntime,会通过注水(hydrate)的方式为其配置模块加载器:
let mut js_runtime = if let Some(u) = options.unconfigured_runtime {
let js_runtime = u.hydrate(services.module_loader);
// ... reload cron handler from op state
js_runtime
} else {
// Full initialization path
let mut extensions = common_extensions::<...>(...);
common_runtime(CommonRuntimeOptions { ... })
};
当没有可用的 unconfigured runtime 时,common_runtime() 函数会从零创建一个 JsRuntime,将所有扩展、快照和配置一并传入 JsRuntime::new()。
注水完成后,以 lazy_init() 注册的扩展会通过 js_runtime.lazy_init_extensions() 获得状态注入——op_state_fn 回调在此时触发,将 blob store、fetch 选项、缓存后端及其他运行时特定的状态写入 OpState。
提示:
UnconfiguredRuntime对deno serve尤其有价值——V8 可以在服务器 socket 配置期间提前完成初始化。在 Unix 上,甚至有一种控制 socket 机制(wait_for_start),可以在实际命令参数到达之前就预先创建运行时。
下一步
我们已经完整追踪了从 Rust 到 JavaScript 的路径:扩展将 op 和 JS 源码打包在一起,#[op2] 宏生成带有快慢两条路径的 V8 绑定,快照在构建时序列化整个 JS 堆,而 UnconfiguredRuntime 则通过拆分初始化流程实现最大程度的并行。在下一篇文章中,我们将沿着反方向前进——探索 JavaScript 模块如何被解析、获取、转译和执行,涵盖模块加载管道、解析器栈,以及 Deno 的双路 TypeScript 编译系统。