[譯文]優雅的現代JavaScript設計模式: 冰凍工廠

YusenMeng發表於2018-09-23

原文地址 Elegant patterns in modern JavaScript: Ice Factory

從上個世紀九十末開始,我就開始斷斷續續的從事JavaScript的開發工作.初始,我並不喜歡它.但是自從瞭解了ES2015(也叫ES6),我開始認為JavaScript是一個強大而且傑出的動態程式語言.

隨著時間流逝,我掌握了幾種能夠程式碼更加簡潔,可測試以及更加有表達力的編碼模式.現在,我將把這些模式分享與你.

我第一個介紹的模式是RORO(稍後會翻譯).如果你沒有閱讀過它,請不要擔心,因為這不會影響這篇文章的閱讀,你可以在其他的時候閱讀它.

今天,我將會給你介紹冰凍工廠模式.

冰凍工廠只是一個函式,它能夠建立並且返回一個不可變物件.我們將在後面解釋這個定義,首先,讓我們看看為什麼這個模式如此的有用.

JavaScript的class並不完美.

通常來說,我們都會把一些相關的函式聚合在一個物件中.例如,在一款電子商務的app中,我們可能有一個cart物件,它暴露了addProductremoveProduct兩個函式.我們可以通過cart.addProduct()以及cart.removeProduct()來呼叫他們.

如果你曾經寫過以類為中心的物件導向的語言,例如Java或者C#, 這可能會使你感覺非常親切自然.

如果你是一個新手, 沒關係,現在你已經見到了cart.addProduct()這個語句.對於這種寫法,我個人持保留態度.

我們該如何建立一個好的cart物件呢?第一個與現在JavaScript相關的直覺應該是使用class.看起來就像這樣:

// ShoppingCart.js
export default class ShoppingCart {
  constructor({db}) {
    this.db = db
  }
  
  addProduct (product) {
    this.db.push(product)
  }
  
  empty () {
    this.db = []
  }
  get products () {
    return Object
      .freeze([...this.db])
  }
  removeProduct (id) {
    // remove a product 
  }
  // other methods
}
// someOtherModule.js
const db = [] 
const cart = new ShoppingCart({db})
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
})
複製程式碼

注: 為了簡單的緣故,我使用一個陣列作為資料庫db.在實際程式碼中,這個應該是類似Model或者Repo這些能夠和真實資料庫互動的物件.

不幸的是,雖然這段程式碼看起來非常棒,但是JavaScript中class的行為可能和你想的不太一樣.

如果你稍不注意,JavaScript會反咬你一口.

例如, 通過new關鍵字建立的物件是可以修改的.因此,你能夠對一個方法重新賦值

const db = []
const cart = new ShoppingCart({db})
cart.addProduct = () => 'nope!' 
// No Error on the line above!
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
}) // output: "nope!" FTW?
複製程式碼

更加糟糕的是,通過new建立的物件,繼承於這個classprototype.因此,修改這個類的原型,將會影響所有通過這個類建立的物件,即使這個修改是在物件建立之後.

看看這個例子:

const cart = new ShoppingCart({db: []})
const other = new ShoppingCart({db: []})
ShoppingCart.prototype
  .addProduct = () => ‘nope!’
// No Error on the line above!
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
}) // output: "nope!"
other.addProduct({ 
  name: 'bar', 
  price: 8.88
}) // output: "nope!"
複製程式碼

實際上,JavaScript中,this是動態繫結的.如果我們把cart物件的方法傳遞出去,將會導致失去this的引用.這一點非常違反直覺的,同時會招來許多麻煩,

一個常見的陷進是我們把一個例項的方法繫結成一個事件的處理函式. 以我們的cart.empty方法為例.

empty () {
    this.db = []
  }
複製程式碼

如果我們直接把這個方法繫結成我們頁面的按鈕點選事件...

<button id="empty">
  Empty cart
</button>
複製程式碼
document
  .querySelector('#empty')
  .addEventListener(
    'click', 
    cart.empty
  )
複製程式碼

當使用者點選這個empty按鈕的時候,他們的購物車仍舊是滿的,並沒有被清空.

