Skip to content

JavaScript 中的设计模式

JavaScript 设计模式是一种用于解决特定问题的可重用解决方案。设计模式提供了一种通用的方式来构建可维护、可扩展和可重用的代码。

单例模式

单例模式是一种常见的设计模式,其目的是确保一个类只有一个实例,并提供一个全局访问点。单例模式的目的是限制某个类的实例化次数,确保在整个应用中只存在一个实例(如 vuex 和 redux 中的 store)。

js
var SingletonPattern = (function () {
  var instance

  function createInstance() {
    // Singleton code here
    return {
      method: function () {
        console.log('Singleton method')
      },
    }
  }

  return {
    getInstance: function () {
      if (!instance) {
        instance = createInstance()
      }
      return instance
    },
  }
})()

var instance1 = SingletonPattern.getInstance()
var instance2 = SingletonPattern.getInstance()

console.log(instance1 === instance2) // true
instance1.method() // Singleton method

模块模式

模块模式是一种用于封装和组织 JavaScript 代码的设计模式,它通过使用闭包来创建私有作用域,从而实现信息隐藏和模块化。

js
var ModulePattern = (function () {
  // 私有变量
  var privateVariable = 'I am private'

  // 私有方法
  function privateMethod() {
    console.log('This is a private method')
  }

  // 公共变量和方法
  return {
    publicVariable: 'I am public',
    publicMethod: function () {
      console.log('This is a public method')
    },
    getPrivateVariable: function () {
      return privateVariable
    },
  }
})()

这个模块模式使用了一个返回对象的匿名函数。在这个匿名函数内部,首先定义了私有变量和函数。 然后,将一个对象字面量作为函数的值返回。返回的对象字面量中只包含可以公开的属性和方法。由于这个对象是在匿名函数内部定义的,因此它的公有方法有权访问私有变量和函数。从本质上来讲,这个对象字面量定义的是单例的公共接口。这种模式在需要对单例进行某些初始化,同时又需要维护其私有变量时是非常有用的。

工厂模式

工厂模式使用简单的函数创建对象,为对象添加展性和方法,然后返回对象。

js
function createPerson(name, age, job) {
  let o = new Object()
  o.name = name
  o.age = age
  o.job = job
  o.sayName = function () {
    console.log(this.name)
  }
  return o
}
let person1 = createPerson('Nicholas', 29, 'Software Engineer')
let person2 = createPerson('Greg', 27, 'Doctor')

这里,函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。

构造函数模式

像 Object 和 Array 这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

比如,前面的例子使用构造函数模式可以这样写:

js
function Person(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = function () {
    console.log(this.name)
  }
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // Nicholas
person2.sayName() // Greg

在这个例子中,Person()构造函数代替了 createPerson()工厂函数。实际上,Person()内部的代码跟 createPerson()基本是一样的,只是有如下区别。

  • 没有显式地创建对象。
  • 属性和方法直接赋值给了 this。
  • 没有 return。

使用new 操作符调用构造函数会执行如下操作。

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
构造函数的问题

构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。ECMAScript 中的函数是对象,因此每次定义函数时,都会 初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:

js
function Person(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = new Function('console.log(this.name)') // 逻辑等价
}

这样理解这个构造函数可以更清楚地知道,每个 Person 实例都会有自己的 Function 实例用于显示 name 属性。因此不同实例上的函数虽然同名却不相等,如下所示:

js
console.log(person1.sayName == person2.sayName) // false

因为都是做一样的事,所以没必要定义两个不同的 Function 实例。要解决这个问题,可以把函数定义转移到构造函数外部:

js
function Person(name, age, job) {
  this.name = name
  this.age = age
  this.job = job
  this.sayName = sayName
}
function sayName() {
  console.log(this.name)
}
let person1 = new Person('Nicholas', 29, 'Software Engineer')
let person2 = new Person('Greg', 27, 'Doctor')
person1.sayName() // Nicholas
person2.sayName() // Greg

在这里,sayName()被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局 sayName() 函数。因为这一次 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1 和 person2 共享了定义在全局作用域上的 sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。

js
function Person() {
  this.name = 'Nicholas'
  this.age = 29
  this.job = 'Software Engineer'
}
Person.prototype.sayName = function () {
  console.log(this.name)
}
let person1 = new Person()
person1.sayName() // "Nicholas"
let person2 = new Person()
person2.sayName() // "Nicholas"
console.log(person1.sayName == person2.sayName) // true
js
let Person = function () {
  this.name = 'Nicholas'
  this.age = 29
  this.job = 'Software Engineer'
}
Person.prototype.sayName = function () {
  console.log(this.name)
}
let person1 = new Person()
person1.sayName() // "Nicholas"
let person2 = new Person()
person2.sayName() // "Nicholas"
console.log(person1.sayName == person2.sayName) // true

与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1 和 person2 访问的都是相同的 sayName()函数。

拓展内容

模仿块级作用域

ES6 之前 JavaScript 没有块级作用域的概念。这意味着在块语句中定义的变量,实际上是在包含函数中而非语句中创建的,来看下面的例子。

js
function outputNumbers(count) {
  for (var i = 0; i < count; i++) {
    alert(i)
  }
  alert(i) // 计数count
}

这个函数中定义了一个 for 循环,而变量 i 的初始值被设置为 0。在 Java、C++等语言中,变量 i 只会在 for 循环的语句块中有定义,循环一旦结束,变量 i 就会被销毁。可是在 JavaScript 中,变量 i 是定义在 ouputNumbers()的活动对象中的,因此从它有定义开始,就可以在函数内部随处访问它。即使像下面这样错误地重新声明同一个变量,也不会改变它的值。

js
function outputNumbers(count) {
  for (var i = 0; i < count; i++) {
    alert(i)
  }
  var i
  alert(i) // 计数count
}

JavaScript 从来不会告诉你是否多次声明了同一个变量,遇到这种情况,它只会对后续的声明视而不见(不过,它会执行后续声明中的变量初始化)。匿名函数可以用来模仿块级作用域并避免这个问题。用作块级作用域(通常称为私有作用域)的匿名函数的语法如下所示。

js
;(function () {
  // 这里是块级作用域
})()

无论在什么地方,只要临时需要一些变量,就可以使用私有作用域,例如:

js
function outputNumbers(count) {
  ;(function () {
    for (var i = 0; i < count; i++) {
      alert(i)
    }
  })()
  alert(i) //导致一个错误!
}

在这个重写后的 outputNumbers()函数中,我们在 for 循环外部插入了一个私有作用域。在匿名函数中定义的任何变量,都会在执行结束时被销毁。因此,变量 i 只能在循环中使用,使用后即被销毁。而在私有作用域中能够访问变量 count,是因为这个匿名函数是一个闭包,它能够访问包含作用域中的所有变量。

ES6 中可以使用 let 来声明一个可重新赋值的块级作用域局部变量。

js
function outputNumbers(count) {
  for (let i = 0; i < count; i++) {
    alert(i)
  }
  alert(i) //导致一个错误!
}

一道经典的面试题:

js
function createFunctions() {
  var result = new Array()
  for (var i = 0; i < 10; i++) {
    result[i] = function () {
      return i
    }
  }
  return result
}
createFunctions()[1]() // 10

这个函数会返回一个函数数组。表面上看,似乎每个函数都应该返自己的索引值,即位置 0 的函数返回 0,位置 1 的函数返回 1,以此类推。但实际上,每个函数都返回 10。因为每个函数的作用域链中都保存着 createFunctions()函数的活动对象,所以它们引用的都是同一个变量 i。当 createFunctions()函数返回后,变量 i 的值是 10,此时每个函数都引用着保存变量 i 的同一个变量对象,所以在每个函数内部 i 的值都是 10。

我们可以通过创建另一个匿名函数强制让闭包的行为符合预期。

js
function createFunctions() {
  var result = new Array()
  for (var i = 0; i < 10; i++) {
    result[i] = (function (num) {
      return function () {
        return num
      }
    })(i)
  }
  return result
}
createFunctions()[1]() // 1
js
function createFunctions() {
  var result = new Array()
  for (let i = 0; i < 10; i++) {
    result[i] = function () {
      return i
    }
  }
  return result
}
createFunctions()[1]() // 1