jquery源码解析

前言

其实这剖析jQuery源码系列文章是我自己酝酿很久的想法,当然因为之前的不成熟以及理解不深刻一直不敢提笔写。近期阅读的书籍多,激发我写文章的欲望,于是把这个计划开始实施。

为什么要选择jQuery源码,我想对于一个前端开发人员来说,不言自明。jQuery的出现对于众多前端开发人员来说,是一个无比强大的武器。那为什么jQuery如此流行,深得大家喜欢?我认为秘籍就在于jQuery的API设计。

在jQuery的设计中,最主要的两块就是(个人看法):

  1. 提供工具方法;
  2. DOM元素的操作,包括选取,属性取值等等。 对于一个库来说,第一点是必然要有所体现的,没有基础的工具方法,如何成为库。

对于Javascript来说,最重要的一块就是DOM操作,jQuery在DOM操作这块的贡献是巨大的,以至于现在很多新手完全不会用原生的JS API(例如document.getElementById等)了,jQuery把复杂的DOM选取映射到了简单的CSS选择器,对复杂的DOM操作(不同浏览器的DOM操作接口不一致)封装非常简单的委托API,以达到其核心的目的:The Write Less, Do More

我选择的jQuery版本是1.9.0,一开始肯定是先把jQuery最简单的部分做剖析。

1.外层沙箱以及命名空间$

几乎稍微有点经验前端人员都这么做,为了避免声明了一些全局变量而污染,把代码放在一个“沙箱执行”,然后在暴露出命名空间(可以为API,函数,对象):


(function( window, undefined ) {
     //用一个函数域包起来,就是所谓的沙箱
     //在这里边var定义的变量,属于这个函数域内的局部变量,避免污染全局
     //把当前沙箱需要的外部变量通过函数参数引入进来
     //只要保证参数对内提供的接口的一致性,你还可以随意替换传进来的这个参数
    "use strict";
    window.jQuery = window.$ = jQuery;
})( window );

有人会疑问,为什么要第二个参数undefined 。在这里,jquery中有一个针对压缩的小小策略。

先看以下代码:


(function( window, undefined ) {
  var a = undefined;
  if (a == undefined){blabla...}

  ....
  if (c == undefined) return;
})( window );

经过压缩后,可以变成:


(function(w, u) {
  var a = u;
  if (a == u){blabla...}

  ....
  if (c == u) return;
})(w);

因为这个外层函数只传了一个参数,因此沙箱执行时,u自然会undefined,把9个字母缩成1个字母,可以看出压缩后的代码减少一些字节数。 评论中nodejser对undefined的补充中也给出了另一种解答:

在ECMAScript5之前undefined都是可写的,也就是undefined可以赋值的。jQuery作者这么做的目的还有防止2B程序员对undefined进行赋值后使得代码出现了不可预料的bug。

常用的还有另一种写法:

1
2
3
( function (window) {
    // JS代码
})(window, undefined);

比较推崇的是 jQuery 的写法。二者有何不同呢,当我们的代码运行在更早期的环境当中(pre-ES5,eg. Internet Explorer 8),undefined 仅是一个变量且它的值是可以被覆盖的。意味着你可以做这样的操作:

1
2
undefined = 42
console.log(undefined)  // 42

当使用第一种方式,可以确保你需要的 undefined 确实就是 undefined。

另外不得不提出的是,jQuery 在这里有一个针对压缩优化细节,使用第一种方式,在代码压缩的时候,window 和 undefined 都可以压缩为 1 个字母并且确保它们就是 window 和 undefined。

1
2
3
4
5
// 压缩策略
// w -> windwow , u -> undefined
( function (w, u) {
 
})(window);

沙箱中第一句"use strict";是表示使用javascript的严格模式,对于低级的浏览器,这里相当一字符串,所以兼容性是没问题的,详细的话,在阮一峰的文章Javascript 严格模式详解有介绍。

最后jQuery暴露一个全局的命名空间jQuery(为了书写更简单,一个简写就是$,幸好Javascript用$来做变量命名是合法的)

实际上jQuery是一个函数,为什么要这样设计呢,是因为:

  1. 函数也是对象,于是在jQuery这个命名空间上可以绑定工具方法
  2. 函数可以有原型prototype,每当通过dom = $("#id")取得的所谓jQuery对象,本质就是dom = new jQuery('#id'); 如果懂得原型的话,就知道如果在jQuery的原型上绑定方法,像上边那样生成的实例dom可以调用这些方法。

