首页 > 代码库 > 问答形式阅读jQuery源码(二)

问答形式阅读jQuery源码(二)

这一篇笔者主要以设计的角度探索jQuery的源代码,很多人说jQuery设计过于个人主义话,其实这样说是有一定偏见的,因为好的设计是可通用的、共通的,jQuery这么好用,我们怎么能说他的设计是个人主义呢?好了开始正题。


提问:jQuery是怎么暴露自己的api的?

任何框架其实都是个门面模式,外部与框架的通信必须通过一个统一的门面,而这个门面就是我们说所的api。因此学习任何框架的源码,我们都要弄清两件事:

1.哪些是私有方法,因为私有方法是框架自己内部使用,是他不希望暴露给外围用户的,这些方法是不能作为api,即便用户可以看到他们。

2.哪些方法是api,他们是真正暴露给用户使用的。这些方法的定义往往面向接口,相对稳定,不会因为框架内部修改而改变。只有这样,框架的使用者才不会因为升级框架而修改他们自身的代码,符合“开闭原则”。

那么jQuery是怎么实现门面模式,暴露自己的api呢?

答: jQuery是创建在window上面的,而且在window上仅创建两个对象,一个是“$”,一个是“jQuery”,并且二者是一样的对象。

window.jQuery = window.$ = jQuery;

jQuery为什么要暴露两个一样的对象呢?主要是jQuery是六个字符,打起来比较麻烦,所以就用一个字符的别名“$”来替代,这样使用者可以少打五个字符-_-||。很多框架也是暴露两个对象,比如underscore、lodash。

jQuery(以下简称$)本身是一个函数,通过调用这个函数我们可以返回一个对象,我们称为jQuery对象,jQuery对象的原型是jQuery.fn.init,在这原型上jQuery提供了很多方法供使用者使用。$虽然是个函数,但是函数也是可以有其成员变量的,所以$自身的成员变量我们也是可以利用的。

因此jQuery提供了三种api:

一个是jQuery本身,他就是一个函数,同时也是一个api,可以创建jQuery对象。

另一个jQuery对象上的api,jQuery通过扩展原型的形式,提供列jQuery对象上的种种成员方法,供用户使用。

最后是$函数上面的成员方法,这些方法同样可以作为全局方法、util方法来使用。

并且jQuery并未注明私有(因为js自身语法的限制,所以很多私有成员在外部还是能看到,对于这种私有成员,我们会创建一个命名规则加以区分,如“$”、“_”、“$$”开头等),所有暴露的方法全部是api。


提问:jQuery是如何创建在window上面的?

答:jQuery的主要构建模式为先用一个IIFE将自身扩展起来,这样的好处是不污染全局作用域。同时使用了严格模式"use strict",严格模式的声明必须放到IIFE里面,同样是为了不污染全局,毕竟你不可能让你的用户必须使用严格模式。

jQuery正在的构造方法是通过传递参数的形式,作为IIFE块的参数传进去的。

同时jQuery识别commonjs,jQuery会去识别exports,你可以直接require(‘jquery.js’)将jQuery引入。需要注意的是,如果在commonjs环境下,如果全局作用域支持document对象,就创建在全局作用域上,如果不支持就返回一个新的工厂函数,使用者在需要的时候通过这个新的构造函数,去创建jQuery,同时还需将document传递进入。

//使用IIFE,将jQuery创建的整个过程封装到一个闭包里,然后将全局变量(如果是浏览器环境就是window,如果是commonjs环境就是当前作用域)和工厂函数传入进去
(function( global, factory ) {
    //严格模式在闭包中,同样不会对全局作用域产生污染
    "use strict";

    //这里面是判断是否是commonjs环境,如果是就用commonjs把jQuery的构造结果输出去。如果不是就用全局变量构建jQuery
    if ( typeof module === "object" && typeof module.exports === "object" ) {

        module.exports = global.document ?
            factory( global, true ) :
            function( w ) {
                if ( !w.document ) {
                    throw new Error( "jQuery requires a window with a document" );
                }
                return factory( w );
            };
    } else {
        factory( global );
    }

//根据有没有window判断是否是浏览器环境
})( typeof window !== "undefined" ? window : this, function( window, noGlobal ) {

    //正在的构建过程
    var jQuery = function( selector, context ) {
        return new jQuery.fn.init( selector, context );
    }
    
    //如果用commonjs输出就不在window上面构建jQuery了,而是直接以返回值输出
    if ( !noGlobal ) {
        window.jQuery = window.$ = jQuery;
    }

    return jQuery;
}); 

