首页 > 人工智能 > 人工智能 > JS/TS:看着没毛病?运行就崩
每个 JavaScript/TypeScript 开发者,想必都在代码库里亲手埋过雷。
我说的可不是那些能让构建直接报错、或者让测试卡死在流水线上的低级错误。
我说的是那些 安静的刺客。
它们静悄悄地躺在你的工程里,不报错、不警告、甚至连代码评审(PR Review)时最挑剔的架构师也对它们睁一只眼闭一只眼。所有人都觉得天下太平,直到有一天,生产环境突然开始抽风。
于是,你不得不在凌晨两点从床上爬起来排查问题,盯着屏幕怀疑人生:“这代码怎么看都没毛病啊,到底是哪儿崩了?”
在过去三年多的 JS/TS 深水区摸爬滚打中,我被这些“安静的刺客”暗算过无数次。不是一次两次,而是反反复复地踩坑。
我仔细盘点了一下,类似的隐形错误大概有 20 多个。全堆在一篇里实在太长了,所以今天这篇作为上集,咱们先聊聊最核心的底层地基: 类型建模、错误处理以及模块架构。
因为地基要是歪了,往上盖的每一层业务代码,都会变成日后的维护噩梦。
这个错误看起来不起眼,但绝对能排在今天的“作死榜”第一名。
我见过太多 TypeScript 项目,要么直接在配置里把
strict 关掉,要么打从初始化起就压根没打开过。
// tsconfig.json —— 经典的“睁眼瞎”配置{ "compilerOptions": { "strict": false
}
}刚开始开发的时候,你会觉得爽翻了。代码编译一路绿灯,没有红线报错,没有任何阻碍。
但你不知道的是,此时的 TypeScript 正在对你的代码“全自动放水”。它会极其宽容地允许:
本该是字符串的地方传进了
null
本该是对象的地方塞进了
undefined
各路隐式的
any 像病毒一样悄悄渗透进你的函数
你以为自己用上了高级的 TypeScript,其实你只是默默承受了编译配置的复杂性,却只换回了残缺的安全感。
听我一句劝,把它打开:
{ "compilerOptions": { "strict": true
}
}这一个简单的配置开关,背后其实打包激活了
strictNullChecks、
noImplicitAny、
strictFunctionTypes 等一大堆真正能在生产环境救你狗命的严格检查。
没错,如果你的项目已经是个庞然大物,开启这个开关的瞬间,终端大概率会当场“爆炸”,成百上千个红线报错会扑面而来。
但请记住, 这些并不是新引入的报错,它们本就是潜伏在代码里的 Bug,只是过去被藏起来了而已。
我的避坑建议:不要指望一天就能把老项目的报错全改完。你可以先开启
strict,如果报错多到崩溃,就先把它关掉,然后把子里面的细分配置项(比如最核心的
strictNullChecks)一个一个单独开启,分批消化。
如果你的 TypeScript 连严格模式都没开,那你其实并不是在写 TS,你只是在写一种带语法装饰的 JavaScript。
any 糊弄编译器,是在给未来的自己挖坑我完全理解大家为什么喜欢用
any。有时候业务催得急,TypeScript 的类型推导又搁那儿疯狂报错不配合,你只想赶紧把功能上线,于是手一抖:
// ❌ 典型的“我回头一定重构”式写法function formatUser(user: any) { return user.profile.name.toLowerCase();
}报错消失了,世界清静了,感觉工作又推进了一大步。
但本质上,你是在对编译器说:“这块儿你别管了,我心里有数。”TypeScript 也乐得清闲:“行,那你自己自生自灭吧。”
用
any 最致命的地方在于,
它具有极强的传染性。这个入参的
user 还会流向其他的函数、组件,而其他函数会盲目信任它。最后,大半个代码库都在没有类型保护的“裸奔”状态下运行。
更安全的解法:先验证,后使用
面对不确定结构的外界数据,我们可以把类型定为
unknown,并在使用前强制进行类型守卫(Type Guard):
// ✅ 严格校验数据后再享受类型红利type User = { profile: { name: string;
};
};function isUser(data: unknown): data is User { if (typeof data !== "object" || data === null) return false; const maybe = data as { profile?: unknown }; if (typeof maybe.profile !== "object" || maybe.profile === null) { return false;
} return typeof (maybe.profile as { name?: unknown }).name === "string";
}function formatUser(user: unknown) { if (!isUser(user)) { throw new Error("无效的用户数据");
} return user.profile.name.toLowerCase();
}这时候,你不仅没有盲目信任外部输入,TypeScript 也能稳稳地推导出后续的结构。
如果数据的类型本身就很灵动,不要用
any,去用泛型(Generics):
// ✅ 清爽且可复用的泛型设计function getLastItem<T>(list: T[]): T | undefined { return list[list.length - 1];
}这里顺便提醒一个容易被忽略的细节: TypeScript 会无条件信任你的类型守卫函数。如果你的校验逻辑写漏了,编译器是不会看出来的。这种“虚假的安全感”,往往比完全没有类型还要危险。
很多人为了追求所谓的“高扩展性”和“灵活性”,喜欢把类型设计得极其宽泛。这种蜜汁操作往往会成为日后逻辑混乱的根源。
最常见的就是这种“全员可选”的药丸对象:
// ❌ 所有人都是可选的... 约等于什么约束都没有type RequestState = {
data?: string[];
error?: string;
isLoading?: boolean;
};初看之下,这个对象好像能完美适配各种请求场景。但你仔细揉碎了想想:
data 和
error 能同时存在吗?
页面正处于
isLoading 状态时,
data 会有值吗?
按照上面的松散定义,下面这些离谱的数据结构在类型层面上全都是合法的:
{ data: ["A"], error: "服务器崩了", isLoading: false } // 既成功又失败?{ isLoading: true, data: ["怎么还有老数据?"] }编译器放行了,但你的前端业务逻辑代码绝对会当场死给你看。
更好的解法:用可辨识联合类型(Discriminated Unions)把状态彻底隔开
// ✅ 每种状态的边界,白纸黑字划分得清清楚楚type RequestState =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };这样一改造,业务数据的逻辑瞬间就理顺了。它们彼此互斥,绝对不会出现既在加载又报错误的诡异组合。
而在消费这些数据时,代码也会变得干净得不可思议:
function renderState(state: RequestState) { switch (state.status) { case "loading": return "加载中..."; case "success": return state.data.join(", "); // data 绝对存在,不需要 ?.
case "error": return `错误: ${state.message}`; // message 绝对存在
}
}发现了吗?
那些恶心的可选链(
?.)、防御性
null 检查和各种兜底判断全都消失了。因为 TypeScript 在对应的
case 分支里,已经百分之百确信有什么、没什么了。
当类型里的字段全都是可选时,意味着这个类型已经没有任何可靠性可言了。
TypeScript 的类型推导能力确实很强,以至于大家都养成了“能不写类型就不写”的习惯。
// ❌ 看着挺清爽,其实埋下了隐性错误export async function fetchUser(userId: string) { const result = await database
.query("users")
.findById(userId); if (!result) { throw new Error("用户未找到");
} return result;
}没有报错,运行良好。可问题出在哪儿?
问题出在这个函数的
返回值类型并不是固定的。它是跟着你底层的
database.query() 的返回结构实时联动的。
这就意味着,如果半年后某个新人改动了底层数据库查询、重命名了某个字段,或者为了优化性能少查了两个列:
这个
fetchUser 函数由于是自动推导,依然能够顺利编译通过,完全不报错。
但是, 所有在外部调用了这个导出函数的上层业务代码,其拿到的类型全都在无形中悄然改变了。
你在不知不觉中,亲手把一个破坏性变更(Breaking Change)扩散到了整个系统。
更好的解法:显式声明契约
对于任何需要暴露给外部使用的函数,必须硬编码锁死它的返回契约:
type User = { id: string;
name: string;
email: string;
};// ✅ 哪怕底层翻江倒海,对外的契约坚如磐石export async function fetchUser(userId: string): Promise<User> { const result = await database
.query("users")
.findById(userId); if (!result) { throw new Error("用户未找到");
} return result;
}这样一来,如果底层查询哪天突然不返回
email 字段了,这个函数在编译阶段就会当场拦截报错,把风险死死锁在源头,而不是等它扩散到前端渲染层去报
Cannot read property of undefined。
我个人的黄金法则:
如果是对外的导出函数(Exported Functions):把它当作一套公开的 API 来看待,必须手动写明入参和返回值的精确类型。
如果是内部的辅助小函数(Internal Helpers):放心大胆地交给 TypeScript 去自动推导,没必要增加冗余代码。
这种错误往往发生在异步代码看起来特别轻量、顺眼的时候。
你写了一个异步的 webhook 或者是后台同步任务,你想着反正它自己在后台跑就行,不需要阻塞主线程返回,于是:
// ❌ 异步任务启动了,但没有任何人在这儿盯着它app.post("/webhook", (req, res) => {
syncWebhookEvent(req.body);
res.sendStatus(200);
});接口响应飞快,用户体验极佳,代码看起来干净利落。
然而,灭顶之灾就在这里。
syncWebhookEvent() 它的本质是一个 Promise。如果这个异步任务在执行到一半时由于网络波动或者数据库超时骤然失败了,
没有任何人能捕获到这个异常。
它既没有
await,也没有
.catch()。它就像一团充满怨气的未处理拒绝(Unhandled Rejection),无依无靠地漂浮在你的 Node.js 运行时里,随时可能导致进程直接崩溃。
更恶心的是,你的接口已经信誓旦旦地给第三方回调平台返回了
200 OK,对方以为数据已经同步成功,再也不会重试,而你的系统内部其实早已溃不成军(数据丢失、订单状态对不上、日志一片空白)。
如果当前请求需要确保这段异步工作顺利完成,那就老老实实等待它:
// ✅ 妥善等待,并把可能的异常交由框架的错误中间件接管app.post("/webhook", async (req, res, next) => { try { await syncWebhookEvent(req.body);
res.sendStatus(200);
} catch (err) {
next(err);
}
});那如果我真的不需要阻塞响应,就是想搞“甩手掌柜(Fire-and-Forget)”模式的后台处理呢?可以,但绝对不能对异常放任不管:
// ✅ 虽然我不等它执行完,但我做好了随时兜底失败的准备syncWebhookEvent(req.body).catch((err) => {
logger.error("Webhook 同步彻底失败", { webhookId: req.body.id,
err,
});
});
res.sendStatus(202); // 202 Accepted 往往比 200 更符合这种“已受理未完成”的语境请记住,一个没有被处理的 Promise 绝对不叫“后台异步任务”。
在项目刚起步、体量还小的时候,直接引入具体服务用起来是最爽的。想查库?Import 进来;想发邮件?Import 进来:
// ❌ 开发一分钟,后面写单元测试痛苦一整天import { database } from "../infra/database";import { mailer } from "../infra/mailer";export async function placeOrder(input: NewOrderInput) { const order = await database.orders.insert(input); await mailer.send({ to: order.customerEmail, subject: "订单已收到", body: buildOrderEmail(order),
}); return order;
}业务逻辑看起来简单明了,对吧?
但你发现没有,你的核心核心业务逻辑(下单),已经被牢牢地钉死在了具体的数据库模块、邮件模块以及硬编码的文件路径上。
这就意味着,当你想给
placeOrder 写个纯粹的业务逻辑单元测试时,你根本没法干净地运行它。你必须在测试框架里写各种大刀阔斧的魔改 Mock:
jest.mock("../infra/database"); // 脆弱的路径 Mock这种测试极其脆弱。哪天你稍微调整了一下基础设施的文件组织架构,或者换了个导出的名字,你的单元测试就会成片成片地报错——而这期间你根本没有动过任何一行核心的下单业务代码。
更高级的做法:通过参数注入依赖(Dependency Injection)
不要让业务函数自己去满世界找工具,而是让外部在调用它时把工具递给它。
// ✅ 业务函数只认工具的“形状”(接口),不认具体的牌子type OrderDeps = { orders: {
insert(input: NewOrderInput): Promise<Order>;
};
email: {
send(message: { to: string; subject: string; body: string }): Promise<void>;
};
};export function makeOrderService({ orders, email }: OrderDeps) { return { async placeOrder(input: NewOrderInput) { const order = await orders.insert(input); await email.send({ to: order.customerEmail, subject: "订单已收到", body: buildOrderEmail(order),
}); return order;
},
};
}现在,这个核心下单逻辑再也不关心具体的
orders 背后到底是真实的 PostgreSQL、一份内存里的临时 Map、还是一个第三方 API 的客户端。它只认具体的行为契约。
这就让写单元测试变成了一种享受:
const fakeOrders = { insert: async (input: NewOrderInput) => ({ id: "order_123", ...input }),
};const sentEmails: unknown[] = [];const fakeEmail = { send: async (msg: unknown) => { sentEmails.push(msg); },
};// 没有任何黑魔法,纯粹的参数传递const service = makeOrderService({ orders: fakeOrders, email: fakeEmail });await service.placeOrder({ customerEmail: "test@qq.com", total: 499 });至于真实的数据库链接和邮件服务,在应用的入口边缘文件(比如
app.ts 或
server.ts 启动时)拼装在一起即可。核心业务逻辑应当是一个纯净的温室,不该沾染外界基础设施的泥土。
微服务听起来太高大上了。独立的仓库、自主的部署流水线、极佳的扩展性……架构图画出来简直美如画。
但在一个处于早期阶段的小团队里,过早切分微服务往往会演变成一场灾难。
你可能会迅速把工程搞成这样:
apps/ gateway-api/ auth-service/ product-service/ billing-service/ email-service/
每个微服务都有自己独立的 Dockerfile、配置环境、数据库迁移脚本和日志采集系统。
这时候,一个原本极其简单的需求变更(比如要在订单上加一个字段),在微服务体系下可能需要:
改动订单服务,发布。
改动计费服务,发布。
改动网关层,发布。
顺便还要处理因为网络延迟或调用契约对不上导致的漫长调试。
这不叫高内聚低耦合,这叫自我制造链路障碍。
更务实的起点:模块化单体(Modular Monolith)
在业务还没摸透之前,完全可以把它们放在同一个代码库和同一个部署单元里,但 在目录和逻辑层面上划分出铁律般的模块边界:
src/ modules/ auth/ # 专注鉴权 orders/ # 专注订单规则 billing/ # 专注支付行为 notifications/ # 专注触达
共享同一个大数据库没问题,但模块之间有一个严厉的禁忌: 严禁跨模块直接去捞别人的数据库表。
// ❌ 订单模块直接伸手去篡改计费模块的流水表(大忌)await db.billingTransactions.findMany({ where: { orderId } });如果订单需要知道计费信息,应当通过计费模块暴露出来的干净的 Service 接口去进行内网通信:
// ✅ 走正规渠道,隔着清晰的模块边界进行调用await billingService.getTransactionsForOrder(orderId);
这样划分好之后,整个系统既享受了单体应用极低的运维和部署成本,又保证了逻辑结构的清晰。日后如果计费模块由于高并发真的需要剥离成独立的微服务,由于边界本来就是干净的,迁移工作也就是顺水推舟的事。
一个巨型长函数的诞生往往是循序渐进的。最开始它只有几行,后来加了一点入参校验,再后来加了一层条件判断,接着加入了计费、打折、发送通知、打点日志……
回过神来的时候,它已经变成了这种史诗级的流水账:
// ❌ 一个函数把五个人的活儿全干了export async function completePurchase(req: Request, res: Response) { // 1. 读取请求体...
// 2. 校验客户身份信息...
// 3. 校验购物车里的商品...
// 4. 查库存...
// 5. 计算原价...
// 6. 校验并扣减优惠券...
// 7. 算税率...
// 8. 调第三方网关扣款...
// 9. 落库保存订单...
// 10. 发送确认邮件...
// 11. 给大数据团队报送埋点...
res.json({ success: true });
}这种函数最让人痛苦的不是行数多,而是 读代码的人被迫把所有的细枝末节同时塞进脑子里。你想看一眼主要的业务主线,却不得不先被迫在上下滚动中看完 50 行怎么算税率的底层公式。
更好的做法:用具名函数把核心主线串成一个清晰的故事
抽取函数不仅仅是为了复用,更是为了 利用函数名提供语义化的抽象。
// ✅ 核心流程的主线任务,一眼望到底export async function completePurchase(req: Request, res: Response) { const checkout = parseCheckoutRequest(req.body); await reserveStock(checkout.items);
const invoice = buildInvoice({ items: checkout.items, couponCode: checkout.couponCode, shippingAddress: checkout.shippingAddress,
});
const payment = await chargePaymentMethod({ amount: invoice.total, method: checkout.paymentMethod,
});
const order = await saveOrder({ checkout, invoice, payment }); await sendOrderReceipt(order);
recordCheckoutEvent(order);
res.json({ orderId: order.id });
}现在,任何人来看这段核心代码,都能在几秒钟之内彻底搞懂整个商城的结账主流程。
如果具体到哪天计算价格算错了,大家自然知道该点进
buildInvoice 这个细分上下文里去排查,而不需要在成百上千行的巨型流水账函数里大海捞针。
顶层函数负责宏观叙事,底层函数负责提供细节。
很多刚入行的同学写控制器(Controller/Route Handler)时,习惯一路顺手写下去:
// ❌ 整个应用的业务生命线全被捏死在了一个特定的 HTTP 路由里app.post("/orders", async (req, res) => { const cartItems = req.body.items; let finalAmount = 0;
// 疯狂的查库、算价、判断库存逻辑...
for (const item of cartItems) { const product = await productRepository.findById(item.productId); if (product.availableStock < item.quantity) { return res.status(400).json({ error: "库存不足" });
}
finalAmount += product.price * item.quantity;
}
const order = await orderRepository.create({ userId: req.user.id, total: finalAmount });
res.json(order);
});功能完全正常。但这就带来了一个巨大的隐患: 你的核心商业逻辑,跟 Web 框架(Express/Koa/Nest等)的请求响应对象彻底焊死了。
如果三个月后,业务要求加上一个通过定时任务(Cron Job)自动触发的批量下单功能、一个命令行工具(CLI)的测试下单命令、或者改用 GraphQL/WebSocket 通信,你该怎么办? 光影天堂
难道要在别的地方再去把这几百行查库、算价的代码无脑复制一遍吗?
更好的习惯:控制器保持极度的薄,把业务挪入领域服务层
路由控制器的职责应该纯粹到极点: 解析网络请求 -> 调用业务服务 -> 返回统一格式的 HTTP 响应。
// ✅ 控制器:只负责和网络协议打交道,保持清爽app.post("/orders", async (req, res) => { const input = parseOrderRequest(req.body); const order = await orderService.createOrder({
input, userId: req.user.id,
});
res.json(toOrderResponse(order));
});而真正的商业逻辑,应该安安静静地呆在独立的、不依赖特定框架的 Service 类或函数里:
// ✅ 纯净的业务服务类:可以被任何触发渠道(HTTP、消息队列、命令行)高频复用class OrderService { async createOrder(params: { input: CreateOrderInput; userId: string }) { const { input, userId } = params; await this.ensureItemsAreAvailable(input.items); const total = await this.calculateFinalAmount(input.items, input.couponCode);
return this.orders.create({ userId, items: input.items, total });
} // 拆分出来的私有业务逻辑细节...}区分职责的一条铁律:
控制器(Controller)应该回答:“用户想要干什么?”
服务层(Service)应该回答:“公司的核心业务规矩是怎样的?”
把这两者切分开,你的核心代码才具备长久的生命力和跨平台移植的能力。
循环依赖(Circular Dependencies)是一种经常让人抓狂的报错。代码单看起来都特正常:文件 A 引用了文件 B,文件 B 反过来又引入了文件 A。
在静态代码分析时,可能毫无违和感,但当应用在运行时真正去解析模块树的瞬间,整个程序可能当场猝死。
// customer.tsimport { Purchase } from "./purchase";export class Customer { purchases: Purchase[] = [];
}// purchase.tsimport { Customer } from "./customer";// ❌ 致命继承:在执行到这一行时,基类 Customer 很有可能还是一个未定义的 undefined 占位符export class Purchase extends Customer {
createdAt = new Date();
}紧接着你的控制台就会甩出一段让人摸不着头脑的报错:
TypeError: Class extends value undefined is not a constructor。最坑的是,调用栈的指针往往会指向那个倒霉的受害者继承类,而不是幕后真正的循环模块链接。
解法:抽离第三方的公共纽带
循环引用之所以出现,本质上是因为这两个模块过于亲密,共同占有了某个交叉的概念。解决它的最优雅方式,是把它们共同依赖的基础定义抽离到第三个完全独立的纯净文件里。 晨曦影院
// contracts.ts —— 没有任何运行时逻辑的纯契约定义export interface CustomerInfo { id: string;
name: string;
}export interface PurchaseInfo { id: string;
customer: CustomerInfo;
}现在,两个实体各自去引用这个无害的类型契约,模块之间的强耦合闭环被瞬间斩断:
// customer.ts —— 只导入纯类型,这类导入在编译成 JS 后会被完全抹去import type { PurchaseInfo } from "./contracts";export class Customer { purchases: PurchaseInfo[] = [];
}
我的避坑绝招:在工程里尽早配上 ESLint 插件。直接在 CI 流水线里配上
eslint-plugin-import 的
no-cycle 严格规则。一旦有人在提交代码时手滑写出了循环引用,流水线会立马亮起红灯拒绝合并,把问题直接扼杀在萌芽期。
以上就是我们盘点的系统底层设计中最容易让人踩坑的前 10 个隐形错误。
我们今天聊的,并不是那些“忘写分号”或者“拼错单词”的低级语法失误。它们都是在实际的高强度项目迭代中,因为赶进度、图方便而顺手写出来的系统沉疴。
松散的类型、漏掉的错误处理、一锅端的功能封装……这些小瑕疵在当下可能完全不耽误你下班。但随着时间的推移,它们会产生利滚利的巨额技术债,直到把整个团队的维护效率彻底拖垮。
在接下来的下集(第二部分)中,我们将继续深入,去聊聊更偏向 运行时、异步并发控制、性能陷阱以及复杂数据校验方面的另外十个高能隐形坑。
感谢读到这里的你。如果觉得这些排雷指南对你当下的工程有启发,别忘了点个赞、在评论区聊聊你通宵排查过最恶心的 Bug 到底长啥样。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69920611/viewspace-3171665/ ,如需转载,请注明出处,否则将追究法律责任。