【译】理解 Javascript 中常用的一些设计模式

原文地址: Understanding Design Patterns in JavaScript

当我们开始一个新的项目的时候,通常不会立刻开始实际编码的工作,而是首先会理清楚项目的一些需求、问题和目标。在我们可以开始编写代码后, 或者如果你正在处理的是一个更复杂的项目的话,那么我们应该考虑去选择一种最适合项目的设计模式去帮助我们完成工作。

如何理解设计模式

在软件工程中,设计模式是一种用来解决软件设计中一些共通问题的解决方案。设计模式往往是开发工程师最佳实践后的经验总结。通常我们可以把一个设计模式理解为是一个编程模板。

为什么我们需要设计模式

大部分的开发者通常认为设计模式是在浪费时间,或者说他们也并不太知如何适当的去使用这些设计模式。但是适当的使用设计模式的确可以帮助我们编写出更好健壮的代码。

更重要的是,设计模式往往为我们提供了一些沟通上的便利,它可以及时的向正在学习你代码的人展示出你代码意图。例如,如果你在项目中使用了装饰者模式的话,那么一个新的开发者可能马上就知道了你的代码是在干什么,他们可以更多的去关注业务本身,而无需花费过度的精力去理解你的代码。

好了,接让我们看看我们在 Javascript 中常用的一些设计模式吧。

模块模式(Module Pattern)

模块是指一个包含自身完整逻辑代码的代码块,当我们去更新模块中的代码时而不用影响其他代码的部分。模块可以让我们轻易的规避命名空间的污染问题,例如模块拥有自己独立的变量作用域,我们也可以在其他项目中复用我们的模块。

模块是现代 Javascript 应用开发里的不可或缺的部分,它帮助我们轻易的组织和维护代码。在 Javascript 中模块化的方式有很多,通常我们把这些模块化的方法称之为模块化模式。像 Bit 这个工具,它不需要任何重构工作,就可以把我们的模块或者组件转换成其他任何项目中可以复用的代码了。

Javascript 并没有像其他编程语言拥有访问修饰符的特性(现在 Typescript 之类的语言包含这些特性),例如你无法声明变量为私有(private)或者公开(public)。所以模块化模式也常常被用来去模拟封装(Encapsulation) 的理念。

这种模式我们可以通过即行函数 IIFE(immediately-invoked function expression)、闭包和函数作用域来模拟,例如:

const myModule = (function() {
  
  const privateVariable = 'Hello World';
  
  function privateMethod() {
    console.log(privateVariable);
  }
  return {
    publicMethod: function() {
      privateMethod();
    }
  }
})();
myModule.publicMethod();

上面的代码是即行函数的方法,通过立即执行函数,并把返回结果指向了 myModule 变量。由于闭包、返回的对象仍然可以访问定义在即行函数内的函数和变量,当然这些操作是在执行完即行函数产生实例之后了。我们可以看到,变量和方法被定义在即行函数内部,对于模块外部的作用域来说即达到了 private 的效果。

当代码被执行后,变量 myModule 类似于:

const myModule = {
 publicMethod: function() {
   privateMethod();
     }
};

所以,当我们调用 publicMethod 后,会转而调用privateMethod 方法,示例:

// Prints 'Hello World'
module.publicMethod();

揭示模块(Revealing Module Pattern)

揭示模块模式是 Christian Heilmann 对模块化做到一个改进版本。模块模式本身的问题是每次我们都需要创建一个公共方法,然而仅仅是为了调用私有方法和变量。 通过揭示模块模式,我们将返回对象的属性映射到我们想要暴露的即行函数内部的私有函数上。这就是为什么我们称这个方法揭示模式的原因了。请看示例:

const myRevealingModule = (function() {
  
  let privateVar = 'Peter';
  const publicVar  = 'Hello World';
  function privateFunction() {
    console.log('Name: '+ privateVar);
  }
  
  function publicSetName(name) {
    privateVar = name;
  }
  function publicGetName() {
    privateFunction();
  }
  /** reveal methods and variables by assigning them to object  properties */
return {
    setName: publicSetName,
    greeting: publicVar,
    getName: publicGetName
  };
})();
myRevealingModule.setName('Mark');
// prints Name: Mark
myRevealingModule.getName();

可以看到的是,这种模式让我们可以更容易理解哪些方法和变量是公开的,可读性有了一定的提升。当代码执行后,myRevealingModule看起来类似于这样子:

const myRevealingModule = {
  setName: publicSetName,
  greeting: publicVar,
  getName: publicGetName
};

myRevealingModule.setName('Mark')myRevealingModule.getName() 分别执行了所引用的publicSetNamepublicGetName 方法。如下:

myRevealingModule.setName('Mark');
// prints Name: Mark
myRevealingModule.getName();

