DOM节点操作占我们前端工作很大一部分,其节点的操作又占50%以上。由于选择器引擎的出现,让繁琐的元素选择简单化,并且一下子返回一大堆元素,这个情景时刻暗示着我们操作元素就像css为元素添加样式那样,一操作就操作一组元素。
一些大胆的API设计被提出来。当然我们认为时髦新颖的设计其实都是很久以前被忽略的设计或者其它领域的设计。例如:集化操作,这是数据库层里边的ROM就有的。链式操作,javascript对象基于原型的构造为它大开方便之门。信息密集的DSL,有rails这座高峰让大家崇拜。jQuery集众所长,让节点操作简单到极致。对于jQuery,每个方法重载的厉害。作为一个流行的产品,必须模仿着众。本章以mass框架的节点操作模块为主,其原因是mass分层做的比较好,node模块相当于jQuery2.0中的manipulation模块与traversing模块,node_fix则是兼容ie6-8的部分。(这里体现了AMD规范设计的优势)
我们来看DOM的操作包括哪些,CRUD(数据库所说的),C就是创建,在集化操作里,innerHTML可以满足我们一下创建多个节点的需求。
R,就是读取查找,如果解释查找的话,选择器引擎已经为我们做了,如果是读取,那么我们还可以将innerHTML,innerText,outerHTML这些属于元素内容的东西划归它处理
U,就是更新,innerHTML,innerText,outerHTML,这就出现一个问题,需要两个API来处理,还是合成一个。jQuery的RU结合的方式建议只用框架底层的API,到UI层面还是分开。底层这样做,可以保证API的数量少,使用门槛降低,高层的API,则数量非常庞杂,窗口、面板、跑马灯、拖放等都有一大堆方法,他们在每一个项目中调用的次数比底层API少多了。因此,我们不会专门去严谨,只有用到了才会去看。我们尽量做到语意化。java传统的getXXX setXXX addXXX removeXXX是我们的首选。
最后是D,移除,我们在jQuery中有3个API,remove detach empty,各有各的使用范围。因此,节点的操作是围着DOM树操作。既然是树,就有插入操作,jQuery划分为4个API,其实我们可以看做是IE的insertAdjacentXXX的强化版。还有clone,克隆允许,克隆缓存数据与事件。
本章围绕mass的node与node_fix模块,jQuery的manipulation模块。
https://github.com/RubyLouvre/mass-Framework/blob/master/node.js
https://github.com/RubyLouvre/mass-Framework/blob/master/node_fix.js
https://github.com/jquery/jquery/blob/master/src/manipulation.js
一:节点的创建
浏览器提供了多种手段创建API。从流行度来看,依然是document.createElement,innerHTML,insertAdjacentHTML,createContextualFragment。
document.createElement基本不用说什么,它传入一个标签名,然后返回此类元素的节点。并且对于浏览器不支持的标签,也会返回(这也成为了ie6-ie8支持html5新标签的救命稻草)。ie6-ie8中,还有一种方法,能允许标签连同属性一起生成,比如document.createElement("<div id=aaa></div>"),此方法常见生生name属性的input与iframe元素。因为ie6-7下这两个元素只读,不允许修改。
function createNameElement(type, name) { var element = null; //尝试ie方式 try { element = document.createElement(‘<‘ + type + ‘ name="‘ + name + ‘">‘); } catch (e) {} if (!element || element.nodeName != type.toUpperCase()) { // non -ie element = document.createElement(type); element.name = name; } return element; } createNameElement("dd","aa"); //=> <dd></dd> name = aa
innerHTML本来是IE的私有实现,在jQuery1.0就开始挖掘innerHTML了,这不但是innerHTML的创建效率比createElement快2-10倍不等,还因为innerHTML一下能生产一大堆节点。这与jQuery推崇的宗旨不谋而合,但innerHTML发生了兼容性的问题,比如IE会对用户字符串进行trimleft操作,本意是只能去除空白,但FF认为要忠于用户操作,单元位置要生成文本节点。
var div = document.createElement("div"); div.innerHTML = "<b>2</b><b>2</b>" console.log(div.childNodes.length); //ie6-8 3.其它浏览器4 console.log(div.firstChild.nodeType); //ie6 1,其它3
IE下有些元素的innerHTML是只读的,重写innerHTML会报错,这就导致我们在动态插入时,不能转求appendChild,insertBefore来处理:
来自MSDN:IE的innerHTML会忽略掉no-scope element.no-scope element是IE的内部概念,隐藏的很深。仅在MSDN说明注释节点是no-scope element,或在官方论坛中透露一点内容,script和style也是no-scope element。经过这么多年的挖掘,大致确认注释,style,script,link,meta,noscript表示功能性的标签为no-scope element,想要用innerHTML生成它们,必须在它们之前加上文字或其它标签。
//ie6 8 var div = document.createElement("div"); div.innerHTML = ‘<meta charset=utf-8 />‘; //0 alert(div.childNodes.length) div.innerHTML = ‘x<meta charset=utf-8 />‘; //2 alert(div.childNodes.length)
另外,一个周知的问题是innerHTML不会执行script标签里的脚本(其实也不然,如果浏览器支持script标签的defer属性,它就能执行。这个特性比较难检测,因此,jQuery一类的直接用正则把它里边的内容抽取出来,直接全局eval了)。 mass的思路是,反正innerHTML赋值后已经将它们转换为节点了,那么再将它们抽取出来,再用document.createElement("script")生成的节点代替就行了。
最后,就是一些元素不能单独作为div的子元素,比如td, th元素,需要在最外边包裹几层,才能放到innerHTML中解释,否则浏览器就会将其当成普通文本节点生成。这个是jQuery团队发现的,现在所有框架都借用此技术生成节点。如果把这些标签比做是标签,那么孵化它们出来的父元素就是胎盘。在w3c规范中,它们都是这样一组组分成不同的模块。
胚胎 | 胎盘 |
area | map |
param | object |
col | tbody,table,colgroup |
legend | fieldset |
option,optground | select |
thead,tfoot,tbody,colgroup | table |
tr | table,tbody |
td,th | table,tbody,tr |
一直以前,人们都是使用完整的闭合标签来包裹这些特殊的标签,直到人们发现浏览器会自动补全闭合标签后。
var div = document.createElement("div"); div.innerHTML = ‘<table><tbody><tr></tr></tbody></table>‘; alert(div.getElementsByTagName("tr").length); //1 div.innerHTML = ‘<table><tbody><tr>‘; alert(div.getElementsByTagName("tr").length); //1
能自动补全的有body,colgrounp,dd,dt,head,html,li,optgroup,option,p,tbody,td,tfoot,th,thead,tr.在网速奇慢的年代是一个优化,也是吸引开发者到自己的阵营。
现在已经不推荐这样做,浏览器会固守规则,少写结束标签,很容易引起镶嵌错误,xhtml布道者就是抓住这一点死命抨击html4。
insertAdjacentHTML,也是IE的私有实现,dhtml的产物,比起其他的API,它具有灵活的插入方式。
(更多参考:http://www.cnblogs.com/ahthw/p/4309343.html)
他们一一对应jQuery的prepend,append,before,after。因此,用它来构造这几个方法,代码量会大大减少。但是实现的过程要比我们想象的复杂,我们可以另外一个insertAdjacentHTML来搞定,
insertAdjacentHTML兼容情况如下所示:
浏览器 | chorme | FF | IE | Opera | Safari(webkit) |
版本 | 1 | 8 | 4 | 7 | 4(527) |
如果浏览器不支持insertAdjacentHTML,那么我们可以用下面介绍的crateContextualFragment来模拟(模拟函数略)。
createContextualFragment是FF推出来的私有实现,它是Range对象的一个实例方法,相当于insertAdjacentHTML直接将内容插入到DOM树,createContextualFragment则是允许我们将字符串转换为文档碎片,然后由你决定插入到哪里。
在著名的emberjs中,如果支持Range ,那么它的html,append,prepend,after等方法都用createContextualFragment与deleteContents实现。createContextualFragment和insertAdjacentHTML一样,要字符串遵循HTML的嵌套规则。
此外,我们还可以用document.write来创建内容,但我们动态添加节点时多发生在dom树建完之后,因此不太合适,这里就不展开了。
之后我们看看mass是怎么实现的,它的结构与jQuery一样,通过两个构造器与一个原型实现无new实例化,这样我们的链式操作就不会被new关键字打断。
function $(a, b) { //第一个构造器 return new $.fn.init(a, b); //第二个构造器 } //将原型对象放到一个名字更短更好记的属性名中 //这是jQuery人性化的体现,也方便扩展原型方法 $.fn = $.prototype = { init : function (a, b) { this.a = a; this.b = b; } } //共用一个原型 $.fn.init.prototype = $.fn; var a = $(1, 2) console.log(a instanceof $); console.log(a instanceof $.fn.init)
上面的这个结构非常重要,所有jQuery风格的类库框架都沿袭它实现的链式操作
根据jQuery官方的介绍,它包含9种不同的传参方法。
jQuery(selector[,context])
jQuery(element)
jQuery(elementArray)
jQuery(object)
jQuery(jQuery object)
jQuery()
jQuery(html,[ownerDocument])
jQuery(html,attributes)
jQuery(callback)
若按功能来分,它大致分为3种:选择器,domparser与domReady。
由于重载的太多了,因此基本上号称jQuery-compatible的类库框架都没有实现它所有重载。如果抛开这些细节,我们不难发现,除了最后的domReady,其它一切目的不过是想获取要操作的节点罢了。为了更方便的操作,这些节点与实例通过数字进行并联,构成一个类数组对象,因此,你会看到它绑定了push,unshift,pop,shift,splice,sort,reverse,each,map等数组方法,让它看起来就是一个数组。
labor相当于jQuery的pushStack,用于构建下一个类数组对象,比如map,lt,gt,eq等方法就是内部调用它来实现,但jQuery的pushStack远没有这么简单,它还有一个prevObject属性,保存着上次操作的对象。链式操作越多,被引用不能释放的东西就越多,或者处于未来只能使用querySelectorAll做选择器的考量,它们都是好东西。工作业务中,只高亮表格偶数行这一需要也很频繁。因此,做成一个独立的方法是明智的选择。
mass而根据用户传入字符生成一堆节点功能则是由parseHTML方法实现的。parseHTML是一个复杂的方法,它对不同的浏览器做了分级处理,对于ie6-8,框架还会夹在node_fix模块,里边有fixparseHTML,为它打补丁(此处有征对ie6 8的修复方法)。
二.节点的插入
原生的DOM接口是非常简单的,参数类型确定,不会重载,每次只能处理一个元素节点;而jQuery式的方法则相反,虽然名字短,但参数类型复杂,过度重载,对于插入这样的写操作,是进行批处理的。
为了简化处理逻辑,jQuery的做法是统统转换为文档碎片,然后将它复制到与当前jQuery对象里面包含的节点集合相同的个数,一个个插入。
<div>1</div> <div>2</div> <div>3</div> <div>4</div> <script type="text/javascript"> window.onload = function () { var a = document.createElement("span") a.innerHTML = "span"; $("div").append(a) } </script>
为了提高性能,合理利用高级api,mass的做法是能用createContextualFragment就用createContextualFragment.能用insertAdjacentHTML的就用insertAdjacentHTML,否则就转化为文档碎片,通过appendChild,insertBefore插入,这意味着里边分支会很复杂,我们需要搞个适配器,让它尽可能地分流。
至与API的命名,将沿袭jQuery的那几个名字,append,prepend,before,after与replace。值的一提的是,由于这几个方法太受欢迎,w3c在DOM4决定原生的支持它们。参数可以是字符串与DOM节点。
mass的这5个方法都是通过manipulate方法实现。
"append,prepend,before,after,replace".replace($.rword, function(method) { $.fn[method] = function(item) { return manipulation (this, method, item, this.ownerDocument); }; $.fn[method + "To"] = function(item) { $(item, this.ownerDocument) [method] (this); return this } })
mass的makeFragment函数,这里涉及到两个重要的知识点:NodeList的循环操作,文档碎片的复制。
NodeList看起来像数组,但它在插入节点时会立刻改变长度
<div id="test"> <a href="http://www.baidu.com/">link</a> </div> <script type="text/javascript"> window.onload = function () { var els = document.getElementsByTagName("a"); var div = document.getElementById("test"); for (var i = 0; i < els.length; i++) { var ele = document.createElement("a"); ele.setAttribute("href", "http://www.google.com/"); ele.appendChild(document.createTextNode("new link")); div.appendChild(ele);//添加一个新的链接 } } </script>
上面讲陷入死循环,我们在循环它时,我们最好将它的length保存到一个变量中, 然后比较是否中断循环。
第二个是碎片对象的复制问题,我们大可以使用原生的cloneNode(true),但在IE下,attachEvent绑定的事件会跟着被复制。由于不是我们框架绑定事件,那么再移除时就无法找到对应的引用了。
除此之外,jQuery还提供了wrap,wrapAll,wrappInner这三种特殊的插入操作。
wrap为当前元素提供了一个共同的父节点,此父节点将动态插入到远节点的父亲底下。这个我们可以轻松在IE下用neo.applyElement(old,"outside")实现。
wrapAll则是为一堆元素提供了一个共同的父节点,插入到第一个元素的父亲节点下,其它元素则统统挪到新节点底下。
wrapInner是为当前的元素插入一个新节点,然后将它之前的孩子挪到新节点底下,这个我们可以在IE下轻松用neo.applyElement(old,"inside")实现
这样看来,applyElement真是很强大,可以在标准浏览器扩展一下,让它应用的更广。
if (!document.documentElement.applyElement && typeof HTMLElement !== "undefined") { HTMLElement.prototype.removeNode = function(deep) { if (this.parentNode) { if (!deep) { var fragment; var range = this.ownerDocument.createRange(); range.selectNodeContents(this); fragment = range.extractContents(); range.setStartBefore(this); range.insertNode(fragment); range.detach() } return this.parentNode.removeChild(this); } if (!deep) { var range = this.ownerDocument.createRange(); range.selectNodeContents(this); range.deleteContents(); range.detach() } return this; } HTMLElement.prototype.applyElement = function(newNode, where) { newNode = newNode.removeNode(false); switch ((where || ‘outside‘).toLowerCase()) { case ‘inside‘ : var fragment; var range = this.ownerDocument.createRange(); range.selectNodeContents(this); range.surroundContents(newNode); range.detach(); beark; case ‘outside‘ : var range = this.ownerDocument.createRange(); range.selectNode(this); range.surroundContents(newNode); range.detach(); beark; default : throw new Error (‘DOMException.NOT_SUPPORTED_ERR(9)‘) } return newNode; } }
三.节点的复制
IE下对元素的复制与innerHTML一样,存在许多bug,非常著名的就是IE会多复制attachEvent事件。另外,根据测算,标准浏览器的cloneNode,只会复制元素写在标签内的属性与通过setAttribute设置的属性,而IE6-IE8还支持通过node.aaa = ‘xxx‘设置的属性复制。
如果是这样还好办,但IE在复制时不但会多复制一些,还会少复制一些,这让程序员不好处理。mass和jQuery一样,支持两个参数,第一个是复制节点,但不复制数据与事件。默认为false.第二个决定如何复制它的子孙,默认是遵循参数一。
$.fn.clone = function (dataAndEvent, deepDataAndEvents) { dataAndEvent = dataAndEvent == null ? false :dataAndEvents; deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; return this.map(function() { return cloneNode(this, dataAndEvents, deepDataAndEvents); }) }
可以看出,此方法只对参数进行处理,具体操作由cloneNode执行。
function cloneNode (node, dataAndEvents, deepDataAndEvents) { if (node.nodeType === 1) { var neo = $.fixCloneNode(node), //复制元素attibutes src, neos, i; if (dataAndEvents) { $.mergeData(neo, node); //复制数据与事件 if (deepDataAndEvents) {//处理子孙的复制 src = node[TAGS] ("*"); neos = neo[TAGS] ("*"); for (i = 0; src[i], i++) { $.mergeData(neos[i], src[i]); } } } src = neos = null; return neo; } else { return node.cloneNode(true); } }
cloneNode是做了分层设计的,如果在标准浏览器中fixCloneNode只是一个标准的cloneNode(true).关于更多fixCloneNode模块,请移步mass 的node_fix模块。详见fixNode函数。
这这个里,不得不提是mootools团队挖掘出来的mergeAttributes hack.早些年,为了在ie6-ie8中不复制attachEvent,jQuery被逼动用outerHTML来生成新的节点。
//应该是jquery 1.4 manipulation模块 clone : function (events) { var ret = this.map(function() { if ( !jQuery.support.noCloneEvent && !jQuery.isXMLDoc(this) ) { var html = this.outerHTML, ownerDocument = this.ownerDocument; if (!html) { var div = ownerDocument.createElement("div"); div.appendChild( this.cloneNode(true) ); html = div.innerHTML; } return jQuery.clean([html.replace(rinlinejQuery, "") .replace(rleadingWhitespace, "")], ownerDocument)[0]; } else { return this.cloneNode(true) } }) //复制事件 if (events === true) { cloneCopyEvent( this, ret); cloneCopyEvent( this.find("*"), ret.find("*")); } return ret; }
即便这样,还是很臃肿,唯一的解决之道就是时间(等事件淘汰IE6等这些古老的浏览器,或者项目主管非常有魄力的支持新锐浏览器)。在zopto等手机框架。把这些数据都存储在data-*属性中。直接用一个cloneNode(true)就搞定节点的复制。
clone : function() { return this.map( function() { return this.cloneNode(true) } ) } //zepto
四.节点的移除
浏览器提供了很多节点移除的方法,常见的有removeChild、removeNode,动态创建一个元素节点或文档碎片,再appendChild,创建Range对象选中目标节点后,deleteContents。
removeNode是IE的私有实现,opera也实现了此方法。它的作用是将目标节点从文档树中删除,返回目标节点。它有一个参数,为布尔值,其默认值为false:即仅删除目标节点,保留子节点。true时同removeChild的用法。
deleteContents是比较偏门的API,兼容性差。
removeChild在IE6-7中存在内存泄露的问题,与IE的CG回收比较失败引起的。由于太底层,不展开说了。给出EXT的解决方案,像EXT这样庞大的UI库,所有的节点都是动态生成,因此是非常重视CG回收的。
var removeNode = IE6 || IE7 ? function() { var d ;//这里的ie6还ie7自己来实现 return function(node) { if (node && node.tagName != ‘BODY‘) { d = d || document.createElement(‘DIV‘); d.appendChild(node); d.innerHTML = ‘‘; } } }() : function(node) { if (node && node.parentNode && node.tagName != ‘BODY‘) { node.parentNode.removeChild(node); } }
为什么这么写,因为在IE6-8中存在一个叫DOM超空间(DOM hyperspace)的概念,当元素移出DOM树,但有javascript关联时元素不会消失,它被保留在一个叫超空间的地方。《PPK读javascript》一书中指出,可以用是否存在parentNode来判定元素是否才超空间。
window.onload = function () { var div = document.createElement("div"); alert(div.parentNode) //null document.body.removeChild(document.appendChild(div)); alert(div.parentNode); //ie6-8 object,其它的为null if (div.parentNode) { alert (div.parentNode.nodeType); // => 11 文档碎片 } }
上一个的alert出null , 这个所有的浏览器都一样,因此有时我们误以为可以当做节点是否在DOM的基准。但当元素插入DOM树再移出时,就有差异了。ie6-ie8弹出的是一个文档碎片。因此,可以想象ie的性能为什么这么差了,ie为自以为这样可以重复使用元素,但通常用户移出了就不管,因此,久而久之,内存允许了这么多碎片。加之其他的问题,就很容易造成泄露
我们看一下,innerHTML清除元素会怎么样。
<body><div id="test"></div></body> <script type="text/javascript"> window.onload = function() { var div = document.getElementById(‘test‘); document.body.innerHTML = ‘‘; alert(div.parentNode); //null } </script>
结果在IE下也是null,但这也不能说明innerHTML就比removeChild好。我们继续来一个实验
window.onload = function() { var div1 = document.getElementById(‘test1‘); div1.parentNode.removeChild(div1); alert(div1.id + ":" + div1.innerHTML); // test1:test1 var div2 = document.getElementById(‘test2‘); div2.parentNode.innerHTML = ""; alert(div2.id + ":" + div2.innerHTML); //(ie)test2: }
这时我们就发现,当用removeChild移出节点时,原来的元素结构没有发生变化,但在innerHTML时,ie6-ie8会直接清空里边的内容,只剩下空壳。而标准浏览器则与removeChild保持一致。
打个比喻,IE下,removeChild掰断树枝,但树枝可以再次使用。而innerHTML就是把所需树枝拔下来烧掉。鉴于IE下内存管理这么失败,能这么干净的清除节点正是我们寻找的方法!所有EXT从1.0到4.0,此方法没有什么大改变。
对于jQuery这样的框架类库来说。估计很难走这条路,它已经被自己的缓存系统绑架了(移除节点时需要逐个监测元素,从缓存系统中逐个移除对于的缓冲体,否则会造成浏览器宕机)。不过最不好的是,jQuery通过类数组结构与preObject困住节点的方式,造成了jQuery即便是使用innerHTML,元素节点在IE下还是位于DOM超空间中。
jQuery在性能上没有优势,于是在移除节点上造势 。它提供了三种移除节点的方式。remove,移除节点的同时从数据缓存上移除对应的数据。empty,只清空元素的内部,相当于IE下的removeChild(false)。detach,移除节点但不清除数据。前两种好理解。但为什么要创建第deatch方法呢?
从我们的工作业务看,DOM操作远不止这些,还有UI交互,样式渲染等。但后者都是基于前者上运作。
纯粹的javascript操作不会带来什么消耗,95%以上能耗是DOM操作引起的。出于性能考虑,我们最佳的做法是在设置样式前,将元素移出DOM树,处理完再插回来。
但绝大多数操作DOM的方法都与数据缓存方法关联在一起,若用remove方法,会让它们无法进行数据清理工作,导致内存泄露。而detach就是基于此需要而设计的。从设计理念来看,有点像数据库操作的事务。deatch开始一下,就开始一连串DOM操作,就算怎么操作,也不会伤及DOM树的其它元素。最后conmmit(append)一下,将最后的结果显示出来。
下面是实现过程
"remove, empty, detach".replace($.rword, function() { $.fn[method] = function() { var isRemove = method !== "empty"; for (var i = 0, node; node = this[i++];) { if (node.nodeType === 1) { //移除匹配操作 var array = $.slice(node[TGAS]("*")).concat(isRemove ? node : []); if (method !== "detach") { array.forEach(cleanNode); } } if (isRemove) { if (node.parentNode) { node.parentNode.removeChild(node); } } else { while (node.firstChild) { node.removeChild(node.firstChild); } } } return this; } });
如果我们的框架没有像jQuery那样引入庞大的数据缓存系统,而是像zopto.js那样通过h5的data-*来缓存数据,那么许多东西都可以简化了。这也意味着我们打算不兼容ie6 7 8,那么就可以使用deleteContens或textContent;
例如,我们事先一个清空元素内部的API:
方法1:
function clearChild (node) { //node可以是元素节点或文档碎片 while (node.firstChild) { node.removeChild(node.firstChild) } return node }
方法2,使用deleteContents,创建一个Range对象,然后通过setStartBefore,setEndAfter选择边界,最后清空它们的节点。
var deleteRange = document.createRange(); function clearChild(node) { //node可以是元素节点或碎片 deleteRange.setStartBefore(node.firstChild) deleteRange.setEndAfter(node.lastChild) deleteRange.deleteContents(); return node }
方法3:使用textContent .textContent是W3C版本中的innerText. 在较新的浏览器里兼容性特别好。并且同时存在于元素节点与文档碎片中。
function clearChild (node) { //node可以是元素节点或碎片 node.texrContent = ""; return node; }
(本文未完结,由于本章篇幅比较长,请关注本文更新)
以下将更新:
五. innerHTML , innerText, outerHTML的处理
六. 一些奇葩的元素节点
上一章: 第七章:选择器引擎 下一章: 第九章: 数据缓存系统