document.all 是浏览器中一个非常特殊的存在——它是 HTML DOM 中最早的集合对象之一,却因为历史原因被设计成"不可检测"(undetectable)的对象。它的特殊性在于:

  • typeof document.all 返回 'undefined'

  • document.all == undefined 返回 true

  • document.all === undefined 返回 false

  • 它既支持数组索引访问 document.all[0],又支持函数调用 document.all(0)

  • 两种访问方式返回相同的结果

本文将深入探讨如何通过 Node.js C++ 插件(Addon)技术,在 Node.js 环境中完美模拟这个特殊对象。

一、技术背景

1.1 Node.js Addon 是什么?

Node.js Addon 是用 C/C++ 编写的动态链接库,通过 Node.js 的 N-API 或 V8 API 与 JavaScript 交互。它允许我们在 Node.js 中调用高性能的 C++ 代码,或实现 JavaScript 无法直接完成的功能。

1.2 V8 引擎的核心概念

在实现过程中,我们需要了解几个 V8 的核心概念:

  • Isolate:V8 引擎的隔离实例,每个线程拥有独立的 Isolate

  • Context:JavaScript 执行上下文,包含全局对象和内置函数

  • ObjectTemplate:对象模板,用于创建具有相同属性和行为的对象

  • FunctionTemplate:函数模板,用于创建可调用的对象

  • InternalField:对象的内部存储字段,用于存储 C++ 数据

二、核心技术点解析

2.1 不可检测对象(Undetectable Object)

V8 提供了 MarkAsUndetectable() 方法,可以让对象在 JavaScript 中表现为"不可检测":

Local<ObjectTemplate> desc = ObjectTemplate::New(isolate);
desc->MarkAsUndetectable();

这个标记会影响:

  • typeof 运算符返回 'undefined'

  • 在布尔上下文中转换为 false

  • == 比较时与 undefinednull 相等

2.2 函数调用模拟

通过 SetCallAsFunctionHandler 可以让对象像函数一样被调用:

desc->SetCallAsFunctionHandler(CallHandler);

当 JavaScript 代码执行 document.all(0) 时,会触发 CallHandler 回调。

2.3 属性拦截器(Property Interceptor)

V8 的拦截器机制让我们可以拦截对对象属性的读写操作:

索引属性拦截器(Indexed Property Handler)

desc->SetHandler(v8::IndexedPropertyHandlerConfiguration(
    IndexedGetterCallback,    // 获取属性时的回调
    IndexedSetterCallback,    // 设置属性时的回调
    nullptr,                  // 查询回调
    nullptr,                  // 删除回调
    nullptr,                  // 枚举回调
    nullptr,                  // 定义回调
    Local<Value>()            // 数据
));

命名属性拦截器(Named Property Handler)

desc->SetHandler(v8::NamedPropertyHandlerConfiguration(
    NamedGetterCallback,      // 获取属性时的回调
    nullptr,                  // 设置回调
    nullptr,                  // 查询回调
    nullptr,                  // 删除回调
    nullptr,                  // 枚举回调
    nullptr,                  // 定义回调
    Local<Value>()            // 数据
));

2.4 数据存储策略

使用 InternalField 存储数据,确保在拦截器回调中能够访问到:

// 设置内部字段数量
desc->SetInternalFieldCount(1);

// 存储数据
result->SetInternalField(0, elementsArr);

// 读取数据
Local<Value> elementsVal = self->GetInternalField(0);

三、完整实现代码

#include <node.h>
#include <v8.h>
#include <string>
#include <vector>

using namespace v8;

// 获取 length 属性
static void LengthGetter(Local<Name> property, const PropertyCallbackInfo<Value>& info) {
    Isolate *isolate = info.GetIsolate();
    Local<Context> context = isolate->GetCurrentContext();
    
    // 检查是否为 "length"
    Local<String> lengthStr = String::NewFromUtf8(isolate, "length").ToLocalChecked();
    if (property->StrictEquals(lengthStr)) {
        Local<Object> self = info.Holder();
        Local<Value> elementsVal = self->GetInternalField(0);
        
        if (!elementsVal.IsEmpty() && elementsVal->IsArray()) {
            Local<Array> elementsArr = elementsVal.As<Array>();
            info.GetReturnValue().Set(Number::New(isolate, elementsArr->Length()));
        } else {
            info.GetReturnValue().Set(Number::New(isolate, 0));
        }
        return;
    }
    
    // 对于其他命名属性,返回 undefined
    info.GetReturnValue().SetUndefined();
}

// 索引属性的 Getter 回调
static void IndexedGetterCallback(uint32_t index, const PropertyCallbackInfo<Value>& info) {
    Isolate *isolate = info.GetIsolate();
    Local<Context> context = isolate->GetCurrentContext();
    
    Local<Object> self = info.Holder();
    Local<Value> elementsVal = self->GetInternalField(0);
    
    if (!elementsVal.IsEmpty() && elementsVal->IsArray()) {
        Local<Array> elementsArr = elementsVal.As<Array>();
        if (index < (uint32_t)elementsArr->Length()) {
            MaybeLocal<Value> element = elementsArr->Get(context, index);
            if (!element.IsEmpty()) {
                info.GetReturnValue().Set(element.ToLocalChecked());
                return;
            }
        }
    }
    
    info.GetReturnValue().SetUndefined();
}

