Sep
5
大家有没有注意到js中有两种对象,一种叫object一般翻译为“对象”,一种叫Object一般不作翻译。typeof得到的"object"即object,除了翻译为对象之外,在讨论时更常见的叫法叫“引用对象”。如果要用实际的例子,"typeof {}"与"typeof []"得到的都是"object"即对象;"[].toString()"得到的[object Array]而"{}.toString()"得到的[object Object]即表示[]属于object数据结构是Array而{}属于object数据结构是Object,同理比如我createElement('div')即能得到一个[object HTMLDivElement]。
因此可见,js中除了基础类型以外的万物均为object,也就是常说的js是一门object base语言。翻译问题上一般称js中的一类类型为“数据类型(data types)”,即number / string / boolean / symbol / null / undefined / bigint(新增) / object这几种基础类型。而归属于object下的Object / Array / Map / Set / TypedArray / Date以至于各种dom object到内部对象,则被称为“数据结构类型(data structures)”(参看https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures)。本文则讨论的是object类型的实质与数据结构类型的实质。
基本判断
从JS侧对比我们创建了4个对象,分别继承于一个pure JS Class,一个Object,一个Array,并且从控制台观察其原型链上的特点。观察着一组代码的结果:
1. PureClass和ObjectClass看起来是完全一致的,他们的原型链上级都是Object本身,就是我们常说的一个class如果不继承于其他任意类型,它至少是继承于Object。
2. 当然观察ArrayClass与MapClass,显然Array与Map的上级也都继承于Object,就是常说的JS中万物都继承于Object,不过既然是指定的Prototype关系,我们能够找到从根上并不是Object的js类型么?目前看是不行。
3. 除了原型链上的属性和方法,我们可以在类上与实际创建的实例上附加任何属性与方法,如果原型链上的方法getter/setter有对象储存能力,任意object本身也有储存能力也就是一个任意的map,这两个储存空间是交叉的么?
4. 考虑到每一个object都有公共的能力即key-value储存能力,那么原型链上的数据结构类型,实际上更相似除了基础能力之外native实现上的区别。比如我们常说Array的数据的实际储存是连续指针队列而不是hashmap来为数组操作提速,那么可以认为对Array这种数据结构对象进行操作时,number类型的kv操作并没有储存到object的公用储存区域,而是Array类型的独立实现。
5. 既然对象的native实现能够通过原型链体现,如果在js runtime中对其原型链直接进行交换,能否实现对数据native实现的,来观察到组件结构的内部实现在对象被new之后能否发生改变呢?测试方式如下图:
6. 显然,双方都出现了异常,这个交换是失败的。也就是说,虽然可以骗过instanceof甚至Object.toString,但是对象本身的Native实现并没有被交换。
7. 注意到本质是Set伪装为Array的场景,在交换后尝试通过setter设置数组数据,但是获取length仍然是0,也就是说a. length方法成功的被交换出来了。b. 但是length并没有从array的储存区域找到数据,有可能是压根没找到array的储存区或者通过arr[0] = 0并没办法将数据储存进array数据类型的储存区。
8. 而Array伪装为Set的场景,伪装为set之后,仍然能调用到array的length并且调用set的add会抛出异常:X.prototype.y called on incompatible type(https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Errors/Called_on_incompatible_type)
9. 同时还有一个知识是JS中的内建函数的实现存在于多个层,最简单的jscore也会有jsbitcode层与native层两种实现方式。因此可以假定,能够跟着原型链一起转移的函数实现即js层实现,对应无法转移的即在native,js层只是一个interface。如上called on incompatible type即是在原型链中的函数无法找到native实现而抛出的错误。
10. 那么我们可以得到结论即js中的object(对象/引用对象)具有公共特性与私有特性,公共特性即object公有的key-value储存能力,而私有特性即数据结构类型决定的native实现,数据结构在实例创建时决定并且无法转移。
11. 一个数据结构类型可能会绑定对象的数据储存能力与部分方法的实现。这根据对象的实现方案决定。同时对于非标准JSCore的对象,如DOM API对象等,均为native实现。
创建数据结构类型
以上的结论均是基于js层的观测与分析,如果我们能自己创建一个“数据类型”,就最好不过了。而幸运的是,我们常用的JSCore就提供了从native创建一个数据类型,也就是native Class的方案。一个简单的类创建起来如下:
如上即定义出了一个名叫MyClass的数据结构并抛到了js环境中,通过"new MyClass().toString()"可以明确的得到[object MyClass]来证明这确实是我们自己的数据结构(在js中的class定义,都是[object Object])。虽说如此,我们并没有做什么特殊的数据结构封装,那么刚才描述到的JS内建类型的这么多行为,是如何发生的呢?这段代码中的核心是JSClassDefinition这个结构,接下来我们就利用这个结构,来构成刚才我们讨论的种种行为。
1. 静态类型或者动态类型。这个翻译不够准确不过注意观察在js中的内建类型确实有两种:
绝大部分class都是function,但是也有部分class是object,而定义为function的数据类型可以类似函数的方式直接被调用,如new Array()和Array()都是成立的。在JSClassDefinition中,有一个定义为:
如果类型定义了callAsFunction的实现函数,类型则会被表现为function,并且作为function调用的native实现是JSObjectCallAsFunctionCallback,我们可以根据函数调用入参数,this等数据去作为工厂函数构成对象,当然也可以通过exception抛出异常:只能通过构造器调用。确实有一部分类型虽然是function但是并不能调用成功。
2. 我们也注意到,一些静态类型或者通过工厂方法创建的类型,直接用构造器创建会抛出异常:
这个约束是通过
即callAsConstructor这个实现函数决定的,如上图中Uncaught TypeError: Math is not a constructor这个异常即是指的Math这个类型并没有callAsConstructor实现。而Uncaught TypeError: Illegal constructor则是在HTMLDivElement中虽然存在callAsConstructor的实现,但是实现内部通过exception直接抛出异常了。结合上一条callAsFunction,我们基本上可以控制一个数据类型的实例创建方式——到底是不能实例化(静态类型),还是不能只能通过工厂函数实例化,还是一些别的参数手段控制。
3. 同时还有两个方法是一定要实现的。一个是hasInstance,是instanceof时会调用,标准实现为:
如果不做实现,new MyClass() instanceof MyClass也会得到false,如果xjb做实现一律返回true,new Object() instanceof MyClass也会得到true。那hasInstance自定义的意义何在呢?在处理内部虚拟的静态类型和派生类型时,需要通过hasInstance去磨平派生类型的差异。
另一个是JSObjectConvertToTypeCallback convertToType,即当需要自动转类时如何进行处理,如果没有实现就是一律使用Object的toString即一律得到[object MyClass]了,在特殊类型上还是需要折腾折腾明确出来保证结果符合预期。
4. 同时还有一个附加功能,即通过attributes = kJSClassAttributeNoAutomaticPrototype可以定义对象实例是否生成原型对象。如果不自动生成原型,那么所有的静态属性和方法会直接附加到实例上,而如果自动生成原型,所有的静态属性和方法会聚集在原型中,实例继承于原型。看起来如下:
如果我们像上面那样对数据的原型进行交换,属性聚集在原型的case下,相关方法当然就会被交换掉
5. 通过setProperty,getProperty,hasProperty,deleteProperty,getPropertyNames这一些列属性,我们可以对该class的key-value操作的实现进行替换。比如我把setProperty设置为空实现,那么该类型的实例无论obj.xx = 还是obj[xx] = 还是啥都无法设置上属性,当然通过__proto__替换原型链也没有什么用。因此如果我们有特殊的储存数据结构如TypedArray还是Array,我们完全可以替换以上方法并且在自己的储存空间内进行数据储存。
当然实际分析来看,就算js各个内建常用数据结构确实是独立结构,jsdebugger也为这些常用数据结构开了单独的口子以方便在控制台进行数据查看。
同时,注意到创建对象的实例时,JSObjectMake(JSContextRef ctx, JSClassRef jsClass, void* data)即每一个对象能够绑定一个任意data指针,并且这一份数据和object的生命周期是绑定的,我们可以推测绝大部分内建数据结构的实际数据都是承载在data中的(这一点通过对Array的JSObject执行JSObjectGetPrivate得到证实),而data作为object的附加数据显然不会随着原型链的改变而改变。对应的如果要让我们上文提到的更换array原型链奇效,需要js有能力直接操作对象附加data。正好jscore还真提供了方法JSObjectSetPrivate完成这个事情,因此我们原理上可以自行native处理完成对象的数据结构转变,当然如果考虑到data重建,这个成本和直接正常new一个新对象并且进行数据拷贝差别不大。
——————
JSClassDefinition中的核心功能也就以上了。我们可以注意到,就算仅仅以上能力,一个native Class即数据结构就可以完全打破预期中的js执行并且完成各种意想不到的能力。因此各个js内建数据结构发生不符合纯js逻辑的事情,也是符合逻辑并且完全可以理解的了。
希望通过object的行为与native class即数据结构的定义,看透各个JS内建对象的本质,也为我们js容器拓展提供帮助。
因此可见,js中除了基础类型以外的万物均为object,也就是常说的js是一门object base语言。翻译问题上一般称js中的一类类型为“数据类型(data types)”,即number / string / boolean / symbol / null / undefined / bigint(新增) / object这几种基础类型。而归属于object下的Object / Array / Map / Set / TypedArray / Date以至于各种dom object到内部对象,则被称为“数据结构类型(data structures)”(参看https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures)。本文则讨论的是object类型的实质与数据结构类型的实质。
基本判断
从JS侧对比我们创建了4个对象,分别继承于一个pure JS Class,一个Object,一个Array,并且从控制台观察其原型链上的特点。观察着一组代码的结果:
class PureClass {
}
class ObjectClass extends Object {
}
class ArrayClass extends Array {
}
class MapClass extends Map {
}
let a = new PureClass();
let b = new ObjectClass();
let c = new ArrayClass();
let d = new MapClass();
console.log(a);
console.log(b);
console.log(c);
console.log(d);
1. PureClass和ObjectClass看起来是完全一致的,他们的原型链上级都是Object本身,就是我们常说的一个class如果不继承于其他任意类型,它至少是继承于Object。
2. 当然观察ArrayClass与MapClass,显然Array与Map的上级也都继承于Object,就是常说的JS中万物都继承于Object,不过既然是指定的Prototype关系,我们能够找到从根上并不是Object的js类型么?目前看是不行。
3. 除了原型链上的属性和方法,我们可以在类上与实际创建的实例上附加任何属性与方法,如果原型链上的方法getter/setter有对象储存能力,任意object本身也有储存能力也就是一个任意的map,这两个储存空间是交叉的么?
4. 考虑到每一个object都有公共的能力即key-value储存能力,那么原型链上的数据结构类型,实际上更相似除了基础能力之外native实现上的区别。比如我们常说Array的数据的实际储存是连续指针队列而不是hashmap来为数组操作提速,那么可以认为对Array这种数据结构对象进行操作时,number类型的kv操作并没有储存到object的公用储存区域,而是Array类型的独立实现。
5. 既然对象的native实现能够通过原型链体现,如果在js runtime中对其原型链直接进行交换,能否实现对数据native实现的,来观察到组件结构的内部实现在对象被new之后能否发生改变呢?测试方式如下图:
6. 显然,双方都出现了异常,这个交换是失败的。也就是说,虽然可以骗过instanceof甚至Object.toString,但是对象本身的Native实现并没有被交换。
7. 注意到本质是Set伪装为Array的场景,在交换后尝试通过setter设置数组数据,但是获取length仍然是0,也就是说a. length方法成功的被交换出来了。b. 但是length并没有从array的储存区域找到数据,有可能是压根没找到array的储存区或者通过arr[0] = 0并没办法将数据储存进array数据类型的储存区。
8. 而Array伪装为Set的场景,伪装为set之后,仍然能调用到array的length并且调用set的add会抛出异常:X.prototype.y called on incompatible type(https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Errors/Called_on_incompatible_type)
9. 同时还有一个知识是JS中的内建函数的实现存在于多个层,最简单的jscore也会有jsbitcode层与native层两种实现方式。因此可以假定,能够跟着原型链一起转移的函数实现即js层实现,对应无法转移的即在native,js层只是一个interface。如上called on incompatible type即是在原型链中的函数无法找到native实现而抛出的错误。
10. 那么我们可以得到结论即js中的object(对象/引用对象)具有公共特性与私有特性,公共特性即object公有的key-value储存能力,而私有特性即数据结构类型决定的native实现,数据结构在实例创建时决定并且无法转移。
11. 一个数据结构类型可能会绑定对象的数据储存能力与部分方法的实现。这根据对象的实现方案决定。同时对于非标准JSCore的对象,如DOM API对象等,均为native实现。
创建数据结构类型
以上的结论均是基于js层的观测与分析,如果我们能自己创建一个“数据类型”,就最好不过了。而幸运的是,我们常用的JSCore就提供了从native创建一个数据类型,也就是native Class的方案。一个简单的类创建起来如下:
JSClassRef MyClass();
JSObjectRef myCallAsConstructor(JSContextRef ctx, JSObjectRef constructor, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception){
return constructor;
}
JSValueRef myCallAsFunction(JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
return JSObjectMake(ctx, MyClass(), nullptr);
}
void myFinalize(JSObjectRef object){
}
bool myHasInstance(JSContextRef ctx, JSObjectRef constructor, JSValueRef possibleInstance, JSValueRef* exception) {
return JSValueIsObjectOfClass(ctx, possibleInstance, MyClass());
}
JSClassRef MyClass() {
static JSClassRef my_class;
if (!my_class) {
JSClassDefinition classDefinition = kJSClassDefinitionEmpty;
static JSStaticFunction staticFunctions[] = {
{ 0, 0, 0 }
};
static JSStaticValue staticValues[] = {
{ 0, 0, 0, 0 }
};
classDefinition.className = "MyClass";
classDefinition.attributes = kJSClassAttributeNoAutomaticPrototype;
classDefinition.staticFunctions = staticFunctions;
classDefinition.staticValues = staticValues;
classDefinition.finalize = myFinalize;
classDefinition.callAsConstructor = myCallAsConstructor;
classDefinition.hasInstance = myHasInstance;
my_class = JSClassCreate(&classDefinition);
}
return my_class;
}
//注册
{
JSObjectRef classObj = JSObjectMake(jscoreContext, MyClass(), nullptr);
JSObjectSetProperty(jscoreContext, globalObject, JSStringCreateWithUTF8CString("MyClass"), classObj, kJSPropertyAttributeNone, nullptr);
}
如上即定义出了一个名叫MyClass的数据结构并抛到了js环境中,通过"new MyClass().toString()"可以明确的得到[object MyClass]来证明这确实是我们自己的数据结构(在js中的class定义,都是[object Object])。虽说如此,我们并没有做什么特殊的数据结构封装,那么刚才描述到的JS内建类型的这么多行为,是如何发生的呢?这段代码中的核心是JSClassDefinition这个结构,接下来我们就利用这个结构,来构成刚才我们讨论的种种行为。
1. 静态类型或者动态类型。这个翻译不够准确不过注意观察在js中的内建类型确实有两种:
绝大部分class都是function,但是也有部分class是object,而定义为function的数据类型可以类似函数的方式直接被调用,如new Array()和Array()都是成立的。在JSClassDefinition中,有一个定义为:
typedef JSValueRef
(*JSObjectCallAsFunctionCallback) (JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception);
//JSClassDefinition
JSObjectCallAsFunctionCallback callAsFunction;
如果类型定义了callAsFunction的实现函数,类型则会被表现为function,并且作为function调用的native实现是JSObjectCallAsFunctionCallback,我们可以根据函数调用入参数,this等数据去作为工厂函数构成对象,当然也可以通过exception抛出异常:只能通过构造器调用。确实有一部分类型虽然是function但是并不能调用成功。
2. 我们也注意到,一些静态类型或者通过工厂方法创建的类型,直接用构造器创建会抛出异常:
这个约束是通过
typedef JSObjectRef
(*JSObjectCallAsConstructorCallback) (JSContextRef ctx, JSObjectRef constructor, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception);
//JSClassDefinition
JSObjectCallAsConstructorCallback callAsConstructor;
即callAsConstructor这个实现函数决定的,如上图中Uncaught TypeError: Math is not a constructor这个异常即是指的Math这个类型并没有callAsConstructor实现。而Uncaught TypeError: Illegal constructor则是在HTMLDivElement中虽然存在callAsConstructor的实现,但是实现内部通过exception直接抛出异常了。结合上一条callAsFunction,我们基本上可以控制一个数据类型的实例创建方式——到底是不能实例化(静态类型),还是不能只能通过工厂函数实例化,还是一些别的参数手段控制。
3. 同时还有两个方法是一定要实现的。一个是hasInstance,是instanceof时会调用,标准实现为:
bool myHasInstance(JSContextRef ctx, JSObjectRef constructor, JSValueRef possibleInstance, JSValueRef* exception) {
return JSValueIsObjectOfClass(ctx, possibleInstance, MyClass());
}
如果不做实现,new MyClass() instanceof MyClass也会得到false,如果xjb做实现一律返回true,new Object() instanceof MyClass也会得到true。那hasInstance自定义的意义何在呢?在处理内部虚拟的静态类型和派生类型时,需要通过hasInstance去磨平派生类型的差异。
另一个是JSObjectConvertToTypeCallback convertToType,即当需要自动转类时如何进行处理,如果没有实现就是一律使用Object的toString即一律得到[object MyClass]了,在特殊类型上还是需要折腾折腾明确出来保证结果符合预期。
4. 同时还有一个附加功能,即通过attributes = kJSClassAttributeNoAutomaticPrototype可以定义对象实例是否生成原型对象。如果不自动生成原型,那么所有的静态属性和方法会直接附加到实例上,而如果自动生成原型,所有的静态属性和方法会聚集在原型中,实例继承于原型。看起来如下:
如果我们像上面那样对数据的原型进行交换,属性聚集在原型的case下,相关方法当然就会被交换掉
5. 通过setProperty,getProperty,hasProperty,deleteProperty,getPropertyNames这一些列属性,我们可以对该class的key-value操作的实现进行替换。比如我把setProperty设置为空实现,那么该类型的实例无论obj.xx = 还是obj[xx] = 还是啥都无法设置上属性,当然通过__proto__替换原型链也没有什么用。因此如果我们有特殊的储存数据结构如TypedArray还是Array,我们完全可以替换以上方法并且在自己的储存空间内进行数据储存。
当然实际分析来看,就算js各个内建常用数据结构确实是独立结构,jsdebugger也为这些常用数据结构开了单独的口子以方便在控制台进行数据查看。
同时,注意到创建对象的实例时,JSObjectMake(JSContextRef ctx, JSClassRef jsClass, void* data)即每一个对象能够绑定一个任意data指针,并且这一份数据和object的生命周期是绑定的,我们可以推测绝大部分内建数据结构的实际数据都是承载在data中的(这一点通过对Array的JSObject执行JSObjectGetPrivate得到证实),而data作为object的附加数据显然不会随着原型链的改变而改变。对应的如果要让我们上文提到的更换array原型链奇效,需要js有能力直接操作对象附加data。正好jscore还真提供了方法JSObjectSetPrivate完成这个事情,因此我们原理上可以自行native处理完成对象的数据结构转变,当然如果考虑到data重建,这个成本和直接正常new一个新对象并且进行数据拷贝差别不大。
——————
JSClassDefinition中的核心功能也就以上了。我们可以注意到,就算仅仅以上能力,一个native Class即数据结构就可以完全打破预期中的js执行并且完成各种意想不到的能力。因此各个js内建数据结构发生不符合纯js逻辑的事情,也是符合逻辑并且完全可以理解的了。
希望通过object的行为与native class即数据结构的定义,看透各个JS内建对象的本质,也为我们js容器拓展提供帮助。