揭示模式相对于模块模式的优点

  • 我们可以通过修改返回声明中的代码,轻易的修改属性成员是否为公开或者私有。
  • 返回对象无需包含方法定义,所有右侧的表达式都是定义在即行函数内部的,代码更精简可读性也更好。

ES6 模块(ES6 Modules)

在 ES6 之前,JavaScript 是并没有内置模块系统的,所以开发者不得不依赖第三方库或者模块模式去实现模块化,好在 ES6 让 Javascript 拥有原生模块化的能力了。

ES6 的模块是被存储在文件中的。每个文件只能有一个模块。模块内部的任何东西默认都是私有的。函数、变量、类都是需要通过export关键字来向外暴露。 模块内的代码通常运行在 strict 模式。

导出模块

这里有 2 中导出函数和变量的声明方法:

  • 通过直接在函数、变量前添加 export 关键字声明:
// utils.js
export const greeting = 'Hello World';
export function sum(num1, num2) {
  console.log('Sum:', num1, num2);
  return num1 + num2;
}
export function subtract(num1, num2) {
  console.log('Subtract:', num1, num2);
  return num1 - num2;
}
// This is a private function
function privateLog() {
  console.log('Private Function');
}
  • 通过在包含函数或者变量代码的尾部添加 export 导出关键字,例如:
// utils.js
function multiply(num1, num2) {
  console.log('Multiply:', num1, num2);
  return num1 * num2;
}
function divide(num1, num2) {
  console.log('Divide:', num1, num2);
  return num1 / num2;
}
// This is a private function
function privateLog() {
  console.log('Private Function');
}
export {multiply, divide};

导入模块

与导出模块类似,这里也有 2 种使用import关键字导入模块的方法。看例子:

// main.js
// importing multiple items
import { sum, multiply } from './utils.js';
console.log(sum(3, 7));
console.log(multiply(3, 7));
  • 导入所有模块
// main.js
// importing all of module
import * as utils from './utils.js';
console.log(utils.sum(3, 7));
console.log(utils.multiply(3, 7));

重命名导入/导出模块

如果有时候你想避免命名冲突,你可以在导出或者导入模块时进行重命名操作。例如:

  • 导出重命名
// utils.js
function sum(num1, num2) {
  console.log('Sum:', num1, num2);
  return num1 + num2;
}
function multiply(num1, num2) {
  console.log('Multiply:', num1, num2);
  return num1 * num2;
}
export {sum as add, multiply};
  • 导入重命名
// main.js
import { add, multiply as mult } from './utils.js';
console.log(add(3, 7));
console.log(mult(3, 7));

单例模式(Singleton Pattern)

单例是一个仅仅只能被实例化一次的对象,如果单例实例不存在,单例模式就会创建一个新的类实例,如果实例存在的话,则会直接返回实例对象的引用,任何重复性的执行构造函数只会返回同一个实例对象。

JavaScript 语言本身就是支持单例模式的,不过我们一般并不称它为单例模式,我们通常叫它字面量对象,例如:

const user = {
  name: 'Peter',
  age: 25,
  job: 'Teacher',
  greet: function() {
    console.log('Hello!');
  }
};

JavaScript 中的每个对象在内存中都是唯一的,当我们调用 User对象时,实质上也是返回的对象引用地址。假如我们想要拷贝某个对象到另外一个变量,并且修改变量,该如何办呢?如下:

const user1 = user;
user1.name = 'Mark';

我们会得到结果是两个对象的name都被修改了, 因为赋值的时候是引用赋值,而不是值赋值。所以内存中只有一份对象。请看:

// prints 'Mark'
console.log(user.name);
// prints 'Mark'
console.log(user1.name);
// prints true
console.log(user === user1);

单例模式可以通过构造函数来实现,看代码:

let instance = null;
function User() {
  if(instance) {
    return instance;
  }
  instance = this;
  this.name = 'Peter';
  this.age = 25;
  
  return instance;
}
const user1 = new User();
const user2 = new User();
// prints true
console.log(user1 === user2);

当执行User构造函数的时候,首先会检测对象实例是否存在,如果存在直接返回,否则会把 this 对象赋值instance, 最后返回instance。单例模式也可以通过模块化的方法实现,如下:

const singleton = (function() {
  let instance;
  
  function init() {
    return {
      name: 'Peter',
      age: 24,
    };
  }
  return {
    getInstance: function() {
      if(!instance) {
        instance = init();
      }

      return instance;
    }
  }
})();
const instanceA = singleton.getInstance();
const instanceB = singleton.getInstance();
// prints true
console.log(instanceA === instanceB);