// 索引属性的 Setter 回调
static void IndexedSetterCallback(uint32_t index, Local<Value> value, const PropertyCallbackInfo<Value>& info) {
    Isolate *isolate = info.GetIsolate();
    Local<Context> context = isolate->GetCurrentContext();
    
    Local<Object> self = info.Holder();
    Local<Value> elementsVal = self->GetInternalField(0);
    
    if (!elementsVal.IsEmpty() && elementsVal->IsArray()) {
        Local<Array> elementsArr = elementsVal.As<Array>();
        
        // 如果索引超出长度,扩展数组
        if (index >= (uint32_t)elementsArr->Length()) {
            // 设置数组长度,用 undefined 填充空位
            for (uint32_t i = elementsArr->Length(); i <= index; i++) {
                elementsArr->Set(context, i, Undefined(isolate)).Check();
            }
        }
        elementsArr->Set(context, index, value).Check();
        
        info.GetReturnValue().Set(value);
        return;
    }
    
    // 如果 internal field 不是数组,创建一个新数组
    Local<Array> newArr = Array::New(isolate);
    for (uint32_t i = 0; i <= index; i++) {
        newArr->Set(context, i, Undefined(isolate)).Check();
    }
    newArr->Set(context, index, value).Check();
    self->SetInternalField(0, newArr);
    info.GetReturnValue().Set(value);
}

// 获取索引对应的元素(用于函数调用)
static void GetIndexElement(const FunctionCallbackInfo<Value>& args) {
    Isolate *isolate = args.GetIsolate();
    Local<Context> context = isolate->GetCurrentContext();
    
    Local<Object> self = args.Holder();
    
    if (args.Length() > 0 && args[0]->IsInt32()) {
        int index = args[0].As<Int32>()->Value();
        
        Local<Value> elementsVal = self->GetInternalField(0);
        if (!elementsVal.IsEmpty() && elementsVal->IsArray()) {
            Local<Array> elementsArr = elementsVal.As<Array>();
            if (index >= 0 && index < (int)elementsArr->Length()) {
                MaybeLocal<Value> element = elementsArr->Get(context, index);
                if (!element.IsEmpty()) {
                    args.GetReturnValue().Set(element.ToLocalChecked());
                    return;
                }
            }
        }
    }
    
    args.GetReturnValue().SetUndefined();
}

// 当作为函数调用时的处理器
static void CallHandler(const FunctionCallbackInfo<Value>& args) {
    GetIndexElement(args);
}

void CreateUndetectable(const FunctionCallbackInfo<Value> &args)
{
    Isolate *isolate = args.GetIsolate();
    Local<Context> context = isolate->GetCurrentContext();

    Local<ObjectTemplate> desc = ObjectTemplate::New(isolate);
    desc->SetInternalFieldCount(1);
    desc->MarkAsUndetectable();
    desc->SetCallAsFunctionHandler(CallHandler);
    
    // 设置命名属性拦截器(用于 length 属性)
    desc->SetHandler(v8::NamedPropertyHandlerConfiguration(
        LengthGetter,         // getter
        nullptr,              // setter
        nullptr,              // query
        nullptr,              // deleter
        nullptr,              // enumerator
        nullptr,              // definer
        Local<Value>()        // data
    ));
    
    // 设置索引属性拦截器(包含 getter 和 setter)
    desc->SetHandler(v8::IndexedPropertyHandlerConfiguration(
        IndexedGetterCallback,    // getter
        IndexedSetterCallback,    // setter
        nullptr,                  // query
        nullptr,                  // deleter
        nullptr,                  // enumerator
        nullptr,                  // definer
        Local<Value>()            // data
    ));
    
    MaybeLocal<Object> obj = desc->NewInstance(context);
    if (!obj.IsEmpty()) {
        Local<Object> result = obj.ToLocalChecked();
        
        // 初始化空的元素数组
        Local<Array> elementsArr = Array::New(isolate);
        
        // 如果传入了数组参数,使用传入的数组
        if (args.Length() > 0 && args[0]->IsArray()) {
            elementsArr = args[0].As<Array>();
        }
        
        result->SetInternalField(0, elementsArr);
        args.GetReturnValue().Set(result);
    }
}

// 初始化
void Init(Local<Object> exports) {
    NODE_SET_METHOD(exports, "undetectable", CreateUndetectable);
}

NODE_MODULE(NODE_GYP_MODULE_NAME, Init)

四、编译

不同版本的 Node.js 使用的 V8 API 可能有差异。我当前的环境配置如下:

  • nodejs v20.19.3

  • node-gyp v11.4.2

  • python v3.9.18

由于是编译c++代码,所以我们还需要配置c++环境,可以通过安装Visual Studio的方式来安装c++环境

我们新建一个文件夹,文件夹中新建三个文件分别是native.ccindex.js以及binding.gyp

  • native.cc 用来存放我们编写的c++代码

  • index.js 用来存放我们的测试代码

  • binding.gyp 用来存放编译配置的文件

{
  "targets": [
    {
      "target_name": "native",  # 编译后的模块名称
      "sources": [ "native.cc" ]  # 编译的源文件
    }
  ]
}

五、测试

我们在index.js文件中编写我们的测试代码

const path = require("path");

// 导入我们的node插件
const addon = require(path.join(__dirname, "build/Release/native.node"));

const elements = [
    { name: "test1" },
    { name: "test2" },
    { name: "test3" }
];

document = {
    all: new addon.undetectable(elements)
}

console.log(document.all);  // {}
console.log(typeof document.all);  // undefined
console.log(document.all == undefined);  // true
console.log(document.all === undefined);  // false
console.log(document.all[0] === document.all(0));  // true
console.log(document.all.length);  // 3