01 基础知识

《JavaScript 设计模式与开发实践》是去年在多看阅读上买的电子书,拖延症晚期患者在快一年后终于把这本书粗略读完,顺便做个笔记,加以总结,以便往后重新翻阅温习。

设计模式

设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案

所有设计模式的实现都遵循一条原则,即“找出程序中变化的地方,并将变化封装起来”。

本书内容分为三大部分:

第一部分讲解 JavaScript 面向对象和函数式编程方面的知识,主要包括静态类型语言和动态类型语言的区别及其在实现设计模式时的异同,以及封装、继承、多态在动态类型语言中的体现,此外还介绍了 JavaScript 基于原型继承的面向对象系统的来龙去脉,给学习设计模式做好铺垫。

第二部分是核心部分,通过从普通到更好的代码示例,由浅到深地讲解了 14 种设计模式。

第三部分主要讲解面向对象的设计原则及其在设计模式中的体现,还介绍了一些常见的面向对象编程技巧和日常开发中的代码重构。

本篇是第一部分基础知识的相关总结。

面向对象的 JavaScript

JavaScript 没有提供传统面向对象语言中的类式继承,而是通过原型委托的方式来实现对象与对象之间的继承。

动态类型语言和鸭子类型

编程语言按照数据类型大体可以分为两类,一类是静态类型语言,另一类是动态类型语言。

静态类型语言在编译时便已确定变量的类型,而动态类型语言的变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。

动态类型语言无需进行类型检测,可以尝试调用任何对象的任意方法,而无需去考虑它原本是否被设计为拥有该方法。

这一切都建立在鸭子类型(duck typing)的概念上,鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注 HAS-A,而不是 IS-A

在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,不必借助超类型的帮助,就能轻松实现一个原则:“面向接口编程,而不是面向实现编程”

在静态类型语言中,要实现“面向接口编程”并不容易,往往要通过抽象类或者接口等将对象进行向上转型。当对象的真正类型被隐藏在它的超类型身后,这些对象才能在类型检查系统的“监视”之下互相被替换使用,才能体现出对象多态性的价值。

多态

多态的实际含义:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果

多态背后的思想是将“不变的事物”与“可能改变的事物”分离开来。把不变的部分隔离,把可变的部分封装,这给予了我们扩展程序的能力,也符合开放—封闭原则。

继承与多态

对于静态类型的面向对象语言通常被设计为可以向上转型:当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。

使用继承来得到多态效果,是让对象表现出多态性的最常用手段。继承通常包括实现继承和接口继承。

public abstract class Animal {
abstract void makeSound(); // 抽象方法
}
public class Chicken extends Animal {
public void makeSound() {
System.out.println( "咯咯咯" );
}
}
public class Duck extends Animal {
public void makeSound() {
System.out.println( "嘎嘎嘎" );
}
}
public class AnimalSoun{
public void makeSound(Animal animal) { // 接受Animal类型的参数
animal.makeSound();
}
}
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); // 输出:咯咯咯
}

上面 java 例子中先创建一个 Animal 抽象类,再分别让 Duck 和 Chicken 都继承自 Animal 抽象类,然后让 AnimalSound 类的 makeSound 方法接受 Animal 类型的参数,而不是具体的 Duck 类型或者 Chicken 类型。

JavaScript 的多态

要实现多态先要消除类型之间的耦合关系。在 Java 中,可以通过向上转型来实现多态。而 JavaScript 的变量类型在运行期是可变的,这意味着 JavaScript 对象的多态性是与生俱来的,并不需要诸如向上转型之类的技术来取得多态的效果。

Martin Fowler 在《重构:改善既有代码的设计》里写到:

多态的最根本好处在于,你不必再向对象询问“你是什么类型”而后根据得到的答案调用对象的某个行为——你只管调用该行为就是了,其他的一切多态机制都会为你安排妥当。

换句话说,多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。

let googleMap = {
show: function() {
console.log('开始渲染谷歌地图')
}
}
let baiduMap = {
show: function() {
console.log('开始渲染百度地图')
}
}
let renderMap = function(map) {
if (map.show instanceof Function) {
map.show()
}
}
renderMap(googleMap) // 输出:开始渲染谷歌地图
renderMap(baiduMap) // 输出:开始渲染百度地图

在 JavaScript 函数本身也是对象,函数用来封装行为并且能够被四处传递。当对一些函数发出“调用”的消息时,这些函数会返回不同的执行结果,这是“多态性”的一种体现,也是很多设计模式在 JavaScript 中可以用高阶函数来代替实现的原因。

封装

封装的目的是将信息隐藏,一般而言的封装是封装数据和封装实现,更广义的封装还包括封装类型和封装变化。