提问:jQuery支持在nodejs上运行吗?

既然jQuery支持commonjs,那么他可以在node里面运行吗?

答:我们在npm运行

npm install jQuery

确实是可以安装的,但是我们看看他的代码就会发现,这和我们现在阅读的并不是一个东西。我们阅读的版本的jQuery的package.json里面有,入口文件是dist/jquery.js,而我们在node上面安装入口函数却不是这个。我们再去npm的jQuery包首页,发现他的git地址并不是我们现在阅读的地址,而且版本也和我们的不一致,这说明npm上面的jQuery并非是我们现在阅读的这个项目。

既然npm中的jQuery和我们现在阅读的jQuery不是一个项目,所以笔者并未对其再做深入的探索。jQuery完全依赖于浏览器模型,如果node没有这个模型是不能运行的,为了运行jQuery去模拟这样一个模型有些小题大做的感觉。笔者之前使用过另一个在node端仿jQuery项目——cheerio,cheerio的api很jQuery很像,熟悉jQuery的朋友可以很快上手,我们可以使用这个来处理node中的dom操作,这对于抓包抽取数据等工作非常适合。

总之jQuery是为浏览器设计的,在非浏览器环境下尽量不要考虑使用,因为肯定有更好的替代品。


提问:$函数具体都是实现了什么? 

答:艾伦将$函数视为反模式设计,这是因为$是jQuery的唯一入口,并且强行将几种不同的功能重载为一个功能。这样的好处是很明显的,简化了对外的api,使得整个jQuery的api更加的简洁,学习起来更加简单快捷。jQuery整个框架都是以快速简洁为目的,这个设计很符合他自身的设计需求。

但是这样的设计是反模式的,主要是和“职责单一原则”冲突,强行将几种完全不同的功能重载在一起,很不利于使用者对其的理解。重载函数是指相同功能的是参数不同的几个函数的同名策略。因为这些函数功能相同,同名更有利于大家学习与维护。不同功能的函数重载在一起是不可取的,这是不符合设计模式的。

不过适当的反模式,换来的是api的简洁与使用,这是有利于用户学习与使用的。

具体如下:

首先$函数就是new了一个$.fn.init对象:

var jQuery = function( selector, context ) {
    return new jQuery.fn.init( selector, context );
}

这个jQuery.fn.init方法的具体做了什么?笔者总结,共4中功能:

1.通过jQuery选择器选择dom,并将其封装为jQuery对象返回

2.将html碎片生成dom碎片,并将其封装为jQuery对象返回

3.对于domcontentloaded事件的封装与实现

4.将任意对象封装为jQuery对象


提问:jQuery是如何对自身扩展的?

答:jQuery中最核心的函数是$.extend,他实现类似ES6的Object.assign函数,他的最终目的是实现Mixin设计模式。

Mixin模式,也叫织入模式。就是一些提供能够被一个或者一组子类简单继承功能的类,意在重用其功能。与传统继承的思想不同,Mixin是通过扩展对象的方法实现的,这样的好处就是,可以先创建对象,然后再对其扩展。这个设计模式是JavaScript中最重要设计模式之一,他充分利用了JavaScript的能够对对象动态扩展的功能,能够实现原型模式等、继承等功能。

$.extend函数的核心目的就是对Mixin模式的实现,当然$.extend的功能不只如此,还可以做克隆对象、深拷贝、替代Object.assign等功能。不过为自身扩展才是这个函数最核心的功能,我们想来看看jQuery对象的创建过程。

jQuery本身就是一个函数,在其创建之后,又为自己创建了一个基础的原型fn。

jQuery.fn = jQuery.prototype = {
    // 非常少的几个方法
    ...
}

然后又在自身和自身原型上定义了extend函数。

jQuery.extend = jQuery.fn.extend = function() {
     ...      
}

接着使用extend扩展自身的及其原型上的功能。

jQuery.extend( {
    ...
})
jQuery.fn.extend( {
    ...
})

整个jQuery的创建过程就是使用Mixin模式对自身不断地扩展功能。同时因为Mixin模式的扩展是创建对象后才进行的,所以我们不必担心扩展功能时候去修改先前的代码,更加体现“开闭原则”。

