首页 / 文章

审计utils.js:六种可替代手动编写辅助函数的内置函数

了解哪些原生 JavaScript API 可以替代深度克隆技巧、防御性复制、手动超时处理、延迟承诺以及 lodash 的 groupBy 方法,以及使用时需要注意哪些限制。

发布者:REACTAPP.TOP阅读约 5 分钟1052 词

来源: 6 Modern JavaScript Features You’re Probably Still Working Around

许多代码库仍然包含为平台多年前已解决的问题而设计的辅助函数和库导入。这些新增内容悄然加入,因此人们养成的旧有解决方案习惯始终未被重新审视。以下是六个值得检查您代码中 utils.js 的内置函数,包括它们各自解决的缺陷以及在删除任何内容之前应了解的局限性。

使用 structuredClone() 替代 JSON 反复序列化

最常见的手动深度复制方法是将对象序列化后再反序列化:

const copy = JSON.parse(JSON.stringify(original));

这种方法在遇到 JSON 无法表示的内容时会出错:Date 对象会被转成字符串,设置为 undefined 的属性会被丢弃,Map 和 Set 会毫无预警地变成空对象,而循环引用则会导致 JSON.stringify 抛出异常。

structuredClone() 是现代浏览器和 Node.js 中内置的全局函数,无需导入任何模块。以一个包含日期、集合和映射的对象为例:

const original = {
  date: new Date(),
  items: new Set([1, 2, 3]),
  meta: new Map([['key', 'value']]),
};

使用该函数克隆后,这些数据类型都能保持完整,循环结构也能被正确复制:

const copy = structuredClone(original);
// Dates, Sets, Maps, and circular refs all survive intact

它采用了 postMessage 和 IndexedDB 背后的结构化克隆算法,因此能够处理手动克隆会遗漏的边缘情况。不过它也有一些限制:函数和 DOM 节点无法被克隆,否则会引发错误;而用户自定义类的实例会被还原为没有原型属性的普通对象。

使用 toSorted() 和 toReversed() 而非防御性复制

.sort() 和 .reverse() 会直接修改原数组,因此对通过引用共享的数组进行排序会影响到所有地方。以这个数组为例:

const scores = [30, 10, 20];

toSorted() 会返回一个已排序的新数组,而不会修改原数组:

const sorted = scores.toSorted((a, b) => a - b);console.log(scores); // [30, 10, 20] — untouched
console.log(sorted);  // [10, 20, 30]

toReversed() 和 toSpliced() 也采用相同的返回副本的模式。如果你的代码像 React reducer 那样,为了保持状态不可变而大量使用 [...arr].sort(),那么就不再需要展开操作了。关于这些返回副本方法的更多内容,请参阅 可替代常见样板代码的现代 JavaScript 数组方法。

使用 Object.hasOwn() 而非 hasOwnProperty

通过对象调用该方法会隐藏一个陷阱:

obj.hasOwnProperty('key'); // breaks if obj = Object.create(null)

使用 Object.create(null) 创建的对象没有原型,因此它不会继承 hasOwnProperty,调用该属性时会抛出 TypeError。如果对象自身定义了同名的属性,也会出现同样的情况。Object.hasOwn() 是一个静态函数,因此它不依赖于对象继承了什么:

Object.hasOwn(obj, 'key'); // works regardless of the object's prototype chain

虽然这种替换看似微不足道,但它能避免那些只有在生产环境中、当调用者传入未预料到的对象结构时才会出现的故障。

使用 AbortSignal.timeout() 而非手动定时器

过去,要为 fetch 设置截止时间,需要使用 AbortController 结合能够触发它的 setTimeout:

const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 5000);

之后还必须记得在请求处理完成后清除定时器:

fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));

现在,信号可以自带截止时间了:

fetch(url, { signal: AbortSignal.timeout(5000) });

仅一行代码,无需进行任何清理操作,也不会留下定时器。当定时器触发时,请求会以TimeoutError或DOMException的形式被拒绝,这样就能将超时与用户主动发起的取消操作区分开来。若要将超时与其他取消机制结合使用,请参阅当前文档中的AbortSignal.any()。

使用Promise.withResolvers()而非随意使用resolve和reject

有时需要从 Promise 的执行器外部来决定其最终状态。传统的解决方法是在构造函数回调中声明变量并为其赋值:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

这种方法确实可行,但通过闭包将函数“偷带”出来容易让人产生误解。Promise.withResolvers()会同时返回 Promise 以及两个用于决定状态的函数:

const { promise, resolve, reject } = Promise.withResolvers();

当您将异步任务放入队列中,或用 promises 包装事件驱动型 API 时,这种用法非常合适。如需更完整的示例,请参阅使用 Promise.withResolvers 协调 LLM 工具调用。

使用 Object.groupBy() 而非 lodash groupBy

按键对记录进行分组是人们导入工具库的最常见原因之一。假设有如下人员列表:

const people = [
  { name: 'Sam', age: 21 },
  { name: 'Lee', age: 34 },
  { name: 'Kai', age: 21 },
];

Object.groupBy() 接受一个可迭代对象和一个回调函数,然后返回一个对象,其中每个键对应一个数组:

const byAge = Object.groupBy(people, (p) => p.age);
// { 21: [Sam, Kai], 34: [Lee] }

注意其名称。早先的提案将此功能称为 Array.prototype.group(),但在标准化之前它被重命名并移至静态方法 Object.groupBy()(当键不是字符串时则使用 Map.groupBy()),目前只有这些静态版本被正式提供。该方法的原型为 null,且是此列表中最新的 API,因此在移除相关依赖之前,请先确认您所使用的浏览器及 Node.js 版本是否支持。

在删除这些辅助函数之前

这些改动都不会影响您的架构,但每项改动都能消除易出错的编程模式或不必要的依赖。建议的操作顺序如下:

  • 在 caniuse.com 或 MDN 上根据您实际的目标环境检查支持情况,因为其中一些 API 比其他的新,旧版浏览器可能不支持它们。
  • 在确实可以不再使用这些辅助函数之前,继续保留对应的填充代码。
  • 逐步替换调用点,在涉及输出形状的重要场景中对克隆和分组操作进行测试。
  • 只有当没有任何代码引用该旧辅助函数或库时,才将其移除。