简单来说,就是把jQuery看成是一个类,在原型上绑定方法就相当于成员方法,在jQuery上绑定工具方法,相当于类的静态方法,举例如下:


jQuery.A = function(){};
jQuery.prototype.B = function(){};

相当于:


Class jQuery{
    public static A(){}
    public B(){}
}

面向对象的思想在jQuery是有所体现的,也给我很多思考,面向对象的思想指导了如何设计一个更合理的API,乃至于库。所谓的封装,继承,通通的都是为了前边那个目的,如何设计出更好的API,我认为这才是面向对象的精髓。

jQuery对象的构建

先看jQuery源码中如何对jQuery赋值的:

jQuery = function( selector, context ) {
        // The jQuery object is actually just the init constructor 'enhanced'
        return new jQuery.fn.init( selector, context, rootjQuery );
    }
    

我就是被new jQuery.fn.init()这里弄晕了,先在这里暂停,回想一下平常我是如何使用jQuery的($即对应‘jQuery'):

$('body').css('background','red');
$.parseJSON('{}');

要实现这两种调用,$('body')应该是一个实例对象,css是每个实例共享的方法,是原型上的方法。而$则是一个类,parseJSON则是类的静态方法。
接下来,我们试着往这个结果上靠。

如何不用new关键字得到jQuery对象

回想一下平常我都是怎么构建实例对象的,通常我会这样写一个Prince类:

function Prince(name){
  this.name=name;
  this.body="human";
}

Prince.prototype.change=function(){
  this.body="frog";
};

然后我会这样去获取一个Prince实例对象:

var prince=new Prince("Harry");
prince.change();

如果我年纪大了忘记用new关键字了,程序就报错了:

var a=Prince('harry');
a.change();//error,"Cannot read property 'change' of undefined"

除了调用方法会出错之外,window还被挂载了两个变量上去,何其无辜。

但是获取jQuery对象(以下简称JQ对象)用new和不用new都可以,返回的是一样样的。

console.log($('*').length);//14
console.log(new $('*').length);//14

为了做到这点,我们很容易想到需要在构造函数内部返回对象。引用下我在另一篇博文JavaScript中的普通函数与构造函数里写的:

构造函数有return值怎么办?
构造函数里没有显式调用return时,默认是返回this对象,也就是新创建的实例对象。
当构造函数里调用return时,分两种情况:
1.return的是五种简单数据类型:String,Number,Boolean,Null,Undefined。
这种情况下,忽视return值,依然返回this对象。
2.return的是Object
这种情况下,不再返回this对象,而是返回return语句的返回值。

所以我们应该在jQuery构造函数内部去返回一个对象,这样就可以不用new的方式去创建JQ对象了,其实这时候,构造函数就相当于一个工厂函数了。
那么核心问题来了。

该返回什么样的对象?对于这个对象有何要求?

这个对象必须可以调用jQuery.prototype上的方法。

我们使用或自己写jQuery插件的时候会经常遇到$.fn这个对象,很多插件都是通过扩展这个对象来实现的。
$.fn其实对应着jQuery.prototype,$和fn分别是jQuery和prototype的简写方式,只要我们把方法扩展到这个原型对象身上,通过$()获取的JQ对象都是可以访问到方法的。
例如:

$.fn.greeting=function(){alert('hi')};
$('body').greeting();//alert 'hi'

所以,工厂函数内部返回的对象一定要可以调用jQuery.prototype上的方法。

是时候看John Resig到底是怎么做的啦。

jQuery源码

jQuery = function( selector, context ) {
    return new jQuery.fn.init( selector, context, rootjQuery );
},
jQuery.fn = jQuery.prototype = { //fn即对应prototype
    constructor: jQuery,
    init: function( selector, context, rootjQuery ) {
        ...
        return this;
    }
    ...
}
jQuery.fn.init.prototype = jQuery.fn;

在chrome里调试时候添加JQ对象的watch,会看到类似如下的结果:

$('*'): n.fn.init[14]

看到上面这段源码,原因就很明显了,其实我们所说的JQ对象根本就是init函数的实例对象,而init则是jQuery原型上的一个对象,它本身是没有什么方法的,全靠从jQuery原型上拿。

"jQuery.fn.init.prototype = jQuery.fn"这句很重要,它将init的原型指向jQuery的原型,所以JQ对象才可以访问‘css'、'show'、'hide'这些写在jQuery.fn上的方法。

我们可能会有疑问,为何要从init这绕这么一大圈来访问jQuery的原型,而不是直接返回一个jQuery实例直接通过这个实例来访问自身原型?比如说代码可以写成这样:

jQuery = function( selector, context ) {
        return new jQuery();
} 

问题很明显,这样做只会大家一起死,死在循环里。

好,那我接受init的存在,但是我这样写难道不可以吗?

jQuery = function( selector, context ) {
        return jQuery.fn.init();//不同点在于去掉了new关键字
}

让我们做点动作来证明加上new是有用的。

jQuery = function( selector, context ) {
    return jQuery.fn.init();
},
jQuery.fn = jQuery.prototype = {
    init: function() {
            this.name='sheila';
            return this;
    },
    anotherName:'sunwukong'
};
var jq=jQuery();
console.log(jq.anotherName);//"sunwukong"
console.log(jq.name);//"sheila"

上面这段代码是为了说明this的作用域问题,其不仅能访问init函数内部,还能向上一层到fn对象。我听人家说,做框架的,作用域要独立才好呢。
给它加上new关键字:

...
return new jQuery.fn.init();
...

console.log(jq.anotherName);//undefined
console.log(jq.name);//"sheila"

这样this的作用域就独立出来了。

经博友评论提醒,加不加new还牵涉到一个更重要的问题:返回的对象究竟是谁。不加new的情况下,'jQuery.fn.init()'相当于调用方法,this指向的以及最后返回的都是同一个jQuery.fn对象,$('body')和$('p')就没有区分了。显然,这是不合理的。而加了new,就是每次用构造函数实例化了一个新对象,彼此都是不同的。


2、jQuery核心的工具方法

以下方法是在jQuery的core定义的工具方法(可以去github的jQuery项目),core是整个jQuery最核心的组成部分,所以从这部分先剖析: $.trim() 去除字符串两端的空格。(内部调用7次) $.each() 遍历数组或对象,这个方法在jQuery内部中被使用很多次,有几个不错的用法,之后剖析再举例吧。(内部调用59次) $.inArray() 返回一个值在数组中的索引位置。如果该值不在数组中,则返回-1。(内部调用9次) $.grep() 返回数组中符合某种标准的元素。(内部调用6次) $.merge() 合并两个数组。(内部调用11次) $.map() 将一个数组中的元素转换到另一个数组中。(内部调用12次) $.makeArray() 将对象转化为数组。(内部调用6次) $.globalEval() 在全局作用域下执行一段js脚本。(内部调用2次) $.proxy() 接受一个函数,然后返回一个新函数,并且这个新函数始终保持了特定的上下文(context)语境。(内部调用0次) $.nodeName() 返回DOM节点的节点名字,或者判断DOM节点名是否为某某名字。(内部调用51次) $.extend() 将多个对象,合并到第一个对象。(内部调用42次)

以下均是对类型的判断,本文只是针对$.type做一下讨论,isXXX的方法基本都是调用$.type来实现,不对它们做细节探讨。 $.type() 判断对象的类别(函数对象、日期对象、数组对象、正则对象等等)。这个方法的实现就是用$.each辅助的。(内部调用65次) $.isArray() 判断某个参数是否为数组。(内部调用12次) $.isEmptyObject() 判断某个对象是否为空(不含有任何属性)。(内部调用4次) $.isFunction() 判断某个参数是否为函数。(内部调用32次) $.isPlainObject() 判断某个参数是否为用"{}"或"new Object"建立的对象。(内部调用4次) $.isWindow() 判断是否为window对象。(内部调用6次)

以下三个函数比较简单,没必要在文章剖析。 $.noop() 一个空函数,个人觉得是用来作为一个默认的回调函数,无需每次去定义一个空的function消耗资源。(内部调用2次) $.now() 获取当前时间戳,代码很简单:return (new Date()).getTime();。(内部调用4次) $.error() 报错,对外抛出一个异常,代码很简单:throw new Error(msg);。(内部调用2次)

以下三个是jQuery主要用来在ajax处理返回数据时使用,其中parseJSON这个接口在实际工程中被用得最多,经常用来把一段文本解析成json格式 $.parseHTML() 解析HTML,之后再单独一节写。(内部调用2次) $.parseJSON() 解析JSON,之后再单独一节写。(内部调用2次) $.parseXML() 解析XML,之后再单独一节写。(内部调用1次)

其中我认为是内部辅助函数如下: $.access() 这个函数我更认为是jQuery内部的辅助函数,没必要暴漏出来,在内部用于去一些对象的属性值等,在之后剖析到DOM操作等再细细探讨一下。(内部调用9次) $.camelCase() 转化成骆驼峰命名。(内部调用12次)

开始窥探源码吧!

2.0 在开始之前

在jQuery一开始的定义里边,有这么一小段



  class2type = {},
  core_deletedIds = [],
  core_version = "1.9.0",

  // Save a reference to some core methods
  core_concat = core_deletedIds.concat,
  core_push = core_deletedIds.push,
  core_slice = core_deletedIds.slice,
  core_indexOf = core_deletedIds.indexOf,
  core_toString = class2type.toString,
  core_hasOwn = class2type.hasOwnProperty,
  core_trim = core_version.trim,

  //等同以下代码:
  core_concat = Array.prototype.concat, 
  //文章一开始的介绍有稍微提到prototype
  //core_deletedIds是一个数组实例
  //core_deletedIds.concat方法就相当于调了Array类中的成员方法concat。

需要调用concat时可以通过以下方法调用,关于call跟apply的用法自行理解,:) var arr = []; 方式一:arr.concat(); 方式二:core_concat.call(arr); 方式三:core_concat.apply(arr);

