Typescript玩轉設計模式 之 建立型模式

螞蟻金服資料體驗技術發表於2017-11-02

作者簡介 joey 螞蟻金服·資料體驗技術團隊

前言

我們團隊的工作是用單頁面應用的方式實現web工具。涉及到數萬到十數萬行的前端程式碼的管理,而且專案週期長達數年。

怎麼樣很好地管理好這種量級的前端程式碼,在迭代的過程中能保持程式碼的新鮮度,對我們來說是個挑戰。

一個執行良好的專案,除了要有好的架構外,還需要各個功能模組有良好的設計,學習設計模式,就是希望能有技巧地設計新功能和重構已有程式碼。

在網上看到很多說法,說學習設計模式作用不大,有些模式已經過時了,不學也能工作,學了反而容易過度設計。

我認為對事物的理解是“學習——領悟——突破”的過程。不懂的時候先學習,當學到的東西和實踐經驗有差異時結合思考可以領悟,等領悟到了其中的原理時,就可以不拘泥於學到的內容從而根據自己的場景靈活運用了。而過度設計顯然還是在學習和領悟之間而已。

設計模式也是這樣,《設計模式》裡列舉的23種設計模式並不是全部,模式的運用上往往也不是分得那麼清楚,常常是多種模式混合使用。學習設計模式就像《倚天屠龍記》裡張無忌學習太極拳一樣,先學習招式,再打幾遍,最終忘記這些招式。23種設計模式只是招式,我們學習的目的是為了提高自己的設計水平,達到能結合場景信手拈來設計方案,不拘泥於招式的“大乘”境界。

在學習設計模式的過程中,我發現4人幫的原書demo程式碼是C++的,而網上設計模式文章的demo多是java的。因此結合前端的js語言特性,整理了一遍各個模式的demo,方便有志於學習設計模式的同學們理解,共同進步。

場景描述

假設我們要實現一個迷宮,原始程式碼如下:

function createMazeDemo() {
  const maze = new Maze();
  
  const r1 = new Room(1);
  const r2 = new Room(2);
  const door = new Door(r1, r2);

  maze.addRoom(r1);
  maze.addRoom(r2);
  r1.setSide('east', new Wall());
  r1.setSide('west', door);
  
  return maze;
}

createMazeDemo();
複製程式碼

我們已經實現了一個迷宮,這時候新的需求來了,迷宮裡所有的東西都被施了魔法,但還是要重用現有的佈局(所有構件類,如Room、Wall、Door都要換成新的類)。

可以看到這樣的硬編碼方式不夠靈活,那麼如何改造createMaze方法以讓他方便地用新型別的物件建立迷宮呢?

通用概念定義

  • 系統:整個程式生成的內容,如迷宮就是一個系統;
  • 產品:組成系統的物件,如迷宮的門,房間,牆分別是一種產品;

抽象工廠(Abstract factory)

定義

提供一個建立一系列相關或相互依賴物件的介面,而無需指定它們具體的類。

結構

抽象工廠模式包含如下角色:

  • AbstractFactory:抽象工廠
  • ConcreteFactory:具體工廠
  • AbstractProduct:抽象產品
  • Product:具體產品

Typescript玩轉設計模式 之 建立型模式

示例

// 迷宮的基類
class Maze {
  addRoom(room: Room): void {
  }
}
// 牆的基類
class Wall {
}
// 房間的基類
class Room {
  constructor(id: number) {
  }
  setSide(direction: string, content: Room|Wall): void {
  }
}
// 門的基類
class Door {
  constructor(roo1: Room, room2: Room) {
  }
}
// 迷宮工廠的基類,定義了生成迷宮各個構件的介面和預設實現,
// 子類可以複寫介面的實現,返回不同的具體類物件。
class MazeFactory {
  makeMaze(): Maze {
    return new Maze();
  }
  makeWall(): Wall {
    return new Wall();
  }
  makeRoom(roomId: number): Room {
    return new Room(roomId);
  }
  makeDoor(room1: Room, room2: Room): Door {
    return new Door(room1, room2);
  }
}

// 通過傳入工廠物件,呼叫工廠的介面方法建立迷宮,
// 由於工廠的介面都是一樣的,所以傳入不同的工廠物件,就能建立出不同系列的具體產品
function createMazeDemo(factory: MazeFactory): Maze {
  const maze = factory.makeMaze();
  const r1 = factory.makeRoom(1);
  const r2 = factory.makeRoom(2);
  const door = factory.makeDoor(r1, r2);

  maze.addRoom(r1);
  maze.addRoom(r2);
  
  r1.setSide('east', factory.makeWall());
  r1.setSide('west', door);
  
  return maze;
}

