JavaScript 进阶学习(二)—— 基于原型链继承的 js 工具库的实现方法

前言

写这篇文章的目的很简单,就是想把之前一些不太清晰的概念梳理一下,网上这类教程很多,但是本文尽可能还原问题本质,注意知识点之间的联系。相信看过我前面的博客的朋友一定知道我写文章的风格了,尽可能详尽,而且不是只是为了解决某一个小问题而写,方便大家知识点更体系,一篇内容其实相当于一章节的内容,容量有点大,我也不是一天完成的,一般是一周时间左右,所以大家阅读的话可能也需要一些时间才能有所收获。作为进阶教程,本文将简要讲述 JavaScript 面向对象编程的内容,但是不会介绍什么是接口,什么是对象,什么是对象属性,什么是对象方法,但是相信你看完了后自然理所当然的理解了这些基本概念。

重新认识 window 对象

Window 与 window 的区别

在开始学习之前我们首先用一个工具,就是浏览器自带的开发者工具控制台。我这里用 hbuider 直接打开这个工具,在【web 浏览器】预览工具栏右键单击会弹出一个框框,在这个框框中选择【Console】,然后在命令行输入 js 代码我们就可以看到执行结果,这里我们先输入一个window,然后会发现有json结构的内容。

chrome toolbar

我们如果不用控制台,直接用document.write(window);,页面上写出:[object Window],很显然内容不及这里直观,所以我们后面的很多内容会在控制台演示。熟练使用这个工具会给我们的开发带来很多好处,我想很多新手吐槽 JavaScript 不易调试,其实很多时候是他们不会调试。

很多博客在文章一开始讲一大堆理论,为了避免落入俗套,我们这里先做几个有趣的实验,我们在控制台继续输入window.top,window.window甚至window.window.window,...,我们会发现得到的结果居然一模一样。

> window === window.window               // true
> window === window.top                  // true
> window.top === window.window           // true
> window.window === window.window.window // true

是不是有点晕了,这是什么鬼。。。不要急,我们接着看,我们来个更晕的,哈哈!

> window
// Window {top: Window, window: Window, location: Location, chrome: Object, document: document…}
> Window
// function Window() { [native code] }
> window.Window
// function Window() { [native code] }
> Window.window
// undefined

这到底是什么鬼?windowWindow到底是什么关系?

我们下面来接着看看他们各自的类型:

> typeof Window            // "function"
> typeof window            // "object"
> window instanceof Window // true

查阅资料发现:

window 对象表示一个包含 DOM 文档的窗口,其 document 属性指向窗口中载入的 DOM 文档 。window 对象实现了 Window 接口,此接口继承自 AbstractView 接口。一些额外的全局函数、命名空间、对象、接口和构造函数与 window 没有典型的关联,但却是有效的。这个接口从 EventTarget 接口继承属性,通过 WindowTimers 、WindowBase64 和 WindowEventHandlers 实现属性。

这么说相信新手应该没啥感觉,最好还是举个例子说说,比如我们去吃饭要点菜,Window 说白了一个菜单,window 是端上桌子的那道菜,至于这道菜色香味以及制作方法和 Window 无关,只和 window 有关。

Window 规定了对象的类型,所以我们不难理解window instanceof Window的值为啥是trueWindowfunction。那么这里我们就清楚了windowWindow的具体方法实现,而Window对象没有Window属性,所以Window.windowundefined,所以我们需要关注的是Window的属性方法。

我们在前面可以看出来window对象自身有topwindow属性,类型为 Window,并且值是window本身;另外有个Window属性,值是Window对象,自然至此前面的内容也解释清楚了。

window 对象 document 属性( 指向当前窗口内的文档节点)

window.document 指向 document 对象的引用,document 对象是 Document 接口接口的具体实现。Document 接口代表在浏览器及服务器中加载任意 web 页面,也作为 web 页面内容(DOM tree, 包含如 <body> 和 <table< 等 element)的入口。其它为文档(document)提供了全局性的函数,例如获取页面的 URL、在文档中创建新的 element 的函数。Document 接口描述了支持所有文档类型的属性和方法。根据文档的类型(如 HTML、XML、SVG 等等 ),对文档对象操作的 API 也会不一样:HTML 文档(text/html 类型的内容)实现了 HTMLDocument 接口,而 SVG 文档实现了 SVGDocument 接口。

