[译文]优雅的现代JavaScript设计模式: 冰冻工厂

6,059 阅读10分钟

原文地址 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有过非常精彩的探讨.

最后,我想和你介绍一些我在这些代码例子中所用到的一些个人习惯:

你可能喜欢其他的代码风格,那都是可以的.风格并不是设计模式,不需要严格的遵守.

这里,相信我们已经明确的了解了,冰冻工厂模式的定义是使用一个函数来创建和返回一个不可变对象.具体怎么写这个函数取决于你.

如果你觉得这篇文章非常有用,请点关注并收藏,并且转发给你的朋友们,让他们也能够了解.