// 標準系列工廠物件,工廠的每個產品都是標準的
const standardSeries = new MazeFactory();
// 建立出標準的迷宮
createMazeDemo(standardSeries);

// 附了魔法的房間,繼承自房間的基類
class MagicRoom extends Room {
  ...
}

// 附了魔法的門,繼承自門的基類
class MagicDoor extends Door {
  ...
}

// 魔法系列的工廠,工廠的房間和門是被附了魔法的
class MagicMazeFactory extends MazeFactory {
  makeRoom(roomId: number): Room {
    return new MagicRoom(roomId);
  }
  makeDoor(room1: Room, room2: Room): Door {
    return new MagicDoor(room1, room2);
  }
  ...
}

// 魔法系列工廠物件,工廠建立出的門和房間是附了魔法的
const magicSeries = new MagicMazeFactory();
createMazeDemo(magicSeries);
複製程式碼

適用場景

  • 一系列相關的產品物件。如有魔法的房間和有魔法的門,都屬於有魔法的迷宮構件,有一定相關性;
  • 系統要由多個產品系列的一個來配置。如當使用者有多種迷宮樣式風格可以選擇,當選擇黑色風格時,所有迷宮的構件都需要是黑色風格的構件;
  • 系統要獨立於具體的產品類。如編寫迷宮程式時,不需要關心用的是哪個具體的房間類,只需要知道房間基類的介面就可以操作房間。各種迷宮工廠返回的房間類都是繼承自房間基類,介面是一致的,即使普通房間換成了有魔法的房間,也不需要操作房間的程式碼,如迷宮的佈局程式碼,關心改變。

優點

  • 分離了具體的類。如迷宮要改成有魔法的迷宮,迷宮的佈區域性分程式碼createMazeDemo不需要修改;
  • 易於交換產品系列。迷宮換成有魔法的迷宮時,只需要對createMazeDemo傳入新的工廠物件即可;
  • 有利於產品的一致性。同一個系列的產品,都是相關性比較高的。如魔法迷宮的具體物件,都是帶魔法的。

缺點

  • 難以支援新種類的產品。這是因為抽象工廠的介面確定了可以被建立的產品集合,支援新種類的產品就需要擴充套件該工廠介面,這將涉及到抽象工廠類及其所有子類的改變。

相關模式

  • 抽象工廠模式通常用工廠方法實現,也可以用原型模式實現。
  • 一個具體的工廠通常是一個單例模式。

建造者(Builder)

定義

將一個複雜物件的構建與它的表示分離,使得同樣的構建過程可以建立不同的表示。

結構

建造者模式包含如下角色:

  • Builder:抽象建造者
  • ConcreteBuilder:具體建造者
  • Director:指揮者
  • Product:產品角色

Typescript玩轉設計模式 之 建立型模式

示例

import { Maze, Wall, Room, Door } from './common';

// 迷宮建造者基類,定義了所有生成迷宮構件的介面,以及最終返回完整迷宮的介面
// 自身不建立迷宮,僅僅定義介面
class MazeBuilder {
  buildMaze(): void {}
  buildWall(roomId: number, direction: string): void {}
  buildRoom(roomId: number): void {}
  buildDoor(roomId1: number, roomId2: number): void {}
  getCommonWall(roomId1: number, roomId2: number): Wall { return new Wall(); };
  getMaze(): Maze|null { return null; }
}

// 建立迷宮的流程
// 相比最原始的程式碼,使用建造者模式只需要宣告建造過程,而不需要知道建造過程中用到的每個構件的所有資訊
// 比如,建造門的時候,只需要宣告要建造一扇門,而不需要關心建造門的方法內部是如何將門與房間關聯起來的
// 建造者模式只在迷宮被完全建造完成時,才從建造者物件裡取出整個迷宮,從而能很好地反映出完整的建造過程
function createMaze(builder: MazeBuilder) {
  builder.buildMaze();
  builder.buildRoom(1);
  builder.buildRoom(2);
  builder.buildDoor(1, 2);
  
  return builder.getMaze();
}

