jQuery2.x源码解析(DOM操作篇)

jQuery2.x源码解析(构建篇) 

jQuery2.x源码解析(设计篇) 

jQuery2.x源码解析(回调篇) 

jQuery2.x源码解析(缓存篇) 

jQuery这个类库最为核心重要的功能就是DOM操作了。DOM是由w3c制定的为HTMLXML文档编写的应用程序接口,全称叫做W3C DOM,它使得开发者能够修改html和xml的内容和展现方式,将网页与脚本或编程语言连接起来。

但是标准在各个浏览器中的实现是不一样的,同时DOM发展也是循序渐进的,不断地增加新的api,因此各个浏览器乃至各个版本对于DOM实现的也是不一样的。jQuery这个类库最为核心的功能,就是能够将各个主流浏览器的主流版本的DOM处理方法统一起来,让开发人员不必去过分了解各个浏览器对于DOM处理的细节与差异,写一次代码就能在各个浏览器中运行,并且取得相同的效果。

jQuery这个框架目前的流行程度在下降,除了新的理论新的框架的兴起外,一个主要原因就是各个主流浏览器对于DOM处理的差异正在缩小,DOM处理差异大的浏览器日益趋于被淘汰,所以jQuery对我们而言仅是一个封装了DOM处理的工具,存在的意义也日趋下降。但是从mdn的Element预定义的几个api就能发现,新增加的api都是在模仿jQuery的,可见jQuery的api是多么经典。

阮一峰老师曾经有一篇jQuery-free化的文章,里面介绍了很多不使用jQuery也可以高效开发的技巧,这些技巧都是仿照jQuery,并在深入了解了DOM基础上封装起来的。查看jQuery对于DOM操作的源码,不但会了解到标准的DOM操作方法,学习DOM的api的优化,还能学习到关于浏览器兼容的一些“黑魔法”、“黑科技”。

jQuery封装的DOM大致可以分为3大类:DOM操作、DOM遍历、DOM事件,篇幅有限,我们先着重看一看DOM操作相关的jQuery的源码。下面我们就一起看看jQuery的DOM操作相关的部分。


提问:jQuery的DOM操作原理是什么?

答:jQuery的核心思想是简化DOM的概念,去除出Element外的所有其他Node接口的概念,我们只需考虑元素节点Element就可以了。原始的DOM操作包括Element、Attr、Text、Comment、Document等对象的操作,而在开发中我们基本上只会处理Element,Element相关的如Attr、Text都可以以字符串的形式操作,所以将DOM的操作简化为对Element的操作是可行的。jQuery使用jQuery对象的形式将Element对象进行封装,把复杂的DOM定义简化为对jQuery对象,使即使不熟悉DOM概念的人也可以轻松操作DOM对象。

将Element对象封装为jQuery对象,再在jQuery对象的原型上面定义出统一接口,做成一个门面模式。用户不用去考虑不同浏览器下Element处理的差异,因为内部用策略模式或者判断树将各个主流浏览器的差异性已经屏蔽掉了;并且做好了持续升级的准备,以对应浏即使览器不断更新换代,带来新的功能,淘汰旧的功能后,基于jQuery开发的功能依旧可以不受影响地正常运行。

jQuery对于DOM封装的另一个核心就是其缓存功能,jQuery将jQuery对象的变量(如事件、动画、第三方扩展的属性)都保存缓存中,而缓存又保存在实际Element对象中,所以当这个Element对象被再一次封装为jQuery对象的时候,是可以继承之前的缓存进而可以继续之前的操作。这个特性使得我们不用特意去缓存jQuery对象的引用,需要用的时候随时可以再一次将Element对象封装为jQuery对象,而不用担心两个的jQuery对象操作上会产生数据上的差异。即jQuery对象不保存任何Element元素相关信息,所有的相关信息都保存在DOM本身中。我们开发jQuery插件的时候,也要保证这一原则,否则插件在使用过程中可能会出现问题。

此外,jQuery优化了DOM处理过程,让DOM处理效率更高,因为它封装了一些DOM处理的技巧,当然如果你已经熟知了这些技巧,那么你使用jQuery后效率不会有提升反而会下降(jQuery在统一浏览器操作也会牺牲一部分性能,很多受兼容性限制的底层高效的api会被用更耗时但兼容性更好的方法取代掉)。因为jQuery的优化是技巧上面的,所以肯定不及Virtual DOM这种从渲染算法上优化明显。

