
前几天做 code review,看到一段很典型的代码:一个下单接口里,先 fetch 拉第三方数据,直接 .then 拿来往数据库塞,塞完还顺手发了封邮件——全挤在一个请求里。写的人没错,功能是通的,本地跑得飞快。
但我盯着它看了两秒,心里想的是另一句话:这玩意儿现在没事,是因为量还小。
很多人对「可扩展性」有个误解,觉得那是性能问题——机器不够快、代码不够优。真到了线上你会发现,JavaScript 系统崩,几乎从来不是因为慢。它崩,是因为复杂度在你没注意的地方,一点一点悄悄堆起来了:数据形状慢慢跑偏,状态越来越难预测,日志开始只讲半个故事,一次延迟毛刺你连从哪查都不知道。
所以可扩展性的核心,不是速度,是控制力、可见性和纪律。下面这 10 个库,就是我这些年反复伸手去拿的那几个。这篇我一个一个讲,每个都配一个真实的业务场景——如果你是初学者,看完应该能明白:这库到底在哪个环节救我,我什么时候该用它。
先给张全景图,让你知道这 10 个库分别堵在系统的哪个位置,后面就不会看着看着迷路:
请求进来
│
▼
┌──────────────────────┐
│ ① Zod / ⑩ Ajv 校验 │ ← 脏数据挡在门口
└──────────┬───────────┘
│ 干净的数据
▼
② TanStack Query(前端拿数据)
③ Axios(统一发请求)
│
▼
┌──────────────────────┐
│ 业务逻辑层 │
└───┬──────────────┬───┘
快活儿 │ │ 慢活儿
立即返回 │ ▼
│ ⑤ BullMQ 丢队列 → 后台慢慢消化
▼
⑥ Prisma 访问数据库
⑦ Day.js 处理时间 ⑧ Lodash 处理数据
│
全程被 ④ Pino 记日志 + ⑨ OpenTelemetry 追踪链路 盯着
它是干嘛的: 一个数据校验库。你先定义好数据「应该长什么样」,进来的数据不符合,直接报错。
业务场景: 你写了个电商下单接口,前端会 POST 一个 JSON 过来,里面有 商品ID、数量、收货地址。理想情况下 数量 是个正整数。但现实是——前端可能因为一个 bug 传了 数量: "abc",或者压根没传地址。
如果你不校验,直接 const qty = body.quantity 拿来用,这个 "abc" 会一路往里钻,可能在计算总价时变成 NaN,最后生成一张金额为「NaN 元」的订单——而报错的地方离出错的地方隔了十万八千里,你查半天都定位不到根源。
Zod 怎么解决: 在接口最外层设一道关卡,数据不合格根本进不来。
import { z } from "zod";
const OrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(), // 必须是正整数
address: z.string().min(1), // 不能为空
});
// 类型直接从 schema 推出来,不用再手写 interface
type Order = z.infer<typeof OrderSchema>;
// 校验:不合格直接抛错,干净的数据才往下走
const order = OrderSchema.parse(req.body);
一处定义,既是运行时的校验,又是 TypeScript 的类型来源。对初学者来说这点特别香:你不用再维护「一份 interface + 一份手写校验」两套东西,改一个地方全同步。
顺带一个时效性: Zod 已经到 v4(当前 4.4.x),解析速度是 v3 的约 14 倍,还拆出一个 zod/mini,gzip 后才 ~1.9KB,前端包体敏感的项目可以直接上。
什么时候用: 只要有「外部数据进入你系统」的边界——接口入参、第三方回调、读配置文件——都值得用它挡一道。
它是干嘛的: 帮你管理「从服务器拿来的数据」,自动处理缓存、重试、后台刷新。(就是原来的 React Query。)
业务场景: 你做一个后台管理系统,顶部导航栏要显示「当前登录用户名」,侧边栏要显示「用户头像」,个人中心页也要显示用户信息。三个组件,各自写了一遍 fetch('/api/user')。
结果就是:打开页面,同一个接口被请求了三次;用户改了昵称,导航栏更新了、侧边栏还是旧的,数据对不上。你还得在每个组件里手写 loading、error、data 三个 useState,写到吐。
TanStack Query 怎么解决: 它把服务端数据当成「唯一真相来源」统一托管。你只要给每份数据起个 key,它自动帮你缓存、去重、同步。
function useUser() {
return useQuery({
queryKey: ["user"], // 同一个 key,三个组件共享一份缓存
queryFn: () => fetchUser(),
});
}
// 任何组件里:
const { data, isLoading, error } = useUser();
三个组件用同一个 key,接口只会请求一次,数据永远一致。用户改了昵称,你让这个 key 失效,所有用到它的地方一起刷新。loading 和 error 它也帮你算好了,组件瞬间清爽。
什么时候用: 只要你的 React 项目里有「从后端拉数据」的需求,几乎都该用它,而不是 useEffect + useState 手搓。当前稳定版 v5.101.x。
它是干嘛的: 一个 HTTP 请求库,最大价值是用「拦截器」把所有请求的公共逻辑统一管起来。
业务场景: 你的系统所有接口都要在请求头带上登录 token。如果每次调接口都手动 headers: { Authorization: token },几十个接口就要写几十遍,哪天 token 格式变了你得改到手软。更别提 token 过期要自动跳登录页、接口报错要统一弹提示——这些逻辑散落各处就是灾难。
Axios 怎么解决: 用拦截器,在「所有请求发出前」和「所有响应回来后」各设一个统一入口。
const api = axios.create({ baseURL: "/api", timeout: 10000 });
// 所有请求自动带上 token
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
// 所有响应统一处理错误
api.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) redirectToLogin(); // 过期跳登录
returnPromise.reject(err);
}
);
写一次,全局生效。这就是「把抽象放在对的地方」。
但这里有件新事你得知道: 2026 年 3 月 31 日,Axios 维护者的 npm 账号被攻破,被人推了两个带恶意代码的版本(1.14.1 和 0.30.4),事后才处理。这提醒我们——依赖越核心,供应链风险越致命。
所以我现在的判断是:Node 22+ 的新项目,原生 fetch 已经够用了(Node 18 起就内置)。真正离不开 Axios 的,是你重度依赖拦截器、要它默认把 HTTP 错误直接 reject、或要兼容老版本 Node 的场景。别再无脑装 Axios,先想清楚你到底要不要那层封装。
什么时候用: 需要统一鉴权、统一错误处理、请求/响应拦截时,Axios 依旧顺手;纯粹发个简单请求,原生 fetch 就行。
它是干嘛的: 生产级的日志库,输出的是结构化日志(JSON),而不是一行行纯文本。
业务场景: 一个用户投诉「我昨晚下单一直失败」。你打开服务器,日志里几十万行 console.log('下单失败') 躺在那——全长一个样,你根本没法从中捞出「就是他那一次请求」到底为什么挂了。
Pino/Winston 怎么解决: 每条日志都带上结构化字段(用户ID、请求ID、错误级别),事后可以像查数据库一样精确检索。
import pino from "pino";
const logger = pino();
logger.info({ userId: "u_123", orderId: "o_456", event: "order_created" }, "下单成功");
logger.error({ userId: "u_123", err: e.message }, "下单失败"); // 带上下文
日志接进观测平台后,你直接搜 userId = u_123,那个用户所有的操作轨迹一目了然,几分钟就能定位。好日志不拖慢你,它是别的都失灵时救你命的那根绳。
Pino vs Winston: 追求性能、Node 服务,我首选 Pino(够快);要更多花哨的传输/格式化配置,Winston 生态更全。初学者从 Pino 上手就行。
什么时候用: 任何要上线的服务,从第一天就别用 console.log 当正式日志。
它是干嘛的: 一个基于 Redis 的任务队列,帮你把「慢的、不急的」活儿从接口里挪到后台慢慢做。
业务场景: 用户点「提交订单」,你的接口里干了这么几件事:写订单入库、发短信通知、生成 PDF 发票、通知商家。后面三件都要调外部服务,加起来要 4~5 秒。用户就得对着转圈等 5 秒,体验极差;万一短信服务抖一下,整个下单直接失败。
BullMQ 怎么解决: 接口里只干「写订单入库」这件必须同步的事,剩下三件丢进队列,立刻返回「下单成功」,后台的 Worker 再慢慢消化。
import { Queue } from"bullmq";
const notifyQueue = new Queue("order-notify");
// 接口里:核心逻辑做完,杂活丢队列,立刻返回
await saveOrder(order);
await notifyQueue.add("after-order", { orderId: order.id });
return { ok: true }; // 用户 200ms 就拿到响应
// 另一个进程里:Worker 慢慢处理,失败还能自动重试
new Worker("order-notify", async (job) => {
await sendSms(job.data.orderId);
await generateInvoice(job.data.orderId);
});
接口响应从 5 秒变 200 毫秒,短信服务挂了也只是「这条任务重试」,而不是「用户下单失败」。队列不是优化项,它是架构本身。
什么时候用: 接口里出现「发邮件/短信、处理文件、调第三方、跑报表」这类耗时又不必须马上完成的活儿,就该丢队列。
它是干嘛的: 一个现代 ORM(帮你用 TypeScript 操作数据库,不用手写 SQL),核心价值是类型安全和规范的迁移。
业务场景: 三个人一起开发,A 在数据库加了个字段,没告诉 B,B 本地跑起来直接报错;C 写查询时把 userName 拼成了 usreName,运行时才发现,还查了一下午。数据库一旦「失去纪律」,协作就是灾难。
Prisma 怎么解决: 你在一个 schema.prisma 文件里集中定义所有表结构,改动通过迁移命令同步给全队;查询是类型安全的,字段拼错编辑器当场标红。
// 字段拼错 / 类型不对,写的时候就报错,不用等运行
const user = await prisma.user.findUnique({
where: { id: userId },
select: { name: true, email: true },
});
它逼着你有意识地去设计数据结构,而不是随手加字段。团队协作、重构时特别稳。
一个大新闻: 2025 年 11 月 Prisma 发布 v7,彻底扔掉了原来那个笨重的 Rust 查询引擎,换成纯 TypeScript 运行时,查询快了约 3 倍,包体积小了差不多 90%。以前大家嫌 Prisma「那个二进制太重」,现在这个包袱基本解开了,新项目上它比一年前轻太多。
什么时候用: 用 Node/TypeScript 写后端、要跟数据库打交道、又想要类型安全和规范迁移时,Prisma 是很稳的选择。
它是干嘛的: 一个轻量的日期时间处理库,API 简单、不可变、包体小。
业务场景: 你的服务器用 UTC 时间存数据,用户在北京(东八区)。用户明明晚上 8 点下的单,页面上却显示中午 12 点——差了 8 小时。再比如你要显示「3 天前」「还剩 2 小时」这种相对时间,用原生 Date 手写能把人写疯。时间相关的 bug 有个可怕的特点:它不会让程序崩溃,而是悄悄把数据搞错,等你发现时已经错了一片。
Day.js 怎么解决: 用一套干净的链式 API 处理格式化、时区、相对时间,而且它「不可变」——不会像原生 Date 那样一不小心就改了原对象。
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
dayjs(order.createdAt).format("YYYY-MM-DD HH:mm"); // 统一格式化
dayjs(order.createdAt).fromNow(); // "3 小时前"
什么时候用: 只要项目里有「格式化时间、算时间差、处理时区」的需求,就别硬啃原生 Date。(补一句:原生 Temporal API 正在铺开,未来可能接管这块,但眼下 Day.js 仍是最稳的现成选择。)
它是干嘛的: 一个工具函数库,提供深克隆、安全取值、数组/对象转换等一堆经过千锤百炼的工具。
业务场景: 你要从一个层层嵌套的接口数据里取 user.profile.address.city,但 profile 可能是 null,直接点下去就报 Cannot read property of undefined,页面白屏。或者你想复制一份配置对象改改,用 {...obj} 只是浅拷贝,改了副本结果原对象也跟着变,埋下诡异的 bug。
Lodash 怎么解决:
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
const city = get(user, "profile.address.city", "未知"); // 中间是 null 也不报错
const copy = cloneDeep(config); // 真正的深拷贝
注意我上面是 import get from "lodash/get" 而不是 import _ from "lodash"——按需引入,别把整个库拖进来,否则包体白白胖一大圈。
但要清醒一点: 现在原生的可选链 ?.、structuredClone、数组方法已经能替掉 Lodash 里很大一部分功能。所以每次伸手前问一句:这个原生真做不到吗? 包体敏感的项目还可以看看 es-toolkit 这种更现代的替代品。
什么时候用: 深克隆、复杂的安全取值、麻烦的数据转换——原生写起来啰嗦又容易出错时,用 Lodash 对应函数;简单操作优先用原生。
它是干嘛的: 一套厂商中立的可观测性标准,统一收集链路追踪(Traces)、指标(Metrics)、日志(Logs)。
业务场景: 你的系统拆成了好几个服务:一个请求先到网关,再到订单服务,订单服务又调库存服务和支付服务。某天用户反馈「下单好慢」,你一看总共花了 3 秒——但你不知道这 3 秒卡在哪一跳。是支付服务慢?还是库存服务?光看单个服务的日志根本串不起来。
OpenTelemetry 怎么解决: 它给每个请求发一个贯穿全程的「trace id」,请求走到哪个服务都带着它。最后你能看到一张完整的瀑布图:网关 20ms、订单 30ms、库存 50ms、支付 2900ms——瓶颈一眼锁定。
为什么选它: 「厂商中立」很关键——你按它的标准埋点,后端想换成 Jaeger、Grafana 还是别的观测平台都行,不被某一家绑死。
什么时候用: 系统一旦拆成多个服务(微服务),或者你经常遇到「慢,但不知道慢在哪」的问题,可观测性就是必需品,不是锦上添花。可观测性把「扩展」从猜谜变成了工程。
它是干嘛的: 一个基于 JSON Schema 的高性能校验器。作用和 Zod 类似(都是校验数据),但主打一个快。
业务场景: 你做的是一个 API 网关,每秒要过几万条请求,每条都得校验格式。这种量级下,校验本身要是慢一点,就成了整个系统的瓶颈。这时候比「开发体验」更重要的是「吞吐量」。
Ajv 怎么解决: 它把 JSON Schema 预编译成校验函数,校验的时候快得离谱,专门为网关、高吞吐服务、契约驱动的架构准备。
import Ajv from "ajv";
const ajv = new Ajv();
const validate = ajv.compile({
type: "object",
properties: { id: { type: "string" }, qty: { type: "integer", minimum: 1 } },
required: ["id", "qty"],
});
if (!validate(data)) console.log(validate.errors); // 极快
Zod 还是 Ajv? 一句话记住:中小规模、要类型推导、图开发爽——用 Zod;网关级、每秒上万、每一微秒都算钱——上 Ajv。 它俩不冲突,做的是同一件事:让错误在门口就暴露。
什么时候用: 高吞吐、契约驱动(比如已经有一堆 JSON Schema 定义)的场景,Ajv 是首选。
回头看这 10 个库,它们没有一个是靠「新」「潮」上位的。它们能长期留在我的工具箱里,是因为都在做同一件事:
让意图变明确,让歧义变少,让错误尽早、大声地暴露,逼着你保持纪律。
如果你是初学者,不用一次全上。我的建议是按这个顺序去认识它们:先用 Zod 守住数据边界,再用 TanStack Query 管好前端数据,接口层加上 Axios,上线前配好 Pino 日志——光这四个,就能让你的项目稳一大截。剩下的等真遇到对应的问题(活儿太慢、时间错乱、服务查不动),再对号入座地引入。
一个库如果是在帮你管理复杂度,值得留下;如果它是在帮你藏起复杂度,早晚有一天会反咬你一口。生产系统,奖励的是无聊、可预测的选择,惩罚的是自作聪明的捷径。
💬 聊两句: 文里我更新了对 Axios 的判断——2026 那次 npm 供应链攻击之后,我倾向 Node 22+ 新项目直接用原生 fetch。你怎么站队?
评论区告诉我你的选择和理由,尤其想听听重度用拦截器的同学现在怎么打算 👀