从上个世纪九十末开始, 我就开始断断续续的从事 JavaScript 的开发工作. 初始, 我并不喜欢它. 但是自从了解了 ES2015(也叫 ES6), 我开始认为 JavaScript 是一个强大而且杰出的动态编程语言.
随着时间流逝, 我掌握了几种能够代码更加简洁, 可测试以及更加有表达力的编码模式. 现在, 我将把这些模式分享与你.
我第一个介绍的模式是 RORO(稍后会翻译). 如果你没有阅读过它, 请不要担心, 因为这不会影响这篇文章的阅读, 你可以在其他的时候阅读它.
今天, 我将会给你介绍冰冻工厂模式.
冰冻工厂只是一个函数, 它能够创建并且返回一个不可变对象. 我们将在后面解释这个定义, 首先, 让我们看看为什么这个模式如此的有用.
JavaScript 的 class 并不完美.
通常来说, 我们都会把一些相关的函数聚合在一个对象中. 例如, 在一款电子商务的 App 中, 我们可能有一个 cart 对象, 它暴露了 addProduct 和 removeProduct 两个函数. 我们可以通过 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 创建的对象, 继承于这个 class 的 prototype. 因此, 修改这个类的原型, 将会影响所有通过这个类创建的对象, 即使这个修改是在对象创建之后.
看看这个例子:
- 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 https://medium.com/@mpjme 说的非常好:
JavaScript 中的 new 和 this 有时候会反直觉, 奇怪, 如彩虹陷阱一般
冰冻工厂模式来拯救你
正如我之前所说的那样, 一个冰工厂是一个创建并且返回不可变对象的函数. 通过冰工厂模式, 我们的购物车例子改写成如下模式:
- // 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 https://en.wikipedia.org/wiki/Douglas_Crockford 在这个视频 https://www.YouTube.com/watch?v=rhV6hlL_wMc 中就展示了相关的代码 (视频需要科学上网).
这段是 Crockford 演示的代码, 他把这个创建对象的函数称之为 constructor.
我的冰冻工厂模式应用在 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, 我们可能很快会意识到, 添加和删除商品的概念会不断的冒出来.
伴随着我们的购物车对象, 我们可能会有一个类别对象和一个订单对象. 所有的这些对象都可能暴露不同版本的 addProduct 和 removeProduct 函数.
我们都知道, 复制重复代码是不好的行为, 所以我们最终可能会尝试创建一个类似商品列表的对象, 我们的购物车, 类别以及订单对象都继承于它.
但是, 除了通过继承一个商品列表对象来扩展我们的对象, 我们还可以采用另外一个理论, 它来自于一本非常有影响力的书, 是这么写的:
"Favor object composition over class inheritance." - Design Patterns: Elements of Reusable Object-Oriented Software.
我们应该更多的采用对象组合而不是继承 - 设计模式
这里附上这本书的链接 设计模式 https://book.douban.com/subject/1052241/
实际上, 这本书的作者, 我们俗称的四人帮之一, 还说到
"...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 https://medium.com/@dan_abramov 曾经在他的文章 How to use Classes and Sleep at Night 有过非常精彩的探讨.
最后, 我想和你介绍一些我在这些代码例子中所用到的一些个人习惯:
我使用函数语句而不是函数表达式
我把函数的返回语句放在了函数的顶端 (通过上面定律使得这变得可能)
我把我们的工厂函数称作 makeX, 而不是 createX 或者 buildX 或者其他.
我的工厂函数使用了一个单一的, 解构对象作为参数
我不用分号 (Crockford 也不赞成使用分号 https://GitHub.com/twbs/Bootstrap/issues/3057 )
还有...
你可能喜欢其他的代码风格, 那都是可以的. 风格并不是设计模式, 不需要严格的遵守.
这里, 相信我们已经明确的了解了, 冰冻工厂模式的定义是使用一个函数来创建和返回一个不可变对象. 具体怎么写这个函数取决于你.
如果你觉得这篇文章非常有用, 请点关注并收藏, 并且转发给你的朋友们, 让他们也能够了解.
来源: https://juejin.im/post/5ba7173f6fb9a05d2b6dbb0d