V8引擎使用篇: 写一个 NodeJS

1,069 阅读6分钟

我从去年开始阅读 V8 引擎源码, 一个原因是我想实现一个 NodeJS

前段时间翻看了 deno 的源代码, 学习了部分思路, 结合我自己的理解, 实现一个 JS RuntimeDemo , 为了跟 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, 设置 属性 aundefined

然后通过 template 实例化 js 对象

截屏2025-02-06 22.26.49.png

设置内部私有属性

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

设置一个private 属性, value1 , 索引是 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

欢迎指出问题