少装一个库之后:我重新补了一遍 JavaScript 原生能力

这篇不是想说“不要用库”。

我自己也用很多库。表单、日期、图表、状态管理、构建工具,成熟库能省很多时间。只是这两年维护项目时,我越来越明显地感觉到:有些库不是因为问题复杂才加的,而是因为我们忘了 JavaScript 原生已经有能力处理。

有一次我整理一个中后台项目的依赖,发现里面有几个很小的工具库:一个处理 query string,一个做深拷贝,一个格式化数字,一个做简单分组。它们都不是大问题,但每个库都会带来版本、兼容、打包和团队认知成本。

后来我开始有意识地补原生 API。不是为了显得“纯”,而是为了在加依赖前多一个判断。

query string 不要再手拼

后台系统里最常见的场景之一,是筛选条件同步到 URL。

我以前见过很多手拼字符串:

1const url = `/orders?keyword=${keyword}&page=${page}&status=${status}`;

第一次看没问题,后面需求一多就开始出错:中文要不要转义,空值要不要保留,多选状态怎么传,已有参数怎么合并。

现在我会优先用 URLURLSearchParams

1function buildOrderUrl(filters) {
2  const url = new URL("/orders", window.location.origin);
3
4  if (filters.keyword) {
5    url.searchParams.set("keyword", filters.keyword);
6  }
7
8  if (filters.page > 1) {
9    url.searchParams.set("page", String(filters.page));
10  }
11
12  for (const status of filters.statusList) {
13    url.searchParams.append("status", status);
14  }
15
16  return `${url.pathname}${url.search}`;
17}

这段代码没有什么技巧,但意图很清楚。它把转义、连接符、重复参数这些细节交给标准 API。

我后来改筛选页时,会把手动拼 query 的逻辑换成 URLSearchParams。收益不是少几行代码,而是后面加筛选项时不容易埋小坑。

反过来,从 URL 还原筛选条件也一样方便。以前我见过同事写正则去切 location.search,遇到值里带 &= 就崩。现在我直接解析:

1function parseOrderFilters(search) {
2  const params = new URLSearchParams(search);
3
4  return {
5    keyword: params.get("keyword") ?? "",
6    page: Number(params.get("page") ?? "1"),
7    statusList: params.getAll("status"),
8  };
9}

这里有个细节我踩过:get 只返回第一个值,多选要用 getAll。我当初就是把多选状态用 get 取出来,结果列表永远只按第一个状态过滤,排查了半天才发现是 API 选错了。

还有一个容易忽略的点,URLSearchParamstoString() 会自动做编码,但它对空格编码成 + 而不是 %20。大部分后端能识别,但我遇到过一个老 PHP 接口只认 %20,那次只能额外做一次 .replace(/\+/g, "%20")。原生 API 不是万能的,但它至少把“怎么编码”这件事变成一个明确的、可讨论的点,而不是散落在各处的字符串拼接。

Intl 比手写格式化可靠

另一个我以前低估的能力是 Intl

很多后台一开始只服务中文用户,于是大家手写金额、日期、百分比格式:

1function formatPrice(value) {
2  return `¥${Number(value).toFixed(2)}`;
3}

如果永远只有人民币和中文,这样也能用。但业务稍微扩展一点,问题就来了:英文环境、不同货币、小数位、时区、相对时间、列表连接方式,每个细节都可能变成补丁。

现在遇到格式化,我会先看看 Intl

1const money = new Intl.NumberFormat("zh-CN", {
2  style: "currency",
3  currency: "CNY",
4}).format(1288);
5
6const time = new Intl.DateTimeFormat("zh-CN", {
7  dateStyle: "medium",
8  timeStyle: "short",
9}).format(new Date());

它不只是代码更短,更重要的是把本地化交给标准实现。

1const relative = new Intl.RelativeTimeFormat("zh-CN", {
2  numeric: "auto",
3}).format(-1, "day");
4
5const list = new Intl.ListFormat("zh-CN", {
6  style: "short",
7  type: "conjunction",
8}).format(["React", "Next.js", "TypeScript"]);

我不会说 Intl 能替代所有日期库。复杂日历、时区计算、业务周期规则,仍然可能需要专业库。但展示层的格式化,先看原生能力通常是值得的。