同时,使用extend扩展jQuery的功能是官方推荐的,jQuery自身代码就是使用这种方式,因此我们扩展jQuery的时候,尽量不应使用“$.fn.xxx = ”这种语句,而是应该使用jQuery为我们暴露的api——“$.fn.extends(...)”,这样才是最标准的用法,尽量不要使用“$.fn.xxx = ...”的形式。只有这样,我们的代码才不会担心未来因为jQuery版本升级,而带来的兼容性问题。


提问:jQuery将自身原型重新命名为“fn”的用意是什么?

jQuery.fn = jQuery.prototype = {
    ...
}

从上面代码可以看出,jQuery的fn就是JavaScript语法原型prototype,为什么要这样设计呢?

答:浅显而说,还是为了简练,利于压缩,因为fn比prototype少了7个字符-_-,但是笔者认为这里还有更深层的含义。

还是回到门面模式上,prototype是JavaScript语法层面上的,是属于jQuery的私有的,同时jQuery还希望把自身原型暴露出去,因此需要对其进行封装,哪怕这个封装仅仅是改一个名字。我们可以想象一下,如果未来jQuery对其自身的api结构进行修改,不再直接使用prototype这个js提供的原型,那么他对外提供的api是可以做到不修改的,因为他暴露的是fn而不是prototype。当然这种修改的可能性是微乎其微的,但是jQuery的作者还是将其考虑进去了,这体现了其作者扎实的基本功,对设计模式和设计原则有着深刻的理解,这是我们应该学习的。

这就是为什么JavaScript存在prototype这个语法,但是jQuery偏不直接使用,而是将其重命名为fn的原因。因此我们在写jQuery的原型扩展的时候,要尽量使用“$.fn.extends({...})”的语句,而不要使用“$.prototype.extends({...})”对其扩展。


提问:jQuery是如何new出jQuery对象的?

看来艾伦的博客的评论,很多人在这里都没搞明白。尤其对它的原型和this的处理没搞明白。

答:我们分析过jQuery的$函数的几个功能,其中大多数功能都是封装jQuery对象。其实$函数本身就是一个工厂函数,jQuery对象就是通过这个工厂函数封装的方法创建出来的。这个过程很精妙,我们之前也说过,真正的jQuery对象的原型是jQuery.fn.inti。

init = jQuery.fn.init = function( selector, context, root ) {...}
init.prototype = jQuery.fn;

从上面的代码我可以看出,init的原型等于jQuery的原型。

为什么要这么做呢?jQuery使用$()代替new $(),这样一下子少了4个字符-_-,同时有也符合工厂模式,毕竟直接使用语法级的new是不符合工厂模式的。同时将jQuery的原型,赋给jQuery.fn.init的原型。这样设计的目的并不仅仅是为了省几个字符,更重要的是jQuery.fn.init的原型也是jQuery的api的一部分,事实上jQuery的原型本身并不是我们的api,因为jQuery对象的原型是jQuery.fn.init对象,而并非是jQuery。但是以jQuery的原型作为api,更利于用户理解与使用。

因此才会有:

jQuery.fn.init.prototype = jQuery.fn;

这句代码的含义是使用jQuery.fn代替jQuery.fn.init.prototype,作为jQuery对外暴露的jQuery对象的原型。因此我们对jQuery.fn的扩展,自然也会扩展到jQuery.fn.init上面,因为jQuery.fn.init的原型是jQuery.fn,而jQuery对象的原型是jQuery.fn.init对象,因此自然也会扩展到jQuery对象上面。

那么jQuery为什么要创建一个jQuery.fn.init来作为jQuery对象的原型,而不直接在jQuery函数里面new自身呢?

这一点艾伦的博客已经给出了解释,直接在构造方法里面new方法创建自身,会陷入死循环。而jQuery设计的漂亮之处,就在于定义了jQuery.fn.init作为jQuery对象的原型,同时这个这个对于用户而言又是透明的,用户无需知道他的存在,也无需知道jQuery.fn.init.prototype的存在。这样暴露出去的api是最简洁的api,利于大家使用。

大家可以参考艾伦的博客,这个过程讲解的非常详细,这里不再叙述。艾伦的博客更多是从语法层面解释的,而笔者更多的是从设计角度考虑的,jQuery之所以这么做,其目的是为了追求对外暴露最简洁的api。因此jQuery内部才会设计的如此复杂与精妙。


提问:jQuery的对象是如何实现集合处理的?

