实机环境声明: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方法改个名。麻烦死了。
问题:
- Array 常用的原生判别技巧可能会返回
true
,或将导致类型判断出错; - 继承了 Array 原生的方法,存在重名冲突风险。
0x02 Array-Like 类数组
根据MDN文档,存在一类对象,只要支持了 .length
属性,即可应用数组的一些方法。这样的对象被称为"类数组"(Array-Like Object)。
术语类数组对象指的是在......
length
转换过程中不抛出的任何对象。在实践中,这样的对象应该实际具有length
属性,并且索引元素的范围在0
到length - 1
之间。
也有一些网站给出了类数组的不同定义:
类数组对象是具有索引访问和长度属性的对象......
综合来看,Copilot Search总结了类数组具有的特征:
- Indexed properties (e.g.,
obj[0]
,obj[1]
). // 数字作为索引的属性- A
length
property indicating the number of elements. // length属性,指明内部元素数量- 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
,最终也就得不到正确的结果。
问题:
- 存在参数的类型转换,无法使用
if(typeof prop === 'number')
劫持; - 此外,在实例化之前还需要额外使用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) ]
问题:
- 每一次增加、删除内部元素时,都需要遍历修改索引对应的值,时间复杂度为O(n);
- 看起来非常花里胡哨,晦涩难懂;
- 需要复制粘贴来创建定义,不能引用、引包、导入
导就完事了; - 如果需要隐藏属性和方法,需要全局(至少是作用域内)的Symbol变量或常量。在多个相似需求类互操作时,可能需要使用** Symbol.for(key)**方法从全局Symbol注册表中获得相同的Symbol对象;
- 也不能隐藏许多个看起来像数字的键。
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了,嘻嘻。