虽然这段话看起来,但是实际上意思很简单:如果我们要想获取一个 document 的内容,我们可以使用 document 对象下的方法属性和方法去获取,比如获取标题:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>hello world</title>
	</head>
	<body>
		<script type="text/javascript">
			// 方法1
			alert(document.title);
			// 方法2
			// alert(window.document.title);
		</script>
	</body>
</html>

我们这里的 document 为啥不加 window 也可以弹出结果呢,因为 window 为顶层对象,这里可以忽略不写,比如alert()方法其实是window.alert()下的方法,我们这里不写 window,一样可以得到结果。另外,我们这里只是获取了 title,至于其他的内容,那就要学习 document 的属性和方法。

__proto__属性(原型指针) 和 prototype 属性(原型对象)

说到这两个属性,我们真的很纠结,这两者到底有什么联系和区别呢?我们先看下面的例子:

> window.prototype === window.__proto__     // false
> Window.prototype === window.__proto__     // true
> window.constructor === Window             // true
> Window.__proto__.__proto__.__proto__.__proto__ // null

卧槽,这是什么鬼?prototype__proto__到底分别各自指什么,Window 链式调用__proto__怎么最后会变成 null? 似乎说到这里谜团越来越多了,我们这里就要跳出 window 对象举个简单例子说说,不然大家真的是晕的。

function person(name) {
	this.name = name;
	this.getName = function() {
		alert(this.name)
	}
}

var zhangsan = new person('zhangsan');
var lisi = new person('lisi');

console.log(zhangsan.name)
console.log(lisi.name)
zhangsan.getName();
lisi.getName();
结果:
"zhangsan"
"lisi"

注:可以使用关键字 this 调用类中的属性, this 是对当前对象的引用。

这样一个例子我们似乎看到了面向对象中继承的特性,在其他面向对象语言中,这里的 person 函数被设计为“类”,但是在 JavaScript 中这里设计得有点畸形的感觉,为啥这么说呢,因为这里的 person 是一个构造函数(constructor),用 new 实例化的也不是其他面向对象语言中的类,而是构造函数,这种设计导致一个问题是啥呢?无法共享属性和方法,每一个实例对象,都有自己的属性和方法的副本!!!

