V8引擎使用篇: 写一个 NodeJS

1,270 阅读6分钟

我从去年开始阅读 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

image.png

其实还是挺简单的, 关键在于如何使用 v8 实现这个想法

V8 使用方法(部分介绍)

关于 V8 初始化, 我之前有篇文章介绍过了, 我这里就略过了, 你也可以直接问 deepseek, 它肯定说的更详细

我主要说下如何用 V8 生成对象和函数, 还有如何自定义 import 行为

JS 对象的初始化

v8::ObjectTemplate 可以被理解为 class, 通过 ObjectTemplate 实例化可以快速生成 js 对象

假设我想生成一个js 对象 -> {a:undefined}

先生成一个 ObjectTemplate, 设置 属性 a 为 undefined

然后通过 template 实例化 js 对象

截屏2025-02-06 22.26.49.png

设置内部私有属性

这里的私有属性在 js 侧是无法访问的

设置一个private 属性, value 是 1 , 索引是 0

截屏2025-02-06 22.57.50.png

设置 Method

设置一个 method c, 先写一个函数, 函数需要有 3 个参数

image.png

  1. scope -> 当前的 HandleScope , 在HandleScope内创建的数据不会被 gc 掉
  2. args -> 函数的参数, args.get(0) 拿到第一个参数, args.this() 拿到 caller
  3. return_value -> 函数的返回值, 通过 return_value.set 写入

比如下面的代码的功能等价于(a:number,b:number) => a+b

image.png

还需要构造函数模板才能嵌入到 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

欢迎指出问题