// 標準迷宮的建造者,繼承自建造者基類
class StandardMazeBuilder extends MazeBuilder {
  currentMaze: Maze;
  constructor() {
    super();
    this.currentMaze = new Maze();
  }
  getMaze(): Maze|null {
    return this.currentMaze;
  }
  buildRoom(roomId: number): void {
    if (this.currentMaze) {
      const room = new Room(roomId);
      this.currentMaze.addRoom(room);

      room.setSide('north', new Wall());
      room.setSide('south', new Wall());
      room.setSide('east', new Wall());
      room.setSide('west', new Wall());
    }
  }
  buildDoor(roomId1: number, roomId2: number): void {
    const r1 = this.currentMaze.getRoom(roomId1);
    const r2 = this.currentMaze.getRoom(roomId2);
    const door = new Door(r1, r2);
    r1.setSide(this.getCommonWall(roomId1, roomId2), door);
    r2.setSide(this.getCommonWall(roomId2, roomId1), door);
  }
}

// 建造一個標準的迷宮
const standardBuilder = new StandardMazeBuilder();
createMaze(standardBuilder);

/**
 * 建造者也可以根本不建造具體的構件,而只是對建造過程進行計數。
 */
// 計數的資料結構宣告
interface Count {
  rooms: number;
  doors: number;
}

// 不建立迷宮,只記數的建造者,也繼承自建造者基類
class CountingMazeBuilder extends MazeBuilder {
  doors = 0;
  rooms = 0;
  buildRoom(): void {
    this.rooms += 1;
  }
  buildDoor(): void {
    this.doors += 1;
  }
  getCounts(): Count {
    return {
      rooms: this.rooms,
      doors: this.doors,
    };
  }
}

const countBuilder = new CountingMazeBuilder();
createMaze(countBuilder);
countBuilder.getCounts();
複製程式碼

適用場景

  • 當建立複雜物件的演算法應該獨立於該物件的組成部分以及他們的裝配方式時。上例來看,建造迷宮的過程相當於演算法,而每個生成構件的方法包含了生成具體物件以及將具體物件裝配到迷宮上的方式。同一個建造過程可以適配多種不同的建造者,而同一個建造者可以支援多種不同的建造過程,兩者是相互獨立的。
  • 當構造過程必須允許被構造的物件有不同的表示時。建造過程相同,但需要支援多種不同的裝配方式,如上例中,相同的建造過程需要支援兩種不同的場景,一種場景需要建造出真實的迷宮,另一種場景只需要統計建造的過程中建了多少間房價和多少扇門。

優點

  • 可以改變一個產品的內部表示。介面可以隱藏產品的表示和內部結構,同時隱藏了產品的裝配過程。因為產品是通過抽象介面構造的,你在改變該產品的內部表示時所要做的只是定義一個新的建造者。
  • 將構造程式碼和表示程式碼分開。建造者模式通過封裝一個複雜物件的建立和表示方式提高了物件的模組性。客戶不需要知道定義產品內部結構的類的所有資訊。
  • 可對構造過程進行更精細的控制。建造者模式僅當該產品完成時才從建造者中取回它,因此建造者模式相比其他建立型模式能更好地反映產品的構造過程。

相關模式

  • 抽象工廠與建造者模式相似,因為它也可以建立複雜物件。主要的區別是建造者模式著重於一步步構造一個複雜物件。而抽象工廠著重於多個系列的產品物件。建造者在最後的一步返回產品,而對於抽象工廠來說,產品是立即返回的。
  • 組合模式通常是用建造者模式生成的。

工廠方法(Factory Method)

定義

定義一個用於建立物件的介面,讓子類決定例項化哪個類。工廠方法使一個類的例項化延遲到其子類。

結構

工廠方法模式包含如下角色:

  • Product:抽象產品
  • ConcreteProduct:具體產品
  • Factory:抽象工廠
  • ConcreteFactory:具體工廠

Typescript玩轉設計模式 之 建立型模式

示例

子類決定例項化具體產品

import { Maze, Wall, Room, Door } from './common';

// 迷宮遊戲類
class MazeGame {
  // 建立迷宮的主方法
  createMaze(): Maze {
    const maze = this.makeMaze();
    const r1 = this.makeRoom(1);
    const r2 = this.makeRoom(2);
    const door = this.makeDoor(r1, r2);

    maze.addRoom(r1);
    maze.addRoom(r2);

    r1.setSide('north', this.makeWall());
    r1.setSide('east', door);

    return maze;
  }
  // 以下是工廠方法,通過工廠方法建立構件,而不是直接在主方法中new出具體類
  // 工廠方法最重要的是定義出返回產品的介面,雖然這裡提供了預設實現,但也可以只提供介面,讓子類來實現
  makeMaze(): Maze { return new Maze(); }
  makeRoom(roomId: number): Room { return new Room(roomId); }
  makeWall(): Wall { return new Wall(); }
  makeDoor(room1: Room, room2: Room): Door { return new Door(room1, room2); }
}
// 建立普通迷宮遊戲
const mazeGame = new MazeGame();
mazeGame.createMaze();