有几个用 Intl 才学到的细节,我记一下。一个是 Intl.NumberFormat 支持 notation: "compact",做数据看板时把 12800 显示成 1.3万 非常省事,不用再自己写一套"万/亿"的判断:

1const compact = new Intl.NumberFormat("zh-CN", {
2  notation: "compact",
3}).format(128000); // 13万

另一个是性能。new Intl.NumberFormat(...) 这个构造本身是有成本的,里面要加载 locale 数据。我以前在一个长列表的 render 里每行都 new 一次,几千行下来明显卡。后来把格式化器提到模块顶层复用,或者直接缓存,掉帧就没了:

1const priceFormatter = new Intl.NumberFormat("zh-CN", {
2  style: "currency",
3  currency: "CNY",
4});
5
6// 列表里直接 priceFormatter.format(row.amount)

还有时区。Intl.DateTimeFormat 可以直接传 timeZone: "Asia/Shanghai",这点在做跨时区的运营后台时很关键——同一个 UTC 时间戳,要保证不同地区运营看到的都是“北京时间”,靠原生就能稳定输出,比手动加减 8 小时靠谱得多。

分组、查找、判断要写出业务意图

数组处理也是一个容易过度工具化的地方。

很多代码本来只是查找、判断、分组,却被写成一段很绕的 reducereduce 很强,但也因为太强,经常让读代码的人猜它到底想表达什么。

现在我会更愿意写语义明确的 API:

1const currentUser = users.find((user) => user.id === userId);
2const hasDisabledItem = items.some((item) => item.disabled);
3const allChecked = items.every((item) => item.checked);

如果目标环境支持,简单分组也可以考虑 Object.groupBy

1const groupedByStatus = Object.groupBy(orders, (order) => order.status);

它的行为很直观,分组函数返回什么,就归到哪个 key 下,丢进控制台跑一下就清楚(Node 21+ / Chrome 117+ 支持):

1Object.groupBy([1, 2, 3, 4], (x) => (x % 2 ? "odd" : "even"));
2// { odd: [1, 3], even: [2, 4] }

但这里我会很谨慎:新 API 不是“能写就写”。要看项目的浏览器范围、Node 版本、构建降级策略和团队熟悉程度。

关于 Object.groupBy 多说一句,它返回的是普通对象,但 key 都是字符串,分组依据如果是数字会被隐式转成字符串。如果分组键有冲突风险,或者想保留原始类型,还有个 Map.groupBy,它的 key 能是任意类型:

1const byUser = Map.groupBy(orders, (order) => order.userId);
2// byUser.get(1001) 拿到该用户的全部订单

这两个 API 是相当新的(2024 才进标准),我在实际项目里还是会确认一下 Node 版本和 browserslist,必要时让 Babel/core-js 补台。判断的成本,本身也是要算进去的。

我自己更频繁用到的其实是 Array.prototype.at。取最后一个元素,arr.at(-1)arr[arr.length - 1] 读起来顺太多,做分页、取最新一条记录时随手就用上了:

1["a", "b", "c"].at(-1); // 'c'

这个支持得早(Node 16.6+ / 各主流浏览器 2021 年起),基本可以放心用。顺带提一句兄弟方法 toSorted,它是 ES2023 才进的非破坏性排序,返回新数组、不动原数组,正好治“sort 原地改”的老坑:

1const arr = ["b", "a"];
2arr.toSorted(); // ['a', 'b']
3arr;            // ['b', 'a'] —— 原数组没变

我的原则是:原生 API 能让业务意图更清楚,并且目标环境支持,就优先用;如果兼容成本高,就不要为了新而新。

structuredClone 适合纯数据快照

深拷贝是另一个历史包袱很多的地方。

过去很多项目用:

1const copy = JSON.parse(JSON.stringify(data));

这个写法有不少坑,比如 DateundefinedMap、循环引用都会出问题。现在如果只是复制一份纯数据快照,我会先考虑 structuredClone

1const draft = structuredClone(settings);
2draft.theme = "dark";
3draft.shortcuts.save = "Cmd+S";

我常用它处理这些场景:

  • 表单草稿。
  • 配置编辑副本。
  • 撤销栈快照。
  • 导入数据的临时处理。

