原型模式——談 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
}
調用深拷貝方法,若屬性爲值類型,則直接返回;若屬性爲引用類型,則遞歸遍歷。這就是遞歸實現深拷貝的核心方法。