jQuery 源码阅读 —— ready

create: 2013-05-26 13:43


其实在我写这篇的时候,Deferred 还有一些细节没有理解,但对于这篇不会有太大的影响。

接下来要说的 ready 是 core.js 中定义的内容。

我想使用过 jQuery 的人一定对下面的内容不陌生:

$(function() {
    //...
})

而这段内容,在我们分析 core.js 的第一篇中就已经讲解过,当为 $ 传入一个函数时,表示在 DOM 加载完毕后执行该函数。

什么是 DOM 加载完毕?你可以理解为页面的结构全部加载,所有的节点都可以访问,这样你的 JS 在执行的时候,不会因为某个要操作的节点不存在而报错。

在使用 jQuery 之前,你是否尝试过这样写:

window.onload = function() {
    //...
}

那么,这个 window.onload 和 jQuery 中的用法有什么区别吗?答案自然是肯定的。

window 的 load 事件触发条件是:页面所有元素加载完毕,包括图片和框架。

这就是说,如果页面有很多图片的情况下,你的代码需要等待所有图片下载完毕后才能运行,如果图片下载很慢,这会使页面在很长一段时间内不能进行交互操作,毕竟此时 JS 代码还不能运行。

而 jQuery 则使用了另外的方式,来保证代码先于 load 事件执行。

在前面的文章对 $ 参数进行分析时,我们看到了如下代码:

// 处理: $(function)
// document ready 的快捷方式
else if ( jQuery.isFunction( selector ) ) {
    return rootjQuery.ready( selector );
}

这里的 rootjQuery 就是 $(document)。所以,你向 jQuery 中传递的参数,最终都是在调用 rootjQuery 的 ready 方法,那让我们来看下这个 ready 方法,它是定义在原型上的:

$.fn = $.prototype = {
    ...
    ready : function(fn) {
        // 增加回调函数
        jQuery.ready.promise().done( fn );
        return this;
    },
    ...
};

很明显,在 ready 方法内部,它又调用了 jQuery.ready.promise(),看到 promise 就已经可以知道了,它返回了一个 promise 对象,将 fn 传入 done 方法,表明当 promise 对象的状态变为 resolved 后会执行它。

jQuery.ready.promise 函数定义在 core.js 文件的下方:

jQuery.ready.promise = function( obj ) {
    if ( !readyList ) {
        readyList = jQuery.Deferred();
        ...
    }
    return readyList.promise( obj );
};

在 core.js 文件的开始处定义了一个变量 readyList,通过以上代码可知,readyList 是真正的 Deferred 对象。

在 if 分支内,是检测 DOM 是否加载的主体方法:

if ( document.readyState === "complete" ) {
    setTimeout( jQuery.ready );
}

readyState 属性表明了当前 document 的状态,具体内容可以查看这里。如果它的值是 complete,则表明已经加载结束,此时可以触发你添加的函数了。这里使用了 setTimeout,并且没有传第二个时间参数,这实际上是一个常见的技巧,就是让 setTimeout 中的内容 “尽快”执行,具体解释查看这里

现在代码又跳到 jQuery 的静态方法 ready 里了,没办法,跟着走吧:

// DOM 是否准备好了?如果是的话设置该值为 true
isReady: false,

// 一个计数器,用于记录在 ready 事件触发前还有多少个函数在等待。查看 #6781
readyWait: 1,

// 保持或释放 ready 事件
holdReady: function( hold ) {
    if ( hold ) {
        jQuery.readyWait++;
    } else {
        jQuery.ready( true );
    }
},

// 处理 DOM ready
ready: function( wait ) {

    // 如果还有未解决的函数或 DOM 已经准备完毕,终止执行
    if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) {
        return;
    }

    // 确保 body 存在,至少 IE 有些过于热心了 (ticket #5443).
    if ( !document.body ) {
        return setTimeout( jQuery.ready );
    }

    // 记住 DOM 已经准备就绪
    jQuery.isReady = true;

    if ( wait !== true && --jQuery.readyWait > 0 ) {
        return;
    }

    // 如果绑定了函数,执行它们
    readyList.resolveWith( document, [ jQuery ] );

    // 触发 ready 事件
    if ( jQuery.fn.trigger ) {
        jQuery( document ).trigger("ready").off("ready");
    }
}

