JavaScript(Node.JS) 使自定义类可以通过下标访问内部可迭代值

实机环境声明:Node.js 24.5.0

0x00 故事背景

近期在折腾项目时,产生一个需求:我的一个业务类 MyClass内部有一个内置数组 values,但每次要迭代其内部的值时,都需要调用比较冗长的 instance.values[114514]

javascript 复制代码
class MyClass {
    constructor(...args) {
        this.values = [...args];
    }
}

let arr = new MyClass(1, 1, 4, 5);
console.log(arr.values[2]); // -> 4

能不能让自定义的类也像数组那样,通过下标/直接索引访问呢?

javascript 复制代码
console.log(arr[3]); // -?-> 5

0x01 继承 Array 实现

使用继承方式是最简单的实现,行为上,MyClass 也会变得和数组很像。真棒!

js 复制代码
class ExtendsArray extends Array {
    constructor(...v) {
        super(...v);
    }
}

let extended_array = new ExtendsArray(1, 2, 3);
console.log("Extending Array:", extended_array[0]); // Expect: 1
console.log(extended_array[1]); // 2

console.log(Object.keys(extended_array)); // [ '0', '1', '2' ]
console.log(Object.getOwnPropertyNames(extended_array)); // [ 'length', '0', '1', '2' ]
console.log(Array.isArray(extended_array)); // true, which is dangerous
console.log(extended_array instanceof Array); // true, which is even more dangerous
console.log(extended_array.constructor.name); // "ExtendsArray" !== "Array"

let original_array = [1, 2, 3];
console.log("comparison: original array: ", original_array);
console.log(Object.keys(original_array)); //
console.log(Object.getOwnPropertyNames(original_array)); //
console.log(Array.isArray(original_array)); // true, which is normal

但是注意到,在第 13 行的 Array.isArray()判断和 14 行的 instanceof Array中,该判断均返回了 true

我们的自定义类变得太像数组了。这是不是一件好事呢?我们将不得不使用 instance.constructor.name来判断其真正的构造函数。这样更长了喂!

此外,由于继承关系,我们的自定义类还因而在原型链上拥有了一大堆属于Array的方法。假如我们既需要原版的Array.push(),又需要自己实现另一个逻辑含义完全不同的.push(),我们将不得不另寻同义词,为自己的push方法改个名。麻烦死了。

问题:

  1. Array 常用的原生判别技巧可能会返回 true,或将导致类型判断出错;
  2. 继承了 Array 原生的方法,存在重名冲突风险。

0x02 Array-Like 类数组

根据MDN文档,存在一类对象,只要支持了 .length属性,即可应用数组的一些方法。这样的对象被称为"类数组"(Array-Like Object)。

术语类数组对象指的是在...... length 转换过程中不抛出的任何对象。在实践中,这样的对象应该实际具有 length 属性,并且索引元素的范围在 0length - 1 之间。

也有一些网站给出了类数组的不同定义:

类数组对象是具有索引访问和长度属性的对象......

综合来看,Copilot Search总结了类数组具有的特征:

  1. Indexed properties (e.g., obj[0], obj[1]). // 数字作为索引的属性
  2. A length property indicating the number of elements. // length属性,指明内部元素数量
  3. Lack of standard array methods like .push(), .pop(), etc., because they are not true arrays. // 缺少标准的数组方法

注意到,第一点特征正好指向了我们所需要的"方括号访问"这一需求的本质:**对象内部有一系列属性,键名是一个自然增长的整数,值指向了其内部元素的元素。**所以,我们只需要在创建对象时为其增加一个数字作为键的映射即可。

javascript 复制代码
console.log("Array-Like Object be like:");

class MyArrayLike {
    constructor(...v) {
        this.values = [...v];
        this.values.forEach((value, index) => {
            this[index] = value;
        });
    }
    get length() {
        return this.values.length;
    }
}
let my_array_like = new MyArrayLike(1, 2, 3);
console.log(my_array_like[0], my_array_like[1], my_array_like[2]); // 1 2 3
console.log(my_array_like.length); // 3

看起来很简洁,但先别急。让我们来看这个对象里面实际上都有些啥:

javascript 复制代码
let many_numbers = [1, 2, 11, 22, 33, 45, 56, 68, 79, 81, 91, 100, -1];
let my_array_like2 = new MyArrayLike(...many_numbers);
console.log(Object.keys(my_array_like2)); // [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 'values']  注意到其中并没有虚拟属性length,下同
console.log(Object.getOwnPropertyNames(my_array_like2)); // [ '0', '1', '2', ... 11 more numbers,, '12 'values ]

属性列表变得有些长了。而我把这称作......必要的代价。