比如:每一个实例对象都有 getName(),都是从父亲构造器中继承得到,这样就产生多个副本,但是我们希望这个方法是公用的,避免多个副本的资源浪费,我们希望能够把公用的属性方法提取出来,然后实例化的对象也可以引用,但是不会直接拷贝一份作为副本。这个时候构造函数(constructor)显得有点力不从心了,JavaScript 的设计者引入了一个重要的属性 prototype,这个属性包含一个对象(通常称为“prototype 对象")。我们把这个例子改成用 prototype 写试试:

function person(name) {
  this.name = name;
}
person.prototype.getName = function() {
  alert(this.name);
};

var zhangsan = new person("zhangsan");
var lisi = new person("lisi");

这样我们多个实例化对象可以公用同一个方法,换句话说所有的实例对象共享同一个 prototype 对象,通常称为原型。一层层的继承实现了链条式的"原型链"(prototype chain),JavaScript 因此通过这个原型链实现继承。至于为啥最开始怎么设计,都是为了开发者简单,但是也因此给大家的感觉是特别,而且特别难理解,但是事实上其实并没有那么神奇!!!

prototype 属性很特殊,它还有一个隐式的 constructor,指向了构造函数本身。

> person.prototype.constructor === person // true
> zhangsan.constructor === person         // true
> zhangsan.constructor === person.prototype.constructor // true

说了这个多,我们一直没有解释__proto__属性,我们上面讲了可以通过构造函数的prototype属性实现继承共用公用的属性方法,但是我们没有说明实例化对象如何访问到它所继承的对象的原型对象,这里的__proto__属性就是这个作用。我们再回过头去看之前的问题:因为window是通过实例化Window得到,自然我们访问Window原型对象有两种方法:1.直接通过 Window 的 prototype 属性;2.通过实例化子对象的__proto__访问父对象的原型对象。这两种方法实现的结果一模一样。

Window.prototype === window.__proto__; // true

另外在 JavaScript 中有一个很特别的地方:万物皆对象,万物皆为空。 怎么理解呢,在 JavaScript 中的一切都源于对象,而且最顶层的对象是 null 对象,这会让人很费解的。所以当我们通过__proto__不断的寻找最顶层的原型对象时会发现为 null。

基于原型的编程不是面向对象编程中体现的风格,且行为重用(在基于类的语言中也称为继承)是通过装饰它作为原型的现有对象的过程实现的。这种模式也被称为弱类化,原型化,或基于实例的编程。

最后有几点需要说明的是:

  • 每个构造函数都有一个原型对象(prototype),原型对象都包含一个指向构造函数的指针(constructor),而实例都包含一个指向原型对象的内部指针(__proto__)。
  • 除了使用__proto__方式访问对象的原型,还可以通过Object.getPrototypeOf方法来获取对象的原型,以及通过Object.setPrototypeOf方法来重写对象的原型。__proto__属性只有浏览器才需要部署,其他环境可以没有这个属性,而且前后的两根下划线,表示它本质是一个内部属性,不应该对使用者暴露。
  • instanceof 和 Object.isPrototypeOf()可以判断两个对象是否是继承关系。如上面那个例子:
// instanceof 运算符返回一个布尔值,表示一个对象是否由某个构造函数创建。
> zhangsan instanceof person
=> true
// Object.isPrototypeOf()只要某个对象处在原型链上,都返回true。
> person.prototype.isPrototypeOf(zhangsan)
=> true

这里推荐大家看看下面几篇文章:

如何打造一个自己的类 jQuery 的 js 工具库?

文章写到本来是准备重新开篇的,刚刚上面在 window 下将原型链继承不知道会不会有点误导一些朋友,因为最开始是准备以 window 对象入手将面向对象的内容整理一下,发现写着写着有点零散了,因为 window 对象有很多其他内容值得将,但是篇幅和本文主题影响,只能先停下后面再开篇补充,讲了原型链继承的理论知识,我们自然要实际动手做点项目才能说明问题。

基本概念讲解

如果我们去查看一些 js 库的写法,我们会发现经常有这样一种结构:

(function(w, undefined) {
  //...
})(window);

在理解为什么要这样写之前我们首先要明白什么 JavaScript 的作用域,什么是匿名函数,什么是闭包?

作用域

  • 在 es6 之前,JavaScript 是遵循函数作用域,不支持块级作用域。
var i = 0;
if (i < 2) {
  var i = 2;
}
alert(i); // 2
  • 在 es6 中支持使用 let 声明了一个块级域的本地变量,并且可以同时初始化该变量。
var i = 0;
if (i < 2) {
  let i = 2;
}
alert(i); // 0
  • 函数内部可以直接读取函数全局变量。函数内的变量如果是使用 var 申明,则是局部变量,作用域范围为函数体内部,不可读取;但是需要注意的是未经过 var 申明,就变成了全局变量,在函数外部也可以调用。
// 局部变量类型:
var i = 0;
var fn = function() {
  if (i < 2) {
    var i = 2;
  }
};
fn();
alert(i); // 0

// 全局变量类型
var i = 0;
var fn = function() {
  if (i < 2) {
    i = 2;
  }
};
fn();
alert(i); // 2
  • 变量提升:一个变量或函数可以在它被引用之后声明。
【变量】
foo = 2
var foo;
// 被隐式地解释为:
var foo;
foo = 2;

【函数】
hoisted(); // logs "foo"
function hoisted() {
  console.log("foo");
}

匿名函数:没有函数名称的函数

匿名函数是这样的:

function(arg1,arg2){
	// code
}

但是通常我们会把匿名函数写成自执行的匿名函数:

(function(arg1,arg2){
	// code
})(a1,a2);

等价于:
var fn = function(arg1,arg2){
	// code
}
fn(a1,a2);

其实这里就是实参与形参的关系,arg1,arg2 在函数体内作为形参被引用,a1,a2 作为实参在调用的时候传入到函数体中被调用,至于变量内部存储原理这里不做深入探究,毕竟学过编程的人应该都清楚。

我们现在回过头来看看本小节开头说的那个例子,为啥要那样写呢?

(function(w, undefined) {
  //...
})(window);
  • 为什么要传入 window?通过传入 window 变量,使得 window 由全局变量变为局部变量,当在我们封装的代码块中访问 window 时,不需要将作用域链回退到顶层作用域,这样可以更快的访问 window;同时将 window 作为参数传入,可以在压缩代码时进行优化。
  • 为什么要传入 undefined?在只执行匿名函数的作用域内,确保 undefined 是真的未定义。因为 undefined 能够被重写,赋予新的值。

闭包

我们前面说了在函数外可以调用函数内未经过 var 声明的全局变量,但是如何从外部读取函数局部变量呢?我们可以在函数内部再定义一个函数。

var fn = function() {
  var name = "local";
  var f = function() {
    alert(name);
  };
  return f;
};

// 调用
var resurlt = fn();
resurlt();
// or
fn()();

闭包主要有两个作用:一是可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。

读取函数内部变量我们很好理解,但是至于内部变量的值保存在存储中这个就有点难理解,我们看个例子:

var fn = function() {
  var i = 0;
  add = function() {
    i++;
  };
  var f = function() {
    alert(i);
  };
  return f;
};

var result = fn();
result(); // 0
add();
result(); // 1

add 未加 var 声明是全局变量,如果变量 i 不在内存中存储,那么我们第一次和第二次调用result()值都应该为 0。原因在于我们将 fn()的返回值 f()数赋值给一个全局变量,由于这个全局变量一直处于内存中,f 函数同样也在内存中,f()函数依赖于 fn()函数,因此 fn()中的局部变量 i 一直处于内存之中。如果上面的例子在调用的时候使用fn()()则不会出现这种情况。

1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在 IE 中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。 2)闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。—— 学习 Javascript 闭包(Closure)