封装数据

在许多语言的对象系统中,封装数据是由语法解析来实现的,如 java 提供了 private、public、protected 等关键字来提供不同的访问权限。但 JavaScript 并没有提供对这些关键字的支持,只能依赖变量的作用域来实现封装特性,而且只能模拟出 public 和 private 这两种封装性。

let myObject = (function() {
let __name = 'sven' // 私有(private)变量
return {
getName: function() {
// 公开(public)方法
return __name
}
}
})()
console.log(myObject.getName()) // 输出:sven
console.log(myObject.__name) // 输出:undefined

封装实现

封装的目的是将信息隐藏,封装应该被视为“任何形式的封装”,也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。

从封装实现细节来讲,封装使得对象内部的变化对其他对象而言是不可见的,对象对它自己的行为负责,其他对象都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。当修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。

封装类型

封装类型是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使工厂方法模式、组合模式诞生的原因之一。

在 JavaScript 中并没有对抽象类和接口的支持。JavaScript 本身也是一门类型模糊的语言。在封装类型方面,JavaScript 没有能力,也没有必要做得更多。

封装变化

从设计模式的角度出发,封装在更重要的层面体现为封装变化。《设计模式》一书曾提到如下文字:

考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能够在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。

通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,只需要替换那些容易变化的部分,这可以最大程度地保证程序的稳定性和可扩展性。

原型模式

在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。而在原型编程的思想中,一个对象是通过克隆另外一个对象所得到的。原型模式不单是一种设计模式,也被称为一种编程泛型。

使用克隆的原型模式

从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。原型模式选择了另外一种方式,不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。

原型模式的实现关键,是语言本身是否提供了 clone 方法。ECMAScript5 提供了 Object.create 方法,可以用来克隆对象。

在不支持 Object.create 方法的浏览器中,则可以使用以下代码:

Object.create =
Object.create ||
function(obj) {
var F = function() {}
F.prototype = obj
return new F()
}

原型编程泛型

基于原型链的委托机制就是原型继承的本质。原型编程范型至少包括以下基本规则:

  • 所有的数据都是对象。

  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。

  • 对象会记住它的原型。

  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。

我们不能说在 JavaScript 中所有的数据都是对象,但可以说绝大部分数据都是对象。JavaScript 中的根对象是 Object.prototype 对象,它是一个空的对象。JavaScript 所有对象实际上都是从这个对象克隆而来的,Object.prototype 对象就是它们的原型。

var obj1 = new Object()
var obj2 = {}
console.log(Object.getPrototypeOf(obj1) === Object.prototype) // 输出:true
console.log(Object.getPrototypeOf(obj2) === Object.prototype) // 输出:true

目前一直在讨论“对象的原型”,就 JavaScript 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对于“对象把请求委托给它自己的原型”这句话,更好的说法是对象把请求委托给它的构造器的原型。

JavaScript 给对象提供了一个名为 __proto__ 的隐藏属性,某个对象的 __proto__ 属性默认会指向它的构造器的原型对象,即{Constructor}.prototype

var A = function() {}
A.prototype = { name: 'sven' }
var B = function() {}
B.prototype = new A()
var b = new B()
console.log(b.name) // 输出:sven

和把 B.prototype 直接指向一个字面量对象相比,通过 B.prototype = new A() 形成的原型链比之前多了一层。但二者之间没有本质上的区别,都是将对象构造器的原型指向另外一个对象,继承总是发生在对象和对象之间。

原型继承的未来

美中不足是在当前的 JavaScript 引擎下,通过 Object.create 来创建对象的效率并不高,通常比通过构造函数创建对象要慢。此外通过 Object.create(null) 可以创建出没有原型的对象。

ECMAScript6 带来了新的 Class 语法。这让 JavaScript 看起来像是一门基于类的语言,但其背后仍是通过原型机制来创建对象。

原型模式是一种设计模式,也是一种编程泛型,它构成了 JavaScript 这门语言的根本。

this、call 和 apply

this

JavaScript 的 this 总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。

this 指向

除去不常用的 with 和 eval 的情况,具体到实际应用中,this 的指向大致可以分为以下 4 种。

  1. 作为对象的方法调用。

  2. 作为普通函数调用

  3. 构造器调用。

  4. Function.prototype.callFunction.prototype.apply 调用。

关于 4 种调用方式的 this 指向,不再累述,可查阅《JavaScript 秘密花园》。

this 丢失

当方法作为普通函数调用时容易出现 this 丢失问题。例如获取根据 id 获取节点:

var getId = function(id) {
return document.getElementById(id)
}
getId('div1') // ok!