问题: Object.keys(obj)属性列表会充斥着看起来像数字的字符串。

0x03 Proxy 实现

注意到,使用下标访问和使用点操作符获取属性,都是访问特定属性的操作。那么可以使用 Proxy 代理,劫持 get 方法吗?

回顾发现,proxy handler.get 的签名如下:

typescript 复制代码
(local function)(
    target: {values: any[]},
    prop: string | symbol,
    receiver: any
): any

其中第二个参数 prop 被标记为 string | symbol类型,似乎并未包括数字类型。

那么尝试通过检测 typeof prop === typeof 1919来劫持,实验如下:

javascript 复制代码
class ProxiedArray {
    constructor(...v) {
        this.values = [...v];
    }
}

let proxy_arr = new Proxy(new ProxiedArray(1, 2, 3), {
    get: function (target, prop, receiver) {
        console.log(prop, typeof prop);
        if (typeof prop === "number") return target.values[prop];
        return target[prop];
    },
});

console.log(proxy_arr[0], proxy_arr[1]); // undefined undefined

调试发现,形参 prop 被强制转换为了 string 类型,因此 if(typeof prop === 'number')校验永远为 false,最终也就得不到正确的结果。

问题:

  1. 存在参数的类型转换,无法使用 if(typeof prop === 'number')劫持;
  2. 此外,在实例化之前还需要额外使用Proxy构造函数进一步包装,这让代码显得更加复杂。为此可能衍生出创建返回Proxy对象的工厂函数的需求,更复杂了。

参考:MDN文档有关方括号属性访问符的类型说明

0x04 Object.defineProperty 拦截行为

MDN中提到了 Object.defineProperty 的使用,它也可以用于属性访问劫持。其原型如下:

typescript 复制代码
 (method) ObjectConstructor.defineProperty<this>(o: this, p: PropertyKey, attributes: PropertyDescriptor & ThisType<any>): this

Adds a property to an object, or modifies attributes of an existing property.

@param o --- Object on which to add or modify the property. This can be a native JavaScript object (that is, a user-defined object or a built in object) or a DOM object.

@param p --- The property name.

@param attributes --- Descriptor for the property. It can be for a data property or an accessor property.

懒得翻译了凑合看吧

最终代码形态如下:

javascript 复制代码
class MyClass {
    constructor(...v) {
        this.values = [...v];
        this.__create_indexer__("values");
    }
    /**
     * @description
     * This instance method let the instance to have an indexer property that can be accessed by index.
     * 这个实例方法让实例拥有可以用索引访问的索引属性。
     * To use it, copy the code and paste it into the class definition of the instance.
     * 要使用它,复制代码并将其粘贴到实例类的定义中。
     * Finally, call the instance method with the name of the property that you want to index within the constructor.
     * 最后,在构造函数中调用实例方法,并传入你想要索引的属性的名称。
     * @param {string | symbol} indexed_prop
     * @author Clement_Levi
     */
    __create_indexer__(indexed_prop) {
        if (!Object.hasOwnProperty.call(this, indexed_prop)) return;
        if (!this[indexed_prop].length) return;
        this.__indexer__ = indexed_prop;
        let i = 0;
        while (i < this[this.__indexer__].length) {
            let currentIndex = i;
            Object.defineProperty(this, currentIndex, {
                get: () => {
                    return this[indexed_prop][currentIndex];
                },
            });
            i++;
        }
    }
}

let arr = new MyClass(1, 2, 3);
console.log(arr[0],arr[1],arr[2]); // 1 2 3

let many_numbers_for_define = [1, 2, 11, 22, 33, 45, 56, 68, 79, 81, 91, 100, -1];
let defined_array_2 = new MyClass(...many_numbers_for_define);
console.log(defined_array_2[12],defined_array_2[0],defined_array_2[3]);  // -1 1 22
console.log(Object.getOwnPropertyNames(defined_array_2)); // [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 'values', __indexer__]

如果需要做得足够Array-Like,使其支持 .length属性,以便从外部遍历的话,额外定义一个 get length(){return this.values.length}计算属性就好了。

到目前为止,上述代码已经完全足够满足我的需求。我并不需要使用类似 .push()等方法,因为我的业务场景下,我的自定义类几乎是不可变的。所有元素均在MyClass实例化时就确定下来。

如果我需要一个可变的、可用下标访问的MyClass类,只需同样编写push方法的逻辑,并且在每次push时都调用即可:

javascript 复制代码
push(...v){
    this.values.push(...v);
    this.__create_indexer__("values");    // 更新所有元素索引
    return v[-1];
}

最后,注意到引入的下划线方法和下划线属性露在外面了。我们也可以使用Symbol来隐藏它们:

