jQuery源码解读笔记

2018-11-01

前言

基于妙味课堂的jQuery源码视频的笔记,其中jQuery版本为2.0.3。

第1-94行

最外层的立即执行函数

jQuery的所有代码都被包含在一个立即执行函数(Immediately Invoked Function Expression, IIFE)中,如下所示:

(function(window, undefined){
    // all the code included here
})(window);

这样做的好处在于避免全局变量污染。

传入window变量是为了减少在内部使用该变量时进行查找的作用域链长度,从而改善性能。

传入undefined是原因在于,它是全局对象的一个属性,在现代浏览器中是不可写的,而某些低版本浏览器却有可能在全局作用域下修改它的值;由于它不是一个js保留字,在非全局作用域下理论上用它作为标识符给它赋值都是可以的(虽然尽量不要这么做);传入以后,不论外部代码如何,可以确保至少在我们的源代码内部undefined就是那个ECAMScript标准中的primitive value

全局对象与方法的暂存

在源代码中将一些现有的对象属性和方法暂存在了一些变量中。这样做的原因,一是在使用时避免了属性读取,有性能好处,二是我们的变量在代码压缩时是会替换成短字符串的从而有利于减少文件大小,而js自带的一些对象、方法、属性却不行。

jQuery对象

关于jQuery对象的代码大致如下:

var jQuery = function(selector, context) {
        return new jQuery.fn.init(selector, context, rootjQuery);
    };
jQuery.fn = jQuery.prototype = {
    constructor: jQuery,
    init: function(selector, context, rootjQuery) {
        // some code
    }
};
jQuery.fn.init.prototype = jQuery.fn;

我们可以看到jQuery本身是定义为一个函数的,在函数内部调用以构造函数的方式调用了它的原型对象jQuery.prototype的方法init()jQueryinit共享了原型对象,这个原型对象的constructor设置为jQuery。看起来jQuery函数负责面子工程,而init函数做了实质工作,这样写使得我们在使用$或者jQuery时可以不用new操作符,而像一个纯粹的函数调用一样,而函数返回的结果却是一个jQuery实例(当然也是init实例,因为譬如A instanceof B,实际上只是检查A的原型链中是否有B.prototype)。我们可以用下图来表示它们之间的关系:

jQuery函数及其原型

另外要说明一下$(xxx)返回的对象除了是jQuery实例,也是init实例,如果我们调用$(null) instanceof jQuery.fn.init,结果会是true。实际上譬如A instanceof B,实际上只是检查A的原型链中是否有B.prototype)。

function F(){}
function test(){}
test.prototype = F.prototype;
console.log(new F() instanceof F);  // true
console.log(new F() instanceof test);   //true

第96-283行

jQuery原型对象总览

这部分定义了jQuery函数的原型对象,提供了实例共享方法和属性。

jQuery.fn = jQuery.prototype = {
    jquery  // 版本号
    constructor //修正constructor
    init()  // jQury()函数真正执行的方法
    selector    // 选择符
    length  // 结果长度
    toArray()   // 转为数组
    get()   // 取出jQuery对象结果“数组”中指定DOM节点
    pushStack() // jQuery对象入栈
    each()  // 循环处理jQuery对象的每一个结果项
    ready() // 文档加载结束所执行的函数
    slice() // 剪切以让jQuery对象结果“数组”部分保留
    first() // 让jQuery对象结果“数组”只保留第一项
    last()  // 让jQuery对象结果“数组”只保留最后一项
    eq()    // 让jQuery对象结果“数组”只保留指定某项
    map()   // 将jQuery对象结果“数组”内容映射修改
    (push())    // 原生数组push方法引用
    (sort())    // 原生数组sort方法引用
    (splice())  // 原生数组splice方法引用
};

关于jQuery.fn.init函数中的context参数

这一部分视频在讲解时认为有时这个参数完全是没有必要的,最主要集中在使用jQuery函数进行标签创建的时候,比如jQuery(‘<li>’, context);实际上在该函数内部,调用了jQuery.parseHTML()函数,而该函数中创建标签最终工作是通过context.createElement()来实现的(当然这里的context已经进行了判断和处理);而createElement函数创建标签时,实际上也为创建出来的标签绑定了ownerDocument属性,因此明确指定context是必要的,一个例子就是iframe