最重要的是jQuery对象支持集合操作、链式操作,这极大的简化了DOM处理,笔者之前已经分析过jQuery的api的设计原理,这些原理同样适用于处理DOM,使得DOM操作变得简便、可以集合处理、可以链式处理、可以异步处理、易于扩展......


提问:jQuery的DOM操作API由哪些部分组成?

答:DOM操作的API也很多,大致可以分为5大类:

1.DOM元素的创建:

jQuery(html,[ownerDoc])
jQuery.fn.clone([Even[,deepEven]])
jQuery.parseHTML(html,[ownerDoc])
jQuery.fn.html([val|fn])

2.DOM元素的插入:

jQuery.fn.append(content|fn)
jQuery.fn.appendTo(content)
jQuery.fn.prepend(content|fn)
jQuery.fn.prependTo(content)
jQuery.fn.replaceWith(content|fn)
jQuery.fn.after(content|fn)
jQuery.fn.before(content|fn)
jQuery.fn.insertAfter(content)
jQuery.fn.insertBefore(content)
jQuery.fn.replaceAll(selector)
jQuery.fn.wrap(html|ele|fn)
jQuery.fn.unwrap()
jQuery.fn.wrapAll(html|ele)
jQuery.fn.wrapInner(html|ele|fn)

3.DOM元素的修改:

jQuery.fn.attr(name|pro|key,val|fn)
jQuery.fn.removeAttr(name)
jQuery.fn.prop(n|p|k,v|f)
jQuery.fn.removeProp(name)
jQuery.fn.html([val|fn])
jQuery.fn.text([val|fn])
jQuery.fn.val([val|fn|arr])

4.DOM元素的删除:

jQuery.fn.empty()
jQuery.fn.remove([expr])
jQuery.fn.detach([expr])
jQuery.fn.html([val|fn])
jQuery.fn.replaceAll(selector)
jQuery.fn.wrap(html|ele|fn)
jQuery.fn.unwrap()
jQuery.fn.wrapAll(html|ele)
jQuery.fn.wrapInner(html|ele|fn)

5.DOM的ready:

jQuery(callback)
jQuery.holdReady(hold)
jQuery.ready(hold)

可以看出,DOM操作的API是非常多的。但是很多API都是对底层API的封装,真正核心的API没有几个,我们可以归纳为这么几个(包括私有API):

1.DOM元素的创建:

jQuery.fn.clone([Even[,deepEven]])
buildFragment

2.DOM元素的插入

domManip
buildFragment

3.DOM元素的修改:

access

4.DOM元素的删除:

jQuery.fn.cleanData( elems )
remove( elem, selector, keepData )

5.DOM的ready

jQuery.ready

接着我们将分别重点看看这几个函数。


提问:jQuery创建元素是如何实现的?

笔者在设计篇的博客中总结了jQuery函数是数种最常用的api的重载函数,其中有一种就是通过Html字符串生成元素对象:

  jQuery(html)

  jQuery(html,ownerDocument)

  jQuery(html,attributes)

这些函数使用起来非常方便,那么jQuery是如何实现这种功能的呢?

答:我们创建元素的时候,可以使用document.createElement来创建Element,但是更高效的一个方法是用innerHTML将html文档文本转变为其对应的元素对象。jQuery函数是个对外暴露的混合接口,其中通过Html文档文本生成元素对象工作就由jQuery.parseHTML来执行的,但是他还不是最底层的实现方法,真正的底层方法是buildFragment。

buildFragment主要作用是创建一个DocumentFragment对象,将不同种类的参数,统一地封装为DocumentFragment子元素的形式。之所以要封装为DocumentFragment是为了插入的时候效率更高效,因为buildFragment实际上是为元素节点的插入而准备的。我们看一看buildFragment的流程图:

注意这么几个地方:

1.该函数支持多参数,支持Node对象、jQuery对象、普通文本、Html文档文本等4种参数类型

2.对于Html文档文本,他会调用innerHTML来生成对应的Element对象

3.调用innerHTML通过Html文档文本生成DOM对象时,jQuery会对于td、tr、option等只能在特定父元素上面创建的元素,将指定的父元素套在Html文档文本外面,确保这些元素是可以创建的,并在innerHTML执行之后,移除多生成的父元素。

4.最后所有元素会被统一套在一个DocumentFragment中,并返回。

5.对于封装到DocumentFragment里面的script标签,jQuery会判断它是不是已经append到document上面了,如果是表示已经执行过了,jQuery会调用内部缓存接口dataPriv为其打个标记,表示已经执行过了,今后再插入到document的时候不会再执行这些script标签了。

