JS 設計模式之原型模式(創建型)

原型模式——談 Prototype 無小事

原型模式不僅是一種設計模式,它還是一種編程範式(programming paradigm),是 JavaScript 面向對象系統實現的根基。

在原型模式下,當我們想要創建一個對象時,會先找到一個對象作爲原型,然後通過克隆原型的方式來創建出一個與原型一樣(共享一套數據/方法)的對象。在 JavaScript 裏,Object.create 方法就是原型模式的天然實現——準確地說,只要我們還在藉助 Prototype 來實現對象的創建和原型的繼承,那麼我們就是在應用原型模式。

有的設計模式資料中會強調,原型模式就是拷貝出一個新對象,認爲在 JavaScript 類裏實現了深拷貝方法纔算是應用了原型模式。這是非常典型的對 JAVA/C++ 設計模式的生搬硬套,更是對 JavaScript 原型模式的一種誤解。事實上,在 JAVA 中,確實存在原型模式相關的克隆接口規範。但在 JavaScript 中,我們使用原型模式,並不是爲了得到一個副本,而是爲了得到與構造函數(類)相對應的類型的實例、實現數據/方法的共享。克隆是實現這個目的的方法,但克隆本身並不是我們的目的。

一、以類爲中心的語言和以原型爲中心的語言

1、Java 中的類

JavaScript 沒有除了 Prototype 以外應用原型模式的選擇 —— 畢竟原型模式是 JavaScript 這門語言面向對象系統的根本。但在其它語言,比如 JAVA 中,類纔是它面向對象系統的根本。所以說在 JAVA 中,我們可以選擇不使用原型模式 —— 這樣一來,所有的實例都必須要從類中來,當我們希望創建兩個一模一樣的實例時,就只能這樣做(假設實例從 Dog 類中來,必傳參數爲姓名、性別、年齡和品種):

Dog dog = new Dog('旺財', 'male', 3, '柴犬')

Dog dog_copy = new Dog('旺財', 'male', 3, '柴犬')

這裏我們不得不把一模一樣的參數傳兩遍,非常麻煩。而原型模式允許我們通過調用克隆方法的方式達到同樣的目的,比較方便,所以 Java 專門針對原型模式設計了一套接口和方法,在必要的場景下會通過原型方法來應用原型模式。當然,在更多的情況下,Java 仍以“實例化類”這種方式來創建對象。

2、JavaScript 中的“類”

雖然說 ES6 支持類,但 ES6 的類其實是原型繼承的語法糖,類語法不會爲 JavaScript 引入新的面向對象的繼承模型。

當我們嘗試用 class 去定義一個 Dog 類時:

class Dog {
  constructor(name, age) {
    this.name = name
    this.age = age
  }

  eat() {
    console.log('肉骨頭真好喫')
  }
}

其實完全等價於寫了這麼一個構造函數:

function Dog(name, age) {
  this.name = name
  this.age = age
}

Dog.prototype.eat = function () {
  console.log('肉骨頭真好喫')
}

所以說 JavaScript 這門語言的根本就是原型模式。在 Java 等強類型語言中,原型模式的出現是爲了實現類型之間的解耦。而 JavaScript 本身類型就比較模糊,不存在類型耦合的問題,所以說平時不會刻意地去使用原型模式。因此不必強行把原型模式當作一種設計模式去理解,把它作爲一種編程範式來討論會更合適。

二、談原型模式,其實是談原型範式

原型編程範式的核心思想就是利用實例來描述對象,用實例作爲定義對象和繼承的基礎。在 JavaScript 中,原型編程範式的體現就是基於原型鏈的繼承。這其中,對原型、原型鏈的理解是關鍵。

1、原型

在 JavaScript 中,每個構造函數都擁有一個 prototype 屬性,它指向構造函數的原型對象,這個原型對象中有一個 construtor 屬性指回構造函數;每個實例都有一個__proto__屬性,當我們使用構造函數去創建實例時,實例的__proto__屬性就會指向構造函數的原型對象。

具體來說,當我們這樣使用構造函數創建一個對象時:

// 創建一個 Dog 構造函數
function Dog(name, age) {
  this.name = name
  this.age = age
}

Dog.prototype.eat = function () {
  console.log('肉骨頭真好喫')
}

// 使用 Dog 構造函數創建 dog 實例
const dog = new Dog('旺財', 3)

這段代碼裏的幾個實體之間就存在着這樣的關係:

2、原型鏈

現在在上面那段代碼的基礎上,進行兩個方法調用:

// 輸出"肉骨頭真好喫"
dog.eat()

// 輸出"[object Object]"
dog.toString()

明明沒有在 dog 實例裏手動定義 eat 方法和 toString 方法,它們還是被成功地調用了。這是因爲訪問一個 JavaScript 實例的屬性/方法時,它首先搜索這個實例本身;當發現實例沒有定義對應的屬性/方法時,它會轉而去搜索實例的原型對象;如果原型對象中也搜索不到,它就去搜索原型對象的原型對象,這個搜索的軌跡,就叫做原型鏈。

以上面的 eat 方法和 toString 方法的調用過程爲例,它的搜索過程就是這樣子的:

上面這些彼此相連的 prototype,就組成了一個原型鏈。 幾乎所有 JavaScript 中的對象都是位於原型鏈頂端的 Object 的實例,除了Object.prototype(當然,如果手動用 Object.create(null) 創建一個沒有任何原型的對象,那它也不是 Object 的實例)。

三、對象的深拷貝

“模擬 JAVA 中的克隆接口”、“JavaScript 實現原型模式” 其實就是 “實現 JS 中的深拷貝”

實現 JavaScript 中的深拷貝,有一種非常取巧的方式 —— JSON.stringify:

const liLei = {
  name: 'lilei',
  age: 28,
  habits: ['coding', 'hiking', 'running']
}

const liLeiStr = JSON.stringify(liLei)
const liLeiCopy = JSON.parse(liLeiStr)

liLeiCopy.habits.splice(0, 1)
console.log('李雷副本的 habits 數組是', liLeiCopy.habits)
console.log('李雷的 habits 數組是', liLei.habits)

進控制檯檢驗,可以發現引用類型也被成功拷貝了,副本和本體相互不干擾~

但是這個方法存在一些侷限性,比如無法處理 function、無法處理正則等等——只有當你的對象是一個嚴格的 JSON 對象時,可以順利使用這個方法。

深拷貝沒有完美方案,每一種方案都有它的邊界 case,多數情況下涉及到遞歸。遞歸實現深拷貝的核心思路:

function deepClone(obj) {
  // 如果是值類型 或 null,則直接 return
  if (typeof obj !== 'object' || obj === null) {
    return obj
  }

  // 定義結果對象
  let copy = {}

  // 如果對象是數組,則定義結果數組
  if (obj.constructor === Array) {
    copy = []
  }

  // 遍歷對象的 key
  for (let key in obj) {
    // 如果 key 是對象的自有屬性
    if (obj.hasOwnProperty(key)) {
      // 遞歸調用深拷貝方法
      copy[key] = deepClone(obj[key])
    }
  }

  return copy
}

調用深拷貝方法,若屬性爲值類型,則直接返回;若屬性爲引用類型,則遞歸遍歷。這就是遞歸實現深拷貝的核心方法。

拓展閱讀:
jQuery 中的 extend 方法源碼 (opens new window)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章