因此函数中有 context && context.nodeType ? context.ownerDocument || context : document这样的代码,这样不论调用时是否传递了该参数、该参数时候合理,都能让代码正常运行。

第285-347行

jQuery拷贝继承函数

jQuery.extend = jQuery.fn.extend = function(){
    // detail code
};

这部分实现了一个功能完善的对象拷贝功能,即extend函数。可以传参选择深拷贝/浅拷贝、传入单个对象实现插件扩展、传入多个对象时该函数作为一个工具性函数执行函数拷贝继承。

值得一提的是,在传入单个对象实现插件扩展时,函数内部实际上将传入的对象由this来继承,而jQueryjQuery.fn虽然共享了这个函数,然而分别在它们上面调用该函数时,this是不一样的。在jQuery上调用extend,插件被扩展到jQuery上,使用插件方法的语法类似jQuery.<extendedMethod>();而在jQuery.fn上调用extend,插件被扩展在jQuery实例的原型对象上,使用插件方法的语法类似jQuery().<extendedMethod>()

第349-817行

jQuery对象工具方法总览

jQuery.extend({
    expando //生成唯一JQ字符串(内部)
    noConflict()    //防止变量名冲突
    isReady //DOM是否加载完(内部)
    readyWait   //等待多少文件的计数器(内部)
    holdReady() //推迟DOM触发
    ready() //准备DOM触发
    isFunction()    //是否为函数
    isArray()   //是否为数组
    isWindow()  //是否是window
    isNumeric() //是否是常规数字(不能是NaN、无穷)
    type()  //判断数据类型
    isPlainObject() //是否是对象字面量(或使用Object构造函数创建的对象实例)
    isEmptyObject() //是否是空对象(无自定义属性) 
    error() //抛出异常信息
    parseHTML()  //根据字符串解析成DOM节点数组
    parseJSON()  //解析JSON
    parseXML()   //解析XML
    noop()   //一个空匿名函数
    globalEval() //全局解析js代码字符串
    camelCase()  //将css属性名转为驼峰形式
    nodeName()   //比较判断dom节点的节点名
    each()  //遍历集合
    trim()  //去除字符串前后空格
    makeArray() //类数组转为真的数组
    inArray()   //数组版indexOf
    merge() //(类)数组合并
    grep()  //数组项过滤
    map()   //映射新数组
    guid    //唯一标识符(内部)
    proxy() //改this指向
    access()    //多功能值操作(内部)
    now //当前时间
    swap()  //css交换(内部)
});

这部分使用之前定义的拷贝函数extendjQuery对象增加了一系列工具方法和属性。

插入script标签然后立即移除

globalEval函数中,其中一个分支(541行)使用了插入script标签的方式来执行代码,源码如下:

document.head.appendChild( script ).parentNode.removeChild( script );

这段代码将包含执行代码的script标签插入文档,然后立即移出,那么为什么在移出以前,script标签的内部代码一定执行完了呢?为此我在segmentFault就此提问,下面说说我的理解。

首先我写了下面这样的实验代码。

var a = 1,
    code = 'a = 2; console.log(\'inner code\')',
    doc = document,
    body = doc.body,
    script = doc.createElement('script');
script.innerHTML = code;
console.log('outside code 1');
body.appendChild(script).parentNode.removeChild(script);
console.log('outside code 2');
console.log('a = ' + a);

运行结果如下:

运行结果

我们可以看到插入script标签后代码执行进入了其内部,这就好像一个中断,上面结果图片我们可以看到inner code后面显示的VM315:1,这可能是内部引擎防止这段代码的地方;中断完成前,外层代码的parentNode属性读取和removeChild函数调用应该都不会执行,也就是说在移除script标签前其内部的代码已经执行完毕了。

第2847-3042行

功能概述

这部分提供一个统一的回调函数管理对象,通过let callbackManager = $.Callbacks(模式选项);的方式获得这个管理对象;内部实际上将各回调函数存储在一个数组中,它提供了addremovefire等方法来实现会回调函数的添加、删除、触发等操作。更多使用说明可查看官网API。如下是一段使用示例代码:

function a(arg){
    console.log('a: ' + arg);
}
function b(arg){
    console.log('b: ' + arg);
}
let manager = $.Callbacks();
manager.add(a);
manager.add(b);
manager.fire('hello');
//a: hello
//b: hello

模式选项