思考下边2个问题:

  1. jQuery为什么要先把这些方法存储起来?
  2. jQuery为什么要采用方式二或者三,而不直接使用方式一的做法? 在不查阅资料的前提下,唯一让我觉得作者这么做的原因是因为效率问题。 以下是我的理解:

    调用实例arr的方法concat时,首先需要辨别当前实例arr的类型是Array,在内存空间中寻找Array的concat内存入口,把当前对象arr的指针和其他参数压入栈,跳转到concat地址开始执行。 当保存了concat方法的入口core_concat时,完全就可以省去前面两个步骤,从而提升一些性能。 nodejser在评论中也给出了另一种答案: var obj = {}; 此时调用obj.concat是非法的,但是如果jQuery采用上边方式二或者三的话,能够解决这个问题。 也即是让类数组也能用到数组的方法(这就是call跟apply带来的另一种用法),尤其在jQuery里边引用一些DOM对象时,也能完美的用这个方法去解决,妙!

2.1 $.trim

jQuery的trim函数是用来去除字符串两端空格(jQuery源码里边使用了7次),这个函数也是使用频率很高的,因为时常要对用户在页面上输入的文本trim一下~

用法:$.trim(" 前尾有空格 ") === "前尾有空格"

jQuery的trim源码如下:


core_version = "1.9.0",
core_trim = core_version.trim,
rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,

trim: core_trim && !core_trim.call("\uFEFF\xA0") ?
  function( text ) {
    return text == null ?
      "" :
      core_trim.call( text );
  } :

  // Otherwise use our own trimming functionality
  function( text ) {
    return text == null ?
      "" :
      ( text + "" ).replace( rtrim, "" );
  }

剖析之:

var core_trim = String.prototype.trim; 

if (core_trim && !core_trim.call("\uFEFF\xA0")) 

相当于: if (String.prototype.trim && "\uFEFF\xA0".trim() !== "") 

高级的浏览器已经支持原生的String的trim方法,但是jQuery还为了避免它没法解析全角空白,所以加多了一个判断:"\uFEFF\xA0".trim() !== ""

\uFEFF是utf8的字节序标记,详见:字节顺序标记 "\xA0"是全角空格 如果以上条件成立了,那就直接用原生的trim函数就好了,展开也即是:


$.trim = function( text ) {
    return text == null ?
        "" :
        text.trim();
}

如果上述条件不成立了,那jQuery就自己实现一个trim方法:


$.trim = function( text ) {
    return text == null ?
        "" :
        ( text + "" ).replace( rtrim, "" );
}

当然你还得自己看懂rtrim这个正则表达的意思了。

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页