但是如果直接把方法赋值给一个变量:

var getId = document.getElementById
getId('div1') // error!

在浏览器中执行这段代码抛出了一个异常,这是因为许多引擎的 document.getElementById 方法的内部实现中需要用到 this。这个 this 本来被期望指向 document,当 getElementById 方法作为 document 对象的属性被调用时,方法内部的 this 确实是指向 document 的。但当用 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')

call 和 apply

Function.prototype.callFunction.prototype.apply 作用一模一样,区别仅在于传入参数形式的不同。

apply 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组或类数组,apply 方法把这个集合中的元素作为参数传递给被调用的函数。

call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,从第二个参数开始往后,每个参数被依次传入函数。

性能上来说 call 的效率比 apply 更高。当使用 call 或者 apply 的时候,如果传入的第一个参数为 null,函数体内的 this 会指向默认的宿主对象,在浏览器中则是 window,但如果是在严格模式下,函数体内的 this 还是为 null(注意不是 undefined)。

有时候使用 call 或者 apply 的目的不在于指定 this 指向,而是另有用途,比如借用其他对象的方法,那么可以传入 null 来代替某个具体的对象:

Math.max.apply(null, [1, 2, 5, 3, 4]) // 输出:5

call 和 apply 的用途主要有 3 种:

  1. 改变 this 指向

  2. Function.prototype.bind

  3. 借用其他对象的方法

改变 this 指向

document.getElementById('div1').onclick = function() {
var func = function() {
console.log(this.id) // 输出:div1
}
func.call(this)
}

Function.prototype.bind

大部分高级浏览器都实现了内置的 Function.prototype.bind,用来指定函数内部的 this 指向,即使没有实现也可以模拟:

Function.prototype.bind = function(context) {
var self = this // 保存原函数
return function() {
// 返回一个新的函数
return self.apply(context, arguments) // 执行新的函数的时候,会把之前传入的 context 当作新函数体内的 this
}
}
var obj = { name: 'sven' }
var func = function() {
console.log(this.name) // 输出:sven
}.bind(obj)
func()

这是一个简化版的 Function.prototype.bind 实现,通常还会把它实现得稍微复杂一点,使得可以往函数中预先填入一些参数:

Function.prototype.bind = function() {
var self = this, // 保存原函数
context = [].shift.call(arguments), // 需要绑定的 this 上下文
args = [].slice.call(arguments) // 剩余的参数转成数组
return function() {
// 返回一个新的函数
// 执行新的函数的时候,会把之前传入的 context 当作新函数体内的 this
// 并且组合两次分别传入的参数,作为新函数的参数
return self.apply(context, [].concat.call(args, [].slice.call(arguments)))
}
}
var obj = { name: 'sven' }
var func = function(a, b, c, d) {
console.log(this.name) // 输出:sven
console.log([a, b, c, d]) // 输出:[ 1, 2, 3, 4 ]
}.bind(obj, 1, 2)
func(3, 4)

闭包和高阶函数

在 JavaScript 版本的设计模式中,许多模式都可以用闭包和高阶函数来实现。

闭包

闭包的作用

闭包常见的作用有封装变量和延续局部变量的寿命。举两个栗子:

  • 封装变量:通过闭包加入缓存机制来提高函数的性能

var mult = (function() {
var cache = {}
var calculate = function() {
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))
}
})()
console.log(mult(1, 2, 3)) // 输出:6
console.log(mult(1, 2, 3)) // 输出:6
  • 延续局部变量的寿命:img 对象经常用于进行数据上报

var report = function(src) {
var img = new Image()
img.src = src
}
report('http://xxx.com/getUserInfo')

但是 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
}
})()

闭包和面向对象设计

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。通常用面向对象思想能实现的功能,用闭包也能实现。

下面来看看这段跟闭包相关的代码:

var extent = function() {
var value = 0
return {
call: function() {
value++
console.log(value)
}
}
}
var extent = extent()
extent.call()

如果换成面向对象的写法:

var extent = {
value: 0,
call: function() {
this.value++
console.log(this.value)
}
}
extent.call()

亦或者:

var Extent = function() {
this.value = 0
}
Extent.prototype.call = function() {
this.value++
console.log(this.value)
}
var extent = new Extent()
extent.call()

闭包实现命令模式

通过上节比较,这里分别使用闭包和面向对象来实现命令模式。

  • 面向对象版本

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))
  • 闭包版本

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))

命令模式的意图是把请求封装为对象,从而分离请求的发起者和请求的接收者之间的耦合关系。在命令被执行之前,可以预先往命令对象中植入命令的接收者。