// 帶炸彈的牆
class BombedWall extends Wall {
  ...
}
// 帶炸彈的房間
class RoomWithABomb extends Room {
  ...
}
// 子類可以複寫工廠方法,以下是帶炸彈的迷宮遊戲類
class BombedMazeGame extends MazeGame {
  // 複寫建立牆的方法,返回一面帶炸彈的牆
  makeWall(): Wall {
    return new BombedWall();
  }
  // 複寫建立房間的方法,返回一個帶炸彈的房間
  makeRoom(roomId: number): Room {
    return new RoomWithABomb(roomId);
  }
}
// 建立帶炸彈的迷宮遊戲
const bombedMazeGame = new BombedMazeGame();
bombedMazeGame.createMaze();
複製程式碼

引數化工廠方法

class Creator {
  createProduct(type: string): Product {
    if (type === 'normal') return new NormalProduct();
    if (type === 'black) return new BlackProduct();
    return new DefaultProduct();
  }
}

// 子類可以很容易地擴充套件或改變工廠方法返回的產品
class MyCreator extends Creator {
  createProduct(type: string): Product {
    // 改變產品
    if (type === 'normal) return new MyNormalProduct();
    // 擴充套件新的產品
    if (type === 'white') return new WhiteProduct();
    // 注意這個操作的最後一件事是呼叫父類的`createProduct`,這是因為子類僅對某些type的處理上與父類不同,對其他的type不感興趣
    return Creator.prototype.createProduct.call(this, type);
  }
}
複製程式碼

適用場景

  • 當一個類不知道他所必須建立的物件的類的時候。
  • 當一個類希望由他的子類來指定他所建立的物件的時候。
  • 當類將建立物件的職責委託給多個幫助子類的某一個,並且你希望將哪一個幫助子類是代理者這一資訊區域性化的時候。這就是引數化工廠方法的場景,將通過引數指定具體類的過程區域性化在工廠方法中。

優點

  • 用工廠方法在一個類的內部建立物件通常比直接建立物件更靈活。相比於在createMaze方法中直接建立物件const r1 = new Room(1);,用工廠方法const r1 = this.makeRoom(1),可以在子類中複寫makeRoom方法來例項化不同的房間,能更靈活地應對需求變化。

相關模式

  • 抽象工廠經常用工廠方法來實現。
  • 工廠方法通常在模板方法模式中被呼叫。
  • 原型模式不需要建立子類,但是通常要求一個針對產品類的初始化操作。而工廠方法不需要這樣的操作。

原型(Prototype)

定義

用原型例項指定建立物件的種類,並且通過複用這些原型建立新的物件。

示例

在其他語言裡,原型模式是通過拷貝一個物件,然後修改新物件的屬性,從而減少類的定義和例項化的開銷。

但由於js天然支援prototype,因此原型的實現方式與其他類繼承語言有些不同,不需要通過物件提供clone方法來實現模型模式。

import { Maze, Wall, Room, Door } from './common';

interface Prototype {
  prototype?: any;
}

// 根據原型返回物件
function getNewInstance(prototype: Prototype, ...args: any[]): Wall|Maze|Room|Door {
  const proto = Object.create(prototype);
  const Kls = class {};
  Kls.prototype = proto;
  return new Kls(...args);
}

// 迷宮工廠,定義了生成構件的介面
class MazeFactory {
  makeWall(): Wall { return new Wall(); }
  makeDoor(r1: Room, r2: Room): Door { return new Door(r1, r2); }
  makeRoom(id: number): Room { return new Room(id); }
  makeMaze(): Maze { return new Maze(); }
}

// 原型迷宮工廠,根據初始化時傳入的原型改變返回的迷宮構件
class MazePrototypeFactory extends MazeFactory {
  mazePrototype: Prototype;
  wallPrototype: Prototype;
  roomPrototype: Prototype;
  doorPrototype: Prototype;