这里简要讲解了一下闭包的一些作用,主要是为了帮助我们理解为啥一些 js 库采用闭包。

jQuery 中链式调用的实现原理

首先我们怎么实现私有命名空间?通过定义一个匿名函数,创建了一个"私有"的命名空间,该命名空间的变量和方法,不会破坏全局的命名空间,我们只暴漏出一个顶层的对象供外部调用即可。

前面我们讲到 window 对象的时候有个知识点没有说的是,我们在页面定义一个全局变量的时候,这个全局变量最终是会在 window 对象下,对于调用 window 对象下的属性和方法我们一般无需通过window.的形式就可以调用。同理当我们引用 jQuery 这种库的时候,jQuery 对象会在引用页面的 window 对象下,这是因为 jQuery 库最后会将 jQuery 或者$对象挂在 window 对象下,这样就实现了顶层对象的暴漏。

下面我们实现一个适用于现代浏览器的极小 DOM 操作库,主要解决移动端,所以我们这里取名为 mjs。

(function(w, undefined) {
  // 构造函数
  var mjs = function(selector, context) {
    return new mjs.fn.init(selector, context);
  };
  // 构造函数mjs的原型对象
  mjs.fn = mjs.prototype = {
    constructor: mjs,
    init: function(selector, context) {
      //...
    }
  };

  mjs.fn.init.prototype = mjs.fn;
  // 为window全局变量添加mjs对象
  w.mjs = w.m = mjs;
})(window);

这样我们就可以无需 new mjs(),直接使用 mjs.* 或者 m.* 链式调用相关方法。

selector(选择器)

下面我们实现一个最简的选择器,这里我不考虑兼容低级版本浏览器,使用 querySelectorAll 实现。我们接着上面的完善 mjs.prototype.init 方法。我们如果不考虑链式调用,我们最简单的选择器甚至可以长这样:

var $ = function (selector) {
   return document.querySelector(selector);
}

调用:
$(".content")

如果想给选择器加一个上下文,我们进一步可以这样:

/**
 * 选择器
 * @param {Object} selector
 * @param {Object} context
 */
var $ = function(selector, context) {
  var context = context || document;
  var el = context.querySelectorAll(selector);
  return Array.prototype.slice.call(el);
};

// 调用
var divObj = $(".div", $(".content")[0]);
console.log(divObj[0].innerHTML);

当然我们这里补充完整就是这样了:

// 构造函数mjs的原型对象
mjs.prototype = {
  constructor: mjs,
  init: function(selector, context) {
    if (!selector) {
      return mjs;
    } else if (typeof selector === "object") {
      var selector = [selector];
      for (var i = 0; i < selector.length; i++) {
        this[i] = selector[i];
      }
      this.length = selector.length;
      return mjs;
    } else if (typeof selector === "string") {
      var selector = selector.trim();
      var context = context || document;
      var el = context.querySelectorAll(selector);
      var dom = Array.prototype.slice.call(el);
      var length = dom.length;
      for (var i = 0; i < length; i++) {
        this[i] = dom[i];
      }
      this.context = context;
      this.selector = selector;
      this.length = length;
      return this;
    }
  }
};

这里我们先只完成最简单的选择器功能,还有当 selector 是 function 类型的我们没有进行判断,这里不贴出来,大家具体可以看看源代码。我们可以验证一下我们封装的这个选择器:

<div class="divBox">
  div1
  <span>span1</span>
</div>;

console.log(mjs(".divBox")[0].innerHTML);
// "div1<span>span1</span>"

console.log(mjs(".divBox span")[0].innerHTML);
// "span1"

var divBox = mjs(".divBox")[0];
console.log(mjs("span", divBox)[0].innerHTML);
// "span1"

因为 innerHTML 是原生 DOM 操作的方法,我们 mjs 对象没有这个方法,所以我们这里是将 mjs 对象转成了原生 DOM 对象,转换方法:mjs(selector)[0]。

html()、text()、attr()

为了简单起见我们继续封装,先完成一个 html()方法。

...
html: function (content) {
	if (content === undefined && this[0].nodeType === 1) {
      		return this[0].innerHTML.trim();
      	}else{
      		var len = this.length;
		for (var i = 0; i < len; i++) {
			this[i].innerHTML = content;
		}
		return this;
      	}
},
text: function (val) {
    if (!arguments.length) {
		return this[0].textContent.trim();
	} else {
		for (var i = 0; i < this.length; i++) {
		    this[i].innerText = val;
		}
		return this;
	}
}
...

上面的例子我们可以这样调用:

// 直接获取内容,默认获取第一个匹配项
console.log(mjs(".divBox").html());
console.log(mjs(".divBox span").html());
console.log(mjs(".divBox span").text());
// 更新内容,默认更新所有匹配项
mjs(".divBox span").html("我是新的内容");
mjs(".divBox span").text("我是新的内容");
// 支持上下文查找方法
console.log(mjs("span", mjs(".divBox")[0]).html());
// 设置属性
mjs(".divBox").attr("name", "divBox");
// 获取属性
console.log(mjs(".divBox").attr("name"));

prepend()、append()、before()、after()、remove()

prepend: function(str) {
	var len = this.length;
  		for (var i = 0; i < len; i++) {
        this[i].insertAdjacentHTML('afterbegin', str);
    }
    return this;
},
append: function (str) {
	var len = this.length;
    for (var i = 0; i < len; i++) {
        this[i].insertAdjacentHTML('beforeend', str);
    }
    return this;
},
before: function (str) {
    var len = this.length;
    for (var i = 0; i < len; i++) {
    	this[i].insertAdjacentHTML('beforebegin', str);
    }
    return this;
},
after: function (str) {
    var len = this.length;
    for (var i = 0; i < len; i++) {
        this[i].insertAdjacentHTML('afterend', str);
    }
    return this;
},
remove: function () {
	var len = this.length;
	for (var i = 0; i < len; i++) {
		this[i].parentNode.removeChild(this[i]);
	}
  	return this;
}

调用:

// 添加元素
mjs(".divBox").prepend("<ul><li>prepend</li></ul>");
mjs(".divBox").append("<ul><li>append</li></ul>");
mjs(".divBox").before("<ul><li>before</li></ul>");
mjs(".divBox").after("<ul><li>after</li></ul>");
// 删除元素
mjs(".divBox").remove();

insertAdjacentHTML() 将指定的文本解析为 HTML 或 XML,然后将结果节点插入到 DOM 树中的指定位置处。该方法不会重新解析调用该方法的元素,因此不会影响到元素内已存在的元素节点。从而可以避免额外的解析操作,比直接使用 innerHTML 方法要快。——MDN insertAdjacentHTML

语法: element.insertAdjacentHTML(position, text); position 是相对于 element 元素的位置,并且只能是以下的字符串之一:

  • beforebegin: 在 element 元素的前面。
  • afterbegin:在 element 元素的第一个子节点前面。
  • beforeend:在 element 元素的最后一个子节点后面。
  • afterend:在 element 元素的后面。

hasClass()、addClass()、removeClass()、toggleClass()

...
hasClass: function (cls) {
	return this[0].classList.contains(cls);
},
addClass: function (cls) {
	var len = this.length;
	for (var i = 0; i < len; i++) {
		if(!this[i].classList.contains(cls)){
			this[i].classList.add(cls);
		}
	}
	return this;
},
removeClass: function (cls) {
	var len = this.length;
	for (var i = 0; i < len; i++) {
		if(this[i].classList.contains(cls)){
			this[i].classList.remove(cls);
		}
	}
	return this;
},
toggleClass: function (cls) {
	return this[0].classList.toggle(cls);
}
...

调用方法:

// hasClass(返回值为布尔值)
console.log(mjs(".divBox").hasClass("divBox"));
// addClass
mjs(".divBox").addClass("red");
// removeClass
mjs(".divBox").removeClass("red");
// toggleClass
mjs(".divBox").toggleClass("red");

css()

css: function (attr,val) {
	var len = this.length;
	for(var i = 0;i < len; i++) {
		if(arguments.length === 1){
			var obj = arguments[0];
			if(typeof obj === 'string'){
				return getComputedStyle(this[i],null)[attr];
			}else if(typeof obj === 'object'){
		        for(var attr in obj){
					this[i].style[attr] = obj[attr];
				}
			}
		} else {
			if(typeof val === 'function'){
				this[i].style[attr] = val();
			}else{
				this[i].style[attr] = val;
			}
        }
    }
	return this;
}

调用方法:

// 获取样式属性值
console.log(mjs(".divBox").css("color"));
// 设置样式属性值
// 方法1
mjs(".divBox").css("color", "red");
// 方法2
mjs(".divBox").css({
  width: "100px",
  color: "white",
  "background-color": "#98bf21",
  "font-family": "Arial",
  "font-size": "20px",
  padding: "5px"
});
// 方法3
mjs(".divBox").css("background-color", function() {
  return "#F00";
});

find()、first()、last()、eq(index)、parent()

find: function(selector){
	return this.init(selector,this[0])
},
first: function(){
	return this.init(this[0])
},
last: function(){
	return this.init(this[this.length-1])
},
eq: function(index){
	return this.init(this[index])
},
parent: function(){
	return this.init(this[0].parentNode);
}

我们前面想通过上下文查找内容:

console.log(mjs("span", mjs(".divBox")[0]).html());

我们可以通过 find 方法这样写:

console.log(mjs('.divBox').find('span').html())			console.log(mjs('.divBox span').first().html())
console.log(mjs('.divBox span').last().html())
console.log(mjs('.divBox span').eq(1).html())
console.log(mjs('.divBox span').eq(1).parent().html())

关键在于 mjs 对象和原生 dom 的区别和相互转换。

至此我们封装了一个简单的类 jQuery 的工具库,当然对于一个完整的工具库,比如 jQuery、zepto 等,它们功能肯定是更为完善,封装了更多的方法,在异常处理及性能、可拓展性方法做得更好,由于本文的重点不是为了完成一个完整的库,在此只是抛砖引玉,只是学习一下常用的思想,有兴趣的朋友可以继续完善这个库。

参考

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!