这一篇笔者主要以设计的角度探索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( "hello world", 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等,他们是如何封装的?
答:秘密就在,以上函数都调用了这个私有函数进行封装的。
首先需要他们提供一个重载函数:
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。
关于版本更多信息可以参考笔者以前的博客。