  constructor(mazePrototype: Prototype, wallPrototype: Prototype, roomPrototype: Prototype, doorPrototype: Prototype) {
    super();
    this.mazePrototype = mazePrototype;
    this.wallPrototype = wallPrototype;
    this.roomPrototype = roomPrototype;
    this.doorPrototype = doorPrototype;
  }
  makeMaze() {
    return getNewInstance(this.mazePrototype);
  }
  makeRoom(id: number) {
    return getNewInstance(this.roomPrototype, id);
  }
  makeWall() {
    return getNewInstance(this.wallPrototype);
  }
  makeDoor(r1: Room, r2: Room): Door {
    const door = getNewInstance(this.doorPrototype, r1, r2);
    return door;
  }
}

// 建立迷宮的過程
function createMaze(factory: MazeFactory): Maze {
  const maze = factory.makeMaze();
  const r1 = factory.makeRoom(1);
  const r2 = factory.makeRoom(2);
  const door = factory.makeDoor(r1, r2);

  maze.addRoom(r1);
  maze.addRoom(r2);
  
  r1.setSide('east', factory.makeWall());
  r1.setSide('west', door);
  
  return maze;
}

// 各個迷宮構件的原型
const mazePrototype = {...};
const wallPrototype = {...};
const roomPrototype = {...};
const doorPrototype = {...};

// 生成簡單的迷宮
const simpleMazeFactory = new MazePrototypeFactory(mazePrototype, wallPrototype, roomPrototype, doorPrototype);
createMaze(simpleMazeFactory);

// 帶有炸彈的迷宮構件的原型
const bombedWallPrototype = {...};
const roomWithABombPrototype = {...};
// 生成帶有炸彈的迷宮
const bombedMazeFactory = new MazePrototypeFactory(mazePrototype, bombedWallPrototype, roomWithABombPrototype, doorPrototype);
createMaze(bombedMazeFactory);
複製程式碼

適用場景

  • 要例項化的類是在執行時刻指定時。如通過動態載入的方式拿到原型後,在程式執行的過程中根據原型直接建立出例項,而不是通過預先定義好的class new出物件。或者類需要根據執行環境動態改變,可以通過修改原型來產生不同的物件。

優點

  • 執行時增加產品。因為可以在執行時動態根據原型生成新的種類的物件。
  • 減少子類的構造,減少在系統中用到的類的數量。
  • 用類動態配置應用。在執行時動態載入類。

相關模式

  • 原型模式和抽象工廠模式在某種方面是互相競爭的。但是他們也可以一起使用。抽象工廠可以儲存一個原型的集合,並且返回產品物件。
  • 大量使用組合模式和裝飾模式的設計通常也可以從原型模式中獲益。

單例(Singleton)

定義

保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點。

結構

單例模式包含如下角色:

  • Singleton:單例

Typescript玩轉設計模式 之 建立型模式

示例

簡單的單例

class MazeFactory {
  // 將constructor設為私有,防止通過new該類產生多個物件,破壞單例
  private constructor() {}
  static instance: MazeFactory;
  // 如果已經有了物件,則返回快取的物件,不然就建立一個物件並快取,保證系統內最多隻有一個該類的物件
  static getInstance(): MazeFactory {
    if (!MazeFactory.instance) {
      MazeFactory.instance = new MazeFactory();
    }
    return MazeFactory.instance;
  }
}
複製程式碼

根據引數選擇要例項化的迷宮工廠

class BombedMazeFactory extends MazeFactory {
  ...
}

class AdvancedMazeFactory {
  // 將constructor設為私有,防止通過new該類產生多個物件,破壞單例
  private constructor() {}
  static instance: MazeFactory;
  static getInstance(type: string): MazeFactory {
    if (!AdvancedMazeFactory.instance) {
      if (type === 'bombed') {
        AdvancedMazeFactory.instance = new BombedMazeFactory();
      } else {
        AdvancedMazeFactory.instance = new MazeFactory();
      }
    }
    return AdvancedMazeFactory.instance;
  }
}
複製程式碼

適用場景

  • 當類只能有一個例項,而且使用者可以從一個眾所周知的訪問點訪問它時;
  • 當這個唯一的例項應該是通過子類化可擴充套件的,如上例中根據引數擴充套件,並且使用者應該無需更改程式碼就能使用一個擴充套件的例項時;

優點

  • 對唯一例項可以進行有效的受控訪問。
  • 防止儲存唯一例項的全域性變數汙染名稱空間。
  • 可以在例項化方法中改變具體使用的例項。
  • 允許可變數目的例項。這個模式使你易於改變你的想法,並允許單例的類同時存在多個例項。因為例項化的入口在一個地方,可以方便地控制允許同時存在的例項數量。

相關模式

  • 很多模式可以用單例實現,如抽象工廠,建造者,和原型。

參考文件

本文只介紹了建立型模式,對後續模式感興趣的同學可以關注專欄或者傳送簡歷至'chaofeng.lcf####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~

原文地址:https://juejin.im/post/59fa88ac5188255a6a0d5f31

相關文章