
代码跑起来,只是及格线。让下一个人读懂、改得动、出了问题能追查,才算真正写完了。
我刚工作那几年,一直以为高级工程师厉害,是因为他们懂得更多。
更多框架、更多设计模式、更多算法技巧。于是我拼命学,掘金、GitHub、技术公众号,恨不得把所有库的源码都看一遍。
直到有一次,线上出了个 Bug,我被拉进一个有五年经验的老工程师的修复流程里。
他打开出问题的文件,三秒钟定位到了错误位置。
不是因为他更聪明。是因为那段代码是他写的——他刻意把错误藏得很浅。
那一刻我才意识到:他和我的差距,不是知识量,是写代码时想的问题不一样。
我在想"怎么让这个功能跑起来",他在想"这段代码出了问题,下一个人能不能在 3 分钟内找到原因"。
这篇文章,是我从几位老工程师身上观察到的 7 个思维模式。它们不绑定框架,不依赖语言,但会让你写出来的代码,在需求变更、线上告警、团队交接时,表现出完全不同的质量。
很多人写函数,像在挖隧道——一层一层往里钻。
先判断用户存不存在,再判断参数合不合法,再判断权限够不够,一路嵌套下去,等真正的业务逻辑终于出现,已经在第六层缩进里了。
用户输入
└─ if (user)
└─ if (input.email)
└─ if (user.canEdit)
└─ 真正的业务逻辑 ← 读到这里眼睛已经累了
老工程师不这么写。他们先把所有"不该继续"的情况处理掉,剩下的才是主流程。
async function updateUserProfile(userId: string, input: ProfileInput) {
const user = await getUser(userId);
if (!user) thrownew NotFoundError('用户不存在');
if (!input.email) thrownew ValidationError('邮箱不能为空');
if (!user.canEditProfile) thrownew ForbiddenError('无权修改资料');
// 到这里,所有异常已经被清空,这一行就是全部重点
return saveProfile(user.id, input);
}
// JavaScript 版本
asyncfunction updateUserProfile(userId, input) {
const user = await getUser(userId);
if (!user) thrownewError('用户不存在');
if (!input.email) thrownewError('邮箱不能为空');
if (!user.canEditProfile) thrownewError('无权修改资料');
return saveProfile(user.id, input);
}
看起来只是格式调整,实际上改变了读代码的方式。
线上出问题时,你首先想的是"什么情况会导致这个函数走不下去",而不是"正常情况下会发生什么"。提前返回,就是把"失败路径"暴露在最显眼的地方,让排查变成一件顺序往下读的事,而不是在缩进迷宫里转圈。
核心思维:减少读代码时需要在脑子里维持的"上下文状态"。每多一层嵌套,读者就要多记住一个条件。把条件清完再写主逻辑,是对下一个人的礼貌。
变量名写 data、result、item、temp,代码能跑,但它在撒谎。
它告诉你"这里有个东西",却不告诉你"这个东西是什么、为什么重要"。
// 这段代码在干什么?
const result = await getData(id);
if (result.status === 'active') {
await process(result);
}
三个月后你回来看这段代码,getData 取的是什么?result.status === 'active' 代表什么业务含义?process 又做了什么事?
换个写法:
const subscription = await getSubscription(subscriptionId);
if (subscription.isBillable) {
await chargeSubscription(subscription);
}
这段代码讲了一个完整的业务故事:取出订阅,如果可以计费,就扣款。
一行注释都不需要。
命名不只是"好看"的问题,它直接影响错误发生的概率。我见过一个线上故障,根因是一个叫 activeUsers 的变量,实际包含了"已停用但未删除"的用户。名字给了错误的保证,调用方没有防御,直接用出了问题。
名字越精准,错误的藏身之地就越少。
核心思维:命名应该表达业务意图,而不是数据结构。
invoicesPendingPayment比unpaidList更长,但长得有价值——它携带了别人不需要再去猜的信息。
第三方 API、支付网关、短信服务、外部数据库——这些你管不了。
它们会改字段名,会删接口,会返回你完全没预期的数据格式。如果你让这些外部数据的结构直接流进你的业务逻辑,就等于把一个你控制不了的人,拉进了你的内部架构。
// ❌ 外部结构直接泄漏进业务逻辑
const userName = response.data.user_name;
const isVip = response.data.member_level === 'VIP';
const expireAt = response.data.expire_ts * 1000; // 还要做单位转换
这段代码今天没问题。等第三方悄悄把 user_name 改成 userName,把 expire_ts 改成 expireTime,你才发现这种写法散落在十几个文件里。
老工程师会在边界处建一个"翻译层":
// ✅ 外部数据只在一处被消化
function mapMemberInfo(response: ThirdPartyMemberResponse): MemberInfo {
return {
name: response.data.user_name,
isVip: response.data.member_level === 'VIP',
expireAt: new Date(response.data.expire_ts * 1000),
};
}
翻译层之外,整个系统只认识 MemberInfo,不知道也不在意第三方叫它什么。
第三方 API 响应
│
▼
┌─────────────┐
│ mapMember │ ← 唯一知道外部格式的地方
│ Info() │
└──────┬──────┘
│
▼
内部 MemberInfo
┌──────────────────┐
│ 业务逻辑 │
│ UI 组件 │ ← 只认识你自己定义的类型
│ 数据库写入 │
└──────────────────┘
这个模式不只适用于第三方 API,同样适用于:
核心思维:你控制不了的东西,就把它关在门口。让外部的混乱止步于边界层,不要让它成为你整个系统的语言。
我曾经写过这样的类型定义:
type User = {
id?: string;
email?: string;
role?: string;
status?: string;
};
每个字段都可选,因为这样 TypeScript 不报错。
结果是:整个系统里,每一处用到 User 的代码都要自我防御——这个 id 存在吗?role 是预期的值吗?status 是什么意思?
老工程师看到这段代码直接说了一句话:"你这个类型允许了不该存在的状态。"
改法是把不同阶段、不同状态的对象,拆成不同的类型:
// 未保存的草稿用户
type DraftUser = {
email: string;
role: 'admin' | 'member';
};
// 已入库的用户
type SavedUser = {
id: string;
email: string;
role: 'admin' | 'member';
status: 'active' | 'disabled';
};
这么一拆,"没有 id 的已保存用户"就不可能存在了——类型本身拒绝了它。
这个思维在业务建模里特别重要。订单不只是订单,它有待付款、已付款、已发货、已完成、已退款之分。支付不只是支付,它有待确认、已授权、已扣款、已失败之分。
type Payment =
| { state: 'pending'; id: string }
| { state: 'authorized'; id: string; authCode: string }
| { state: 'captured'; id: string; receiptNo: string }
| { state: 'failed'; id: string; failReason: string };
// 发送收据只接受"已扣款"状态的支付,编译期就能保证
function sendReceipt(payment: Extract<Payment, { state: 'captured' }>) {
return emailReceipt(payment.receiptNo);
}
用 JavaScript 动态语言同样可以做到,在构造函数、工厂函数或校验层里做状态约束,效果一样。
核心思维:与其在每个调用方都加防御判断,不如从源头让"不合理的状态"无处落脚。类型是设计,不是装饰。
你有没有写过这种函数:既判断条件,又发请求,又写数据库,又发邮件?
async function refundOrder(orderId: string) {
const order = await getOrder(orderId);
if (order.status !== 'paid') thrownewError('订单未支付');
if (order.refundedAt) thrownewError('已退款');
if (order.amount <= 0) thrownewError('金额异常');
await paymentGateway.refund(order.paymentId);
await markOrderRefunded(order.id);
await sendRefundEmail(order.userId);
}
这个函数问题不大——直到你需要给退款逻辑写单元测试。
你会发现,要测"订单已退款应该报错"这一条规则,你得 mock 支付网关、mock 数据库写入、mock 邮件发送。测一个纯逻辑判断,要搭这么大的架子。
慢慢地,测试越写越累,团队干脆不写了。
老工程师会先把决策抽成纯函数:
function getRefundEligibility(order: Order): RefundEligibility {
if (order.status !== 'paid') return { allowed: false, reason: '订单未支付' };
if (order.refundedAt) return { allowed: false, reason: '已退款,请勿重复操作' };
if (order.amount <= 0) return { allowed: false, reason: '退款金额异常' };
return { allowed: true };
}
决策层(纯函数,无副作用)
┌──────────────────────┐
│ getRefundEligibility│ ← 只做判断,不碰外部世界
│ (order) → allowed? │ ← 随便测,不需要 mock 任何东西
└──────────┬───────────┘
│ allowed: true
▼
执行层(有副作用)
┌──────────────────────┐
│ paymentGateway │
│ markOrderRefunded │ ← 只在决策通过后才触发
│ sendRefundEmail │
└──────────────────────┘
执行层变得干净:
async function refundOrder(orderId: string) {
const order = await getOrder(orderId);
const eligibility = getRefundEligibility(order);
if (!eligibility.allowed) thrownew ValidationError(eligibility.reason);
await paymentGateway.refund(order.paymentId);
await markOrderRefunded(order.id);
await sendRefundEmail(order.userId);
}
业务规则现在是可见的、可测试的、可复用的。
权限判断、定价计算、通知触发条件、工作流流转——这些"决策"都适合用这个模式拆出来。
核心思维:把业务规则藏在有副作用的函数里,等于把规则变成了谜题。把决策提出来,规则才是一等公民。
你见过多少次这样的报错:
Something went wrong
有什么用?
老工程师写错误,像写给三类人看的:接口调用方、日志排查者、最终用户。
// ❌ 没有诊断价值的错误
throw new Error('操作失败');
// ✅ 有机器可读编码 + 人类可读描述 + 上下文信息
throw new BusinessError({
code: 'ORDER_ALREADY_REFUNDED',
message: '该订单已完成退款,如有疑问请联系客服',
details: { orderId, refundedAt: order.refundedAt },
requestId,
});
对于 API 接口,前端最怕的是靠解析错误文本来判断业务状态:
// ❌ 前端被迫解析字符串,脆得一碰就断
if (error.message.includes('已退款')) {
showDuplicateRefundTip();
}
// ✅ 前端通过 code 判断,稳定可靠
if (error.code === 'ORDER_ALREADY_REFUNDED') {
showDuplicateRefundTip();
}
日志也一样,不是 console.log('退款失败') 了事,而是带上足够的上下文:
logger.warn('退款资格校验未通过', {
orderId,
userId,
reason: eligibility.reason,
requestId,
});
注意:日志里绝对不能出现密码、支付卡号、短信验证码、用户身份证号这类敏感信息。有用的上下文是 ID、状态、流程节点,不是用户隐私。
核心思维:错误信息是系统对外的沟通语言。写得越模糊,出问题时付出的代价越大。
功能在本地跑通了,不等于这个改动做完了。
合并之后的代码会被 Review、被测试、可能被回滚、一定会被将来的某个人阅读。
我见过很多这样的 PR:
feat: 完善支付流程
- 重构发票服务
- 重命名支付字段
- 修改退款逻辑
- 更新 Dashboard UI
- 新增 Webhook 处理
- 修复账户状态 Bug
- 更新单元测试
这不是一个 PR,这是一个黑盒。
Reviewer 不知道哪些改动是功能必要的,哪些是顺手重构的。出了问题不知道 revert 哪一个。测试用例覆盖的是哪个改动的行为?
老工程师会把这个拆成:
PR 1:重命名支付字段(纯重构,无行为变更)
PR 2:提取退款资格校验函数(+测试)
PR 3:将退款资格校验接入支付流程
PR 4:更新 Dashboard 展示退款原因
PR 5:新增 Webhook 重试机制
每个 PR 只讲一件事。
一个好的 PR diff 应该能回答三个问题:
┌──────────────────────────────────┐
│ 改了什么?(What changed) │
│ 为什么改?(Why it changed) │
│ 现在行为有什么不同?(New behavior)│
└──────────────────────────────────┘
这种习惯在平时看起来"麻烦",在出线上问题的时候会救你的命。小而聚焦的 diff,是高压时刻团队最宝贵的礼物。
核心思维:代码不是写给机器的,是写给人的。可 Review、可回滚、可追溯的改动,才是真正"交付完成"的改动。
把这 7 个模式摊开来:
提前返回、精准命名、边界封装、合法状态建模、决策与执行分离、有意义的错误、干净的 diff。
它们看起来各讲各的,但底层其实只有一件事:
减少下一个人需要猜测的东西。
猜变量名是什么意思,猜函数会不会有副作用,猜错误是哪里抛出来的,猜这个状态算不算合法,猜这个 PR 改了什么……
每一次猜测,都是认知成本。认知成本堆多了,就变成"不敢动这段代码"、"先测试一遍再说"、"这个功能不知道影不影响那里"。
一个让人频繁猜测的代码库,会让整个团队陷入低效的焦虑状态。不是因为团队不行,是因为代码一直在消耗信任。
会写代码,是让功能跑起来。
写对代码,是让下一个人不需要猜。
💬 你最近有没有遇到"让你猜了很久"的代码? 是一个神秘变量名、一个嵌套了七层的 if、还是一个不知道会触发什么副作用的函数? 评论区分享你的「翻车现场」,最有共鸣的我会在下期文章里引用,顺手帮你分析怎么改 👀