這個失敗是靜默的,因為this將會指向這個button,而不是指向cart.因此,我們的cart.empty方法最後會給button建立一個新的屬性db並且賦值為[],而不是影響cart物件中的db.

這種型別的bug可能會讓你奔潰,因為並沒有錯誤發生,你通常的直覺告訴你這應該是對的,但是實際上不是.

為了讓它能夠正常的工作,我們可以這麼做:

document
  .querySelector("#empty")
  .addEventListener(
    "click", 
    () => cart.empty()
  )
複製程式碼

我認為Mattias Petter Johansson說的非常好:

JavaScript中的newthis有時候會反直覺,奇怪,如彩虹陷阱一般

冰凍工廠模式來拯救你

正如我之前所說的那樣,一個冰工廠是一個建立並且返回不可變物件的函式.通過冰工廠模式,我們的購物車例子改寫成如下模式:

// makeShoppingCart.js
export default function makeShoppingCart({
  db
}) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    // others
  })
  function addProduct (product) {
    db.push(product)
  }
  
  function empty () {
    db = []
  }
  function getProducts () {
    return Object
      .freeze([...db])
  }
  function removeProduct (id) {
    // remove a product
  }
  // other functions
}
// someOtherModule.js
const db = []
const cart = makeShoppingCart({ db })
cart.addProduct({ 
  name: 'foo', 
  price: 9.99
})
複製程式碼

需要注意的事,我們奇怪的彩虹陷阱已經沒有了:

  • 我們不再需要new 我們僅僅是呼叫一個普通的JavaScript函式來建立我們的cart物件.

  • 我們不再需要this 我們的成員函式能夠直接訪問db物件.

  • 我們的cart物件是完完全全的不可變. Object.freeze()凍結了cart物件,因此不能夠對其新增新的屬性,修改或者刪除已經存在的屬性以及原型鏈也無法修改.只需要記住, Object.freeze()是淺層的,所以如果我們返回的物件包含了陣列或者其他的物件,我們必須保證Object.freeze()也對它們產生了作用.同樣的,我們所使用的ES模組也是不可變的.你需要使用嚴格模式,防止重新賦值能夠報錯而不是靜默的失敗.

私密性

另外一個冰工廠模式的優勢就是他們能夠擁有私有成員.我們看如下例子

function makeThing(spec) {
  const secret = 'shhh!'
  return Object.freeze({
    doStuff
  })
  function doStuff () {
    // 我們可以在這裡使用 spec 和 secret變數
  }
}
// secret 在這裡無法被訪問
const thing = makeThing()
thing.secret // undefined

複製程式碼

JavaScript使用閉包來完成這個功能,相關的資料你可以在MDN上面查詢.

公認的定律

即使工廠模式已經存在JavaScript裡面很久了,但是冰工廠模式仍舊被強烈的推薦.Douglas Crockford在這個視訊中就展示了相關的程式碼(視訊需要科學上網).

這段是Crockford演示的程式碼,他把這個建立物件的函式稱之為constructor.

Douglas Crockford 演示的程式碼啟發了我

我的冰凍工廠模式應用在Crockford的例子上,程式碼看起來像是這樣.

function makeSomething({ member }) {
  const { other } = makeSomethingElse() 
  
  return Object.freeze({ 
    other,
    method
  }) 
  function method () {
    // code that uses "member"
  }
}
複製程式碼

我利用函式變數提升的優勢,把返回的語句放在了接近頂部的位置,這樣讀者在開始閱讀程式碼直接之前,能夠有一個概覽.

我同時也把spec引數進行了解構,並且把模式改名成了冰凍工廠,這個名字更加方便記憶同時也防止和ES6中的constructor弄混.但實際上,它們是同一個東西.

因此,我由衷的說一句,感謝你,Mr.Crockford

注: 這裡值得一提的事,Crockford認為函式的變數提升是JavaScript的弊端,因而可能認為的版本不正確.我在這篇文章談到了我的理解,更詳細的,在這篇評論中.

繼承怎麼辦?

當我們持續的構建我們的電子商務app,我們可能很快會意識到,新增和刪除商品的概念會不斷的冒出來.