但在 JavaScript 中,函数作为一等对象,本身就可以四处传递,用函数对象而不是普通对象来封装请求显得更加简单和自然。如果需要往函数对象中预先植入命令的接收者,那么闭包可以完成这个工作。

在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来;而在闭包版本的命令模式中,命令接收者会被封闭在闭包形成的环境中。

闭包与内存管理

闭包是一个非常强大的特性,但人们对其也有诸多误解。一种耸人听闻的说法是闭包会造成内存泄露,所以要尽量减少闭包的使用。

跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些 DOM 节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非 JavaScript 的问题。

在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。如果要解决循环引用带来的内存泄露问题,只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

高阶函数

高阶函数是指至少满足下列条件之一的函数:

  • 函数可以作为参数被传递

  • 函数可以作为返回值输出

AOP

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用功能模块。

通常在 JavaScript 中实现 AOP,都是指把一个函数“动态织入”到另外一个函数之中,这种使用 AOP 的方式来给函数添加职责,也是 JavaScript 语言中一种非常特别和巧妙的装饰者模式实现。

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()

高阶函数的应用

高阶函数常见的应用包括:currying、uncurrying、函数节流、分时函数、惰性加载函数等。

  • currying

函数柯里化(function currying)又称部分求值,一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。

一个计算月开销的示例如下,未柯里化之前:

var monthlyCost = 0
var cost = function(money) {
monthlyCost += money
}
cost(100) // 第1天开销
cost(200) // 第2天开销

这段代码每次都会计算当天为止的总开销,但这并不重要,柯里化之后:

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) // 未真正求值
  • uncurrying

通过 call 和 apply 可以借用其他对象的方法,把任意对象当作 this 传入某个方法,方法中用到 this 的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。

uncurrying 可以把把泛化 this 的过程提取出来。

// 方式①
Function.prototype.uncurrying = function() {
var self = this
return function() {
var obj = Array.prototype.shift.call(arguments)
return self.apply(obj, arguments)
}
}
// 方式②
Function.prototype.uncurrying = function() {
var self = this
return function() {
return Function.prototype.call.apply(self, arguments)
}
}

使用方式:

var push = Array.prototype.push.uncurrying()(function() {
push(arguments, 4)
console.log(arguments) // 输出:[1, 2, 3, 4]
})(1, 2, 3)

通过 uncurrying 的方式,Array.prototype.push.call 变成了一个通用的 push 函数。这样一来,push 函数的作用就跟 Array.prototype.push 一样了,同样不仅仅局限于只能操作 array 对象。而对于使用者而言,调用 push 函数的方式也显得更加简洁和意图明了。

  • 函数节流

var throttle = function(fn, interval) {
var __self = fn,
timer,
firstTime = true
return function() {
var __me = this,
args = arguments
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)
}
}
// 注:除了使用 timer 定时器,还可以使用 Date 比较
  • 分时函数

通过函数节流限制函数被频繁调用,但是在一些情况下,某些函数确实通过用户主动调用,但会严重影响页面性能,如用户主动创建一个大列表,在短时间内往页面中大量添加 DOM 节点显然也会让浏览器吃不消,结果往往就是浏览器的卡顿甚至假死。这个问题的解决方案之一是下面的 timeChunk 分时函数,分时函数让创建节点的工作分批进行,比如把 1 秒钟创建 1000 个节点,改为每隔 200 毫秒创建 8 个节点。

var timeChunk = function(ary, fn, count = 1) {
var t,
len = ary.length
var start = function() {
for (var i = 0; i < Math.min(count, len); i++) {
var obj = ary.shift()
fn(obj)
}
}
return function() {
t = setInterval(function() {
if (ary.length === 0) {
// 如果全部节点都已经被创建好
return clearInterval(t)
}
start()
}, 200) // 分批执行的时间间隔,也可以用参数的形式传入
}
}
  • 惰性加载函数

在 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)
}
}

这个函数的缺点是,当它每次被调用的时候都会执行里面的 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)
}
}
})()

目前的 addEvent 函数依然有个缺点,也许从头到尾都没有使用过 addEvent 函数,这样前一次的浏览器嗅探就是完全多余的操作,而且也会稍稍延长页面 ready 的时间。

第三种方案即是惰性载入函数方案。此时 addEvent 依然被声明为一个普通函数,在函数里依然有一些分支判断。但是在第一次进入条件分支之后,在函数内部会重写这个函数,重写之后的函数就是期望的 addEvent 函数,在下一次进入时函数里不再存在条件分支语句:

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)
}

以上便是《JavaScript 常用设计模式》第一部分总结,在 JavaScript 中,闭包和高阶函数的应用极多,很多设计模式都是通过闭包和高阶函数实现的。