我从去年开始阅读 V8 引擎源码, 一个原因是我想实现一个 NodeJS
前段时间翻看了 deno 的源代码, 学习了部分思路, 结合我自己的理解, 实现一个 JS Runtime 的 Demo , 为了跟 nodejs 区分, 我在下文把这个 Runtime 叫做 zjs
因为大部分内容基于我的个人理解, 希望可以起到抛砖引玉的效果, 欢迎在评论区指出问题
技术选型
我很喜欢写 rust, 所以zjs使用 rust + v8 + tokio 实现
设计思路
我理想中的zjs 应该如下所示
需要有一个 main 函数作为程序默认的入口
所有的标准库 api 都返回 Promise 而不是回调
使用 import 加载模块
所以一个简单的 demo 如下
import fs from 'fs';
export async function main(args) {
const dirname = import.meta.url
const file = await fs.openFile(dirname + args[1])
const fileContent = await file.content() // 读文件内容
await file.write(fileContent + " append content") // 写入文件内容
}
使用 import 加载模块, 使用 promise 作为 api 的标准返回值类型
更具体点的实现思路, 当函数 openFile调用的时候, 会通过tokio::spawn 一个 Future 异步运行打开文件的操作, 并且先返回一个 js promise 对象
当 Future 完成的时候, 通知主线程去 resolve 对应的 promise
其实还是挺简单的, 关键在于如何使用 v8 实现这个想法
V8 使用方法(部分介绍)
关于 V8 初始化, 我之前有篇文章介绍过了, 我这里就略过了, 你也可以直接问 deepseek, 它肯定说的更详细
我主要说下如何用 V8 生成对象和函数, 还有如何自定义 import 行为
JS 对象的初始化
v8::ObjectTemplate 可以被理解为 class, 通过 ObjectTemplate 实例化可以快速生成 js 对象
假设我想生成一个js 对象 -> {a:undefined}
先生成一个 ObjectTemplate, 设置 属性 a 为 undefined
然后通过 template 实例化 js 对象
设置内部私有属性
这里的私有属性在 js 侧是无法访问的
设置一个private 属性, value 是 1 , 索引是 0
设置 Method
设置一个 method c, 先写一个函数, 函数需要有 3 个参数
scope-> 当前的HandleScope, 在HandleScope内创建的数据不会被gc掉args-> 函数的参数,args.get(0)拿到第一个参数,args.this()拿到callerreturn_value-> 函数的返回值, 通过return_value.set写入
比如下面的代码的功能等价于(a:number,b:number) => a+b
还需要构造函数模板才能嵌入到 v8 里面
let function = v8::FunctionTemplate::new(scope, method_c);
给函数添加额外的信息
使用 data 传递上下文的信息
// 传递上下文信息给回调函数
let function = v8::FunctionTemplate::builder(callback)
.data(context_data.into()) // 设置额外数据
.build(scope);
// 在回调中获取数据
fn callback(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
_rv: v8::ReturnValue,
) {
let data = args.data(); // 获取之前设置的数据
}
存储全局数据
用 isolate.set_data 存储全局对象数据, 因为 isolate 几乎在任何地方都可以访问到
值得注意的是, 这里写入的必须是指针, 所以需要处理 unsafe
isolate.set_data(slot, ptr);
let ptr = isolate.get_data(slot);
自定义模块导入行为(import)
模块导入 import
如果要自定义 import 行为, 需要使用 v8 Module 编译代码
// 源代码
let mut source = v8::script_compiler::Source::new(source, Some(&script_origin));
// 编译后的 module
let module = v8::script_compiler::compile_module(scope, &mut source);
module 初始化的时候, 需要注入一个 resolve_module_callback
pub fn resolve_module_callback<'s>(
context: v8::Local<'s, v8::Context>, // context
specifier: v8::Local<'s, v8::String>, // 比如 import 'fs', specifier 就是 fs
_import_assertions: v8::Local<'s, v8::FixedArray>, // 暂时用不到
referrer: v8::Local<'s, v8::Module>, // import 调用的模块
) -> Option<v8::Local<'s, v8::Module>> // 返回 import 的模块
module.instantiate_module(scope, resolve_module_callback) // 模块初始化的时候注入
实现了 resolve_module_callback 就可以自定义 import 行为
模块动态导入
import('./lib.js').then(module=>{ })
通过 isolate.set_host_import_module_dynamically_callback 设置动态 import 的回调
元数据
// 把 nodejs 里面对应的 __dirname 对应到
import.meta.url or import.meta.dirname
isolate.set_host_initialize_import_meta_object_callback 设置 import.meta 的初始化回调
pub extern "C" fn host_initialize_import_meta_object_callback(
context: v8::Local<'_, v8::Context>,
module: v8::Local<'_, v8::Module>,
meta: v8::Local<'_, v8::Object>, // meta 对象, 写入自定义 meta data
)
编码实现
主要分为两个部分, 一个是核心异步任务系统, 另外一个是模块系统
异步任务实现
核心部分是生成 异步任务 和 Promise 绑定
之后进入循环等待, 接收到消息后 resolve 对应的 Promise
先定义接口 AsyncTaskDispatcher 如下
create_async_task 创建异步任务, 并返回一个 JS Promise
run_event_loop 循环处理异步任务完成情况, 处理所有的 async_task 的结果
pub trait AsyncTaskDispatcher {
// 异步 block 运行后的结果
type AsyncTaskResult;
// 创建一个异步任务, 然后返回 promise
fn create_async_task<'s, F>(
&self,
scope: &mut v8::HandleScope<'s>,
async_block: F,
) -> Local<'s, Promise>
where
F: Future<Output = Self::AsyncTaskResult> + Send + 'static;
// 进入事件循环
fn run_event_loop(
&mut self,
isolate: &mut v8::Isolate,
scope: &mut v8::HandleScope<'_>,
) -> impl Future<Output = ()>;
}
因为我是用 tokio 实现这个 trait 的
所以我的实现是 TokioAsyncTaskDispatcher, 具体实现的代码, 我贴在最后面
写一个辅助函数, 从 isolate 中提取该对象的指针,然后创建 async task
pub(crate) fn create_async_task_from_scope<'s, F>(
scope: &mut v8::HandleScope<'s>,
async_block: F,
) -> Local<'s, Promise>
where
F: Future<Output = AsyncTaskResult> + Send + 'static,
{
let value_ptr = scope.get_data(0) as *mut TokioAsyncTaskManager;
unsafe { &*value_ptr }.create_async_task(scope, async_block)
}
我把该对象指针放在了 isolate slot 为 0 的地方
实现 file.content() 函数
我在函数调用者 caller 里面放了一个内部属性 file_handler (可以是文件描述符等)
将它传给 async block 运行读取文件的操作, async_block 返回结果 Resolve 或者 Reject
具体代码如下
fn read_file_content(
scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut return_value: v8::ReturnValue,
) {
// 提取内部字段, 可以是文件描述符, 我这里用的是自己封装的 file 句柄
let file_handler = extract_internal_field_file_handler(scope, &args);
// 创建异步函数, 并且返回一个 promise 对象
let promise = create_async_task_from_scope(scope, async move {
let result = file_handler.read_to_end().await;
match result {
Ok(content) => AsyncTaskResult::Resolve(AsyncTaskValue::String(content)),
Err(e) => AsyncTaskResult::Reject(AsyncTaskValue::String(e.to_string().into_bytes())),
}
});
return_value.set(promise.into());
}
如法炮制, 可以用 tokio 实现所有 nodejs 的模块
例如 net, fs 等, 这都是一些重复性工作
模块功能的实现
定义一个模块加载器, 放在 isolate 的全局数据里面
pub struct ModuleLoader {
// Maps module identity hash to its absolute path
id_to_path_map: BTreeMap<i32, PathBuf>,
// cached modules
module_cache: BTreeMap<PathBuf, v8::Global<v8::Module>>,
}
加载的逻辑比较简单, 重点在实现下面的函数回调
pub fn resolve_module_callback<'s>(
context: v8::Local<'s, v8::Context>,
specifier: v8::Local<'s, v8::String>,
_import_assertions: v8::Local<'s, v8::FixedArray>,
referrer: v8::Local<'s, v8::Module>,
) -> Option<v8::Local<'s, v8::Module>> {
// 代码比较多, 我简单写些代码
let module_loader = get_module_loader(context);
// 根据 import 的 module 名 和调用者的模块在 module_loader 里存储的路径, 计算出 import 的 module 路径, 这里还要判断是否为内置模块
let module_path = module_loader.resolve_module_path(referrer, specifier);
module_loader.import_module(module_path)
}
在 ModuleLoader 里面存储了模块和它对应的路径, 当import 触发回调的时候, 根据 referer 的路径加上相对路径加载新的模块(或者加载内置模块)
小结
走到这一步基本原理性的东西就差不多了
关于 AsyncTaskDispatcher 的实现逻辑在这里
代码地址
源代码我放在 github 了, 供大家参考, 还是挺简单的
目前可以直接运行下面的 js 代码
import fs from 'fs';
export async function main() {
const dirname = import.meta.dirname
const file = await fs.openFile(dirname + "text.txt");
const fileContent = await file.content()
await file.seek(0)
await file.write(fileContent + "\n hello world")
print(await file.content())
}
使用方式如下
use zjs::JsRuntime;
#[tokio::main]
async fn main() {
let mut runtime = JsRuntime::new();
let code = include_str!("./example.js");
runtime.execute(code).await;
}
运行示例
cargo run --example example1
欢迎指出问题