isReady 属性表明 DOM 是否准备就绪,至于 holdReady 方法,没有查到哪里用到了。

代码不复杂,看注释应该能够明白。如果确保 DOM 已经准备就绪,则 readyList 会调用 resolveWith 将状态修改为 resolved,所有通过 done 方法加入的函数会被依次执行。随后还会触发 ready 事件,因为页面只会载入一次,ready 事件也只会触发一次,所以在 trigger 后就使用 off 方法将 ready 事件删除。

看完了 ready 方法,再回到上面的 promise 方法中,我们已经看完了第一个判断,接下来看 else:

// 基于标准的浏览器支持 DOMContentLoaded
} else if ( document.addEventListener ) {
    document.addEventListener( "DOMContentLoaded", completed, false );

    window.addEventListener( "load", completed, false );
}

DOMContentLoaded 是另一个事件,当 DOM 结构加载完毕后,该事件会触发,基本上所有现代浏览器都已经支持这个事件。至于又使用 window 来监听 load 事件,则完全是提供一个被选方案,这会使得函数执行不会晚于 window 的 load。这两个事件的处理函数都是 completed,那么来看看这个函数:

completed = function( event ) {
    // readyState === "complete" 对于检测旧版本 IE 下 DOM 是否准备就绪已经足够好了
    if ( document.addEventListener || event.type === "load" || document.readyState === "complete" ) {
        detach();
        jQuery.ready();
    }
},

// 清理 DOM ready 事件
detach = function() {
    if ( document.addEventListener ) {
        document.removeEventListener( "DOMContentLoaded", completed, false );
        window.removeEventListener( "load", completed, false );

    } else {
        document.detachEvent( "onreadystatechange", completed );
        window.detachEvent( "onload", completed );
    }
};

这段就更简单了……简单到我说了都会感觉侮辱了你的智商……让我们赶紧再跳回到 promise 方法中的最后一个分支吧:

    // 如果 IE 的事件模式可用
    } else {
        //确保在 onload 前触发,可能会有些延迟,但对于 iframe 很安全
        document.attachEvent( "onreadystatechange", completed );

        // 退化回 window.onload,它始终会执行
        window.attachEvent( "onload", completed );

        // 如果是 IE 并且不在 frame 中
        // 持续检测来查看 document 是否准备就绪
        var top = false;

        try {
            top = window.frameElement == null && document.documentElement;
        } catch(e) {}

        if ( top && top.doScroll ) {
            (function doScrollCheck() {
                if ( !jQuery.isReady ) {

                    try {
                        // 使用来自 Diego Perini 的技巧
                        // http://javascript.nwbox.com/IEContentLoaded/
                        top.doScroll("left");
                    } catch(e) {
                        return setTimeout( doScrollCheck, 50 );
                    }

                    // 解绑定所有 DOM ready 事件
                    detach();

                    // 执行所有等待的函数
                    jQuery.ready();
                }
            })();
        }
    }

这里就是 ready 的精华所在,也是难点所在。曾经很多人为检测 IE 下的 DOM ready 做了不懈的尝试,Diego Perini 在前人的基础上,总结了这个 hack。

该方法适用于非 frame 页面,变量 top 为 document.documentElement,而 doScroll 方法则是模拟鼠标点击页面滚动条的行为,若该方法调用成功,则表明 document 载入完毕,否则会出现异常,这个 hack 就是利用了 doScroll 的这一点特性来检测 DOM ready。当异常发生时,延迟 50 毫秒,继续调用 doScrollCheck,直至没有异常出现。

最后让我们梳理下思路:

  • 为了判断 DOM 是否可用,首先使用标准浏览器提供的 DOMContentLoaded 事件
  • 如果不支持这个事件,则手动检测,方法是使用 Diego Perini 提供的技巧:top.doScroll("left")
  • 当 DOM 准备就绪,执行 completed 方法,它会将和 DOM ready 有关的事件解除,然后调用 jQuery.ready()
  • jQuery.ready() 方法会执行所有绑定的函数,随后触发 ready 事件