Object.keys(arr) // -> ['values', __indexer__]

javascript 复制代码
// 使用Symbol可以隐藏这俩技术性属性

const createIndexerSymbol = Symbol("createIndexer");
const indexerSymbol = Symbol("indexer");

class MyClassSymbol_ed {
    constructor(...v) {
        this.values = [...v];
        this[createIndexerSymbol]("values");
    }

    [createIndexerSymbol](indexed_prop) {
        if (!Object.hasOwnProperty.call(this, indexed_prop)) return;
        if (!this[indexed_prop].length) return;
        this[indexerSymbol] = indexed_prop;
        let i = 0;
        while (i < this[this[indexerSymbol]].length) {
            let currentIndex = i;
            Object.defineProperty(this, currentIndex, {
                get: () => {
                    // console.log(`accessing index ${currentIndex}`);
                    return that[indexed_prop][currentIndex];
                },
            });
            i++;
        }
    }
}

let arrM = new MyClassSymbol_ed(1, 2, 3);
console.log(arrM[0],arrM[1],arrM[2]); // Expect: 1 2 3

console.log(Object.keys(arrM)); // ['values']
console.log(Object.getOwnPropertyNames(arrM)); // [ '0', '1', '2', 'values' ]
console.log(Object.getOwnPropertySymbols(arrM)); // [ Symbol(indexer) ]

问题:

  1. 每一次增加、删除内部元素时,都需要遍历修改索引对应的值,时间复杂度为O(n);
  2. 看起来非常花里胡哨,晦涩难懂;
  3. 需要复制粘贴来创建定义,不能引用、引包、导入导就完事了
  4. 如果需要隐藏属性和方法,需要全局(至少是作用域内)的Symbol变量或常量。在多个相似需求类互操作时,可能需要使用** Symbol.for(key)**方法从全局Symbol注册表中获得相同的Symbol对象;
  5. 也不能隐藏许多个看起来像数字的键。

0x05 进一步讨论

目前来看,体感上最简洁的应当是Array-Like的实现方案,此方案相对静态,增删改查元素的逻辑都另外需要补充兜底逻辑(如在this[index] = value;处使用匿名getter,从而动态获取this.values[index]指向的值),此处不再展开。有兴趣的读者可把这个问题当作一道习题,此处欢迎有关的讨论。

相对于Array-Like中直接使用this[index] = value;赋值定义的方式,也可以使用后文中的Object.defineProperty方法,二者在效果上看几乎是等同的,但Object.defineProperty可以控制属性的访问权限,比如只读、可写、可枚举等,此处不再展开。

关于Array-Like和Object.defineProperty两种方案,注意到其本质都是在原始对象上创建额外的数字作为键名的索引。二者共同的问题是海量属性无法隐藏。

关于Proxy中涉及 prop参数的问题,或许仍有其他方式加以规避,从而切实实现这种方案。楼主期待更多有关于此的讨论。

最后,上述实验仅在我的Node.js 24.5.0环境下通过,在更多环境和版本中的特性发掘则有赖读者斧正。

0xFF 别人家的孩子:Python中的__getitem__魔术方法

笑不出来地注意到,要实现上述"使用下标直接获取值"的需求,在Python中可以直接重写 __getitem__魔术方法。

python 复制代码
class MyClass:
    def __init__(self, *v):
        self.values = list(v)

    def __getitem__(self, index):
        return self.values[index]

    def __len__(self):
        return len(self.values)

arr = MyClass(1, 2, 3)
print(arr[0], arr[1], arr[2])  # 1 2 3

人生苦短,不过我最近的项目里不太用Python了,嘻嘻。

相关推荐
用户6387994773053 小时前
我把我的 monorepo 迁移到 Bun,这是我的真实反馈
javascript·架构
博客zhu虎康3 小时前
Element中 el-tree 如何隐藏 Tree 组件中的父节点 Checkbox
javascript·vue.js·elementui
欧阳天3 小时前
http环境实现通知
前端·javascript
中微子3 小时前
别再被闭包坑了!React 19.2 官方新方案 useEffectEvent,不懂你就 OUT!
前端·javascript·react.js
1in3 小时前
一文解析UseState的的执行流程
前端·javascript·react.js
Mintopia3 小时前
🧠 对抗性训练如何增强 WebAI 模型的鲁棒性?
前端·javascript·人工智能
鹏多多4 小时前
React无限滚动插件react-infinite-scroll-component的配置+优化+避坑指南
前端·javascript·react.js
云和数据.ChenGuang5 小时前
Component template requires a root element, rather than just错误
前端·javascript·vue.js
艾小码5 小时前
告别JS初学者噩梦:这样写流程控制和函数才叫优雅
前端·javascript