答:曾经笔者一直以为,jQuery对象本质是一个通过原型继承数组对象的方式获得的。但是我们回到上一节的代码,我们将之前的几段代码整理一下,可以得到

jQuery.fn.init.prototype = JQuery.fn = jQuery.prototype = {...};

可以看出jQuery对象就是一个普通对象,不应该说是“Array-like Object”。因为jQuery本身是具备length,其实就是仿造数组,定义了一个带索引和length的普通对象。这种对象我们可以说是“Array-like Object”对象。

jQuery.fn = jQuery.prototype = {
    ...
    length: 0,
}

因为jQuery的原型上定义了length=0,相当于一个空的“Array-like Object”。

我们可以看看jQuery.fn.init构造方法

init = jQuery.fn.init = function( selector, context, root ) {
    if ( !selector ) {
        return this;
    }
    ...
    if ( typeof selector === "string" ) {
        if(...){
            jQuery.merge( this, jQuery.parseHTML(
                match[ 1 ],
                context && context.nodeType ? context.ownerDocument || context : document,
                true
            ) );
    
            return this;
        } else if(...){
            elem = document.getElementById( match[ 2 ] );

            if ( elem ) {
                this[ 0 ] = elem;
                this.length = 1;
            }
            return this;
        }
        ...
    } else if (...) {
        this[ 0 ] = selector;
        this.length = 1;
        return this;
    } else if (...) {
        return ...
    } else...

    return jQuery.makeArray( selector, this );
};

方法在return前,调用了jQuery.makeArray函数、jQuery.merge函数,或者是通过“[]”和“length”来为this扩展,这些都是对ArrayLike对象的处理函数,因为this是拥有jQuery.fn原型的对象,因此这里的this是一个ArrayLike对象,而经过jQuery.makeArray、jQuery.merge等处理过的this仍是一个ArrayLike对象,所以最终返回的就是一个ArrayLike对象。

此外,jQuery还提供了一是判断对象是否是ArrayLikeObject的函数。如果对象是ArrayLike对象,jQuery还提供了诸多处理集合运算的相关函数,如get、filter、each、merge等函数。这些函数本都是数组函数,但是ArrayLike对象实际上都是适用的,事实上很多数组方法,都可以给ArrayLike对象使用,有兴趣的可以查一查“Array-like Object”的相关文章。


提问:jQuery是如何实现链式操作?

答:很简答,就是“return this”。同时对于集合操作,可以使用jQuery.each。

jQuery.each设计的非常巧妙,因为他本身也会返回自身:

jQuery.extends({
    each:function(obj, callback){
        ...
        return obj;        
    }
});
jQuery.fn.extends({
    each: function( callback ) {
        return jQuery.each( this, callback );
    },
});

通过each,我们可以很容易的将很多集合运算包装为支持链式操作的形式。

toggle: function( state ) {
    if ( typeof state === "boolean" ) {
        return state ? this.show() : this.hide();
    }

    return this.each( function() {
        if ( isHidden( this ) ) {
            jQuery( this ).show();
        } else {
            jQuery( this ).hide();
        }
    } );
}

使用这种形式,一个集合操作函数可以被非常容易的包装支持成链式操作。

我们写jQuery插件,很多时候都需要支持jQuery的链接操作功能,使用each来封装我们自己的插件是很好的选择。

同时,jQuery的集合操作函数,也是支持链式操作的,jQuery的集合操作,都会把之前的集合缓存起来,我们可以通过prevObject和end方法获得集合运算前的集合,这样的操作大大增加列链式操作的适用场景。

其他支持链式操作的api有$.Deferred、jQuery的动画操作等,这里暂不展开。


提问:jQuery是如何做版本控制的?

答:我们知道jQuery是要向window占用两个变量名,“$”和“jQuery”,$是别名,而jQuery是真正的名字,所以jQuery在创建的时候,把window上原有的“$”和“jQuery”变量保存起来,然后在创建自身。

并且提供了将保存“$”和“jQuery”变量原有的功能noConflict:

var _jQuery = window.jQuery, _$ = window.$;

jQuery.noConflict = function( deep ) {
    if ( window.$ === jQuery ) {
        window.$ = _$;
    }
    if ( deep && window.jQuery === jQuery ) {
        window.jQuery = _jQuery;
    }
    return jQuery;
};

很多库也是这么做版本控制的,如underscore。

关于版本更多信息可以参考笔者以前的博客jQuery版本兼容实验。

问答形式阅读jQuery源码(二)