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

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

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

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

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

 

这一篇笔者主要以设计的角度探索jQuery的源代码,很多人说jQuery设计过于个人主义话,其实这样说是有一定偏见的,因为好的设计是可通用的、共通的,jQuery这么好用,我们怎么能说他的设计是个人主义呢?记得以前有人吐槽mvvm设计剑走偏锋,导致代码难以维护,不过前几年从mvvm火爆程度来看,另类绝不是不好。好了,开始正题。


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

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

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

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

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

答: jQuery是创建在window上面的,而且在window上仅创建两个变量,一个是“$”,一个是“jQuery”,并且二者指向同一个对象——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.fn)的形式,提供列jQuery对象上的种种成员方法,供用户使用。

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

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


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

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

jQuery正真的构造方法是通过作为IIFE块的参数的形式,传进去IIFE块里面的,在IIFE里面视情况调用这个构造方法。

首先jQuery支持commonjs,可以直接require(‘jquery.js’)将jQuery引入。需要注意的是,在commonjs环境下,如果全局作用域支持document对象,就创建在全局作用域上,如果不支持就返回一个新的工厂函数,使用者在需要的时候通过这个新的构造函数,去创建jQuery,同时还需将document传递进入。jQuery本就是给浏览器中使用的,所以即使支持commonjs,但是运行时候还是离不开浏览器环境。

//使用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;
}); 

这个创建过程和webpack的umd模块的创建过程很像,umd是同时支持amd、commonjs、web的script调用的一种模块化方式,jQuery不支持amd模块,但是同时支持commonjs和web,构造形式也有umd大体一样,可以算一个简化的umd模块。


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

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

答:我们在npm运行

npm install jquery

确实安装了jQuery,但是使用的时候需要用一个存在document的对象对其初始化。此时我们需要jsDom,这个可以在node跑DOM的库。

安装jsDOm

npm install jsdom

然后在node执行

var $ = require("jquery");
var jsdom = require("jsdom");

jsdom.env(
  "<div id='div'>hello world</div>",
  function (err, window) {
    $ = $(window);

        console.log($("#div").html())
  }
);

打印出“hello world”,我们得到了想要的结果。

不过,jQuery完全依赖于浏览器模型,需要jsDom这样的库做支持,为了运行jQuery去模拟这样一个模型有些小题大做的感觉。笔者之前使用过另一个在node端仿jQuery项目——cheerio,cheerio的api很jQuery很像,熟悉jQuery的朋友可以很快上手,我们可以使用这个来处理node中的dom操作,这对于抓包抽取数据等工作非常适合。总之jQuery是为浏览器设计的,在非浏览器环境下尽量不要考虑使用,因为肯定有更好的替代品。

除了这些,npm上面还有一个jQuery的库,名字就叫jQuery(浓浓的山寨味道),笔者曾经以为这是正统的jQuery而误装过这个库。

npm install jQuery

这个库与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对象生成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.prototype就是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”(简称ArrayLike对象)。因为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通过内部的jQuery.uniqueSort确保其集合中不会出现重复的元素,所以jQuery对象不但是一个ArrayLikeObject集合,同时集合里面的元素是不重复的

此外,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是实现setter和getter的重载函数的?

jQuery有个特点,就是很多函数重载的setter和getter方法,同时他们还支持JSON形式的key、value赋值、链式调用等功能,这样的函数有attr、prop、text、html、css、data等,他们是如何封装的?

答:秘密就在access.js,以上函数都调用了这个私有函数进行封装的。

首先需要他们提供一个重载函数:

fn(elem, key)和fn(elem, key,value)

前一个是elem的getter函数,后一个是elem的setter函数。接下来通过access来对fn进行封装,使其能够支持集合操作、JSON形式的key、value赋值、链式操作等功能。

access的入参有elems, fn, key, value, chainable, emptyGet, raw,猜测的含义分别为:

  • elems : 调用fn对自身操作的集合
  • fn : 需要封装的函数
  • key : 键值,如果value是undefined,表示当前是getter调用;或者是一个map,里面是key、value形式传递多个赋值项
  • value : 值,也可以是个函数(function(index, attr))
  • chaunable : true->setter调用;false->getter调用
  • emptyGet : elems为空的返回值
  • raw: true->key是字符串;false->key是函数

我们先确定什么时候函数封装的调用getter,什么时候调用setter。当key是对象,或者value不为undefined的时候,是对setter的调用;否则就是getter调用。

先看getter:

如果key是空(包括undefined、null,不包括0、空字符),会执行

fn.call( elems )

这也是一个重载方法,可以用于对如sum、avg等函数的封装,通过整个elems计算一个值返回。

如果key不是null,则取elems第一个参数的key对应的值;如果elems为空数组,则返回emptyGet。

再看setter:

和getter一样,setter同样是分为key是空和不是空两种情况。

在key是空的情况下,会对整个集合做操作。

如果key是一个JSON,会遍历这个JSON的key,依次递归调用access进行循环赋值。

否则key既不是空,也不是JSON,会用key做key值,依次对elems里面的元素赋值。同时value可以是数组,此时会通过当前elem、elem在elems的位置index、elem的key对应的当前值作为参数,调用value函数,计算最终的value赋值给elem。

access本身是个模板模式,通过access,将fn进行了扩展,这体现了函数式的函数柯里化思想,轻松地创建了众多重载函数,并简化了封装过程。采用柯里化化思想实现模板模式,也体现了JavaScript这门语言的灵活之处。


提问: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版本兼容实验

posted @ 2016-11-06 07:23  laden666666  阅读(1370)  评论(1编辑  收藏  举报