但我也不会把它当万能深拷贝。函数、DOM 节点、响应式代理、复杂 class 实例,本来就不应该被随便 clone。很多深拷贝问题,根源其实是数据模型不清楚。

如果一个对象里混着可序列化数据、运行时状态、组件引用和方法,那该做的不是找更强的 clone,而是拆数据结构。

structuredClone 是真正的深拷贝,改副本不会动到原对象(Node 17+ / 各主流浏览器都已支持):

1const settings = { theme: "light", shortcuts: { save: "Ctrl+S" } };
2const draft = structuredClone(settings);
3draft.shortcuts.save = "Cmd+S";
4settings.shortcuts.save; // 'Ctrl+S' —— 原对象不受影响

要注意它会对函数、DOM 节点这类不可序列化的值直接抛 DataCloneError,这反而是好事——它逼你只拷贝纯数据。

AbortController 不只是取消请求

AbortController 是我这两年用得更多的原生能力。

最常见的是搜索输入防竞态:

1let controller;
2
3async function search(keyword) {
4  controller?.abort();
5  controller = new AbortController();
6
7  const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
8    signal: controller.signal,
9  });
10
11  return response.json();
12}

它的价值不是少一个状态,而是避免旧请求覆盖新结果。

跟取消相关,还有个我现在常用的小工具 Promise.withResolvers(),它把 resolve / reject 从 Promise 构造器里掏出来,避免那种“在 new Promise 外面声明两个 let 再赋值”的样板。它返回的就是三件套(Node 22+ / 各主流浏览器 2024 年起支持):

1const { promise, resolve, reject } = Promise.withResolvers();
2// 拿到的 key:promise, resolve, reject

手动控制一个事件何时 resolve(比如等某个 WebSocket 消息回来)时,这个比闭包传递 resolve 干净不少。

在复杂页面里,请求取消也是体验问题。用户切换筛选、离开页面、关闭弹窗后,旧请求不应该继续回来更新已经不存在的 UI。以前我只在意“请求成功怎么办”,现在会更多考虑“这个结果回来时还是否有效”。

allSettled 很适合仪表盘页面

中后台首页、数据看板、运营面板,经常有多个模块并行请求。

以前我见过这样的代码:

1const [stats, list, notices] = await Promise.all([
2  fetchStats(),
3  fetchList(),
4  fetchNotices(),
5]);

如果三个模块必须同时成功,这样没问题。但更多时候,通知失败不应该让统计也空白,列表失败不应该影响顶部指标。

这时 Promise.allSettled 更贴合业务:

1const results = await Promise.allSettled([
2  fetchStats(),
3  fetchList(),
4  fetchNotices(),
5]);
6
7const [statsResult, listResult, noticesResult] = results;

然后每个模块根据自己的结果展示内容、错误态或重试入口。这个写法很适合“模块之间相对独立”的页面。

我现在会把它和统一错误结构一起用。每个模块失败后,不只是拿到 rejected,还能知道是否可重试、该显示什么文案、要不要上报。

少装库不是目标,少一层不必要的复杂度才是

我现在看依赖,会先问几个问题:

  • 这个需求是不是标准 API 已经能清楚表达?
  • 目标运行环境是否支持?
  • 团队是否熟悉这个原生能力?
  • 引入库以后,是否真的降低长期维护成本?
  • 这个库解决的是核心复杂度,还是只是包了一层简单语法?

有些库必须用,而且应该用。比如复杂日期计算、成熟表单方案、图表渲染、虚拟列表、富文本编辑。这些问题本身就复杂,不应该为了“少依赖”硬写。

但 query、格式化、简单集合操作、请求取消、纯数据快照,这些地方先看一眼原生能力,往往能让代码更轻。

我重新补 JavaScript 原生 API,不是为了追新,也不是为了反库。

更像是给自己补一层判断力:遇到需求时,不要下意识装包,也不要下意识手写轮子。先看看标准能力能不能表达清楚,再决定要不要引入抽象。

query string、格式化、简单集合操作、请求取消、纯数据快照,这几类问题现在我都会先摸一遍标准 API,能表达清楚就不额外加一层依赖,省下来的不只是代码行数,还有以后升级、替换这个库时要牵扯的那一圈成本。