上述的示例代码很类似事件绑定与触发的过程,但该回调函数管理对象通过给定模式选项参数可以实现更加灵活、强大的功能。其主要模式选项参数及其解释如下:

模式 解释
once 只能被触发(fire)一次
memory 记忆功能,添加新函数时,如果之前曾触发过,那么这个新添加的函数添加时会被传入之间的参数被触发
unique 保持回调函数列表中函数的唯一性(不重复出现)
stopOnFalse 在某个函数调用返回false时,取消本次触发过程的后续函数的调用

各种模式的组合测试

下面的一段代码可以测试各种模式组合下的运行效果:

function fn1(arg) {
    document.write(`<span style="color: #666;">fn1:${arg}</span>&nbsp;&nbsp;`)
    return false;
}
function fn2(arg) {
    document.write(`<span style="color: #666;">fn2:${arg}</span>&nbsp;&nbsp;`)
    return false;
}

let options = [''];
['once', 'memory', 'stopOnFalse', 'unique'].forEach(item => {
    let len = options.length;
    for (let i = 0; i < len; i++) {
        options.push((options[i] + ' ' + item).trim());
    }
});
options.sort((item1, item2) => {
    return item1.length - item2.length;
});

options.forEach(option => {
    document.write(`<h4 style="margin: 8px 0px 0px;">模式:${option}</h4>`);

    let cb = $.Callbacks(option);

    document.write('cb.add(fn1); <span style="color: #666;  ">&nbsp;// </span>');
    cb.add(fn1);

    document.write('<br>cb.fire(1); <span style="color: #666;">&nbsp;// </span>');
    cb.fire(1);

    document.write('<br>cb.add(fn2); <span style="color: #666;">&nbsp;// </span>');
    cb.add(fn2);

    document.write('<br>cb.add(fn2); <span style="color: #666;">&nbsp;// </span>');
    cb.add(fn2);

    document.write('<br>cb.fire(2); <span style="color: #666;">&nbsp;// </span>');
    cb.fire(2);

    document.write('<br>cb.remove(fn2); <span style="color: #666;">&nbsp;// </span>');
    cb.remove(fn2);

    document.write('<br>cb.fire(3); <span style="color: #666;">&nbsp;// </span>');
    cb.fire(3);
});

源码解读

// 表示字符串格式的模式和对象格式的模式之间对应关系的缓存对象
var optionsCache = {};

/**
 * 该函数将字符串形式的配置参数转换为对象的形式
 * 如`memory unique`将转变为对象:
 * {
 *      memory: true,
 *      unique: true
 * }
 * 并且会以该对象为值、以`memory unique`为key存在optionsCache缓存对象中
 * @param {String} options 配置参数字符串
 */
function createOptions(options) {
    var object = optionsCache[options] = {}; // 初始化
    jQuery.each(options.match(core_rnotwhite) || [], function (_, flag) {
        // 以所有给定的模式为键,true为值放入选项对象中
        object[flag] = true;
    });
    return object;
}

/*
 * 使用如下的一些选项创建一个回调函数列表(数组)
 * options: 一个用空格分割的选项字符串或者选项对象,它们可以控制回调函数列表的行为
 * 默认情况下,这个回调函数列表表现得就像一个时间回调函数列表一样,并且可以被多次触发
 *
 * 可能的选项:
 *	once:			使得回调函数列表只能被触发一次(就像一个Deferred)
 *	memory:			记录之前回调触发是的参数值,如果列表已经被触发过,
 *                  那么后续有函数加入这个列表式它们会被传入记录的参数,立即执行 (就像一个Deferred)
 *	unique:			保证回调函数列表中的函数不会有重复
 *	stopOnFalse:	如果某一个回调函数返回了false,回调将会终止
 */
