十种常见的JavaScript模式及其各自的权衡得失
保护条款、查找映射表、结果对象、AbortController、allSettled、工厂函数与小型缓存:每种模式何时能发挥优势,又何时会适得其反。
来源: I Reuse These 10 JavaScript Patterns in Almost Every Project
无论代码库是 React 控制面板、Node.js API 还是小型自动化脚本,最终都会面临几类相同的挑战:函数接收到的数据不完整、异步响应的顺序混乱、随着业务规则变化而不断增多的条件判断、相同转换逻辑的不同版本相互脱节,以及错误处理使得“操作失败”与“根本没有可返回的内容”之间的界限变得模糊。框架会不断更迭,但这些问题却始终如一地存在。以下的十种模式都是针对这些问题的务实解决方案。每种方案都能缩小函数需要考虑的状态范围,或让责任归属更加清晰;同时你也会了解到每种方案的局限性,因为它们都无法替代人的判断力。
1. 保护性代码块有助于保持主流程的可读性
当一个函数的真正逻辑嵌在多个层级的 if 块中时,读者需要记住一堆未解决的问题:输入是否存在?调用者是否有权限?记录是否已被找到?其状态是否允许进行此更改?每增加一个条件,就需要多一层缩进,并且要多记一件需要考虑的事情。
保护性语句会提前回答这些问题,一旦答案是否定则立即终止执行。下面的示例会先拒绝缺少 ID 的情况,再拒绝未经授权的用户,接着拒绝文章不存在的情况,最后拒绝文章不是草稿状态的情况,只有通过所有这四项检查后才会执行更新操作。
async function publishArticle(articleId, currentUser) {
if (!articleId) {
throw new Error("Article ID is required");
}
if (!currentUser?.permissions.includes("article:publish")) {
throw new Error("You are not allowed to publish articles");
}
const article = await articleRepository.findById(articleId);
if (!article) {
throw new Error("Article not found");
}
if (article.status !== "draft") {
throw new Error(`Cannot publish an article in ${article.status} status`);
}
return articleRepository.update(articleId, {
status: "published",
});
}
真正的优势并非缩进更少,而是确定性更高。通过第一层检查后即可确认该ID存在;通过第二层检查后则可确定调用者拥有相应权限;通过第三层检查后,article便是一个真实的对象,因此无需再使用可选链操作来遍历剩余代码。最终的update调用可以毫无疑问地被理解。还有一个值得注意的边缘情况:currentUser?.permissions.includes(...)在用户对象缺少permissions数组时仍会抛出TypeError,而这正是下一种模式要解决的不一致性问题。
当一个长函数中为每一个次要条件都设置退出点时,保护逻辑就会变成问题。它们最适合用于处理前提条件、无效状态、资源缺失以及已经完成的结果。如果保护逻辑开始涉及多条不同的业务规则,那么该函数很可能做出了过多决策,应当被拆分。
2. 在边界处一次性标准化数据
JavaScript允许具有相同含义的值以多种形式出现。一个标志值可能是true、"true"、1或"1"。ID在数据库中可以是数字,而在URL中则是字符串。日期可以是Date对象、ISO格式字符串、区域设置字符串或时间戳。如果每个函数都自行解读这些数据,应用程序就会逐渐积累出多种相互冲突的有效数据定义。
解决方法是,在值跨越重要边界时立即进行转换,且在其余代码处理这些值之前完成:
- API处理器将传输层中的字符串转换为服务层所期望的类型。
- 数据适配器将外部响应映射为项目内部的格式。
- 表单提交处理器会在业务逻辑执行前修剪字符串并处理可选字段。
时机很重要。如果延迟转换,防御性检查就会遍布各处:一个函数检测status === true,另一个检测status === "true",还有一个则依赖真值判断。虽然每种方法都适用于当前的调用者,但status的含义却不再稳定。
严格的内部契约也能让测试更加聚焦。当请求层已经传递了真实的布尔值时,服务就不需要为该值的每种文本表述都编写测试。标准化层仍需自身的测试,但其之后的所有环节都会变得更简单。
标准化并不等同于接受一切内容。在有意支持多种表示形式时,标准化是合适的;但含糊或无效的值仍应被拒绝。将"abc"转换为NaN后再让其继续传递,并不属于标准化,这只是将验证工作推迟到了某个更复杂的后续环节。
3. Options对象让调用更具自描述性且更易于扩展
对于一两个显而易见的输入,位置参数尚可使用。但随着函数规模扩大,尤其是当多个参数类型相同时,这种方式就会变得脆弱。像 createUser(name, email, true, false, 3) 这样的调用会迫使读者去查看函数定义才能理解每个参数的含义,而类型声明中的 TypeScript 类型在调用时并不能提供任何帮助。
一旦函数开始接受多个配置类参数,使用单个选项对象通常会更清晰:
await createUser({
name,
email,
sendWelcomeEmail: true,
requireVerification: false,
retryLimit: 3,
});
这样的调用方式能够自行说明用途,可选属性还可以设置默认值(通常通过在函数签名中使用解构来实现),而且添加新选项时也不必要求所有调用者重新调整参数顺序。服务函数、配置工具、数据加载器以及各类实用工具都能从中受益最多。
它还能消除那种典型的布尔参数带来的问题。在调用中直接使用 true 几乎无法表明它要启用哪种行为,而使用带名称的属性则可以。
不要到处都使用这种方式。add({ a, b }) 并不比 add(a, b) 更清晰。当参数用于描述配置、存在多个可选参数、多个值具有相同类型,或者函数签名可能持续变化时,才应使用选项对象。
4. 查找映射将分支结构转化为明确的关系
当某个值需要选择处理程序、格式化器、验证器或策略时,常常会出现一串 if 语句或冗长的 switch 块。每新增一个分支都会延长控制流,并增加一处需要考虑默认行为的点。
查找映射表直接说明了这种对应关系:该键对应特定的行为。在此示例中,订单的状态决定了对应的处理函数;若状态未知,则会明确报错,而不会默默失败。
const statusHandlers = {
pending: handlePendingOrder,
paid: handlePaidOrder,
cancelled: handleCancelledOrder,
};
function processOrder(order) {
const handler = statusHandlers[order.status];
if (!handler) {
throw new Error(`Unsupported order status: ${order.status}`);
}
return handler(order);
}
当各个分支相互独立且遵循同一套规范时,这种模式尤为有效——比如序列化器与格式化器、事件处理函数与状态处理函数、验证器以及访问策略。要支持新的情况,只需添加一条新记录,而无需扩展条件逻辑。
普通对象存在一个隐蔽的陷阱。从 Object.prototype 继承来的键,如 "toString" 或 "constructor",实际上也是函数,因此某个意外的状态值可能会选择这些函数而非触发保护机制。如果键来自用户输入,建议使用 Map、通过 Object.create(null) 创建的对象,或借助 Object.hasOwn 方法进行判断。
当逻辑确实是顺序执行的时,应避免使用查找映射结构。如果决策依赖于状态组合、先前的选择或有序的转换过程,将其转化为映射通常会掩盖逻辑流程而非使其更清晰。只有当一个键能对应一种完整且独立可理解的行为时,映射结构才适用。
应将复杂的逻辑置于地图对象之外。使用命名处理函数可以生成更清晰的堆栈跟踪信息,便于单独测试,同时让地图功能专注于选择操作。
5. 结果对象用于区分预期失败与执行故障
异常适用于处理意外情况,但许多失败结果其实是很常见的。搜索可能一无所获;注册请求可能因邮箱已被占用而被拒绝;状态转换也可能被当前规则禁止。如果所有这些情况都作为普通异常处理,调用者就不得不解析错误信息或捕获宽泛的错误类型才能了解发生了什么。
当某个操作存在多种预期结果时,返回一个小型结果对象可将这些结果纳入契约规范。下面的折扣函数会将“未找到”和“已过期”作为数据返回,每种状态都配有稳定的、可供机器读取的code值,成功时则返回调整后的订单信息。
function applyDiscount(order, discount) {
if (!discount) {
return {
ok: false,
code: "DISCOUNT_NOT_FOUND",
message: "The requested discount does not exist.",
};
}
if (discount.expiresAt < new Date()) {
return {
ok: false,
code: "DISCOUNT_EXPIRED",
message: "The discount has expired.",
};
}
return {
ok: true,
value: {
...order,
total: order.total - discount.amount,
},
};
}
调用方现在无需阅读面向用户的文本,即可根据ok和code的值来决定后续操作。具体的字段名称并非最关键,重要的是保持数据结构的统一性:有些项目将此结构定义为TypeScript中的区分联合类型,以ok作为标识;还有些项目则使用success、data和error。关键在于结果必须明确体现出来。在实际实现中,还需确定当折扣金额超过订单总额时应如何处理,因为此处所示的减法运算可能会产生负数结果。
结果对象并非在所有情况下都能替代异常处理。当依赖项意外失败、违反了不变条件,或者当前层无法对问题采取有效措施时,应抛出异常。而当失败是该领域中的正常现象且期望调用者对此采取行动时,则应返回结果。
其另一个好处是便于测试:失败情况已在返回类型中明确说明,因此很容易进行断言验证。此外,使用宽泛的try/catch结构也不必再将被拒绝的业务规则与断开的数据库连接混为一谈。
6. AbortController使异步操作的所有权更加明确
许多前端错误源于多个异步任务的冲突。用户修改了搜索词,随后又发起了一个新请求,而第一个请求却最后才处理完成。虽然所有请求都成功了,但屏幕上显示的仍是过时的结果。
问题不在于请求本身,而在于所有权归属。应用程序必须决定哪些正在处理的结果仍被允许更新状态。AbortController用于表达这一决策:
- 在搜索框中,每当查询内容改变时就取消之前的请求。
- 在组件中,当组件被卸载时取消所有待处理的任务。
- 在由多个依赖步骤构成的工作流中,为所有步骤传递同一个共享的
signal,这样一次取消操作就能终止整个流程。
故意处理取消操作。通常情况下,中止意味着应用程序认为相关结果已不再重要,这与故障有所不同。使用 fetch 时,被中止的请求会以 "AbortError" 作为错误名称被拒绝,这样你就可以明确识别它。如果将每次中止都记录为错误,会导致监控系统充斥大量冗余信息;而将真正的网络故障误判为中止操作,则会丢失有用的证据。
应用程序越依赖后台数据获取、实时搜索、路由切换以及链式请求,这一点就越重要。在 JavaScript 中启动异步任务轻而易举;AbortController 可用于确定该任务何时不再属于其调用者。如需更深入地了解搜索场景,可参阅如何解决防抖机制无法处理的竞态条件。
7. Promise.allSettled 可保留独立任务的部分结果
当只有所有操作都成功时,组合后的结果才有意义时,才适合使用 Promise.all。而对于独立任务而言,这种做法过于激进:一旦出现一次拒绝,所有已返回的值都会被丢弃。
试想这样一个控制面板:它汇集了来自不同来源的销售数据、通知信息、最新注册用户情况以及系统运行状态。如果某个可选服务出现故障,导致整个控制面板无法显示,往往比仅展示已加载的内容并标出未加载的部分带来更糟糕的用户体验。
Promise.allSettled会等待所有承诺对象完成,并以对象的形式分别报告每个结果——要么是status: “fulfilled”搭配value,要么是status: “rejected”搭配reason。调用者可以利用成功的值,列出失败的项,进而判断整体操作是否仍有意义。
不要仅仅为了让整体看起来可靠就试图同时处理这些操作。从商业角度而言,某些承诺对象组实际上属于单次交易。如果涉及创建订单、预留库存和扣款操作,这些操作必须要么全部成功,要么全部失败;若只收集已完成的操作结果,就会导致状态不一致;此时需要采取补偿措施或更完善的协调策略。
只有当这些任务确实相互独立,且调用方已有针对部分失败情况的处理方案时,才应使用allSettled。真正的问题不在于哪种Promise辅助函数更便捷,而在于一旦某个操作失败后是否还能得到准确的反馈。更多相关选项可参阅九种确保异步JavaScript可靠性的Promise模式。
8. 纯转换函数让数据规则更易于测试
许多应用程序代码将数据处理与获取、日志记录、渲染、持久化以及状态更新混在了一起。因此,要测试某一条简单规则,就必须围绕它重新构建整个环境。
将稳定的转换逻辑封装到小型纯函数中可以解决这个问题。纯函数接收输入、返回输出,且不会对外部产生任何影响。常见的应用场景包括对 API 记录进行规范化处理、计算派生值、对元素进行分组、构建查询对象,或将领域数据映射为视图模型。
更深层次的好处在于实现了数据获取与数据解析之间的清晰边界。负责对员工记录进行分组的函数无需知道这些记录是如何加载的;而负责构建请求载荷的函数,如果相关值可以作为参数传递,则无需依赖组件的状态信息。
调试也会变得更简单。当正确输入产生错误输出时,你可以单独对该输入进行转换操作。如果转换结果正确,接下来就需要排查是哪个部分生成了该输入或处理了该输出。
并非每个表达式都值得单独定义一个函数;一堆单行包装函数只会增加导航难度,却不会带来任何实际意义。只有当该转换体现了可复用的业务规则、存在多种分支情况,或需要单独编写测试时,才应该将其提取出来。
9. 工厂模式揭示依赖关系与共享配置
工厂模式是控制模块依赖项的最简单方法之一。无需从全局位置导入数据库客户端、日志记录器、缓存或配置文件,工厂模式只需将这些依赖作为参数接收,然后返回能够使用这些参数的函数。
这在后端服务及共享模块中尤为实用。依赖关系一目了然,隐藏的耦合度降低,测试时无需模拟模块导入即可使用虚拟的存储库或日志记录器,而生产环境则使用真实的版本。业务逻辑依然专注于功能实现,而非其依赖组件的构建。
工厂模式也适用于那些需要共享稳定配置的函数组。HTTP客户端工厂可以一次性获取基础URL、认证令牌、超时时间以及日志记录器,然后提供使用这些配置的方法,通过闭包来传递配置信息,从而避免在每次调用时重复传递。
当代码库已经提供了依赖注入框架,或者简单的导入即可满足需求时,再自行开发一个只会增加维护负担。只有在依赖关系会显著改变程序行为、测试需要对其加以控制,或是必须让多个配置不同的实例共存时,才应使用工厂模式。其目的在于揭示依赖关系,而非再创建一个需要维护的框架。
10. Narrow Map与WeakMap缓存可避免重复计算
Map和WeakMap非常适合用作本地缓存,因为在没有这些缓存的情况下,相同的复杂计算或特定对象的元数据可能需要被反复计算。解析器可以为每个输入字符串缓存处理结果,格式化工具则可以重复使用那些生成成本较高的数据。WeakMap的键必须是对象,因此可以在不使这些对象保持存活状态的情况下为其附加元数据,只要没有其他引用它们即可。
写入缓存是较简单的部分,而判断缓存中的值是否仍然有效才是难点。
缓存出现问题通常具有可预测的模式:
- 键中遗漏了会影响结果的输入参数,导致在测试环境中看似正常的缓存会在生产环境中提供过时的数据。
- 底层数据发生了变化,却没有任何机制来使该缓存条目失效。
- 就像服务器进程整个生命周期都存在的模块级
Map一样,缓存规模会无限制地增长。
在引入缓存之前,首先要找出真正需要重复处理的成本,并明确其负责人、键的结构、生命周期以及失效规则。通常重新计算更为安全且速度足够快。只有当相关处理成本很高、输入数据稳定且适用范围能够保持较小时,缓存才有存在的价值。
这种模式很好地展示了整个列表的结构。Map让缓存实现变得非常简单,但简单并不等同于正确:只有当你能够说明某个条目何时不再成立时,这种优化才有实际意义。
模式应当消除决策过程,而非将其隐藏
总体而言,这些模式是通过消除歧义而非展示语言特性来确立其价值的。Guard函数会提前处理无效路径;在边界处进行规范化处理可使内部代码获得可预测的值;选项对象能让调用更清晰且便于扩展;查找映射能展现直接关联关系;结果对象能使输出更具意义;AbortController能明确异步任务的归属权;allSettled会如实报告部分失败情况;纯函数能将决策与副作用分离;工厂模式能暴露依赖关系;而小型缓存则在有效性明确时避免重复处理工作。
这些模式都可能被误用。将双参数调用封装在选项对象中纯属形式主义;本应抛出异常的代码却返回结果,只会让情况更加混乱;映射结构可能掩盖有序逻辑;缓存内容可能会过时;而工厂模式则可能是没人真正需要的抽象概念。当人们选择某种模式只是因为它看起来很精致,而非它能真正缓解的实际问题时,它就会变成有害的东西。
核心要点
- 评判一个模式应看它所带来的权衡,而非其外观的复杂程度。
- 优先选择那些成本清晰且好处能在后续变更中依然存在的结构。
- 优秀的模式能明确体现函数的假设、职责、失败方式以及有效路径。
- 最出色的模式会悄然融入背景:读者注意到问题变得更简单了,而非注意到了该模式本身。