问题引发:
最近在整理DOM系列的一些知识点,发现在DOM的某些接口API中,存在一些我想不通的现象。就随便举个例子吧:DOM文档模型中的文本节点,可以通过nodeValue或data属性访问文本节点的文本内容,而且在更新data的时候nodeValue也即时更新,反之亦然。不光是data或nodeVaulue有这种相互影响的关系,其他类型节点也有比如该改变document.title也会随之改变网页标题。当时想弄明白这在JS引擎中是怎么实现的,于是乎进行了以下分析:
通过文本节点的原型链继承关系 textNode.__proto__->Text.prototype->CharacterData.prototype->Node.prototype->EventTarget.prototype->Object.prototype。和原型上的存在的属性和方法很好理解文本节点调用某个方法或属性来自(继承)哪里
但是看下面这段代码
var text=document.createTextNode('文本');
text.hasOwnProperty('data');// false
text.hasOwnProperty('nodeValue');// false
text.data;// "文本"
text.nodeValue;// "文本"
返回false很正常因为data是CharacterData.prototype上的属性,nodeValue是Node.prototype上的属性。text只是通过原型链继承访问这两个属性而已。当我改变内容,
text.data="新文本";
text.hasOwnProperty('data');// false
text.nodeValue;// "新文本"
text.data;// "新文本"
我奇怪为什么已经给text对象添加了data属性,但还是返回false??
疑惑一:按理说在对象自身添加和原型上相同名称的属性在访问的时候只访问到自身属性上的data为止,就算JS引擎最后强制delete了text.data,在text.data试图去访问data的时候还是按原型链查找,在CharacterData.prototype的data上找到值为"新文本"。可是这样又存在一个问题,因为JS引擎中CharacterData.prototype.data是共享的只有一个,那么如果有多个text对象。text1.data,text2.data...它们都访问原型上的这个data值显然和实际情况每个文本节点对象有自己的文本冲突??
疑惑二:改变text.nodeValue的时候text.data值也会改变,反过来也一样,一个的改变会引起另一个的改变。这个JS引擎中是怎么实现的??
自己的思考:
如果把nodeValue和data当成指针而不是具体保存的某个特定值,当然nodeValue和data还是在原型属性上的,当你访问text1.nodeValue的时候原型上的nodeValue指向text1对应的文本值,访问text2.nodeValue的时候原型上的nodeValue执行text2对应的文本....访问data也是如此。在改变data的指向的时候nodeValue也要同时改变指向。
在知乎问了一圈DOM中为什么文本节点的data属性值的改变,文本节点的nodeValue值也会同步改变? 大神回答是Chrome在最近的版本中把DOM中的很多类实例变成了原型上的访问器属性https://groups.google.com/a/chromium.org/forum/m/#!topic/blink-dev/H0MGw0jkdn4。很遗憾我想的不完全正确,我能对这个东西产生疑问但思维却没跳出数据属性的空间忘了居然还有访问器属性这个神奇的东西,于是我也不知道是第几次再需要重温《高程三》P141页了,对属性类型熟悉的同学可直接跳过相关知识点看分析~
相关知识点:
一张图先说明属性类型,数据属性,访问器属性,四种特性之间的关系。
(1).数据属性:包含一个数据值的位置,这个位置可以读取或写入值,数据属性有4个描述其行为的特性。直接在对象上定义的属性,它们的这四个特性值默认为true。通过Object.defineProperty(obj,'attr',{})设置某属性,value为undefined,其余特性为false
- [[Configurable]]:
a.表示能否通过detele删除属性从而重新定义属性
b.能否修改属性的特性
通过字面量形式定义属性后一旦把某属性的configurable设置为false就再不能把其configurable设置为可配置,此时再调用Object.defienProperty()修改除writable特性都会导致错误(注:value特性能不能通过Object.definedProperty()修改取决于writable,若writable之前就为true,那么此时value可以更改,若writeable被更改为false,value则不能更改)。
通过Object.defineProperty(obj, 'attr', {})定义属性后一但某属性的configurable为false后就不能修改其他特性了。
c.能否把属性修改为访问器属性
- [[Enumerable]]:
a.表示能否通过for-in循环返回属性。输出“new 1”是因为Chrome控制台下基本会给每个语句都有返回值。。因为get函数里不是return 某个值,所以get函数返回undefiend。
- [[Writable]]:
a.表示能否修改属性的值,示例参见上面。当某属性的writable为false时,重新给该属性赋值严格模式下会报错,非严格模式会忽略。 - [[Value]]:
a.包含这个属性的数据值,读取属性的时候从这个位置读,写入属性值得时候把新值保存在这个位置。这个特性值默认为undefined。
注:IE8只能在DOM对象上使用Object.defineProperty()方法,而且只能创建访问器属性,由于实现不彻底建议还是不要再IE8中使用该方法。
(2).访问器属性:不包含数据值,它们包含一对getter和setter函数(不过不是必须的)。读取访问器属性时候会调用getter函数,这个函数返回有效的值;写入访问器属性时会调用setter函数并可以传入新值,这个函数负责决定如何处理数据。对于直接在对象上定义的属性,configurable,enumerable特性的默认值为true。
- [[Configurable]]:
a.能否通过detele删除属性从而重新定义属性
b.能否修改属性的特性
c.能否把属性修改为数据属性
注意不能同时设置数据属性和访问器属性会报错。 - [[Enumerable]]:
a.能否通过for-in循环返回属性。
- [[Get]]:在读取属性时引擎自动调用的函数,非严格模式下不设置默认值为undefiend。
- [[Set]]:在写入属性时引擎自动调用的函数,默认值为undefiend。
访问器属性不能直接定义,必须使用Object.defineProperty()来定义。不一定非要同时指定getter和setter,只指定get意味着属性不能写,尝试写入属性会被忽略,严格模式下报错。只指定setter函数的属性不能读,尝试读属性非严格模式下返回undefiend,经测试严格模式下好像并没报错
分析探讨
其实这种关联现象的出现还是有历史原因的,需要*去阅读知乎大神鲁小夫推荐的这篇文章 Intent-to-Ship: Moving DOM attributes to prototype chains 。我简单阐述一下我的理解吧(英语渣请看官见谅)~
追根溯源小科普:
13年左右,Chrome的JS引擎中DOM接口的这些属性都还是被定义在DOM实例对象上的,以元素节点的id属性为例,它是数据属性而不是现在的访问器属性。Chromium团队已经意识到这种表现不符合WebIDL规范,毕竟当时IE和火狐这些属性早一年实现是在原型链上的,Chromium团队觉得自己也应该把DOM属性从实例上移到原型链上然后通过暴露getter/setter访问这些属性,既然是原型链上共享的属性,数据属性因为只有一个值不满足多个实例对象访问各自的id,所以访问器属性是个很好的选择,因为它本质是函数,在函数里面进行对应的处理就好。
属性移到原型链上有什么好处呢?
这样能够使开发人员挂钩DOM属性的getter/setter方法,这将提高DOM操作的可编程性;
因为是在原型上的getter/setter处理JavaScript逻辑,便于开发者操作JavaScript函数库和复写默认的DOM属性表现,开发者可覆写提供的默认的getter/setter方法,实现自定义的逻辑;
从底层来说这也方便C++实现JS引擎提供的这种操作;
还能减少内存消耗(不然怎么说V8引擎那么快呢~~),提高性能,毕竟原型链上的属性是所有实例共享的;
至于兼容性方面,风险很低毕竟IE和FF已经实现了这种改变;
他们还问卷调查过哪些属性建议移动,下图是他们的目标
我的理解:
也就是说data和nodeValue现在是原型对象上的访问器属性,有一对setter和getter函数,可惜看不到getter/setter函数怎么实现的处理逻辑,我就按自己的理解写了下代码思路后面再说。
嗯很好!这些属性的特性值被设置为true,可配置可枚举,给开发人员提供使用的灵活性,我可以重新定义自己的gettr/setter函数逻辑,让id就添加在text身上,text.hasOwnProperty('id')返回true。
先从破坏data和nodeValue的关联开始
//覆写set/get函数,断开默认set/get实现与nodeValue关联的逻辑
Object.defineProperty(CharacterData.prototype,'data',{
configurable:true,
enumerable:true,
set:function(){},
get:function(){}
});
var text=document.createTextNode('文本');
text.data;// undefined
text.nodeValue;// "文本"
/*创建text节点对象,访问其data属性返回undefiend,但是访问nodeValue能返回"文本,因为我并没覆写nodeValue属性的访问器"*/
text.data='新文本';
text.hasOwnProperty('data');// false
text.data;// undefined
text.nodeValue;// "文本"
/*还是返回false,看来处理delete text.data不在set函数中实现*/
delete CharacterData.prototype.data;// true
CharacterData.prototype.hasOwnProperty('data');// false
text.data='新新文本';// "新新文本"
text.nodeValue;// "文本"
text.hasOwnProperty('data');// true;
/*从根源delete掉data属性*/
默认的setter/getter函数怎么实现关联逻辑的,提供一下我的简单思路,肯定没默认的实现好,大家可以集思广益一下互相探讨~~
我猜想引擎中一直有个while(true)循环监听着text.hasPrototype('data');当text.data="文本",可能执行如下操作
获得开发者添加在text对象的data属性值,改变CharacterData.prototype.data的值(默认调用data访问器属性的set方法)和Node.prototype.nodeValue的值(默认调用data访问器属性的set方法),delete掉本应该在text对象上的data属性。
借助于JS引擎默认实现的delete掉text实例上的data属性,我用组合继承模式实现了
(1)data的赋值操作在原型属性上更新值而不会添加到自身属性这一处理逻辑
代码如下
//为了不和引擎中原生接口名冲突,命名采取相似仅为演示理解就好
//组合继承
function Objectt(){
//...
} function EventTargett(){
//...
} //Node
function Nodee(mydata){
if(this instanceof Textt){
CharacterDataa.prototype.data=mydata;
EventTargett.call(this,mydata);
//...需要给text添加属性的话
}
else{
Object.defineProperties(this,{
data:{
configurable:true,
enumerable:true,
set:function(protodata){
mydata=protodata;
},
get:function(){
return mydata;
}
},
length:{
configurable:true,
enumerable:true,
set:undefined,
get:function(){}
},
previousElementSibling:{
configurable:true,
enumerable:true,
set:undefined,
get:function(){}
},
nextElementSibling:{
configurable:true,
enumerable:true,
set:undefined,
get:function(){}
}
});
/*this.substringData=substringData;
this.appendData=appendData;
this.insertData=insertData;
this.deleteData=deleteData;
this.replaceData=replaceData;
this.remove=remove;*/
}
}
Nodee.prototype=new EventTargett();
Nodee.prototype.constructor=Nodee;
Nodee.prototype.__proto__=EventTargett.prototype; //CharacterData
function CharacterDataa(mydata){
//给文本节点自身添加属性
if(this instanceof Textt){
Nodee.call(this,mydata);
//...需要给text添加属性的话
}
//原型上的属性
else{
Object.defineProperties(this,{
wholeText:{
configurable:true,
enumerable:true,
set:undefined,
get:function(){}
}
});
/*this.splitText=splitText;
this.getDestinationInsertionPoints=getDestinationInsertionPoints;*/
}
}
CharacterDataa.prototype=new Nodee();
CharacterDataa.prototype.constructor=CharacterDataa;
CharacterDataa.prototype.__proto__=Nodee.prototype; //Text
function Textt(mydata){
CharacterDataa.call(this,mydata);
//...如果需要给文本节点text自身添加属性的话
}
Textt.prototype=new CharacterDataa();
Textt.prototype.constructor=Textt;
Textt.prototype.__proto__=CharacterDataa.prototype; //export接口
Document.prototype.createTextNode=function(mydata){
if(arguments.length==0){
return 'error';
}
return new Textt(arguments[0].toString());
} //使用
var text1=document.createTextNode('hello');
text1.data;// "hello"
text1.hasOwnProperty('data');// false;
CharacterDataa.prototype.hasOwnProperty('data');// true
在实现过程中也遇到了些问题且存在一些局限性,如下
1.Character.prototype在初始化原型对象的时候需要执行一遍Node函数,new Text(arguments[0].toString())也会执行一遍Node函数,这导致Node函数中get虽然可以作为闭包来用作用域链中的mydata但却不是真正想要的那个,即处在原型对象创建的作用域链中的get想要new Text(arguments[0].toString())创建的作用域链中mydata。后来我的解决方案就是在创建new Text(argumetns[0].toString())时候设置 CharacterData.prototype.data=mydata; 而访问器属性更好可以实现因为它是默认调用set()函数的,我在set函数中将mydata一保存这样就更新Character.prototype初始化原型对象创建作用域链中的mydata值了!
2.如果不借助JS引擎默认的delete操作,我只能事先在代码中创建好text对象,暂时还没有动态创建节点的代码处理思路。
var text=document.createTextNode('文本');
while (true) {
if(text.hasOwnProperty('data')){
CharacterData.prototype.data=text.data;
delete text.data;
}
}
3.不能满足多个text实例,set/get逻辑还需强化不然data真成共享的值了,真的要用到指针指向??
4.还未实现和nodeValue的关联,DOM的API体系太庞大,我得慢慢研究啦,先就这么多我把代码放到GitHub,后续如果有兴趣可以关注下啦~如果大家有什么想法欢迎探讨,我也想多学习学习!