jQuery.Callbacks = function (options) {
    // 如果需要的话,讲一个字符串形式的参数列表转换为对象形式(过程中会首先查看缓存中是否已有)
    options = typeof options === "string" ?
        (optionsCache[options] || createOptions(options)) :
        jQuery.extend({}, options);

    var memory, // 上次触发的参数
        fired, // 列表是否已被触发过的标记
        firing, // 当且列表是否正在触发过程中
        firingStart, // 触发的遍历起始位置(被add和fireWith内部调用)
        firingLength, // 触发的回调函数列表总长度
        firingIndex, // 当前正在触发的回调函数的位置索引
        list = [], // 实际回调函数列表
        stack = !options.once && [], // 触发等待队列,用于可多次触发的回调函数列表,堆中保存的是后续各次调用的参数
        fire = function (data) {
            // 有memory选项时,保存参数data,否则将是falsy的(false或undefined)
            memory = options.memory && data; 
            fired = true;
            firingIndex = firingStart || 0;
            firingStart = 0;
            firingLength = list.length;
            firing = true;
            for (; list && firingIndex < firingLength; firingIndex++) {
                if (list[firingIndex].apply(data[0], data[1]) === false && options.stopOnFalse) {
                    // 在stopOnFalse模式下,确实返回了false,终止触发过程
                    memory = false; // 阻止后续通过add的方式触发
                    break;
                }
            }
            firing = false;
            if (list) {
                // 还没有被禁用(disable())
                if (stack) {
                    // 可多次触发
                    if (stack.length) {
                        // 还有后续触发队列,继续下一轮触发
                        fire(stack.shift());
                    }
                } else if (memory) {
                    // 只能单次触发,但是还有是记忆模式,那么后续add的函数可能还会被触发
                    // 当前需要清空已经被触发过的所有函数
                    list = [];
                } else {
                    // 非记忆、单次触发模式,以后绝不可能以任何方式触发了
                    // 那么可以直接禁用该回调列表
                    self.disable();
                }
            }
        },

        // 实际回调对象
        self = {
            // 向list中添加函数(集合)
            add: function () {
                if (list) {
                    // 非禁用状态
                    var start = list.length; // 当前列表长
                    (function add(args) {
                        jQuery.each(args, function (_, arg) {
                            var type = jQuery.type(arg);
                            if (type === "function") {
                                if (!options.unique || !self.has(arg)) {
                                    // 在非单次触发模式或在单次触发模式时的新函数
                                    list.push(arg);
                                }
                            } else if (arg && arg.length && type !== "string") {
                                // 非空类数组参数,持续解构
                                add(arg);
                            }
                        });
                    })(arguments);

                    if (firing) {
                        // 正处在触发过程中,只需要将回调函数列表长度更新给待触发函数的索引上界
                        firingLength = list.length;
                    } else if (memory) {
                        // 当前不在触发过程中,且是记忆模式、且一定被调用过(否则memory会是undefined),即刻调用新添加的所有函数
                        firingStart = start;
                        fire(memory);
                    }
                }
                return this;
            },
            // 移除回调函数
            remove: function () {
                if (list) {
                    jQuery.each(arguments, function (_, arg) {
                        var index;
                        while ((index = jQuery.inArray(arg, list, index)) > -1) {
                            // 持续移除(因为可能有重复)
                            list.splice(index, 1);
                            if (firing) {
                                // 处理正在触发中的情况
                                if (index <= firingLength) {
                                    // 移除了原本要触发的函数
                                    firingLength--;
                                }
                                if (index <= firingIndex) {
                                    // 移除了已被触发的函数
                                    firingIndex--;
                                }
                            }
                        }
                    });
                }
                return this;
            },
            // 检查列表中是否有给定的函数
            // 空参数表示返回列表是否已有函数
            has: function (fn) {
                return fn ? jQuery.inArray(fn, list) > -1 : !!(list && list.length);
            },
            // 清空列表
            empty: function () {
                list = [];
                firingLength = 0;
                return this;
            },
            // 禁用列表
            disable: function () {
                list = stack = memory = undefined;
                return this;
            },
            // 判断列表是否被禁用
            disabled: function () {
                return !list;
            },
            // 将列表锁止在当前状态
            lock: function () {
                stack = undefined;
                if (!memory) {
                    self.disable();
                }
                return this;
            },
            // 列表是否处于锁止状态
            locked: function () {
                return !stack;
            },
            // 使用给定的上下文和参数调用所有的回调函数
            fireWith: function (context, args) {
                if (list && (!fired || stack)) {
                    args = args || [];
                    args = [context, args.slice ? args.slice() : args];
                    if (firing) {
                        // 正在触发中,放在等待队列中
                        stack.push(args);
                    } else {
                        // 目前不在触发中,直接触发
                        fire(args);
                    }
                }
                return this;
            },
            // 使用给定的参数触发所有回调函数
            fire: function () {
                self.fireWith(this, arguments);
                return this;
            },
            // 列表是否被触发过
            fired: function () {
                return !!fired;
            }
        };

    return self;
};

(本文完)

知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。