现代 JavaScript 中的优雅模式:Ice Factory GenAI 直播!| 2025 年 6 月 4 日

2025-06-11

现代 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
})
Enter fullscreen mode Exit fullscreen mode

注意 :为了简单起见,我使用数组作为 db 参数。在实际代码中,这应该类似于 与实际数据库交互的模型 存储库。

不幸的是——尽管这看起来不错——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?
Enter fullscreen mode Exit fullscreen mode

更糟糕的是,使用new关键字创建的对象会继承创建它们的类的属性prototypeclass因此,对类的更改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!"
Enter fullscreen mode Exit fullscreen mode

此外this,JavaScript 是动态绑定的。因此,如果我们传递cart对象的方法,我们可能会丢失对 的引用this。这非常违反直觉,并且会给我们带来很多麻烦。

一个常见的陷阱是将实例方法分配给事件处理程序。

考虑一下我们的cart.empty方法。

empty () {
    this.db = []
  }
Enter fullscreen mode Exit fullscreen mode

如果我们将该方法直接分配给click网页上按钮的事件……

<button id="empty">
  Empty cart
</button>

---

document
  .querySelector('#empty')
  .addEventListener(
    'click', 
    cart.empty
  )
Enter fullscreen mode Exit fullscreen mode

…当用户点击空的 时button,它们的cart意志将保持满的状态。

默默地失败了,因为this现在将引用而button不是cart。因此,我们的cart.empty方法最终为我们的button调用分配了一个新属性db,并将该属性设置[]为而不是影响cart对象的db

这种错误会让你发疯,因为控制台中没有错误,你的常识会告诉你它应该起作用,但事实却没有。

为了使其发挥作用,我们必须这样做:

document
  .querySelector("#empty")
  .addEventListener(
    "click", 
    () => cart.empty()
  )
Enter fullscreen mode Exit fullscreen mode

或者:

document
  .querySelector("#empty")
  .addEventListener(
    "click", 
    cart.empty.bind(cart)
  )
Enter fullscreen mode Exit fullscreen mode

我认为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
})
Enter fullscreen mode Exit fullscreen mode

请注意我们的“奇怪的云彩虹陷阱”已经消失了:

  • 我们不再需要 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
Enter fullscreen mode Exit fullscreen mode

这是通过 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

我利用函数提升将返回语句放在顶部附近,以便读者在深入了解细节之前可以对正在发生的事情有一个很好的总结。

我还对参数使用了解构spec。并且我将模式重命名为“Ice Factory”,这样更容易记住,也不容易与constructorJavaScript 中的函数混淆class。但本质上是一样的。

所以,我们应该给予赞扬,谢谢你克罗克福德先生。

注: 或许值得一提的是,Crockford 认为函数“提升”是 JavaScript 中“不好的部分”,并且很可能认为我的版本是异端邪说。我在 之前的一篇文章 中讨论过我对此的看法,更具体地说, 是这条评论

那么继承呢?

如果我们继续构建我们的小型电子商务应用程序,我们可能很快就会意识到添加和删除产品的概念在各个地方不断出现。

除了购物车,我们可能还有一个 Catalog 对象和一个 Order 对象。所有这些对象都可能暴露了addProduct和 的某个版本removeProduct

我们知道重复是不好的,所以我们最终会倾向于创建类似产品列表对象的东西,我们的购物车、目录和订单都可以从中继承。

但是,我们不必通过继承产品列表来扩展我们的对象,而是可以采用有史以来最具影响力的编程书籍之一中提供的永恒原则:

“优先考虑对象组合而不是类继承。” 

——设计模式:可重用面向对象软件的元素。

事实上,该书的作者——俗称“四人帮”——继续说道:

“……我们的经验是,设计师过度使用继承作为重用技术,而更多地依赖对象组合通常会使设计变得更具可重用性(并且更简单)。”

以下是我们的产品清单:

function makeProductList({ productDb }) {
  return Object.freeze({
    addProduct,
    empty,
    getProducts,
    removeProduct,
    // others
  )}
  // definitions for 
  // addProduct, etc...
}
Enter fullscreen mode Exit fullscreen mode

这是我们的购物车:

function makeShoppingCart(productList) {

return Object.freeze({
      items: productList,
      someCartSpecificMethod,
      // ...
    )}

function someCartSpecificMethod () {
    // code 
  }
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以将产品列表注入购物车中,如下所示:

const productDb = []
const productList = makeProductList({ productDb })

const cart = makeShoppingCart(productList)
Enter fullscreen mode Exit fullscreen mode

并通过属性使用产品列表items。例如:

cart.items.addProduct()
Enter fullscreen mode Exit fullscreen mode

通过将整个产品列表的方法直接合并到购物车对象中,可能会很吸引人,如下所示:

function makeShoppingCart({ 
   addProduct,
   empty,
   getProducts,
   removeProduct,
   ...others
  }) {

return Object.freeze({
      addProduct,
      empty,
      getProducts,
      removeProduct,
      someOtherMethod,
      ...others
    )}

function someOtherMethod () {
    // code 
  }
}
Enter fullscreen mode Exit fullscreen mode

事实上,在本文的早期版本中,我就是这么做的。但后来有人指出,这样做有点危险(详见此处)。所以,我们最好坚持使用正确的对象组合。

太棒了!我服了!


小心

每当我们学习新东西,尤其是像软件架构和设计这样复杂的东西时,我们总是想要一些硬性规定。我们总是想听到诸如“总是这样做”和“永远不要那样做”之类的话。

我从事这个行业的时间越长,就越意识到,没有永远永远的事情。一切都关乎选择和权衡。

使用 Ice Factory 制作对象比使用类更慢并且占用更多内存。

在我描述的用例类型中,这并不重要。尽管它们比类慢,但冰工厂仍然相当快。

如果您发现自己需要一次性创建数十万个对象,或者如果您处于内存和处理能力极其紧张的情况,那么您可能需要一个类。

请记住,先分析你的应用,不要过早优化。大多数情况下,对象创建不会成为瓶颈。

尽管我之前大肆批评,但类并不总是糟糕的。你不应该仅仅因为一个框架或库使用了类就将其抛弃。事实上,Dan Abramov在他的文章《如何使用类安然入睡》中对此进行了相当精彩的阐述

最后,我需要承认,在我向您展示的代码示例中,我做出了许多自以为是的风格选择:

您可以选择不同的风格,没关系!风格不等于图案。

冰工厂模式就是:使用一个函数来创建并返回一个冻结对象。至于如何编写这个函数,则完全由你自己决定。


如果您觉得这篇文章有用,请帮我点个爱心和独角兽,让我分享!如果您想了解更多类似的内容,请在下方订阅我的“Dev Mastery”简报。谢谢!

订阅 Dev Mastery 简报

我会对您的信息保密,并且绝不会发送垃圾邮件。

鏂囩珷鏉ユ簮锛�https://dev.to/billsourour/elegant-patterns-in-modern-javascript-icefactory-3k5h
PREV
深入研究 JavaScript 闭包、高阶函数和柯里化
NEXT
将领域驱动设计原则应用于 Nest.js 项目 我们的架构 通过我们服务器的典型流程 总结