现代 JavaScript 中的优雅模式:Ice Factory
GenAI LIVE! | 2025年6月4日
照片由Demi DeHerrera在 Unsplash上拍摄
我从上世纪 90 年代末开始断断续续地使用 JavaScript。起初我并不太喜欢它,但随着 ES2015(又名 ES6)的推出,我开始欣赏 JavaScript,认为它是一种出色的、动态的编程语言,拥有强大的表达能力。
随着时间的推移,我采用了几种不同的编码模式,这些模式让我的代码更加简洁、更易于测试、表达能力更强。现在,我将这些模式分享给大家。
我在这里写了关于第一个模式“RORO”的内容 。如果你还没读过,不用担心,你可以按任意顺序阅读。
今天,我想向大家介绍“冰工厂”模式。
冰工厂只是一个创建并返回冻结对象的函数。我们稍后会解释这个说法,但首先让我们来探究一下为什么这个模式如此强大。
JavaScript 类并不那么优雅
将相关函数组合到一个对象中通常是有意义的。例如,在一个电商应用中,我们可能有一个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
})
不幸的是——尽管这看起来不错——JavaScript 中的类的行为与你预期的完全不同。
如果您不小心,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
关键字创建的对象会继承创建它们的类的属性prototype
。class
因此,对类的更改prototype
会影响所有从该类创建的对象——即使是在对象创建后class
进行的更改!
看看这个:
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!"
此外this
,JavaScript 是动态绑定的。因此,如果我们传递cart
对象的方法,我们可能会丢失对 的引用this
。这非常违反直觉,并且会给我们带来很多麻烦。
一个常见的陷阱是将实例方法分配给事件处理程序。
考虑一下我们的cart.empty
方法。
empty () {
this.db = []
}
如果我们将该方法直接分配给click
网页上按钮的事件……
<button id="empty">
Empty cart
</button>
---
document
.querySelector('#empty')
.addEventListener(
'click',
cart.empty
)
…当用户点击空的 时button
,它们的cart
意志将保持满的状态。
它默默地失败了,因为this
现在将引用而button
不是cart
。因此,我们的cart.empty
方法最终为我们的button
调用分配了一个新属性db
,并将该属性设置[]
为而不是影响cart
对象的db
。
这种错误会让你发疯,因为控制台中没有错误,你的常识会告诉你它应该起作用,但事实却没有。
为了使其发挥作用,我们必须这样做:
document
.querySelector("#empty")
.addEventListener(
"click",
() => cart.empty()
)
或者:
document
.querySelector("#empty")
.addEventListener(
"click",
cart.empty.bind(cart)
)
我认为Mattias Petter Johansson 说得最好:
“
new
并且this
(在 JavaScript 中)是某种不直观的、奇怪的云彩虹陷阱。”
冰工厂来救援
正如我之前所说,冰工厂只是一个创建并返回冻结对象的函数。有了冰工厂,我们的购物车示例如下所示:
// 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()
是浅层操作,因此,如果我们返回的对象包含一个array
或另一个object
,我们也必须确保它们也包含。此外,如果您在ES 模块Object.freeze()
之外使用冻结对象,则需要处于严格模式,以确保重新赋值会导致错误,而不是静默失败。
请保留一点隐私
Ice Factories 的另一个优点是它们可以拥有私有成员。例如:
function makeThing(spec) {
const secret = 'shhh!'
return Object.freeze({
doStuff
})
function doStuff () {
// We can use both spec
// and secret in here
}
}
// secret is not accessible out here
const thing = makeThing()
thing.secret // undefined
这是通过 JavaScript 中的闭包实现的,您可以在MDN上阅读更多相关内容。
请稍加确认
尽管工厂函数一直存在于 JavaScript 中,但冰工厂模式很大程度上受到了Douglas Crockford在此视频中展示的一些代码的启发。
以下是 Crockford 演示如何使用他称之为“构造函数”的函数来创建对象:
道格拉斯·克罗克福德 (Douglas Crockford) 演示了启发我的代码。
上面 Crockford 示例的 Ice Factory 版本如下所示:
function makeSomething({ member }) {
const { other } = makeSomethingElse()
return Object.freeze({
other,
method
})
function method () {
// code that uses "member"
}
}
我利用函数提升将返回语句放在顶部附近,以便读者在深入了解细节之前可以对正在发生的事情有一个很好的总结。
我还对参数使用了解构spec
。并且我将模式重命名为“Ice Factory”,这样更容易记住,也不容易与constructor
JavaScript 中的函数混淆class
。但本质上是一样的。
所以,我们应该给予赞扬,谢谢你克罗克福德先生。
注: 或许值得一提的是,Crockford 认为函数“提升”是 JavaScript 中“不好的部分”,并且很可能认为我的版本是异端邪说。我在 之前的一篇文章 中讨论过我对此的看法,更具体地说, 是这条评论。
那么继承呢?
如果我们继续构建我们的小型电子商务应用程序,我们可能很快就会意识到添加和删除产品的概念在各个地方不断出现。
除了购物车,我们可能还有一个 Catalog 对象和一个 Order 对象。所有这些对象都可能暴露了addProduct
和 的某个版本removeProduct
。
我们知道重复是不好的,所以我们最终会倾向于创建类似产品列表对象的东西,我们的购物车、目录和订单都可以从中继承。
但是,我们不必通过继承产品列表来扩展我们的对象,而是可以采用有史以来最具影响力的编程书籍之一中提供的永恒原则:
“优先考虑对象组合而不是类继承。”
——设计模式:可重用面向对象软件的元素。
事实上,该书的作者——俗称“四人帮”——继续说道:
“……我们的经验是,设计师过度使用继承作为重用技术,而更多地依赖对象组合通常会使设计变得更具可重用性(并且更简单)。”
以下是我们的产品清单:
function makeProductList({ productDb }) {
return Object.freeze({
addProduct,
empty,
getProducts,
removeProduct,
// others
)}
// definitions for
// addProduct, etc...
}
这是我们的购物车:
function makeShoppingCart(productList) {
return Object.freeze({
items: productList,
someCartSpecificMethod,
// ...
)}
function someCartSpecificMethod () {
// code
}
}
现在我们可以将产品列表注入购物车中,如下所示:
const productDb = []
const productList = makeProductList({ productDb })
const cart = makeShoppingCart(productList)
并通过属性使用产品列表items
。例如:
cart.items.addProduct()
通过将整个产品列表的方法直接合并到购物车对象中,可能会很吸引人,如下所示:
function makeShoppingCart({
addProduct,
empty,
getProducts,
removeProduct,
...others
}) {
return Object.freeze({
addProduct,
empty,
getProducts,
removeProduct,
someOtherMethod,
...others
)}
function someOtherMethod () {
// code
}
}
事实上,在本文的早期版本中,我就是这么做的。但后来有人指出,这样做有点危险(详见此处)。所以,我们最好坚持使用正确的对象组合。
太棒了!我服了!
每当我们学习新东西,尤其是像软件架构和设计这样复杂的东西时,我们总是想要一些硬性规定。我们总是想听到诸如“总是这样做”和“永远不要那样做”之类的话。
我从事这个行业的时间越长,就越意识到,没有永远和永远的事情。一切都关乎选择和权衡。
使用 Ice Factory 制作对象比使用类更慢并且占用更多内存。
在我描述的用例类型中,这并不重要。尽管它们比类慢,但冰工厂仍然相当快。
如果您发现自己需要一次性创建数十万个对象,或者如果您处于内存和处理能力极其紧张的情况,那么您可能需要一个类。
请记住,先分析你的应用,不要过早优化。大多数情况下,对象创建不会成为瓶颈。
尽管我之前大肆批评,但类并不总是糟糕的。你不应该仅仅因为一个框架或库使用了类就将其抛弃。事实上,Dan Abramov在他的文章《如何使用类安然入睡》中对此进行了相当精彩的阐述。
最后,我需要承认,在我向您展示的代码示例中,我做出了许多自以为是的风格选择:
- 我使用函数语句而不是函数表达式。
- 我把返回语句放在顶部附近(这是通过使用函数语句实现的,见上文)。
- 我将我的工厂函数命名为,
makeX
而不是createX
或buildX
或其他名称。 - 我的工厂函数采用单个、解构的参数对象。
- 我不使用分号(Crockford 也不会赞成这样做)
- 等等…
您可以选择不同的风格,没关系!风格不等于图案。
冰工厂模式就是:使用一个函数来创建并返回一个冻结对象。至于如何编写这个函数,则完全由你自己决定。
如果您觉得这篇文章有用,请帮我点个爱心和独角兽,让我分享!如果您想了解更多类似的内容,请在下方订阅我的“Dev Mastery”简报。谢谢!
订阅 Dev Mastery 简报
我会对您的信息保密,并且绝不会发送垃圾邮件。
鏂囩珷鏉ユ簮锛�https://dev.to/billsourour/elegant-patterns-in-modern-javascript-icefactory-3k5h