少装一个库之后:我重新补了一遍 JavaScript 原生能力
这篇不是想说“不要用库”。
我自己也用很多库。表单、日期、图表、状态管理、构建工具,成熟库能省很多时间。只是这两年维护项目时,我越来越明显地感觉到:有些库不是因为问题复杂才加的,而是因为我们忘了 JavaScript 原生已经有能力处理。
有一次我整理一个中后台项目的依赖,发现里面有几个很小的工具库:一个处理 query string,一个做深拷贝,一个格式化数字,一个做简单分组。它们都不是大问题,但每个库都会带来版本、兼容、打包和团队认知成本。
后来我开始有意识地补原生 API。不是为了显得“纯”,而是为了在加依赖前多一个判断。
query string 不要再手拼
后台系统里最常见的场景之一,是筛选条件同步到 URL。
我以前见过很多手拼字符串:
1const url = `/orders?keyword=${keyword}&page=${page}&status=${status}`;
第一次看没问题,后面需求一多就开始出错:中文要不要转义,空值要不要保留,多选状态怎么传,已有参数怎么合并。
现在我会优先用 URL 和 URLSearchParams。
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 选错了。
还有一个容易忽略的点,URLSearchParams 的 toString() 会自动做编码,但它对空格编码成 + 而不是 %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 小时靠谱得多。
分组、查找、判断要写出业务意图
数组处理也是一个容易过度工具化的地方。
很多代码本来只是查找、判断、分组,却被写成一段很绕的 reduce。reduce 很强,但也因为太强,经常让读代码的人猜它到底想表达什么。
现在我会更愿意写语义明确的 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));
这个写法有不少坑,比如 Date、undefined、Map、循环引用都会出问题。现在如果只是复制一份纯数据快照,我会先考虑 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,能表达清楚就不额外加一层依赖,省下来的不只是代码行数,还有以后升级、替换这个库时要牵扯的那一圈成本。