七种可替代实用工具依赖的原生JavaScript特性
How Temporal 如何通过声明、Map 插入操作、Math.sumPrecise、迭代器辅助函数、Set 方法以及 Promise.withResolvers 来消除 JavaScript 中常见的冗余代码。
过去,要启动一个严肃的 JavaScript 项目,通常需要安装日期库、集合工具和分组辅助函数,然后把所有加载的资源都用防御性的 try...finally 块包裹起来。在最近的几个版本迭代中,一系列经过多年讨论的 TC39 提案已正式成为标准,并被集成到各类主流浏览器、Node.js、Bun 和 Deno 中。本指南将介绍其中的七项提案,说明每项提案解决了什么问题,以及在删除某个依赖之前需要注意的事项。
在开始之前需注意兼容性问题:这些功能在不同时间才被各引擎支持,其中一些还是最近才加入的。请查看目标浏览器及运行时版本的当前兼容性数据,对于尚未获得稳定支持的版本,则需使用填充函数或旧版库。
Temporal:真正的日期和时间 API
任何使用内置的 Date 对象超过几分钟的人都会遇到它的种种问题:
- 月份是以零为起点的(
0表示一月,11表示十二月),而每个月的日期则从1开始。 Date实例是可变的,因此将其传递给辅助函数可能会在不知不觉中改变调用方的值。- 时区转换和夏令时计算需要繁琐的手动运算,或者借助 date-fns、Day.js 或 Luxon 等库才能完成。
Temporal 是一种新的、不可变的日期和时间 API,旨在取代 Date。它并没有从语言中移除 Date,因为现有代码仍可正常运行,只是新编写的代码不再需要它。
为不同概念设计独立类型
Temporal 的核心设计理念是将不同的概念建模为不同的类型:
Temporal.PlainDate:不包含时间信息且与特定时区无关的日历日期,例如生日。Temporal.PlainTime:不包含日期信息的时钟时间,例如07:00设置的闹钟。Temporal.ZonedDateTime:与特定时区及日历相关的精确时刻。
下面的代码片段会读取纽约的当前时间,给一个普通日期加上14天,并计算两个日期之间的月数和天数差。注意,这里使用largestUnit: 'month'来指定计算单位,因此结果会以月数加天数的形式显示,而非单纯的日数。
// 1. Getting current time in a specific time zone
const meetingTime = Temporal.Now.zonedDateTimeISO('America/New_York');
console.log(meetingTime.toString());
// e.g. "2026-09-04T12:09:28-04:00[America/New_York]"
// 2. Safe, readable date math
const deadline = Temporal.PlainDate.from('2026-09-01');
const followUp = deadline.add({ days: 14 });
console.log(followUp.toString()); // "2026-09-15"
// 3. Calculating the exact difference between two dates
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-09-04');
const diff = start.until(end, { largestUnit: 'month' });
console.log(`${diff.months} months and ${diff.days} days`);
// "8 months and 3 days"
所有的 Temporal 对象都是不可变的。调用 add() 方法不会修改原始对象,而是返回一个新对象。对于许多项目而言,这意味着日期处理库可以不必被打包在项目中。在迁移之前,请先阅读常见的 Temporal 使用陷阱。
使用 using 语句进行显式资源管理
内存泄漏和锁无法释放的常见原因就是忘记关闭已打开的资源:
- 读取文件后仍未关闭的文件句柄
- API 路由出错时仍保持连接的数据库连接
- 从未被断开的事件监听器或工作线程
传统的解决方案是 try...finally。当一个函数中需要管理三种资源时,很快就会产生深度嵌套的样板代码。显式的资源管理则引入了 using 声明以及两个常用的符号:Symbol.dispose 和 Symbol.asyncDispose。
资源释放的触发方式
当使用 using 声明某个值时,无论代码块是正常结束还是因错误而终止,引擎都会在执行离开该代码块后立即调用其 [Symbol.dispose]() 方法。如果在同一个作用域中声明了多个资源,它们将按照与声明相反的顺序被释放,这正好与嵌套的 try...finally 块的处理方式一致。
在示例中,DatabaseSession实现了资源释放钩子,而runReport则使用using语句来声明会话。日志顺序显示,清理操作在查询执行完毕且函数退出后才会进行:
// Define a resource that knows how to clean itself up
class DatabaseSession {
constructor(dbName) {
this.connection = `Connected to ${dbName}`;
console.log("Database opened.");
}
query(sql) {
return `Results for: ${sql}`;
}
// Built-in disposal hook
[Symbol.dispose]() {
console.log("Database connection closed automatically!");
}
}
// Using the resource
function runReport() {
using session = new DatabaseSession("AnalyticsDB");
const data = session.query("SELECT * FROM metrics");
console.log(data);
// When runReport finishes (or if it throws),
// session[Symbol.dispose]() runs immediately.
}
runReport();
// Logs:
// 1. "Database opened."
// 2. "Results for: SELECT * FROM metrics"
// 3. "Database connection closed automatically!"
对于需要异步执行的清理操作,比如关闭网络套接字,应实现[Symbol.asyncDispose]()方法,并在异步函数中使用await using来声明相关资源。这样运行时会在继续执行之前等待清理操作完成。
使用getOrInsert与getOrInsertComputed进行Map更新
Map是用于键值集合的理想数据结构,但“先读取,若不存在则创建”的模式一直显得不够简洁。通常情况下,将多个值归集到同一个键下的方式如下:
// The old way: multiple lookups and manual branching
if (!userCache.has(userId)) {
userCache.set(userId, fetchDefaultProfile(userId));
}
const profile = userCache.get(userId);
这需要两次查找(先调用has,再调用get)再加上一个分支判断,只是为了确保该键存在。新的方法将其合并为一次调用和一次查找:
map.getOrInsert(key, defaultValue): 返回key对应的现有值,若不存在则将该键与defaultValue关联后返回。map.getOrInsertComputed(key, callback): 功能相同,但仅在键不存在时才会执行回调函数,从而避免不必要的昂贵操作。
上述示例中这种区别很重要。getOrInsert(userId, fetchDefaultProfile(userId))会在每次访问时都调用fetchDefaultProfile,因为函数参数会在调用前就被求值;而采用计算后的版本则可以避免这种情况。接下来的代码片段将事件按用户ID分组,只有当某个用户首次出现时才会创建一个空数组:
const userActivity = new Map();
// Grouping events under user IDs
function logEvent(userId, eventName) {
// If userId doesn't exist, create an empty array, insert it, and return it.
const events = userActivity.getOrInsertComputed(userId, () => []);
events.push(eventName);
}
logEvent("user_42", "login");
logEvent("user_42", "clicked_button");
console.log(userActivity.get("user_42"));
// ['login', 'clicked_button']
使用Math.sumPrecise获取精确总和
JavaScript中的数字遵循IEEE 754双精度浮点数标准,这会导致下面这种著名的结果:
0.1 + 0.2; // 0.30000000000000004
在发票、购物车或图表汇总中,这类错误会不断累积,最终使总和出现明显偏差。
Math.sumPrecise() 接收一个数字可迭代对象,以无限精度计算这些数字的总和,仅在最后进行一次四舍五入。而简单的 reduce 方法则会在每一步之后都进行四舍五入,这就是误差产生的原因。在示例中,使用 reduce 时这四项发票金额的总和为 0.9999999999999999,而 Math.sumPrecise 的计算结果为 1(控制台输出的是 1,而非 1.0):
const invoiceItems = [0.1, 0.2, 0.3, 0.4];
// Old reduce approach:
const naiveTotal = invoiceItems.reduce((acc, n) => acc + n, 0);
console.log(naiveTotal);
// 0.9999999999999999
// New Math.sumPrecise approach:
const accurateTotal = Math.sumPrecise(invoiceItems);
console.log(accurateTotal);
// 1.0
了解这一限制非常重要。Math.sumPrecise可以消除累积的舍入误差,但它无法改变诸如0.1这类数值在二进制中表示不精确的事实。即便只对[0.1, 0.2]求和,结果依然是0.30000000000000004,因为这两个双精度数的精确和会舍入为该值。对于统计数据和仪表板而言,这确实是一种改进;但在处理金钱相关数据时,整数小单位(分)或十进制计算库仍是更安全的选择。
迭代器辅助函数与Iterator.concat
十多年来,数组一直提供 .map()、.filter() 和 .slice() 方法,而迭代器和生成器(如无限序列或数据库游标)则没有类似功能。若要对生成数千行的生成器使用 .map(),必须先通过 [...generator()] 将其全部转换为数组,这会丧失流式处理带来的内存优势。
迭代器辅助工具为迭代器直接添加了 map、filter、take、drop 和 flatMap 等方法,而 Iterator.concat() 可将多个迭代器合并为一个。所有这些方法都属于惰性计算:只有当调用者请求时才会计算对应元素。
该示例将辅助函数与无限生成器相连。由于求值是惰性的,且take(3)在获取三个值后会停止,因此即使数据源永无止境,循环也会终止:
function* infiniteCounter() {
let count = 1;
while (true) {
yield count++;
}
}
// Grab an iterator from our infinite generator
const stream = infiniteCounter()
.filter(num => num % 2 === 0) // Keep even numbers
.map(num => `Count: ${num}`) // Format them
.take(3); // Stop after 3 values
for (const item of stream) {
console.log(item);
}
// Logs:
// "Count: 2"
// "Count: 4"
// "Count: 6"
若想在不创建中间数组的情况下将多个序列拼接起来,可将其传递给Iterator.concat()。该方法会依次处理每个迭代器:
const firstBatch = [1, 2, 3].values();
const secondBatch = [4, 5, 6].values();
const combined = Iterator.concat(firstBatch, secondBatch);
console.log([...combined]); // [1, 2, 3, 4, 5, 6]
迭代器辅助函数在各大引擎中早已存在;Iterator.concat()则是较新的功能,需单独查看相关文档。
原生集合操作
长期以来,Set除了提供.has()、.add()和.delete()之外功能极为有限。诸如求交集之类的操作都需要先转换为数组再手动过滤:
// The old manual way:
const intersection = new Set([...setA].filter(x => setB.has(x)));
Set.prototype 现已包含真正的集合代数运算:union、intersection、difference、symmetricDifference,以及判断方法 isSubsetOf、isSupersetOf 和 isDisjointFrom。每种运算都会返回一个新的 Set,而不会改变原有集合。
示例中比较了两个角色集,提取出仅管理员拥有的权限,并确认编辑者的角色是管理员角色的子集:
const adminRoles = new Set(['read', 'write', 'delete', 'audit']);
const editorRoles = new Set(['read', 'write']);
// Find privileges exclusive to admins
const adminOnly = adminRoles.difference(editorRoles);
console.log([...adminOnly]); // ['delete', 'audit']
// Check role containment
console.log(editorRoles.isSubsetOf(adminRoles)); // true
他们还避免了使用展开与过滤方法所生成的临时数组。
用于延迟承诺的 Promise.withResolvers
有时,承诺需要在构造函数之外进行处理,例如通过事件处理器或其他地方注册的回调函数。那种“延迟”模式过去需要将变量提升到执行器之外:
// The clunky way
let resolveFn, rejectFn;
const myPromise = new Promise((res, rej) => {
resolveFn = res;
rejectFn = rej;
});
Promise.withResolvers()会将承诺及其resolve和reject函数一起放在一个对象中返回。在示例中,点击处理器用于解决该承诺,而其他代码则只需等待其结果。注意,这段代码使用了顶层await,因此必须在ES模块中或异步函数内部运行:
const { promise, resolve, reject } = Promise.withResolvers();
// Attach your listeners or pass the resolve handle to an event handler
document.getElementById("submit-btn").addEventListener("click", () => {
resolve("User clicked submit!");
}, { once: true });
// Await the promise wherever you need it
const message = await promise;
console.log(message);
这种模式适用于自定义事件队列、与Web Workers的交互,以及将基于回调或发射器的API适配为async代码。
这对你的依赖项意味着什么
其共同点在于一致性:该语言现已涵盖日期处理、资源清理、键值分组以及集合运算等常规任务,这些功能过去都需要依赖用户空间库来实现。在下一个迭代中添加新包之前,请先检查以下目标:
- 如果您的产品要面向现代浏览器或当前版本的 Node.js、Bun 或 Deno,那么许多这类工具已经全局可用。
- 每避免一个依赖项,就能减小代码包体积、加快启动速度,而且任何 JavaScript 开发者都能直接理解代码,无需学习第三方 API。
核心要点
- Temporal 为日期提供了不可变且结构清晰的类型;在编写新代码时,它可替代
Date,但不会移除原有的Date。 using和await using能让资源清理操作自动且有序地执行,从而取代嵌套的try...finally结构。
getOrInsertComputed。Math.sumPrecise 能解决累积的舍入误差问题,但无法处理二进制表示问题;金额数据应以整数形式存储。相关阅读
- 六种可替代常见 JavaScript UI 库的原生 HTML 功能 — 了解 popover、exclusive details、dialog、声明式阴影 DOM、fetchpriority 和 datalist 如何替代自定义 JavaScript,以及它们各自仍存在的局限。
- Node.js 26的那些悄然取代多年临时解决方案的新特性 — 详细介绍Node.js 26的Temporal API、原生TypeScript执行功能、缓存辅助工具以及其他能够消除长期存在的临时解决方案的新功能。
- auditing.utils.js:六种可替代手动编写辅助函数的原生JavaScript内置函数 — 了解哪些原生JavaScript API能够取代深度克隆技巧、防御性复制机制、手动超时处理、延迟承诺功能以及lodash的groupBy方法,同时了解相关使用注意事项。