jQuery源码学习笔记(一)

今天是学习jQuery的第一天,先明确一下学习目标:通过用自己的思路仿写jQuery库,更好地理解它的底层原理和编程思想。

假设世界上还没有jQuery这样一个库,我们要从哪里开始呢?如果我们现在正在做一件事情,比如说喝水。那么,我们为什么要喝水呢?原因很简单,肯定是在此之前我们感觉到口渴了。也就是说我们产生了一个喝水的需求,而这个需求促使我们去做喝水这件事情。有没有发现,不单单喝水,其实我们大部分的行为都是由需求来驱动的。首先得要有一个需求,在需求的推动下我们才会采取相应的行动试图去满足这个需求。写一个jQuery库也是一样,在行动之前,我们得要先搞清楚写这个库的需求在哪里?也就是说:为什么要写这个库?不然,我们根本不知道从何入手。那么,为什么要写一个jQuery库呢?

我们知道,jQuery本质上就是用javascript代码写成的各种方法的集合。但是javascript本身不是已经提供了各种各样的方法和功能了吗?为什么还要再另外写一个库出来呢?原因其实很好猜,那肯定是我们要写的这个库比javascript提供的原生方法更好用了,对吧?恩,到这里我们似乎得到了一个基本需求:写一个比原生javascript提供的方法集更好用的库。这个需求的关键在于“更好用”三个字。怎么才是“更好用”呢?

我们知道一个原则:结构、表现、行为相分离。javascript是负责其中的“行为”的。谁的行为?网页元素的行为。什么样的行为?变化。也就是说,javascript是负责使网页元素发生变化的,对不对?那么要使网页元素发生变化要怎么做呢?很简单,只需要通过两步:

  1. 确定要让哪一个网页元素发生变化。
  2. 确定要使这个元素发生什么样的变化。

从上面两个步骤我们可以总结出javascript工作的基本流程:

  1. 选取目标元素
  2. 操作目标元素实现功能(使目标元素发生变化)

根据这个基本流程,对于上面“更好用”的疑问,我们就有了一个初步的比较具体的答案了:“更好用”指的是“更好的元素选择器”和“更好的功能方法集”。好了,到了这里,我们的需求就更加清晰了:

“写一个js库,提供比原生javascript更好的元素选择器和更好的功能方法集”

虽然这个需求还不是最具体最清晰的,但是已经足够让我们开始行动了。根据上面的需求,我们可以为我们的jQuery库构建一个粗略的框架,这个框架由两部分组成:选择器模块和功能模块。

jQuery源码学习笔记(一)_第1张图片

现在落实到代码层面看看如何编写。先忽略具体的实现细节,我们先看看怎么用代码来构建上图的基本框架。先从最基本的方式入手:

  1. 对于选择器模块,我们定义一个选择器方法来选取目标元素。
  2. 对于功能模块,针对不同的功能我们定义不同的方法来操作选择器方法选取的元素。
function picker(selector){
    //选择器方法,根据传入的selector参数所限制的条件选取符合条件的网页元素
}

function html(elem, htmlText){
    //功能方法,改变网页元素的内部html代码
    //elem: 目标元素
    //htmlText: 要写入目标元素内的html代码文本
}

这样编写虽然也能够实现功能,但是有明显的缺点:

  1. 我们的方法全部都是定义在全局里面的,在实际开发中,如果还需要引入其他js库,很容易发生变量名冲突的问题。 比如这些库如果也声明了一个html变量,这样就会和我们库里的html方法名冲突,导致变量污染,使网页不能正常工作。
  2. 在选出目标元素以后,每次实现功能都要把目标元素作为参数传入到相应的方法内,这样操作起来很繁琐。

这样看来,用这种方式来搭建jQuery库的基本框架根本不能实现我们所谓“更好用”的目标。因此,我们需要改进:

  1. 尽量把所有变量及方法都封装起来,而不是直接暴露在全局范围。
  2. 使用更简便的函数调用方式。

首先,要把变量封装起来,有两种选择:把变量定义在一个函数内或者把变量作为对象的属性来存取数据。其次,相对于每次调用方法都要把元素作为参数传给该方法,更简便的方式应该是目标元素直接通过点操作符调用方法。综合这两点,我们可以看出既可以封装变量和方法又可以通过点操作符来调用函数的唯一选择就是对象了。那么我们怎么把对象和前面确定的基本框架(选择器模块和功能模块)结合起来呢?

