# 什么是设计模式
设计模式就是在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。通俗一点说,设计模式是在某种场合下对某个问题的一种解决方案。如果再通俗一点说,设计模式就是给面向对象软件开发中的一些好的设计取个名字。
设计模式的作用是让人们写出可复用和可维护性高的程序。
所有设计模式的实现都遵循一条原则,即 “找出程序中变化的地方,并将变化封装起来”。
分辨模式的关键是意图而不是结构,模式只有放在具体的环境下才有意义。
GoF 在 1995 年提出了 23 种设计模式。但模式不仅仅局限于这 23 种。在近 20 年的时间里,也许有更多的模式已经被人发现并总结了出来。比如一些 JS 图书中会提到模块模式、沙箱模式等。这些 “模式” 能否被世人公认并流传下来,还有待时间验证。不过某种解决方案要成为一种模式,还是有几个原则要遵守的。这几个原则即是 “再现” “教学” 和 “能够以一个名字来描述这种模式”。
# 静态类型语言和动态类型语言
编程语言按照数据类型大体可以分为两类:一类是静态类型语言,另一类是动态类型语言。
静态类型语言在编译时便已确定变量的类型,而动态类型语言的变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。
🔔 JS 是一门典型的动态类型语言。
# 静态类型语言
- 优点
在编译时就能发现类型不匹配的错误,编辑器可以帮助我们提前避免程序在运行期间有可能发生的一些错误。
如果在程序中明确规定了数据类型,编译器还可以针对这些信息对程序进行一些优化工作,提高程序执行速度。
- 缺点
迫使程序员依照强契约来编写程序,为每个变量规定数据类型,归根结底只是辅助我们编写可靠性高程序的一种手段,而不是编写程序的目的,毕竟大部分人编写程序的目的是为了完成需求交付生产。
类型的声明也会增加更多的代码,在程序编写过程中,这些细节会让程序猿的经历从思考业务逻辑上分散开来。
# 动态类型语言
- 优点
编写的代码数量更少,看起来也更简洁,程序员可以把精力更多地放在业务逻辑上面。虽然不区分类型在某些情况下会让程序变得难以理解,但整体而言,代码量越少,越专注于逻辑表达,对阅读程序是越有帮助的。
- 缺点
无法保证变量的类型,从而在程序的运行期有可能发生跟类型相关的错误。
# 鸭子类型
鸭子类型的通俗说法就是:“如果它走起路来像鸭子,叫起来也像鸭子,那么它就是鸭子。”
鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注 HAS-A,而不是 IS-A。
在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”。例如,一个对象若有 push 和 pop 方法,并且这些方法提供了正确的实现,它就可以被当作栈来使用。一个对象如果有 length 属性,也可以依照下标来存取属性(最好还要拥有 slice 和 splice 等方法),这个对象就可以被当作数组来使用。
在静态类型语言中,要实现 “面向接口编程” 并不是一件容易的事情,往往要通过抽象类或者接口等将对象进行向上转型。当对象的真正类型被隐藏在它的超类型身后,这些对象才能在类型检查系统的 “监视” 下互相被替换使用。只有当对象能够被互相替换使用,才能体现出对象多态性的价值。
“面向接口编程” 是设计模式中最重要的思想,但在 JS 中,“面向接口编程” 的过程跟主流的静态类型语言不一样,因此,在 JS 中实现设计模式的过程与在一些我们熟悉的语言中实现的过程会大相径庭。
# 多态
# 什么是多态?
多态的实际含义是:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。
多态背后的思想是将 “做什么” 和 “谁去做以及怎样去做” 分离开来,也就是将 “不变的事物” 与 “可能改变的事物” 分离开来。
把不变的部分隔离开来,把可变的部分封装起来,这给予了我们扩展程序的能力,程序看起来是可生长的,也是符合开放-封闭原则的。
一个体现对象的多态性的例子如下:
// 把不变的部分隔离出来
var makeSound = function(animal) {
animal.sound();
};
// 把可变的部分各自封装起来
var Duck = function() {};
Duck.prototype.sound = function() {
console.log("嘎嘎嘎");
};
var Chicken = function() {};
Chicken.prototype.sound = function() {
console.log("咯咯咯");
};
makeSound(new Duck());
makeSound(new Chicken());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 类型检查和多态
静态类型语言在编译时会进行类型匹配检查。以 Java 为例,由于在代码编译时要进行严格的类型检查,所以不能给变量赋予不同类型的值,这种类型检查有时候会让代码显得僵硬。
尝试把上面的例子换成 Java 代码:
public class Duck {
// 鸭子类
public void makeSound() {
System.out.println("嘎嘎嘎");
}
}
public class Chicken {
// 鸡类
public void makeSound() {
System.out.println("咯咯咯");
}
}
public class AnimalSound {
public void makeSound(Duck duck) { // (1)
duck.makeSound();
}
}
public class Test {
public static void main(String args[]) {
AnimalSound animalSound = new AnimalSound();
Duck duck = new Duck();
animalSound.makeSound(duck); // 输出:嘎嘎嘎
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
这段代码已经顺利地让鸭子可以发出叫声,但如果现在想让鸡也叫唤起来,我们发现这是一件不可能实现的事情。因为 (1) 处 AnimalSound 类的 makeSound 方法,被我们规定为只能接受 Duck 类型的参数:
public class Test {
public static void main(String args[]) {
AnimalSound animalSound = new AnimalSound();
Chicken chicken = new Chicken();
animalSound.makeSound(chicken); // 报错,只能接受 Duck 类型的参数
}
}
2
3
4
5
6
7
某些时候,在享受静态类型语言类型检查带来的安全性的同时,我们也会感觉被束缚住了手脚。
为了解决这一问题,静态类型的面向对象语言通常被设计为可以向上转型:当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。这就像我们在描述天上的一只麻雀或者一只喜鹊时,通常说 “一只麻雀在飞” 或者 “一只喜鹊在飞”。但如果忽略它们的具体类型,那么也可以说 “一只鸟在飞”。
# 使用继承得到多态效果
使用继承来得到多态效果,是让对象表现出多态性的最常用手段。继承通常包括实现继承和接口继承。
使用实现继承来改造上面的例子如下:
public abstract class Animal { // 抽象类
abstract void makeSound(); // 抽象方法
}
public class Duck extends Animal {
public void makeSound() {
System.out.println('嘎嘎嘎');
}
}
public class Chicken extends Animal {
public void makeSound() {
System.out.println('咯咯咯');
}
}
public class AnimalSound {
public void makeSound(Animal animal) {
animal.sound();
}
}
public class Test {
public static void main(String args[]) {
AnimalSound animalSound = new AnimalSound();
Animal duck = new Duck();
Animal chicken = new Chicken();
animalSound.makeSound(duck);
animalSound.makeSound(chicken);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# JavaScript 的多态
JS 作为一门动态类型语言,它在编译时没有类型检查的过程,既没有检查创建的对象类型,又没有检查传递的参数类型,变量类型在运行期是可变的,因此 JS 对象的多态性是与生俱来的。
# 多态在面向对象程序设计中的作用
Martin Fowler 在《重构:改善既有代码的设计》里写到:
多态的最根本好处在于,你不必再向对象询问 “你是什么类型” 而后根据得到的答案调用对象的某个行为——你只管调用该行为就是了,其他的一切多态机制都会为你安排妥当。
下面这个例子可以很好的诠释上面这段话。
在电影的拍摄现场,当导演喊出 “action” 时,主角开始背台词,照明师负责打打光,后面的群众演员假装中枪倒地,道具师往镜头里撒上雪花。在得到同一个消息时,每个对象都知道自己应该做什么。如果不利用对象的多态性,而是用面向过程的方式来编写这一段代码,那么相当于在电影开始拍摄之后,导演每次都要走到每个人的面前,确认他们的职业分工(类型),然后告诉他们要做什么。如果映射到程序中,那么程序中将充斥着条件分支语句。
将行为分布在各个对象中,并让这些对象各自负责自己的行为,这正是面向对象设计的优点。
再来看一个实际开发中遇到的例子。
假设我们要编写一个地图应用,现在有几家可选的地图 API 提供商供我们接入自己的应用。此时就可以利用多态机制来实现这个需求。
var googleMap = {
show() {
console.log("开始渲染谷歌地图");
}
};
var baiduMap = {
show() {
console.log("开始渲染百度地图");
}
};
var renderMap = function(map) {
if (map.show instanceof Function) {
map.show();
}
};
renderMap(googleMap);
renderMap(baiduMap);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这段代码的多态性就体现在,当我们向谷歌地图对象和百度地图对象分别发出 “展示地图” 的消息时,会分别带调用它们的 show 方法,就会产生各自不同的执行结果。
不过,在这个例子中,我们假设每个地图 API 提供展示地图的方法名都是 show,在实际开发中也许不会如此顺利,这时候可以借助适配器模式来解决问题。
# 设计模式与多态
绝大部分设计模式的实现都离不开多态性的思想。
在 JS 这种将函数作为一等对象的语言中,函数本身也是对象,函数用来封装行为并且能够被四处传递。当我们对一些函数发出 “调用” 的消息时,这些函数会返回不同的执行结果,这是 “多态性” 的一种体现,也是很多设计模式在 JS 中可以用高阶函数来代替实现的原因。
# 封装
封装的目的是将信息隐藏。
一般而言,我们讨论的封装是封装数据和封装实现。不过,更广义的封装,不仅包括这两种,还包括封装类型和封装变化。
封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。
# 封装数据
在许多语言的对象系统中,封装数据是由语法解析来实现的,这些语言也许提供了 private、public、protected 等关键字来提供不同的访问权限。
但 JS 并没有提供对这些关键字的支持,我们只能依赖变量的作用域来实现封装特性,而且只能模拟出 public 和 private 这两种特性。
除了 ES6 提供的 let 之外,一般通过函数来创建作用域:
var myObj = (function() {
var __name = "abc"; // 私有变量 private
return {
getName() {
// 公开方法 public
return __name;
}
};
})();
console.log(myObj.getName()); // abc
console.log(myObj.__name); // undefined
2
3
4
5
6
7
8
9
10
11
12
还有一个值得注意的是,在 ES6 中,还可以通过 Symbol 创建私有属性。
# 封装实现
从封装实现细节来看,封装使得对象内部的变化对其他对象而言是透明的,也就是不可见的。
对象对自己的行为负责。其他对象或者用户都不关心它的内部实现。封装使得对象之间的耦合变得松散,对象之间只通过暴露的 API 接口来通信。
当我们修改一个对象时,可以随意修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。
比如迭代器,迭代器的作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式来顺序访问这个聚合对象。我们编写一个 each 函数,它的作用就是遍历一个聚合对象,使用这个 each 函数的人不用关心它的内部是怎样实现的,只要它提供的功能正确就可以。即使 each 函数修改了内部源代码,只要对外接口或者调用方式没有变化,用户就不用关心它内部实现的改变。
# 封装类型
封装类型是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的。
把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,用户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一,比如工厂方法模式、组合模式等。
不过在 JS 中并没有对抽象类和接口的支持,JS 本身也是一门类型模糊的语言。
在封装类型方面,JS 没有能力,也没有必要做得更多。
对于 JS 的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。
# 封装变化
从设计模式的角度出发,封装在更重要的层面体现为封装变化。
《设计模式》一书中曾提到 “找到变化并封装之”:
考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能够在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。
《设计模式》中一共总结了 23 种设计模式,从意图上区分,它们分别被划分为创建型模式、结构型模式和行为型模式。
创建型模式封装的是创建对象的变化,结构型模式封装的是对象之间的组合关系,行为型模式封装的是对象的行为变化。
通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性和可扩展性。
# 原型模式
在 Brendan Eich 为 JS 设计面向对象系统时,借鉴了 Self 和 Smalltalk 这两门基于原型的语言。Brendan Eich 从一开始就没有打算在 JS 中加入类的概念。
在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。而在原型编程的思想中,类并不是必需的,对象未必需要从类中创建而来。
原型模式不单是一种设计模式,也被称为一种编程范型。
# 使用克隆的原型模式
从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。但原型模式选择了另一种方式,即我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。
既然原型模式是通过克隆来创建对象的,那么很自然地会想到,如果需要一个跟某个对象一模一样的对象,就可以使用原型模式。
原型模式实现的关键,是语言本身是否提供了克隆的方法。ES5 提供了 Object.create
方法,可以用来克隆对象。
var Plane = function() {
this.blood = 100;
this.attackLevel = 1;
this.defineLevel = 1;
};
var plane = new Plane();
var clonePlane = Object.create(plane);
console.log(clonePlane.blood); // 100
console.log(clonePlane.attackLevel); // 1
console.log(clonePlane.defineLevel); // 1
2
3
4
5
6
7
8
9
10
11
12
🔔 在不支持 Object.create 方法的浏览器中,可以使用以下代码:
Object.create =
Object.create ||
function(obj) {
var F = function() {};
F.prototype = obj;
return new F();
};
2
3
4
5
6
7
# 克隆是创建对象的手段
不过原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。
原型模式创建对象的方式就像一个仙女要送给三岁小女孩生日礼物,虽然小女孩可能还不知道飞机或者船怎么说,但她可以指着商店橱柜里的飞机模型说 “我要这个”。
在 JS 这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题。从设计模式角度来讲,原型模式的意义并不算大。但 JS 本身是一门基于原型的面向对象语言,它的对象系统就是使用原型模式来搭建的,在这里称之为原型编程范型也许更合适。
在 JavaScript 中不存在类的概念,对象也并非从类中创建出来的,所有的 JavaScript 对象都是从某个对象上克隆而来的。
# Io 语言
Io (opens new window) 语言在 2002 年由 Steve Dekorte 发明。作为一门基于原型的语言,Io 同样没有类的概念,每一个对象都是基于另外一个对象的克隆。
在 Io 中,根对象名为 Object
,其他所有的对象都克隆自另外一个对象。如果 A 对象是从 B 对象克隆而来的,那么 B 对象就是 A 对象的原型。
Animal := Object clone // 克隆动物对象
Animal makeSound := method('animal makeSound' print);
Dog := Animal clone
Dog eat := method('dog eat' print);
Animal makeSound // animal makeSound
Dog eat // dog eat
2
3
4
5
6
这里,Object 是 Animal 的原型,Animal 是 Dog 的原型,它们之间形成了一条原型链。当我们尝试调用 Dog 对象的某个方法时,而它本身却没有这个方法,那么 Dog 对象就会把这个请求委托给它的原型 Animal 对象,如果 Animal 对象也没有这个方法,那么请求会顺着原型链继续被委托给 Object 对象,这样一来便能得到继承的效果,看起来就像 Animal 是 Dog 的 “父类”,Object 是 Animal 的 “父类”。
# 原型编程范型的一些规则
IO 和 JavaScript 一样, 基于原型链的委托机制就是原型继承的本质。
原型编程中的一个重要特性,即当对象无法响应某个请求时,会把该请求委托给它自己的原型。
原型编程范型至少包含以下基本原则:
所有的数据都是对象。
要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
对象会记住它的原型。
如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。
# JavaScript 中的原型继承
在原型继承方面,JS 的实现原理和 Io 语言非常相似,也要遵守以上原型编程的基本原则。
📌 1. 所有的数据都是对象
JS 在设计的时候,模仿 Java 引入了两套类型机制:基本类型和对象类型。
按照 JS 设计者的本意,除了 undefined 之外,一切都应是对象。为了实现这一目标,number、boolean、string 这几种基本类型数据也可以通过 “包装类” 的方式变成对象类型数据来处理。
我们不能说 JS 中所有的数据都是对象,但可以说绝大部分数据都是对象。
JS 中的根对象是 Object.prototype
对象,Object.prototype 对象是一个空的对象。我们在 JS 中遇到的每个对象,都是从 Object.prototype 对象克隆而来的,Object.prototype 对象就是它们的原型。
var obj1 = new Object();
var obj2 = {};
// ES5 提供的 Object.getPrototypeOf 方法可以用来查看对象的原型
console.log(Object.getPrototypeOf(obj1) === Object.prototype); // true
console.log(Object.getPrototypeOf(obj2) === Object.prototype); // true
2
3
4
5
6
📌 2. 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
在 Io 语言中,克隆一个对象的动作非常明显,但在 JS 中,我们并不需要关心克隆的细节,因为这是引擎内部负责实现的。我们所需要做的只是显示的调用 var obj1 = new Object() 或者 var obj2 = {}。此时,引擎内部会从 Object.prototype 上面克隆一个对象出来,我们最终得到的就是这个对象。
如何利用 new 从构造器中得到一个对象。
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
};
var a = new Person("Mike");
console.log(a.name); // Mike
console.log(a.getName()); // Mike
console.log(Object.getPrototypeOf(a) === Person.prototype); // true
2
3
4
5
6
7
8
9
10
11
在这里 Person 并不是类,而是函数构造器。JS 的函数既可以作为普通函数被调用,也可以作为构造器被调用。当使用 new 来调用函数时,此时的函数就是一个构造器。用 new 来创建对象的过程,实际上也是先克隆 Object.prototype 对象,再进行一些其他额外操作的过程。
注意
JS 是通过克隆 Object.prototype 来得到新的对象,但实际上并不是每次都真正地克隆了一个新的对象。从内存方面的考虑出发,JS 还做了一些额外的处理,具体细节可以看周爱民的《JavaScript 语言精髓与编程实践》。
🔔 可以通过下面这段代码来理解 new 的过程:
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
};
// new 的实现
var objectFactory = function() {
var obj = new Object(), // 从 Object.prototype 上克隆一个空的对象
Constructor = [].shift.call(arguments); // 取得外部传入的构造器,这里是 Person
obj.__proto__ = Constructor.prototype; // 指向正确的原型
var res = Constructor.apply(obj, arguments); // 借用外部传入的构造器给 obj 设置属性
return typeof res === "object" ? res : obj; // 确保构造器总是会返回一个对象
};
var a = objectFactory(Person, "Mike");
console.log(a.name); // Mike
console.log(a.getName()); // Mike
console.log(Object.getPrototypeOf(a) === Person.prototype); // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
📌 3. 对象会记住它的原型
我们一直在说 “对象的原型”,就 JS 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对于 “对象把请求委托给它自己的原型” 这句话,更好的说法是对象把请求委托给它的构造器的原型。
那么对象如何把请求顺利地转交给它的构造器的原型呢?
JS 给对象提供了一个名为 __proto__
的隐藏属性,某个对象的 __proto__
属性默认会指向它的构造器的原型对象,即 {Constructor}.prototype。在一些浏览器中,__proto__
被公开出来,比如 Chrome 或者 Firefox:
var a = new Object();
console.log(a.__proto__ === Object.prototype); // true
2
实际上,__proto__
就是对象跟 “对象构造器的原型” 联系起来的纽带,对象要通过 __proto__
属性来记住它的构造器的原型。
因此在模拟 new 创建对象时,需要手动给 obj 对象设置正确的 __proto__
指向。
obj.__proto__ = Constructor.prototype;
📌 4. 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型
这条规则是原型继承的精髓所在。
JS 的克隆和 Io 语言的克隆有点不一样,Io 中每个对象都可以作为原型被克隆,而在 JS 中,每个对象都是从 Object.prototype 对象克隆而来的,如果是这样的话,我们只能得到单一的继承关系,即每个对象都继承自 Object.prototype 对象,这样的对象系统显然是非常受限的。
实际上,虽然 JS 的对象最初都是由 Object.prototype 对象克隆而来的,但对象构造器的原型并不仅限于 Object.prototype 上,而是可以动态指向其他对象。这样一来,当对象 a 需要借用对象 b 的能力时,可以有选择性的把对象 a 的构造器的原型指向对象 b,从而达到继承的效果。
🔔 下面的代码是最常用的原型继承方式:
var obj = { name: "abc" };
var A = function() {};
A.prototype = obj;
var a = new A();
console.log(a.name); // abc
2
3
4
5
6
7
执行这段代码时,引擎做了以下事情:
首先,尝试遍历对象 a 中的所有属性,但没有找到 name 这个属性。
查找 name 属性的这个请求被委托给对象 a 的构造器的原型,它被
a.__proto__
记录者并且指向 A.prototype,而 A.prototype 被设置为对象 obj。在对象 obj 中找到了 name 属性,并返回它的值。
当我们期望得到一个 “类” 继承另一个 “类” 的效果时,可以用下面的代码来模拟:
var A = function() {};
A.prototype = { name: "abc" };
var B = function() {};
B.prototype = new A();
var b = new B();
console.log(b.name); // abc
2
3
4
5
6
7
8
执行这段代码时,引擎做了以下事情:
首先,尝试遍历对象 b 中的所有属性,但没有找到 name 这个属性。
查找 name 属性的请求被委托给对象 b 的构造器的原型,它被
b.__proto__
记录着并且指向 B.prototype,而 B.prototype 被设置为一个通过 new A() 创建出来的对象。在该对象中依然没有找到 name 属性,于是请求被继续委托给这个对象构造器的原型 A.prototype。
在 A.prototype 中找到了 name 属性,并返回它的值。
和把 B.prototype 直接指向一个字面量对象相比,通过 B.prototype = new A() 形成的原型链比之前多了一层。但二者没有本质上的区别,都是将对象构造器的原型指向另外一个对象,继承总是发生在对象和对象之间。
原型链并不是无限长的,原型链的终点就是 null,即 Object.prototype 的原型是 null。
# 原型继承的未来
设计模式在很多时候其实都体现了语言的不足之处。Peter Norvig 曾说,设计模式是对语言不足的补充,如果要使用设计模式,不如去找一门更好的语言。这句话非常正确。不过,作为 Web 前端开发者,相信 JavaScript 在未来很长一段时间内都是唯一的选择。虽然我们没有办法换一门语言,但语言本身也在发展,说不定哪天某个模式在 JavaScript 中就已经是天然的存在,不再需要拐弯抹角来实现。比如 Object.create 就是原型模式的天然实现。使用 Object.create 来完成原型继承看起来更能体现原型模式的精髓。目前大多数主流浏览器都提供了 Object.create 方法。
但美中不足是在当前的 JavaScript 引擎下,通过 Object.create 来创建对象的效率并不高,通常比通过构造函数创建对象要慢。
此外还有一些值得注意的地方,比如通过设置构造器的 prototype 来实现原型继承的时候,除了根对象 Object.prototype 本身之外,任何对象都会有一个原型。而通过 Object.create(null) 可以创建出没有原型的对象。
此外,ES6 带来了新的 Class 语法。这让 JavaScript 看起来像是一门基于类的语言,但其背后仍是通过原型机制来创建对象。
class Animal {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
class Dog extends Animal {
constructor(name) {
super(name);
}
speak() {
return "woof";
}
}
var dog = new Dog("Scamp");
console.log(dog.getName() + " says " + dog.speak());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# this、call 和 apply
# this 的指向
JS 的 this 总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。
除去不常用的 with 和 eval 的情况,具体到实际应用中,this 的指向大致可以分为以下 4 种。
作为对象的方法调用。
作为普通函数调用。
构造器调用。
Function.prototype.call 或 Function.prototype.apply 调用。
📌 1. 作为对象的方法调用
当函数作为对象的方法被调用时,this 指向该对象:
var obj = {
a: 1,
getA: function() {
alert(this === obj); // true
alert(this.a); // 1
}
};
obj.getA();
2
3
4
5
6
7
8
📌 2. 作为普通函数调用
此时的 this 总是指向全局对象。在浏览器的 JS 里,这个全局对象是 window 对象。
window.name = "globalName";
var getName = function() {
return this.name;
};
console.log(getName()); // globalName
2
3
4
5
或者
window.name = "globalName";
var myObject = {
name: "sven",
getName: function() {
return this.name;
}
};
var getName = myObject.getName;
console.log(getName()); // globalName
2
3
4
5
6
7
8
9
有时候我们会遇到一些困扰,比如在 div 节点的事件函数内部,有一个局部的 callback 方法, callback 被作为普通函数调用时,callback 内部的 this 指向了 window,但我们往往是想让它指向该 div 节点,见如下代码:
<html>
<body>
<div id="div1">我是一个 div</div>
</body>
<script>
window.id = "window";
document.getElementById("div1").onclick = function() {
alert(this.id); // div1
var callback = function() {
alert(this.id); // window
};
callback();
};
</script>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
此时有一种简单的解决方案,可以用一个变量保存 div 节点的引用:
<html>
<body>
<div id="div1">我是一个 div</div>
</body>
<script>
window.id = "window";
document.getElementById("div1").onclick = function() {
var that = this; // 保存 div 的引用
var callback = function() {
alert(that.id); // div1
};
callback();
};
</script>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
注意
在 ES5 的 strict 模式下,这种情况下的 this 已经被规定为不会指向全局对象,而是 undefined:
function func() {
"use strict";
alert(this); // undefined
}
func();
2
3
4
5
📌 3. 构造器调用
除了宿主提供的一些内置函数,大部分 JS 函数都可以当作构造器使用。构造器的外表跟普通函数一模一样,它们的区别在于被调用的方式。当用 new 调用函数时,该函数总会返回一个对象,通常情况下,构造器里的 this 就指向返回的这个对象:
var MyClass = function() {
this.name = "sven";
};
var obj = new MyClass();
alert(obj.name); // sven
2
3
4
5
注意
如果构造器显式地返回了一个 object 类型的对象,那么此次运算结果最终会返回这个对象,而不是我们之前期待的 this:
var MyClass = function() {
this.name = "sven";
return {
// 显式地返回一个对象
name: "anne"
};
};
var obj = new MyClass();
alert(obj.name); // anne
2
3
4
5
6
7
8
9
如果构造器不显式地返回任何数据,或者是返回一个非对象类型的数据,就不会造成上述问题:
var MyClass = function() {
this.name = "sven";
return "anne"; // 返回 string 类型
};
var obj = new MyClass();
alert(obj.name); // sven
2
3
4
5
6
📌 4. Function.prototype.call 或 Function.prototype.apply 调用
跟普通的函数调用相比,用 Function.prototype.call 或 Function.prototype.apply 可以动态地改变传入函数的 this:
var obj1 = {
name: "sven",
getName: function() {
return this.name;
}
};
var obj2 = {
name: "anne"
};
console.log(obj1.getName()); // sven
console.log(obj1.getName.call(obj2)); // anne
2
3
4
5
6
7
8
9
10
11
call 和 apply 方法能很好地体现 JS 的函数式语言特性,在 JS 中,几乎每一次编写函数式语言风格的代码,都离不开 call 和 apply。在 JS 诸多版本的设计模式中,也 用到了 call 和 apply。
# 丢失的 this
有一个常见的问题:
var obj = {
myName: "sven",
getName: function() {
return this.myName;
}
};
console.log(obj.getName()); // sven
var getName2 = obj.getName;
console.log(getName2()); // undefined
2
3
4
5
6
7
8
9
当调用 obj.getName 时,getName 方法是作为 obj 对象的属性被调用的,此时的 this 指向 obj 对象,所以 obj.getName() 输出'sven'。
当用另外一个变量 getName2 来引用 obj.getName,并且调用 getName2 时,此时是普通函数调用方式,this 是指向全局 window 的,所以程序的执行结果是 undefined。
再看另一个例子。document.getElementById 这个方法名实在有点过长,尝试用一个短的函数来代替它,如同 prototype.js (opens new window) 等一些框架所做过的事情:
var getId = function(id) {
return document.getElementById(id);
};
getId("div1");
2
3
4
思考下为什么不能用下面这种更简单的方式:
<html>
<body>
<div id="div1">我是一个 div</div>
</body>
<script>
var getId = document.getElementById;
getId("div1");
</script>
</html>
2
3
4
5
6
7
8
9
运行这段代码会发现抛出异常。这是因为许多引擎的 document.getElementById 方法的内部实现中需要用到 this。这个 this 本来被期望指向 document,当 getElementById 方法作为 document 对象的属性被调用时,方法内部的 this 确实是指向 document 的。
但当用 getId 来引用 document.getElementById 之后,再调用 getId,此时就成了普通函数调用,函数内部的 this 指向了 window,而不是原来的 document。
可以尝试利用 apply 把 document 当作 this 传入 getId 函数,帮助 “修正” this:
document.getElementById = (function(func) {
return function() {
return func.apply(document, arguments);
};
})(document.getElementById);
var getId = document.getElementById;
var div = getId("div1");
alert(div.id); // div1
2
3
4
5
6
7
8
# call 和 apply 的区别
call 和 apply 的作用一模一样,区别仅在于传入参数形式的不同。
📌 apply
apply 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply 方法把这个集合中的元素作为参数传递给被调用的函数:
var func = function(a, b, c) {
alert([a, b, c]); // [1, 2, 3]
};
func.apply(null, [1, 2, 3]);
2
3
4
📌 call
call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,从第二个参数开始往后,每个参数被依次传入函数:
var func = function(a, b, c) {
alert([a, b, c]); // [1, 2, 3]
};
func.call(null, 1, 2, 3);
2
3
4
当调用一个函数时,JS 的解释器并不会计较形参和实参在数量、类型以及顺序上的区别,JS 的参数在内部就是用一个数组来表示的。从这个意义上说,apply 比 call 的使用率更高,我们不必关心具体有多少参数被传入函数,只要用 apply 一股脑地推过去就可以了。
call 是包装在 apply 上面的一颗语法糖,如果我们明确地知道函数接受多少个参数,而且想一目了然地表达形参和实参的对应关系,那么也可以用 call 来传送参数。
当使用 call 或者 apply 的时候,如果我们传入的第一个参数为 null,函数体内的 this 会指向默认的宿主对象,在浏览器中则是 window:
var func = function(a, b, c) {
alert(this === window); // true
};
func.apply(null, [1, 2, 3]);
2
3
4
但如果是在严格模式下,函数体内的 this 还是为 null:
var func = function(a, b, c) {
"use strict";
alert(this === null); // true
};
func.apply(null, [1, 2, 3]);
2
3
4
5
有时候我们使用 call 或者 apply 的目的不在于指定 this 指向,而是另有用途,比如借用其他对象的方法。那么我们可以传入 null 来代替某个具体的对象:
Math.max.apply(null, [1, 2, 5, 3, 4]); // 5
# call 和 apply 的用途
📌 1. 改变 this 指向
call 和 apply 最常见的用途是改变函数内部的 this 指向:
var obj1 = { name: "sven" };
var obj2 = { name: "anne" };
window.name = "window";
var getName = function() {
alert(this.name);
};
getName(); // window
getName.call(obj1); // sven
getName.call(obj2); // anne
2
3
4
5
6
7
8
9
📌 2. 用来实现 bind
📌 3. 借用其他对象的方法
- 借用方法的第一种场景是 “借用构造函数”,通过这种技术,可以实现一些类似继承的效果:
var A = function(name) {
this.name = name;
};
var B = function() {
A.apply(this, arguments);
};
B.prototype.getName = function() {
return this.name;
};
var b = new B("sven");
console.log(b.getName()); // sven
2
3
4
5
6
7
8
9
10
11
12
13
14
- 借用方法的第二种运用场景跟我们的关系更加密切。
函数的参数列表 arguments 是一个类数组对象,虽然它也有 “下标”,但它并非真正的数组,所以也不能像数组一样,进行排序操作或者往集合里添加一个新的元素。
这种情况下,我们常常会借用 Array.prototype 对象上的方法。比如想往 arguments 中添加一个新的元素,通常会借用 Array.prototype.push:
(function() {
Array.prototype.push.call(arguments, 3);
console.log(arguments); // [1,2,3]
})(1, 2);
2
3
4
在操作 arguments 的时候,我们经常非常频繁地找 Array.prototype 对象借用方法。
想把 arguments 转成真正的数组的时候,可以借用 Array.prototype.slice 方法;想截去 arguments 列表中的头一个元素时,又可以借用 Array.prototype.shift 方法。
这种机制的内部实现原理是什么呢?以 Array.prototype.push 为例,看看 V8 引擎中的具体实现:
function ArrayPush() {
var n = TO_UINT32(this.length); // 被 push 的对象的 length
var m = %_ArgumentsLength(); // push 的参数个数
for (var i = 0; i < m; i++) {
this[i + n] = %_Arguments(i); // 复制元素(1)
}
this.length = n + m; // 修正 length 属性的值(2)
return this.length;
}
2
3
4
5
6
7
8
9
通过这段代码可以看到,Array.prototype.push 实际上是一个属性复制的过程,把参数按照下标依次添加到被 push 的对象上面,顺便修改了这个对象的 length 属性。
至于被修改的对象是谁,到底是数组还是类数组对象,这一点并不重要。
由此可以推断,我们可以把 “任意” 对象传入 Array.prototype.push:
var a = {};
Array.prototype.push.call(a, "first");
alert(a.length); // 1
alert(a[0]); // first
2
3
4
这段代码在绝大部分浏览器里都能顺利执行,但由于引擎的内部实现存在差异,如果在低版本的 IE 浏览器中执行,必须显式地给对象 a 设置 length 属性:
var a = { length: 0 };
之所以把 “任意” 两字加了双引号,是因为可以借用 Array.prototype.push 方法的对象还要满足以下两个条件,从 ArrayPush 函数的(1)处和(2)处也可以猜到,这个对象至少还要满足:
对象本身要可以存取属性;
对象的 length 属性可读写。
对于第一个条件,对象本身存取属性并没有问题。
但如果借用 Array.prototype.push 方法的不是一个 object 类型的数据,而是一个 number 类型的数据呢?
我们无法在 number 身上存取其他数据,一个 number 类型的数据不可能借用到 Array.prototype.push 方法:
var a = 1;
Array.prototype.push.call(a, "first");
alert(a.length); // undefined
alert(a[0]); // undefined
2
3
4
对于第二个条件,函数的 length 属性就是一个只读的属性,表示形参的个数,我们尝试把一个函数当作 this 传入 Array.prototype.push:
var func = function() {};
Array.prototype.push.call(func, "first");
alert(func.length); // Uncaught TypeError: Cannot assign to read only property 'length' of function 'function() {}'
2
3
# 闭包
虽然 JavaScript 是一门完整的面向对象的编程语言,但这门语言同时也拥有许多函数式语言的特性。
函数式语言的鼻祖是 LISP,JavaScript 在设计之初参考了 LISP 两大方言之一的 Scheme,引入了 Lambda 表达式、闭包、高阶函数等特性。
闭包的形成与变量的作用域以及变量的生存周期密切相关。
# 变量的作用域
变量的作用域,就是指变量的有效范围。最常谈到的是在函数中声明的变量作用域。
当在函数中声明一个变量的时候,如果该变量前面没有带上关键字 var,这个变量就会成为全局变量,容易造成命名冲突。
另外一种情况是用 var 关键字在函数中声明变量,这时候的变量即是局部变量,只有在该函数内部才能访问到这个变量,在函数外面是访问不到的。
var func = function() {
var a = 1;
alert(a); // 1
};
func();
alert(a); // Uncaught ReferenceError: a is not defined
2
3
4
5
6
在 JS 中,函数可以用来创造函数作用域。此时的函数像一层半透明的玻璃,在函数里面可以看到外面的变量,而在函数外面则无法看到函数里面的变量。
这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。变量的搜索是从内到外而非从外到内的。
var a = 1;
var func1 = function() {
var b = 2;
var func2 = function() {
var c = 3;
alert(b); // 2
alert(a); // 1
};
func2();
alert(c); // Uncaught ReferenceError: c is not defined
};
func1();
2
3
4
5
6
7
8
9
10
11
12
# 变量的生存周期
对于全局变量来说,全局变量的生存周期当然是永久的,除非我们主动销毁这个全局变量。
而对于在函数内用 var 关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁:
var func = function() {
var a = 1; // 退出函数后局部变量 a 将被销毁
alert(a);
};
func();
2
3
4
5
不过,下面这段代码的结果看起来却并非如此。
var func = function() {
var a = 1;
return function() {
a++;
alert(a);
};
};
var f = f();
f(); // 2
f(); // 3
f(); // 4
func(); // 5
2
3
4
5
6
7
8
9
10
11
12
可以看到,当退出函数后,局部变量 a 并没有消失,而是似乎一直在某个地方存活着。
这是因为当执行 var f = func(); 时,f 返回了一个匿名函数的引用,它可以访问到 func() 被调用时产生的环境,而局部变量 a 一直处在这个环境里。既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包结构,局部变量的生命看起来被延续了。
# 闭包的应用
📌 1. 事件点击
假设页面上有 5 个 div 节点,我们通过循环来给每个 div 绑定 onclick 事件,按照索引顺序,点击第 1 个 div 时弹出 0,点击第 2 个 div 时弹出 1,以此类推。
<html>
<body>
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
<script>
var nodes = document.getElementsByTagName("div");
for (var i = 0, len = nodes.length; i < len; i++) {
nodes[i].onclick = function() {
alert(i);
};
}
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
然而,无论点击哪个 div,最后弹出的结果都是 5。这是因为 div 节点的 onclick 事件是被异步触发的,当事件被触发的时候,for 循环早已结束,此时变量 i 的值已经是 5,所以在 div 的 onclick 事件函数中顺着作用域链从内到外查找变量 i 时,查找到的值总是 5。
解决方法是在闭包的帮助下,把每次循环的 i 值都封闭起来。
当在事件函数中顺着作用域链中从内到外查找变量 i 时,会先找到被封闭在闭包环境中的 i,如果有 5 个 div,这里的 i 就分别 是 0,1,2,3,4:
for (var i = 0, len = nodes.length; i < len; i++) {
(function(i) {
nodes[i].onclick = function() {
console.log(i);
};
})(i);
}
2
3
4
5
6
7
📌 2. 封装变量
闭包可以帮助把一些不需要暴露在全局的变量封装成 “私有变量”。
假设有一个计算乘积的简单函数:
var mult = function() {
var a = 1;
for (i = 0, l = arguments.length; i < l; i++) {
var a = a * arguments[i];
}
return a;
};
2
3
4
5
6
7
mult 函数接受一些 number 类型的参数,并返回这些参数的乘积。现在我们觉得对于那些相同的参数来说,每次都进行计算是一种浪费,我们可以加入缓存机制来提高这个函数的性能:
var cache = {};
var mult = function() {
var args = Array.prototype.join.call(arguments, ",");
if (cache[args]) {
return cache[args];
}
var a = 1;
for (var i = 0, l = arguments.length; i < l; i++) {
a = a * arguments[i];
}
return (cache[args] = a);
};
alert(mult(1, 2, 3)); // 6
2
3
4
5
6
7
8
9
10
11
12
13
我们看到 cache 这个变量仅仅在 mult 函数中被使用,与其让 cache 变量跟 mult 函数一起平行地暴露在全局作用域下,不如把它封闭在 mult 函数内部,这样可以减少页面中的全局变量,以避免这个变量在其他地方被不小心修改而引发错误。
var mult = (function() {
var cache = {};
return function() {
var args = Array.prototype.join.call(arguments, ",");
if (args in cache) {
return cache[args];
}
var a = 1;
for (var i = 0, l = arguments.length; i < l; i++) {
a = a * arguments[i];
}
return (cache[args] = a);
};
})();
alert(mult(1, 2, 3)); // 6
2
3
4
5
6
7
8
9
10
11
12
13
14
15
提炼函数是代码重构中的一种常见技巧。
如果在一个大函数中有一些代码块能够独立出来,我们常常把这些代码块封装在独立的小函数里面。独立出来的小函数有助于代码复用,如果这些小函数有一个良好的命名,它们本身也起到了注释的作用。如果这些小函数不需要在程序的其他地方使用,最好是把它们用闭包封闭起来。
var mult = (function() {
var cache = {};
var calculate = function() {
// 封闭 calculate 函数
var a = 1;
for (var i = 0, l = arguments.length; i < l; i++) {
a = a * arguments[i];
}
return a;
};
return function() {
var args = Array.prototype.join.call(arguments, ",");
if (args in cache) {
return cache[args];
}
return (cache[args] = calculate.apply(null, arguments));
};
})();
alert(mult(1, 2, 3)); // 6
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
📌 3. 延续局部变量的寿命
img 对象经常用于进行数据上报。比如:
var report = function(src) {
var img = new Image();
img.src = src;
};
report("http://xxx.com/getUserInfo");
2
3
4
5
但是通过查询后台的记录我们得知,因为一些低版本浏览器的实现存在 bug,在这些浏览器下使用 report 函数进行数据上报会丢失 30% 左右的数据,也就是说,report 函数并不是每一次都成功发起了 HTTP 请求。
丢失数据的原因是 img 是 report 函数中的局部变量,当 report 函数的调用结束后,img 局部变量随即被销毁,而此时或许还没来得及发出 HTTP 请求,所以此次请求就会丢失掉。
现在我们把 img 变量用闭包封闭起来,便能解决请求丢失的问题:
var report = (function() {
var imgs = [];
return function(src) {
var img = new Image();
imgs.push(img);
img.src = src;
};
})();
2
3
4
5
6
7
8
# 闭包和面向对象设计
过程与数据的结合是形容面向对象中的 “对象” 时经常使用的表达。对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。
通常用面向对象思想能实现的功能,用闭包也能实现。反之亦然。
比如下面这段闭包相关的代码:
var extent = function() {
var value = 0;
return {
call: function() {
value++;
console.log(value);
}
};
};
var extent = extent();
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
11
12
13
如果换成面向对象的写法,就是:
var extent = {
value: 0,
call: function() {
this.value++;
console.log(this.value);
}
};
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
或者
var Extent = function() {
this.value = 0;
};
Extent.prototype.call = function() {
this.value++;
console.log(this.value);
};
var extent = new Extent();
extent.call(); // 1
extent.call(); // 2
extent.call(); // 3
2
3
4
5
6
7
8
9
10
11
# 用闭包实现命令模式
在 JavaScript 版本的各种设计模式实现中,闭包的运用非常广泛。
命令模式的意图是把请求封装为对象,从而分离请求的发起者和请求的接收者(执行者)之间的耦合关系。在命令被执行之前,可以预先往命令对象中植入命令的接收者。
先用面向对象的方式来编写一段命令模式的代码如下:
<html>
<body>
<button id="execute">点击我执行命令</button>
<button id="undo">点击我执行命令</button>
<script>
var Tv = {
open: function() {
console.log("打开电视机");
},
close: function() {
console.log("关上电视机");
}
};
var OpenTvCommand = function(receiver) {
this.receiver = receiver;
};
OpenTvCommand.prototype.execute = function() {
this.receiver.open(); // 执行命令,打开电视机
};
OpenTvCommand.prototype.undo = function() {
this.receiver.close(); // 撤销命令,关闭电视机
};
var setCommand = function(command) {
document.getElementById("execute").onclick = function() {
command.execute(); // 打开电视机
};
document.getElementById("undo").onclick = function() {
command.undo(); // 关上电视机
};
};
setCommand(new OpenTvCommand(Tv));
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来;而在闭包版本的命令模式中,命令接收者会被封闭在闭包形成的环境中。
var Tv = {
open: function() {
console.log("打开电视机");
},
close: function() {
console.log("关上电视机");
}
};
var createCommand = function(receiver) {
var execute = function() {
return receiver.open();
};
var undo = function() {
return receiver.close();
};
return {
execute: execute,
undo: undo
};
};
var setCommand = function(command) {
document.getElementById("execute").onclick = function() {
command.execute(); // 打开电视机
};
document.getElementById("undo").onclick = function() {
command.undo(); // 关闭电视机
};
};
setCommand(createCommand(Tv));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 闭包与内存管理
局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上看,闭包的确会使一些数据无法被及时销毁。
使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。
如果在将来需要回收这些变量,我们可以手动把这些变量设为 null。
跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。
但这本身并非闭包的问题,也并非 JS 的问题。在 IE 浏览器中,由于 BOM 和 DOM 中的对象是使用 C++ 以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。
同样,如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。
# 高阶函数
高阶函数是指至少满足下列条件之一的函数。
函数可以作为参数被传递;
函数可以作为返回值输出。
JS 中的函数显然满足高阶函数的条件,在实际开发中,无论是将函数当作参数传递,还是让函数的执行结果返回另外一个函数,这两种情形都有很多应用场景。
# 函数作为参数传递
把函数当作参数传递,这代表我们可以抽离出一部分容易变化的业务逻辑,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分。
📌 1. 回调函数
在 ajax 异步请求的应用中,回调函数的使用非常频繁。当我们想在 ajax 请求返回之后做一些事情,但又并不知道请求返回的确切时间时,最常见的方案就是把 callback 函数当作参数传入发起 ajax 请求的方法中,待请求完成之后执行 callback 函数:
var getUserInfo = function(userId, callback) {
$.ajax("http://xxx.com/getUserInfo?" + userId, function(data) {
if (typeof callback === "function") {
callback(data);
}
});
};
getUserInfo(13157, function(data) {
alert(data.userName);
});
2
3
4
5
6
7
8
9
10
回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,我们也可以把这些请求封装成一个函数,并把它作为参数传递给另外一个函数,“委托” 给另外一个函数来执行。
比如,我们想在页面中创建 100 个 div 节点,然后把这些 div 节点都设置为隐藏。下面是一种实现方式:
var appendDiv = function() {
for (var i = 0; i < 100; i++) {
var div = document.createElement("div");
div.innerHTML = i;
document.body.appendChild(div);
div.style.display = "none";
}
};
appendDiv();
2
3
4
5
6
7
8
9
但是,把 div.style.display = 'none' 的逻辑硬编码在 appendDiv 里显然是不合理的,这使得 appendDiv 成为了一个难以复用的函数,并不是每个人创建了节点之后就希望它们立刻被隐藏。
于是我们把 div.style.display = 'none' 这行代码抽出来,用回调函数的形式传入 appendDiv 方法:
var appendDiv = function(callback) {
for (var i = 0; i < 100; i++) {
var div = document.createElement("div");
div.innerHTML = i;
document.body.appendChild(div);
if (typeof callback === "function") {
callback(div);
}
}
};
appendDiv(function(node) {
node.style.display = "none";
});
2
3
4
5
6
7
8
9
10
11
12
13
📌 2. Array.prototype.sort
Array.prototype.sort 接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。从 Array.prototype.sort 的使用可以看到,我们的目的是对数组进行排序,这是不变的部分;而使用什么规则去排序,则是可变的部分。把可变的部分封装在函数参数里,动态传入 Array.prototype.sort,使 Array.prototype.sort 方法成为了一个非常灵活的方法。
// 从小到大排列
[1, 4, 3].sort(function(a, b) {
return a - b;
});
// 从大到小排列
[1, 4, 3].sort(function(a, b) {
return b - a;
});
2
3
4
5
6
7
8
9
# 函数作为返回值输出
相比把函数当作参数传递,函数当作返回值输出的应用场景也许更多,也更能体现函数式编程的巧妙。
让函数继续返回一个可执行的函数,意味着运算过程是可延续的。
📌 1. 判断数据的类型
判断一个数据是否是数组,在以往的实现中,可以基于鸭子类型的概念来判断,比如判断这个数据有没有 length 属性,有没有 sort 方法或者 slice 方法等。
但更好的方式是用 Object.prototype.toString
来计算。Object.prototype.toString.call(obj) 返回一个字符串,比如 Object.prototype.toString.call([1,2,3]) 总是返回 "[object Array]",而 Object.prototype.toString.call(“str”)总是返回 "[object String]"。所以我们可以编写一系列的 isType 函数。
var isString = function(obj) {
return Object.prototype.toString.call(obj) === "[object String]";
};
var isArray = function(obj) {
return Object.prototype.toString.call(obj) === "[object Array]";
};
var isNumber = function(obj) {
return Object.prototype.toString.call(obj) === "[object Number]";
};
2
3
4
5
6
7
8
9
这些函数的大部分实现都是相同的,不同的只是 Object.prototype.toString.call(obj) 返回的字符串。为了避免多余的代码,我们可以把这些字符串作为参数提前植入 isType 函数。
var isType = function(type) {
return function(obj) {
return Object.prototype.toString.call(obj) === "[object " + type + "]";
};
};
var isString = isType("String");
var isArray = isType("Array");
var isNumber = isType("Number");
console.log(isArray([1, 2, 3])); // true
2
3
4
5
6
7
8
9
还可以用循环语句,来批量注册这些 isType 函数:
var Type = {};
for (var i = 0, type; (type = ["String", "Number", "Array"][i++]); ) {
(function(type) {
Type["is" + type] = function(obj) {
return Object.prototype.toString.call(obj) === "[object " + type + "]";
};
})(type);
}
Type.isArray([]); // true
Type.isString("str"); // true
2
3
4
5
6
7
8
9
10
📌 2. getSingle
下面是一个单例模式的例子:
var getSingle = function(fn) {
var res;
return function() {
return res || (res = fn.apply(this, arguments));
};
};
2
3
4
5
6
这个高阶函数的例子,既把函数当作参数传递,又让函数执行后返回了另外一个函数。
看看这个函数的效果:
var getScript = getSingle(function() {
return document.createElement("script");
});
var script1 = getScript();
var script2 = getScript();
alert(script1 === script2); // true
2
3
4
5
6
# 高阶函数实现 AOP
AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。
把这些功能抽离出来之后,再通过 “动态织入” 的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。
在 Java 语言中,可以通过反射和动态代理机制来实现 AOP 技术。而在 JS 这种动态语言中,AOP 的实现更加简单,这是 JS 与生俱来的能力。
通常,在 JS 中实现 AOP,都是指把一个函数 “动态织入” 到另外一个函数之中。具体的实现技术有很多,比如通过扩展 Function.prototype:
Function.prototype.before = function(beforefn) {
var __self = this; // 保存原函数的引用
return function() {
// 返回包含了原函数和新函数的 "代理" 函数
beforefn.apply(this, arguments); // 执行新函数,修正 this
return __self.apply(this, arguments); // 执行原函数
};
};
Function.prototype.after = function(afterfn) {
var __self = this;
return function() {
var ret = __self.apply(this, arguments);
afterfn.apply(this, arguments);
return ret;
};
};
var func = function() {
console.log(2);
};
func = func
.before(function() {
console.log(1);
})
.after(function() {
console.log(3);
});
func(); // 1 2 3
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
我们把负责打印数字 1 和打印数字 3 的两个函数通过 AOP 的方式动态植入 func 函数。通过执行上面的代码,我们看到顺利地返回了执行结果 1、2、3。
这种使用 AOP 的方式来给函数添加职责,也是 JS 中一种非常特别和巧妙的装饰者模式实现。
# 高阶函数的其他应用
📌 1. currying
函数柯里化(function currying)的概念最早是由俄国数学家 Moses Schönfinkel 发明的,而后由著名的数理逻辑学家 Haskell Curry 将其丰富和发展,currying 由此得名。
currying 又称部分求值。一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。
假设我们要编写一个计算每月开销的函数。在每天结束之前,我们都要记录今天花掉了多少钱。
var monthlyCost = 0;
var cost = function(money) {
monthlyCost += money;
};
cost(100); // 第 1 天开销
cost(200); // 第 2 天开销
cost(300); // 第 3 天开销
// cost(700); // 第 30 天开销
alert(monthlyCost); // 600
2
3
4
5
6
7
8
9
通过这段代码可以看到,每天结束后我们都会记录并计算到今天为止花掉的钱。但我们其实并不太关心每天花掉了多少钱,而只想知道到月底的时候会花掉多少钱。也就是说,实际上只需要在月底计算一次。
如果在每个月的前 29 天,我们都只是保存好当天的开销,直到第 30 天才进行求值计算,这样就达到了我们的要求。
我们编写一个通用的 function currying(){},function currying(){} 接受一个参数,即将要被 currying 的函数。在这个例子里,这个函数的作用遍历本月每天的开销并求出它们的总和。
var currying = function(fn) {
var args = [];
return function() {
if (arguments.length === 0) {
return fn.apply(this, args);
} else {
[].push.apply(args, arguments);
return arguments.callee;
}
};
};
var cost = (function() {
var money = 0;
return function() {
for (var i = 0, l = arguments.length; i < l; i++) {
money += arguments[i];
}
return money;
};
})();
var cost = currying(cost); // 转化成 currying 函数
cost(100); // 未真正求值
cost(200); // 未真正求值
cost(300); // 未真正求值
alert(cost()); // 求值并输出:600
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
arguments.callee (opens new window) 属性包含当前正在执行的函数。
📌 2. uncurrying
在 JS 中,当我们调用对象的某个方法时,其实不用去关心该对象原本是否被设计为拥有这个方法,这是动态类型语言的特点,也是常说的鸭子类型思想。
同理,一个对象也未必只能使用它自身的方法,前面说过可以通过 call 或 apply 让对象去借用一个原本不属于它的方法。
var obj1 = { name: "sven" };
var obj2 = {
getName: function() {
return this.name;
}
};
console.log(obj2.getName.call(obj1)); // sven
2
3
4
5
6
7
我们也常常让类数组对象去借用 Array.prototype 的方法:
(function() {
Array.prototype.push.call(arguments, 4); // arguments 借用 Array.prototype.push 方法
console.log(arguments); // [1, 2, 3, 4]
})(1, 2, 3);
2
3
4
使用 call 和 apply 可以把任意对象当作 this 传入某个方法,这样一来,方法中用到 this 的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。
那么有没有办法把泛化 this 的过程提取出来呢?
uncurrying 就是用来解决这个问题的。uncurrying 的话题来自 JS 之父 Brendan Eich 在 2011 年发表的一篇 Twitter。
下面是它的实现方式之一:
Function.prototype.uncurrying = function() {
var self = this; // self 此时是 Array.prototype.push
return function() {
// obj 是 {
// "length": 1,
// "O": 1
// }
// arguments 对象的第一个元素被截去,剩下 [2]
var obj = Array.prototype.shift.call(arguments);
return self.apply(obj, arguments); // 相当于 Array.prototype.push.apply(obj, 2)
};
};
var push = Array.prototype.push.uncurrying();
var obj = {
length: 1,
"0": 1
};
push(obj, 2);
console.log(obj); // {0: 1, 1: 2, length: 2}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
先来看看 uncurring 有什么用?
在类数组对象 arguments 借用 Array.prototype 的方法之前,先把 Array.prototype.push.call 这句代码转换为一个通用的 push 函数:
var push = Array.prototype.push.uncurrying();
(function() {
push(arguments, 4);
console.log(arguments); // [1, 2, 3, 4]
})(1, 2, 3);
2
3
4
5
通过 uncurrying 的方式,Array.prototype.push.call 变成了一个通用的 push 函数。这样一来,push 函数的作用就跟 Array.prototype.push 一样了,同样不仅仅局限于只能操作 array 对象。
我们还可以一次性地把 Array.prototype 上的方法 “复制” 到 array 对象上,同样这些方法可操作的对象也不仅仅只是 array 对象:
for (var i = 0, fn, ary = ["push", "shift", "forEach"]; (fn = ary[i++]); ) {
Array[fn] = Array.prototype[fn].uncurrying();
}
var obj = { length: 3, "0": 1, "1": 2, "2": 3 };
Array.push(obj, 4); // 向对象中添加一个元素
console.log(obj.length); // 4
var first = Array.shift(obj); // 截取第一个元素
console.log(first); // 1
console.log(obj); // {0: 2, 1: 3, 2: 4, length: 3}
Array.forEach(obj, function(i, n) {
console.log(n); // 0, 1, 2
});
2
3
4
5
6
7
8
9
10
11
12
甚至 Function.prototype.call 和 Function.prototype.apply 本身也可以被 uncurrying,不过这没有实用价值,只是使得对函数的调用看起来更像 JS 语言的前身 Scheme:
var call = Function.prototype.call.uncurrying();
var fn = function(name) {
console.log(name);
};
call(fn, window, "sven"); // sven
var apply = Function.prototype.apply.uncurrying();
var fn = function(name) {
console.log(this.name); // "sven"
console.log(arguments); // [1, 2, 3]
};
apply(fn, { name: "sven" }, [1, 2, 3]);
2
3
4
5
6
7
8
9
10
11
下面是 uncurring 的另一种实现:
Function.prototype.uncurrying = function() {
var self = this;
return function() {
return Function.prototype.call.apply(self, arguments);
};
};
2
3
4
5
6
📌 3. 函数节流
函数被频繁调用的场景
window.onresize 事件。我们给 window 对象绑定了 resize 事件,当浏览器窗口大小被拖动而改变的时候,这个事件触发的频率非常之高。如果我们在 window.onresize 事件函数里有一些跟 DOM 节点相关的操作,而跟 DOM 节点相关的操作往往是非常消耗性能的,这时候浏览器可能就会吃不消而造成卡顿现象。
mousemove 事件。同样,如果我们给一个 div 节点绑定了拖拽事件(主要是 mousemove),当 div 节点被拖动的时候,也会频繁地触发该拖拽事件函数。
上传进度。微云的上传功能使用了公司提供的一个浏览器插件。该浏览器插件在真正开始上传文件之前,会对文件进行扫描并随时通知 JS 函数,以便在页面中显示当前的扫描进度。但该插件通知的频率非常之高,大约一秒钟 10 次,很显然我们在页面中不需要如此频繁地去提示用户。
函数节流的原理
我们可以借助 setTimeout 来按时间段忽略掉一些事件请求,比如确保在 500ms 内只执行一次请求。
函数节流的代码实现
关于函数节流的代码实现有许多种,下面的 throttle 函数的原理是,将即将被执行的函数用 setTimeout 延迟一段时间执行。如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求。
throttle 函数接受 2 个参数,第一个参数为需要被延迟执行的函数,第二个参数为延迟执行的时间。具体实现代码如下:
var throttle = function(fn, interval) {
var __self = fn, // 保存需要被延迟执行的函数引用
timer, // 定时器
firstTime = true; // 是否是第一次调用
return function() {
var args = arguments,
__me = this;
if (firstTime) {
// 如果是第一次调用,不需延迟执行
__self.apply(__me, args);
return (firstTime = false);
}
if (timer) {
// 如果定时器还在,说明前一次延迟执行还没有完成
return false;
}
timer = setTimeout(function() {
// 延迟一段时间执行
clearTimeout(timer);
timer = null;
__self.apply(__me, args);
}, interval || 500);
};
};
window.onresize = throttle(function() {
console.log(1);
}, 500);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
📌 4. 分时函数
接下来看另一个问题。
某些函数确实是用户主动调用的,但因为一些客观的原因,这些函数会严重地影响页面性能。
一个例子是创建 WebQQ 的 QQ 好友列表。列表中通常会有成百上千个好友,如果一个好友用一个节点来表示,当我们在页面中渲染这个列表的时候,可能要一次性往页面中创建成百上千个节点。在短时间内往页面中大量添加 DOM 节点显然也会让浏览器吃不消,我们看到的结果往往就是浏览器的卡顿甚至假死。
var ary = [];
for (var i = 1; i <= 1000; i++) {
ary.push(i); // 假设 ary 装载了 1000 个好友的数据
}
var renderFriendList = function(data) {
for (var i = 0, l = data.length; i < l; i++) {
var div = document.createElement("div");
div.innerHTML = i;
document.body.appendChild(div);
}
};
renderFriendList(ary);
2
3
4
5
6
7
8
9
10
11
12
解决这个问题的方法就是创建一个 timeChunk 函数,它能让创建节点的工作分批进行,比如把 1 秒钟创建 1000 个节点,改为每隔 200 毫秒创建 8 个节点。
timeChunk 函数接受 3 个参数,第 1 个参数是创建节点时需要用到的数据,第 2 个参数是封装了创建节点逻辑的函数,第 3 个参数表示每一批创建的节点数量。代码如下:
var timeChunk = function(ary, fn, count) {
var obj, t;
var len = ary.length;
var start = function() {
for (var i = 0; i < Math.min(count || 1, ary.length); i++) {
var obj = ary.shift();
fn(obj);
}
};
return function() {
t = setInterval(function() {
if (ary.length === 0) {
// 如果全部节点都已经被创建好
return clearInterval(t);
}
start();
}, 200); // 分批执行的时间间隔,也可以用参数的形式传入
};
};
var ary = [];
for (var i = 1; i <= 1000; i++) {
ary.push(i);
}
var renderFriendList = timeChunk(
ary,
function(n) {
var div = document.createElement("div");
div.innerHTML = n;
document.body.appendChild(div);
},
8
);
renderFriendList();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
📌 5. 惰性加载函数
在 Web 开发中,因为浏览器之间的实现差异,一些兼容性工作不可避免。比如我们需要一个在各个浏览器中能够通用的事件绑定函数 addEvent,常见的写法如下:
var addEvent = function(elem, type, handler) {
if (window.addEventListener) {
return elem.addEventListener(type, handler, false);
}
if (window.attachEvent) {
return elem.attachEvent("on" + type, handler);
}
};
2
3
4
5
6
7
8
这个函数的缺点是,当它每次被调用的时候都会执行里面的 if 条件分支,虽然执行这些 if 分支的开销不算大,但也许有一些方法可以让程序避免这些重复的执行过程。
第二种方案是这样,我们把判断浏览器兼容性的操作提前到代码加载的时候,在代码加载的时候就立刻进行一次判断,以便让 addEvent 返回一个包裹了正确逻辑的函数。代码如下:
var addEvent = (function() {
if (window.addEventListener) {
return function(elem, type, handler) {
elem.addEventListener(type, handler, false);
};
}
if (window.attachEvent) {
return function(elem, type, handler) {
elem.attachEvent("on" + type, handler);
};
}
})();
2
3
4
5
6
7
8
9
10
11
12
目前的 addEvent 函数依然有个缺点,也许我们从头到尾都没有使用过 addEvent 函数,这样看来,前一次的浏览器兼容性判断就是完全多余的操作,而且这也会稍稍延长页面 ready 的时间。
第三种方案就是采用惰性载入函数方案。此时 addEvent 依然被声明为一个普通函数,在函数里依然有一些分支判断。但是在第一次进入条件分支之后,在函数内部会重写这个函数,重写之后的函数就是我们期望的 addEvent 函数,在下一次进入 addEvent 函数的时候,addEvent 函数里不再存在条件分支语句:
<html>
<body>
<div id="div1">点我绑定事件</div>
<script>
var addEvent = function(elem, type, handler) {
if (window.addEventListener) {
addEvent = function(elem, type, handler) {
elem.addEventListener(type, handler, false);
};
} else if (window.attachEvent) {
addEvent = function(elem, type, handler) {
elem.attachEvent("on" + type, handler);
};
}
addEvent(elem, type, handler);
};
var div = document.getElementById("div1");
addEvent(div, "click", function() {
alert(1);
});
addEvent(div, "click", function() {
alert(2);
});
</script>
</body>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
← Docker 介绍与使用 单例模式 →