上面的代码中,我们通过调用singleton.getInstance方法创建了一个新的实例。如果实例已经存在,这个方法将会直接返回实例,否则将会通过init()方法创建并返回一个新的实例对象。

工厂模式(Factory Pattern)

工厂模式是一种使用工厂函数来创建对象的设计模式,该模式不用指定被创建对象准确的类或者构造函数。这种模式通常用来去创建一些不用暴露实例化逻辑的对象。例如我们可以根据依赖对象中传递的不同实例化条件来动态生成所需要的对象:

class Car{
  constructor(options) {
    this.doors = options.doors || 4;
    this.state = options.state || 'brand new';
    this.color = options.color || 'white';
  }
}
class Truck {
  constructor(options) {
    this.doors = options.doors || 4;
    this.state = options.state || 'used';
    this.color = options.color || 'black';
  }
}
class VehicleFactory {
  createVehicle(options) {
    if(options.vehicleType === 'car') {
      return new Car(options);
    } else if(options.vehicleType === 'truck') {
      return new Truck(options);
      }
  }
}

这里我分别定义了一个Car 和一个Truck类,并给对象添加了默认值,这 2 个类分别用来创建各自的 cartruck 对象。然后我定义了一个VehicleFactory类,根据options对象中vehicleType属性来创建和返回新的对象。

const factory = new VehicleFactory();
const car = factory.createVehicle({
  vehicleType: 'car',
  doors: 4,
  color: 'silver',
  state: 'Brand New'
});
const truck= factory.createVehicle({
  vehicleType: 'truck',
  doors: 2,
  color: 'white',
  state: 'used'
});
// Prints Car {doors: 4, state: "Brand New", color: "silver"}
console.log(car);
// Prints Truck {doors: 2, state: "used", color: "white"}
console.log(truck);

这里我用 VehicleFactory 类创建了一个工厂对象,然后分别指定两个options对象 vehicleType属性的值为cartruck,通过 factory.createVehicle 方法分别创建了 CarTruck 对象。

装饰器模式(Decorator Pattern)

装饰器是一种无须修改类或者构造函数即可扩展类对象能力的方法。这种模式可以用来给对象动态添加一些功能,而不需要修改类的底层代码。看下面的简单示例:

function Car(name) {
  this.name = name;
  // Default values
  this.color = 'White';
}
// Creating a new Object to decorate
const tesla= new Car('Tesla Model 3');
// Decorating the object with new functionality
tesla.setColor = function(color) {
  this.color = color;
}
tesla.setPrice = function(price) {
  this.price = price;
}
tesla.setColor('black');
tesla.setPrice(49000);
// prints black
console.log(tesla.color);

我们来看看装饰器另外一个的实践场景:

不同的车的成本是由其自身拥有的功能决定的。如果不用装饰器,我们可能为了不同的功能去创建不同的功能类,而每个类都有一个成本计算函数,例如:

class Car() {
}
class CarWithAC() {
}
class CarWithAutoTransmission {
}
class CarWithPowerLocks {
}
class CarWithACandPowerLocks {
}

但是使用装饰器模式,我们可以创建一个Car 基类,然后通过装饰函数来给对象添加不同功能配置的成本计算方法。如下:

class Car {
  constructor() {
  // Default Cost
  this.cost = function() {
  return 20000;
  }
}
}
// Decorator function
function carWithAC(car) {
  car.hasAC = true;
  const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 500;
  }
}
// Decorator function
function carWithAutoTransmission(car) {
  car.hasAutoTransmission = true;
   const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 2000;
  }
}
// Decorator function
function carWithPowerLocks(car) {
  car.hasPowerLocks = true;
  const prevCost = car.cost();
  car.cost = function() {
    return prevCost + 500;
  }
}

首先,我们创建了一个用来创建Car 对象的基类,然后我们为了不同的功能创建了装饰函数,这些函数接收一个 car 对象作为参数。然后我们重载了 car 对象的cost 函数,该方法返回更新过后的 carcost 值,并且在 car 对象上添加了用来标记对应的功能的属性。所以,当我们去添加一个新的功能时,我们可以像这样来操作:

const car = new Car();
console.log(car.cost());
carWithAC(car);
carWithAutoTransmission(car);
carWithPowerLocks(car);

最后我们可以这样计算这个车的估价:

// Calculating total cost of the car
console.log(car.cost());

结语

我们学习了一些在 JavaScript 应用的设计模式,但是这里提到的仅仅是众多设计模式中的一小部分而已。了解设计模式重要,但是了解如何避免过度使用设计模式同样重要。在使用某种设计模式前,你应该仔细的思考你所处的问题是否有合适的设计模式可以解决。想要知道这个设计模式是否适合解决你的问题,除了学习设计模式的思想,也应该多去了解它的应用场景才行。


最后修改于 2018-11-23