以上就是buildFragment的全部逻辑,buildFragment生成的是DocumentFragment对象,而jQuery.parseHTML却不是,在jQuery.parseHTML中,jQuery又把生成的元素从DocumentFragment对象中取出来,笔者认为这样做实际上是对性能的浪费,但是通过调用buildFragment函数却实现了整个通过html字符串创建元素对象这个复杂逻辑的复用,性能的牺牲是可以接受的。


提问:jQuery是如何克隆元素的?

答:Element.prototype.cloneNode是克隆元素的DOMAPI,jQuery底层自然也用的它。但是事件、缓存、动画、子元素的内容克隆起来是无法保证肯定能被克隆的,所以jQuery将这些东西都通过dataPriv缓存起来,这样克隆的时候可以让新克隆好的对象也引用dataPriv缓存的事件等系统对象,就完成了对事件、缓存、动画等内容的克隆。需要注意的是这个过程是浅拷贝。

同时对于script元素的克隆,也和buildFragment一样,判断它是不是已经append到document上面了,同样会给已经执行过的script元素打个标记。这样这些被克隆的新的script标签,在插入到document的时候,会和原标签有相同的表现行为。

同时在克隆的时候,需要注意早期的webkit浏览器中是不会克隆表单元素的defaultValue、checked和当前用户输入的value的,也就是说用户输入的值和表单元素的默认值是不会被克隆的。jQuery会先实验性的调用cloneNode克隆一个input元素对象,通过新克隆的input元素是否存在defaultValue和checked,来判断当前浏览器是否会存在不能克隆defaultValue和checked的问题,如果有这样的问题就调用原对象的defaultValue和checked覆盖掉新克隆对象的defaultValue和checked。


提问:jQuery是如何实现元素的插入的?

答:jQuery在插入的时候,都会调用domManip做些准备工作。jQuery有多种插入api,但是无论哪个api都调用了这个domManip函数。我们看看domManip函数的具体流程:

需要注意的地方有:

1.通过调用buildFragment将所有待插入的参数统一封装为一个DocumentFragment对象,因为buildFragment函数不支持通过函数作为参数来创建元素对象,因此如果入参是个函数,要先调用一次,生成元素对象后,再递归调用domManip重新走一遍插入的逻辑。

2.jQuery修改了待插入元素中script元素的type属性,使其被插入到document时候不会执行里面的JavaScript代码。

3.回调真正的插入函数的时候,如果插入目标有多个的话,就将被插入对象克隆,确保每个插入目标对象都能获得待插入对象或者是其克隆对象。

4.最后阶段,统一处理script元素,将需要执行的script通过调用globalEval函数执行。


提问:domManip函数在部分浏览器中,对带有checked的Html字符串做了什么特殊处理?

domManip调用buildFragment的时候,除了对函数参数做了特殊处理外,还对带有checked的Html字符串做了特殊的处理,这是为什么?

// We can't cloneNode fragments that contain checked, in WebKit
if ( isFunction ||
    ( l > 1 && typeof value === "string" &&
    true && rchecked.test( value ) ) ) {
    return collection.each( function( index ) {
        var self = collection.eq( index );
        if ( isFunction ) {
            args[ 0 ] = value.call( this, index, self.html() );
        }
        domManip( self, args, callback, ignored );
    } );
}

答:这是笔者一直疑惑的地方,从jQuery的注释来看,是在一些浏览器上,无法正确地克隆元素的checked。但是我们之前分析过,jQuery的clone函数里面已经做了对这种情况的兼容,此处domManip再做处理是完全没有必要。同时这么做还会出现一个bug,就是如果插入目标有多个的话,无法满足确保每个插入目标对象都能插入到待插入对象这一功能。如:

var div1 = $("<div id='div1'>");
var div2 = $("<div id='div2'>");
var span = $("<span>");

div1.add(div2).append("<span checked>",span)

理论上,上述代码中的div1和div2里面都会有两个span,但是实际上在部分浏览器(不支持checked克隆的浏览器)中div1中只有一个span。这是因为当span插入div2的时候,jQuery没有克隆这个span,而是直接插入进div2中,这一span会自动从div1中移除。这一就造成了不同浏览器解析效果的不同,应该算jQuery的一个bug。


提问:jQuery在插入元素的时候,对script元素一共做了哪些处理?