回头看一下我们刚才定义的选择器方法,它返回的实际上是系统为我们提供的DOM元素(或元素数组),而这些DOM元素本身是没有我们自定义的功能模块内的方法的。在这种情况下,我们是没办法使用点操作符来实现函数调用的。所以,我们需要一个自定义对象:

  1. 这个对象(通过属性)存放了我们选取的目标DOM元素。
  2. 这个对象内部已经地定义好了一系列方法,该对象可以直接通过点操作符调用这些方法来操作储存在它内部的DOM元素。

那要构造这样一个对象,我们要怎么做呢?我们需要一个制造对象的“工厂”:构造函数,对不对?

jQuery源码学习笔记(一)_第2张图片

到这里我们可以整理一下思路:我们的jQuery库实质是一个构造函数,它内部定义里了选择器方法(选择器模块)以及操作DOM元素的相关方法(功能模块)。通过new jQuery(selector)调用构造函数时,该函数会根据参数selector限定的条件调用选择器方法获取目标元素,并存放在它正构造的对象内。当对象构造完成并返回给我们后,我们可以通过点操作符调用各种方法直接操作存放在对象内部的目标元素。现在可以更新一下我们的代码了:

//为了更好理解和测试,我们粗略地把代码细节都补全
function jQuery(selector){
    this.find = function(selector) { //选择器方法。它根据selector的限定条件获取元素
        return document.getElementsByTagName(selector); //旨在方便理解,暂时只简单实现getElementsByTagName的功能
    }

    this.html = function(htmlText) { //功能方法,改变网页元素的内部html代码
        console.log(htmlText); //先不具体实现功能,只简单打印参数,旨在理解整体结构而不用分心到具体细节的实现上
    }
    
    //把目标元素存入新构建的jQuery对象
    var elems = this.find(selector); //调用find方法获取符合条件的目标元素
    for(var i = 0; i < elems.length; i++){
        this[i] = elems[i]; //因为符合条件的元素可能不止一个,用索引值属性把它们存入对象内
                            //可以看出我们的jQuery对象实际是一个类数组对象
    }
    this.length = elems.length; //类数组必须有索引值属性以及length属性
}

根据上面的代码,如果我们要获取页面的所有div元素,并把它们内部html代码设为:

Hello, jQuery!

,我们需要进行以下操作:

var divs = jQuery('div'); //获取页面所有div元素
divs.html('

Hello, jQuery!

'); //把每个div元素内部html代码设为

Hello, jQuery!

现在看上去似乎一切都很顺利,我们既把方法封装了起来,又实现了点操作符的操作。但是如果再认真回顾一下这段代码,我们还是会发现一个不可忽视的缺陷:我们把find和html方法定义在了构造函数内部,这样每次我们调用构造函数的时候,系统都要为每个新建的对象重新定义这些实际上是一模一样的方法并开辟新的内存来存放它们。既浪费内存又降低了程序运行的速度。那么怎么办呢?我们既想让每个jQuery对象都能使用这些方法,又不想为每个对象都重新定义和存放它们,要怎么做呢?答案其实很明显:借助原型。

对象和它的祖先(们)之间有一条原型链连接着,对象可以通过原型链继承它祖先(们)的属性和方法。我们可以把find和html方法(以及未来要定义的其他方法)都定义在jQuery对象的原型上。这样系统只需为jQuery对象原型定义一次方法,开辟一次内存即可。而所有的jQuery对象都可以继承定义在它们原型上的方法(所谓的“继承”是指在对象调用属性或方法时,系统可以利用原型链找到定义在对象祖先上相应的属性或方法,而不是把祖先的属性和方法拷贝到具体每一个对象内,因此不会占用额外的内存)。

jQuery源码学习笔记(一)_第3张图片

好了,根据上图,我们进一步更新我们的代码:

//为了更好理解和测试,我们粗略地把代码细节都补全
function jQuery(selector){
    var elems = jQuery.prototype.find(selector); //调用原型上的find方法,获取符合条件的目标元素。
    for(var i = 0; i < elems.length; i++){
        this[i] = elems[i]; //把目标元素存入对应的索引值属性内。
    }
    this.length = elems.length; //类数组必须有索引值属性以及length属性
}

//在原型上定义选择器模块的find方法
jQuery.prototype.find = function(selector) {
    return document.getElementsByTagName(selector); //旨在方便理解,暂时只简单实现getElementsByTagName的功能
}

//在原型上定义功能模块的html方法
jQuery.prototype.html = function(htmlText) {
    console.log(htmlText); //先不具体实现功能,只简单打印参数,旨在理解整体结构而不用分心到具体细节的实现上
}

到了这里,我们的代码已经有明显的改观了。但是现在又出现了一个小瑕疵。从第9行开始,我们的代码又暴露在全局了,不太符合我们封装的原则。我们既想让这些代码在加载的时候执行一次,又不想把它们暴露出来,要怎么办呢?答案很简单,借助立即执行函数。

我们用立即执行函数把所有的代码都包裹起来,这样在页面引入并加载jQuery库的时候这个立即执行函数就会执行一遍。但是,立即执行函数执行完毕后会马上销毁。所以我们需要留一个接口,让我们后续能继续使用jQuery库。办法也很明显,我们只要把构造函数暴露出来留在全局就可以了。现在我们进一步改进我们的代码:

var jQuery = (function(){ //用立即执行函数把所有代码都封装起来
    
    //为了更好理解和测试,我们粗略地把代码细节都补全
    function jQuery(selector) {
        var elems = jQuery.prototype.find(selector); //调用原型上的find方法,获取符合条件的目标元素。
        for (var i = 0; i < elems.length; i++) {
            this[i] = elems[i]; //把目标元素存入对应的索引值属性内。
        }
        this.length = elems.length; //类数组必须有索引值属性以及length属性
    }

    //在原型上定义选择器模块的find方法
    jQuery.prototype.find = function (selector) {
        return document.getElementsByTagName(selector); //旨在方便理解,暂时只简单实现getElementsByTagName的功能
    }

    //在原型上定义功能模块的html方法
    jQuery.prototype.html = function (htmlText) {
        console.log(htmlText); //先不具体实现功能,只简单打印参数,旨在理解整体结构而不用分心到具体细节的实现上
    }
    
    return jQuery; //把构造函数暴露在全局

})();

我们的代码又完善了一点!现在再看看还有什么可以改进的吗?我们在原型链上定义新的方法时,都是通过点操作符的形式来实行的。每添加一个新的方法都要重复输入“jQuery.prototype”这个代码片段。成百上千个方法累积起来会增加整个库的体积。而且每个方法都单独定义,显得比较散乱,不好管理。因此,我们现在思考一下有没有别的方法,可以省去重复的代码片段,而且把原型上的方法集中在一起定义,以便日后的维护和管理。

我们知道,每当我们定义一个函数,系统都会隐式地为这个函数添加一个原型对象,存放在函数的prototype属性内。这个原型是通过构造函数Object构造而成的。除了为这个对象添加了一个特殊的construtor属性(属性值是这个函数的引用)以外,它和我们通过对象字面量构造出来的空对象(var obj = { };)没有任何区别。所以,我们在给对象定义新方法或属性时,不需要在原来的原型对象上逐个添加,而是重新构建一个新对象,统一在新对象内定义方法和属性,然后把这个新对象赋值给函数的prototype属性覆盖原来的原型对象。根据以上的信息,我们进一步改善代码:

var jQuery = (function(){//用立即执行函数把所有代码都封装起来

    //为了更好理解和测试,我们粗略地把代码细节都补全
    function jQuery(selector) {
        var elems = jQuery.prototype.find(selector); //调用原型上的find方法,获取符合条件的目标元素。
        for (var i = 0; i < elems.length; i++) {
            this[i] = elems[i]; //把目标元素存入对应的索引值属性内。
        }
        this.length = elems.length; //类数组必须有索引值属性以及length属性
    }

    jQuery.prototype = { //为jQuery函数重新定义一个新对象作为原型
        
        constructor : jQuery, //添加construtor属性
        
        find : function(selector) { //定义选择器模块的find方法
            return document.getElementsByTagName(selector); //旨在方便理解,暂时只简单实现getElementsByTagName的功能
        },

        html : function(htmlText) { //定义选择器模块的find方法
            console.log(htmlText); //先不具体实现功能,只简单打印参数,旨在理解整体结构而不用分心到具体细节的实现上
        }
    };

    return jQuery; //把构造函数暴露在全局
    
})();

我们的代码又进了一步!那还有没有可以再完善的地方呢?似乎还有一个小尾巴。我们把jQuery构造函数暴露在全局的方式是通过在全局声明一个变量jQuery,然后通过赋值语句把立即执行函数的返回值(也就是jQuery构造函数的引用)赋值给这个jQuery变量。也就是说我们还是在全局内执行了一个声明和一个赋值语句。如果能够把它们也封装到立即执行函数内,又不影响留有全局接口的目的就更好了。要怎么做呢?

我们知道,在全局里声明一个变量:var jQuery实质就是给window对象添加了一个jQuery属性。也就是说var jQuery = "something"; 和window.jQuery = "something";是等价的。那么我们如果在立即执行函数里通过给window添加属性值的形式把jQuery构造函数暴露在全局的话,就不需要在外部来声明变量了。现在我们的代码就可以变成这样:

(function(){ //用立即执行函数把所有代码都封装起来
    
    //为了更好理解和测试,我们粗略地把代码细节都补全
    function jQuery(selector) {
        var elems = jQuery.prototype.find(selector); //调用原型上的find方法,获取符合条件的目标元素。
        for (var i = 0; i < elems.length; i++) {
            this[i] = elems[i]; //把目标元素存入对应的索引值属性内。
        }
        this.length = elems.length; //类数组必须有索引值属性以及length属性
    }

    jQuery.prototype = { //为jQuery函数重新定义一个新对象作为原型
        
        constructor : jQuery, //添加construtor属性
        
        find : function(selector) { //定义选择器模块的find方法
            return document.getElementsByTagName(selector); //旨在方便理解,暂时只简单实现getElementsByTagName的功能
        },

        html : function(htmlText) { //定义选择器模块的find方法
            console.log(htmlText); //先不具体实现功能,只简单打印参数,旨在理解整体结构而不用分心到具体细节的实现上
        }
    };

    window.jQuery = jQuery; //把构造函数暴露在全局

})();

Good job! 现在我们的代码全部都封装在一个立即执行函数内了!然而,新的改变又似乎带来了新的问题。window是在全局作用域内的对象,而我们jQuery库的执行代码都是在立即执行函数的作用域内的。当执行到window.jQuery = jQuery; 这段代码的时候,系统要先在立即执行函数的作用域内查找window对象,如果没有发现才会到全局作用域去寻找。如果日后我们在jQuery库定义了很多变量和方法,直接在立即执行函数内调用window对象的方式就可能会造成程序运行的效率损失。

jQuery源码学习笔记(一)_第4张图片

因此,我们希望把全局的window也放置立即执行函数的作用域内。要怎么做呢?非常简单,根据预编译的知识,函数参数是会被定义和保存在该函数的局部作用域内的。所以我们可以把全局的window作为参数传递给立即执行函数,这样在系统预编译的时候就会把window对象的引用存放在立即执行函数的作用域内。在调用window的时候就无需跨界进入全局去寻找它了。

jQuery源码学习笔记(一)_第5张图片

根据上面的信息,我们对我们的代码进行最后一次升级:

(function(window) { //用立即执行函数把所有代码都封装起来
    
    //为了更好理解和测试,我们粗略地把代码细节都补全
    function jQuery(selector) {
        var elems = jQuery.prototype.find(selector); //调用原型上的find方法,获取符合条件的目标元素。
        for (var i = 0; i < elems.length; i++) {
            this[i] = elems[i]; //把目标元素存入对应的索引值属性内。
        }
        this.length = elems.length; //类数组必须有索引值属性以及length属性
    }

    jQuery.prototype = { //为jQuery函数重新定义一个新对象作为原型
        
        constructor : jQuery, //添加construtor属性
        
        find : function(selector) { //定义选择器模块的find方法
            return document.getElementsByTagName(selector); //旨在方便理解,暂时只简单实现getElementsByTagName的功能
        },

        html : function(htmlText) { //定义选择器模块的find方法
            console.log(htmlText); //先不具体实现功能,只简单打印参数,旨在理解整体结构而不用分心到具体细节的实现上
        }
    };

    window.jQuery = jQuery; //把构造函数暴露在全局

})(window);//把window对象作为参数传递给立即执行函数,以便在局部作用域保留window的引用,加快运行速度

好了!到这里,我们的jQuery库的基本框架就已经基本完成了。下面我们将从选择器模块开始,着手具体功能的实现。


你可能感兴趣的:(jQuery)