伴隨著我們的購物車物件,我們可能會有一個類別物件和一個訂單物件.所有的這些物件都可能暴露不同版本的addProductremoveProduct函式.

我們都知道,複製重複程式碼是不好的行為,所以我們最終可能會嘗試建立一個類似商品列表的物件,我們的購物車, 類別以及訂單物件都繼承於它.

但是,除了通過繼承一個商品列表物件來擴充套件我們的物件,我們還可以採用另外一個理論,它來自於一本非常有影響力的書,是這麼寫的:

“Favor object composition over class inheritance.” – Design Patterns: Elements of Reusable Object-Oriented Software.

我們應該更多的採用物件組合而不是繼承 - 設計模式

這裡附上這本書的連結 設計模式

實際上,這本書的作者,我們俗稱的四人幫之一,還說到

“…our experience is that designers overuse inheritance as a reuse technique, and designs are often made more reusable (and simpler) by depending more on object composition.” 我們的經驗是程式設計師過度的使用繼承作為複用的手段,但是通過物件組合的模式來設計會使得複用更加的廣泛和簡單.

因此,我們的商品列表工廠將是這樣:

function makeProductList({ productDb }) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    // others
  )}
 
  // addProduct 以及其他函式的定義…
}
複製程式碼

然後,我們的購物車工廠將長成這樣:

function makeShoppingCart(productList) {
  return Object.freeze({
    items: productList,
    someCartSpecificMethod,
    // …
)}
function someCartSpecificMethod () {
  // code 
  }
}
複製程式碼

然後,我們可以把商品列表傳入到我們的購物車中,就像這樣:

const productDb = []
const productList = makeProductList({ productDb })
const cart = makeShoppingCart(productList)
複製程式碼

我們將可以通過items屬性來使用productList.如下所示:

cart.items.addProduct()
複製程式碼

我們也可以嘗試通過方法的合併,把整個productList物件融入到我們的購物車物件中.就像這樣

function makeShoppingCart({ 
  addProduct,
  empty,
  getProducts,
  removeProduct,
  …others
}) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    someOtherMethod,
    …others
)}
function someOtherMethod () {
  // code 
  }
}
複製程式碼

實際上,在這篇文章的早些時候的版本,我就是這麼做的.但是後來我發現這有些危險(這裡相關有解釋).所以,我們最好還是通過物件的屬性的方式進行組合.

太棒了,我已經把我的想法傳遞給你了

小心

當我們學習一些新的知識,特別是一些類似架構和設計這類複雜的內容的時候,我們更希望有簡單可遵循的鐵律.我們想聽到類似總要這麼做永遠不要這麼做的話.

但是隨時我工作時間的增長,我越來越意識到不存在總要永遠不要.只有選擇權衡.

通過冰凍工廠的方式建立物件會比普通的使用class消耗更多的記憶體和降低效能.

在我上面所描述的例子中,這不會有什麼影響,即使它們執行起來比class慢,冰凍工廠模式仍舊是非常快的.

如果你發現你需要在一瞬間建立成百上千個物件,或者你工作的團隊對於能耗以及記憶體消耗非常敏感,那麼你可能需要使用class而不是冰凍工廠模式.

記著,首先是構建你的app和防止過早的優化.在大多數時候,物件的建立都不是瓶頸.

雖然我在這裡抱怨,但是class並不總是那麼糟糕. 你不應該因為一個框架或者類庫使用了class就否定它.實際上,Dan Abramov曾經在他的文章How to use Classes and Sleep at Night有過非常精彩的探討.

最後,我想和你介紹一些我在這些程式碼例子中所用到的一些個人習慣:

你可能喜歡其他的程式碼風格,那都是可以的.風格並不是設計模式,不需要嚴格的遵守.

這裡,相信我們已經明確的瞭解了,冰凍工廠模式的定義是使用一個函式來建立和返回一個不可變物件.具體怎麼寫這個函式取決於你.

如果你覺得這篇文章非常有用,請點關注並收藏,並且轉發給你的朋友們,讓他們也能夠了解.

相關文章