答:从上面的流程中我们知道,jQuery在插入的时候,把很大的经历都花在对script元素的处理上。总体思路为,为已经执行过的script元素加标记,再修改其的type属性,使其插入后不会执行,然后等插入后再将type属性修改回来,最后判断未标记的script标签是否已经插入到document上面,如果是表示应该运行,jQuery会通过globalEval执行这些script。我们看看globalEval的流程:

从图中可以看出,globalEval的执行逻辑不够简化。运行脚本无外乎两种方式:1.动态创建script标签 2.使用eval。 globalEval将这两种方式都使用了,笔者认为jQuery只需封装一种即可。

上述流程中,有几点需要注意一下:

1.对于有src属性的script元素,jQuery是用过$.ajax模块执行的。这样会受到$.ajaxSettings里面配置的影响。

2.对于有src属性的script元素,会根据跨域和不跨域使用两种不同的运行方式,这个我们以后可以在jQuery.ajax的解析中再分析。

3.eval在严格模式下和非严格模式下使用方法不同:

eval执行的时候,可以使用不同的作用域。

你可以间接的使用 eval(),如果这么做视为javascript代码在当前作用域上执行。你也可以用变量来引用eval,然后调用它,如果你这么做了,那么这个时候目标字符串中的javascript代码将被直接视为在全局作用域下执行, 这是因为 ES 规范里明确规定了对 eval 的直接调用和间接调用会被区别对待。如:

function test() {
  var x = 2, y = 4;
  console.log(eval("x + y"));  // 结果是6
  var geval = eval;
  console.log(geval("x + y")); // 报x未定义的错

但是在严格模式下,没有这样调用的效果,所以jQuery使用了创建script标签的形式来实现 javascript 代码在全局作用域运行的效果。


提问:元素的移除是怎么实现的?

答:在删除一个元素的时候,jQuery要将jQuery对象对一个Element的所有扩展移除掉,对于jQuery1.x这个过程是必须的,否则会出现内存泄漏;而在jQuery2.x中,这个过程依旧保留。

因为jQuery的所有扩展信息都缓存在Element对象本身里面,所以想要将一个jQuery封装过的Element对象还原只要将这些信息删除即可。此外jQuery对用户事件使用了addEventListener(IE是attachEvent)注册到元素对象上,所以删除完缓存信息还要调用removeEventListener解除jQuery为元素对象增加的事件监听。这一过程笔者会专门在事件篇分析,这里不再深究。达到以上两点,就可以将去除jQuery对Element对象的所有扩展。

jQuery.cleanData就是实现这个功能的,而jQuery在所有涉及到DOM移除的操作的时候,都会调用这个函数。


提问:jQuery.fn.html是如何实现的?

答:我们分析了Element的创建、插入、移除,而jQuery.fn.html这个API同时包括了这三个功能。jQuery.fn.html本身是对Element.prototype.innerHTML的封装,对于元素的创建,jQuery也是使用这个方法,所以jQuery.fn.html并没有调用buildFragment,而是直接调用innerHTML。不过对于tr、td、option等只能在特定父元素下创建的元素,jQuery不会直接用innerHTML,而是会通过append来实现,这样就会调用buildFragment生成这种对象。如:

$("<div>").html("<tr>")
$("<div>")[0].innerHTML = "<tr></tr>"

下面的方法是无法创建出tr对象的,而上面的方法是可以的。因为jQuery会调用buildFragment来创建,会补充table和tbody两个父元素套在“tr”html文本的外边,等创建成功后再移除掉。

jQuery.fn.html这样做,使得其可以在不同情况下优先使用在保证功能实现的情况下效率最高的方法。

同时jQuery.fn.html还是个setter和getter的重载函数,我们在设计篇分析过,jQuery使用模板模式函数柯里化实现setter和getter重载的方法access,这个jQuery.fn.html就是access的一个应用。

jQuery.fn.html是一个非常典型的“jQuery的DOMapi”的实现,支持setter和getter、对浏览器兼容做了调整、对应innerHTML却未必直接调用他,还有很多api使用了这样的封装形式,这里不再一一分析。


提问:jQuery是如何做DOM的ready的?

答:我们说的DOM的ready是指的是DOMContentLoaded这个事件,但是这个事件在早期的ie浏览器中并未提供。所以人们开发的最佳实践是把script标签放到文档的最下端,这样一方面不必在意DOM的ready的实现,因为文档解析到最后,需要解析的Element对象都解析完了,并放入document里面了,在最下边运行的JavaScript代码虽然还是在DOM的ready之前运行,但是是不会出现因被操做的Element还没渲染而出现问题;另一方面,放到最后,可以去除script标签对css、图片的资源的加载影响,因为script是同步加载解析,而图片和css都是异步的。但是jQuery无法左右人们的script是放到head里面还是最后面,所以jQuery无法采取这个方案,还是要回到DOMContentLoaded事件上。

在jQuery1.x系列中,因为要兼容不支持DOMContentLoaded事件的浏览器(ie6、ie7、ie8),所以使用了一个hack来在IE下模拟DOMContentLoaded事件,就是调用setTimeout定时查看浏览器的Scroll是否渲染出来,如果Scroll完成,可以近似表示为DOM的ready也完成了。在jQuery2.x系列中,因为不需要兼容这种的浏览器,所以直接调用DOMContentLoaded就可以实现DOM的ready。

但是DOMContentLoaded和onLoad一样,浏览器只执行一次,jQuery用什么判断是否已经执行过呢?document.readyState就是判断这个的依据。readyState是document的属性,总共有3个值:
loading:    文档正在加载中
interactive: 文档已经加载完成,正在进行css和图片等资源的加载 
complete:     文档的所以资源加载完成
我们所说的DOM的ready就是指的document.readyState等于interactive的时候。document.readyState这个api在ie8就已经有了,但是他不兼容interactive值,而在ie9以后补充了interactive状态,使得我们可以使用。
遗憾的是根据document.readyState在IE9上还是存在bug,因此在ie上面继续使用jQuery1.x的那种和Scroll渲染配合完成判断DOM的ready的方案。

判断完之后如何回调呢?很简单,就是用Promise,jQuery通过new一个$.Deferred(promise)对象来实现对DOM的ready的回调,在DOMContentLoaded中将这个promise给resolve掉,这样就执行了之前注册的回调函数,同时后面新注册的回调也会立刻执行。但是在调用promise之前,jQuery执行了一次setTimeout,在回调篇我们分析出jQuery.Promise是不会产生异步,这和标准的promise规范是不一样的,所有jQuery自己又手动做了一次setTimeout来实现异步。这样使得无论使用在DOM的ready之前注册的回调还是之后注册的回调都会在异步中执行


提问:如何自定义jQuery的ready事件?

jQuery的ready是浏览器的DOMContentLoaded事件,是浏览器解析完整个文档的事件,即DOM已经被解析完,页面已经初始化完成。但是实际开发中会有DOM初始化已经完成,但是页面却未初始化的完成情况。比如从事cordova开发,有一个deviceready的概念,在这个事件执行前,页面视为未初始化完毕。或者一些操作是要在图片和css加载完才能运行的,而这些要使用onload事件。如何将deviceready或者onload代替DOMContentLoaded呢?

答:jQuery是提供了这样的api,就是jQuery.holdReady,他可以暂停与恢复jQuery。当deviceready前执行一次jQuery.holdReady(true),在onload或者deviceready完成后再执行一次jQuery.holdReady(),就可以让jQuery的ready移动到onload或者deviceready之后,完成页面的初始化工作。如:

$.holdReady(true);
document.addEventListener("load",()=>{
    $.holdReady();
})
$(()=>{
    //对图片的操作
    $("image");
})

暂停和恢复都是可以任意次数地叠加的,即如果执行x次$.holdReady(true),必须再执行x次$.holdReady()之后,jQuery的ready才会执行,不过$.holdReady(true)必须放到jQuery的ready前执行才有效。

他是如何实现的呢?非常简单,就是定义一个等待栈变量——readyWait,每次执行$.holdReady(true)都会增加压栈,而每次$.holdReady()执行都会弹栈,等空栈的时候就执行jQuery.ready函数,即将promise给resolve掉。


 

总结

以上就是jQuery的DOM操作的源码解析,从这一节开始代码的难度逐渐提升,从jQuery的issues来看,多数都是集中在这个部分,而且jQuery也为这些issues打了大量的补丁,“#xxxx”的注释也比之前的代码多了很多。从笔者对于这些代码分析的结果来看,很多地方还是有优化的空间,比如globalEval这种复杂的执行方式。jQuery这个库虽然比较简单,但是历史不短,代码贡献者有200多人,里面难免有因为历史原因而留下的可优化空间,总体而言jQuery非常优秀,有兴趣的话欢迎大家一起阅读。

posted @ 2017-01-13 17:50  laden666666  阅读(1090)  评论(0编辑  收藏  举报