
大多数人写代码越写越好,不是因为他们背下了更多语法,而是因为他们终于不再把代码搞得比它本该有的样子更复杂。
咱们先聊个有点扎心的事实。
刚入行那会儿,我一直以为:高级工程师之所以厉害,是因为他们脑子里装了更多"骚操作"——更冷门的 API、更花哨的写法、更高级的算法。我拼命刷这些,以为刷够了就能升级。
后来我跟一群真正的老兵共事过:扛过线上事故、半夜被叫起来回滚、数据库迁移迁到一半卡住、需求被产品改了八遍……我盯着他们的代码看,整个人是懵的。
他们的代码,一点都不炫。
没有花哨的抽象,没有那种"专门写来发朋友圈截图"的炫技,平平无奇,甚至有点无聊。
可奇怪的是——需求一变再变,别人的代码改崩了,他们的代码还活着。
我后来才明白:初级和高级真正的差距,不在"会多少语法",而在 7 个判断习惯。这些习惯跟你用 React 还是 Vue 没关系,它们决定的是——bug 好不好找、代码好不好改、系统会不会被下一个人误解。
这篇文章,我尽量讲得让刚入行的同学也能看懂。每一条都配一个真实业务场景,咱们一个一个来。
📌 先看个场景
假设你在做一个"修改用户资料"的功能。需求很简单:用户改个昵称、邮箱就行。
小白通常这么写——像挖隧道一样,每个判断都往里钻一层:
async function updateUserProfile(userId, input) {
const user = await getUser(userId);
if (user) { // 用户存在吗?
if (input.email) { // 邮箱填了吗?
if (user.canEditProfile) { // 有权限改吗?
return saveProfile(user.id, input); // ← 真正干活的代码在这
}
}
}
thrownewError("更新失败");
}
刚写完看着没问题,对吧?功能也能跑。
但问题来了:过两周,产品说"得加个账号状态检查";再过一周,"还要验证邮箱是否激活";又过几天,"加上操作日志"。每加一条,你就得往里再套一层 if。最后这函数缩进到第六层,真正干活的那行代码被埋在最深处,谁来读都头疼。
高级工程师怎么写? 反过来——先把所有"坏情况"挡在门外,专业叫法是「提前返回」或「卫语句(guard clause)」:
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); // ← 走到这,说明前面全过了,放心干活
}
打个比方:这就像进小区。保安在大门口就把没带门禁卡的、不是本小区的人拦下来,进了门的都是合法住户,里面就不用再反复盘问了。而嵌套写法是——人都进电梯了、进家门了,还在一道道盘问"你是谁、你能进吗"。
嵌套写法(地窖式) 提前返回(保安式)
───────── ──────────
if (用户存在) { if (!用户存在) 打回
if (邮箱填了) { if (!邮箱填了) 打回
if (有权限) { if (!有权限) 打回
...真正逻辑... ...真正逻辑...(顶格,一眼看到)
}
}
}
读者要在脑子里记住:3层状态 读者要记住:0层(看完即忘)
为什么这事重要? 因为读代码比写代码更费劲。尤其线上出 bug 时,你瞪着满屏代码找问题,提前返回能让你一眼看清"什么情况下会失败",而不是在六层缩进里迷路。
💡 小白记住一句话:进门先验票,验过票的就别再怀疑了。
📌 先看个场景
你在做电商后台,要给"还在生效的订阅用户"批量发续费提醒。小白随手写:
const data = await getData(id);
if (data.status === "active") {
await process(data);
}
data、status、process——全是技术词,代码能跑,没毛病。
但半年后呢? 别人接手你的代码,看到 data 一脸懵:这是用户?是订单?是订阅?status === "active" 又是啥意思——账号激活?订阅生效?还是邮箱验证通过?他得把整个函数翻一遍才敢动。
更要命的是真实事故:我见过一个变量叫 activeUsers,团队都以为是"活跃用户",结果里面混着"被停用但还没删除"的用户,群发活动短信时全发出去了,被投诉一大片。名字没写错语法,但它误导了所有人。
高级工程师怎么写? 用「业务含义」命名——让名字直接说人话:
const subscription = await getSubscription(subscriptionId);
if (subscription.isBillable) { // isBillable = 这笔订阅能不能扣费
await chargeSubscription(subscription);
}
看出区别了吗?第二种代码自己会"说话":这是一笔订阅(不是模糊的 data),关键条件是能不能扣费(不是模糊的 status)。下一个人不用猜,扫一眼就懂。
打个比方:这就像你冰箱里的剩菜。贴个标签写"2 月 14 日·红烧肉·三天内吃完",跟只写个"食物",完全是两回事。后者你过两天就不敢吃了——只能扔,或者赌一把。
// 名字长一点没关系,关键是表达清楚业务规则
const usersEligibleForReactivation = await findUsersEligibleForReactivation();
// 一看就懂:符合"可重新激活"条件的用户
💡 别走极端:循环里的
i、临时小回调,简单点没问题。真正要起好名字的,是那些"搞错了会出业务事故"的概念。
📌 先看个场景
你的项目接了微信支付。回调里要拿用户信息,小白直接这么取:
const userName = response.data.user_name;
const isActive = response.data.status === "ACTIVE";
const plan = response.data.subscription.plan_name;
一开始只在一个文件里写。慢慢地,controller 写一遍、后台任务写一遍、埋点写一遍、前端看板又写一遍……半个项目都知道微信返回的字段长什么样了。
然后灾难来了:微信某次小版本更新,把 user_name 改成了 userName。你的项目里有十几处直接引用了这个字段,全挂。改不难,难的是把每一处都找出来,找漏一个就是线上 bug。
高级工程师怎么做? 在外部系统和你自己的代码之间,建一道「墙」,专业叫「适配层」:
// 所有外部数据,先经过这个"翻译官",转成我们自己说了算的结构
function mapWechatUser(res: WechatUserResponse): Customer {
return {
id: res.id,
name: res.user_name, // 对方改字段,只改这一行
isBillable: res.status === "ACTIVE",
planName: res.subscription?.plan_name ?? "免费版",
};
}
这道墙的意义是:让你的系统内部,永远只跟"自己定义的数据结构"打交道。微信怎么改字段,是墙外面的事,墙里面纹丝不动。
打个比方:这就像公司前台。所有外来快递、访客,都先到前台登记、换证,再进办公区。前台就是那道墙——外面再乱,进到里面都是统一规整的。如果没有前台,每个工位都直接对接外卖小哥和快递员,那才叫鸡飞狗跳。
外部世界(你管不着) 墙 内部世界(你说了算)
┌──────────────────────┐ ┌────────┐ ┌──────────────────────┐
│ 微信支付 / 支付宝 │ │ 适配层 │ │ 统一的 Customer 模型 │
│ 字段随版本乱改 │ → │ map() │ → │ 对方改名?只改墙这一处│
│ 第三方 SDK 响应 │ │ 一处 │ │ 其它地方全不动 │
└──────────────────────┘ └────────┘ └──────────────────────┘
💡 小项目不用过度设计。但只要一份外部数据被多个功能用到,就该给它建一道墙。
📌 先看个场景
有次前辈 review 我代码,说了句让我当时挺恼火的话:**"你这写法,让不可能的情况也能存在。"** 功能明明能跑啊!结果两周后真崩了。
问题出在我把用户对象的字段全写成了"可选":
type User = {
id?: string; // 问号 = 这个字段可能有,也可能没有
email?: string;
role?: string;
status?: string;
};
为啥全加问号?因为不加,TypeScript 就报错,加了就不报了,省事。但这不是省事,这是把问题往后拖。
结果整个项目到处都得提心吊胆地问:这用户有 ID 吗?有邮箱吗?能保存吗?能展示吗?——因为类型告诉所有人"这些都可能没有"。
真实事故:给一个"还没验证邮箱的用户"发了正式通知邮件;给一笔"从没扣过款的订单"执行了退款。因为代码假装"草稿用户"和"正式用户"是同一个东西。
高级工程师怎么做? 把不同的状态,老老实实分开建模:
// 草稿用户:还没存进数据库,所以根本没有 id
type DraftUser = {
email: string;
role: "admin" | "member";
};
// 已保存用户:一定有 id 和 status,不用再问"有没有"
type SavedUser = {
id: string;
email: string;
role: "admin" | "member";
status: "active" | "disabled";
};
更进一步,像"订单状态"这种,可能有:待支付、已支付、已发货、已退款……与其用一个模糊的 status 字符串,不如把每种状态能带什么数据都写清楚:
type Payment =
| { state: "pending"; id: string } // 待支付
| { state: "captured"; id: string; receiptId: string } // 已扣款,才有小票号
| { state: "failed"; id: string; reason: string }; // 失败,才有原因
// 发收据的函数,只接受"已扣款"的支付
// 如果你传个"待支付"进来,代码编译都过不了 —— 错误在写的时候就被拦住
function sendReceipt(payment: Extract<Payment, { state: "captured" }>) {
return emailReceipt(payment.receiptId);
}
打个比方:这就像考驾照。"学员"和"正式司机"是两种身份,不能混。让一个还没通过考试的学员单独上高速,本身就该是"系统不允许发生"的事,而不是"上了高速再去检查他有没有驾照"。
💡 这跟语言无关。用 JS 的话,靠校验、构造函数、schema 也能做到。核心纪律是:让错误用法在"写代码时"就暴露,而不是等到线上炸了才发现。
📌 先看个场景
做退款功能。小白通常把"判断能不能退"和"实际退款动作"焊在一起:
async function refundInvoice(invoiceId) {
const invoice = await getInvoice(invoiceId);
if (invoice.status !== "paid") thrownewError("未付款,不可退");
if (invoice.refundedAt) thrownewError("已经退过了");
if (invoice.amount <= 0) thrownewError("金额非法");
await paymentProvider.refund(invoice.paymentId); // 真正退款
await markInvoiceRefunded(invoice.id); // 改数据库
await sendRefundEmail(invoice.customerId); // 发邮件
}
能跑,也不算丑。但有个隐患:你想写个测试,验证"退款规则对不对",结果发现——为了测那几行 if,你得把支付接口、数据库、邮件服务全都假装(mock)一遍,太麻烦了。于是大家干脆不写测试。然后退款规则某天被改坏了,没人发现。
高级工程师怎么做? 把"判断"单独抽出来,做成一个只算结果、不碰外部世界的「纯函数」:
// 只负责判断:能不能退?为什么不能退? 不碰数据库、不发邮件
function getRefundEligibility(invoice: Invoice): RefundEligibility {
if (invoice.status !== "paid") return { allowed: false, reason: "未付款" };
if (invoice.refundedAt) return { allowed: false, reason: "已退款" };
if (invoice.amount <= 0) return { allowed: false, reason: "金额非法" };
return { allowed: true };
}
"动作"部分就变得很干净:
async function refundInvoice(invoiceId: string) {
const invoice = await getInvoice(invoiceId);
const eligibility = getRefundEligibility(invoice); // 先问:能退吗?
if (!eligibility.allowed) thrownew ValidationError(eligibility.reason);
await paymentProvider.refund(invoice.paymentId); // 能退,再动手
await markInvoiceRefunded(invoice.id);
await sendRefundEmail(invoice.customerId);
}
现在测"退款规则",直接喂几个假订单给 getRefundEligibility 就行,不用 mock 任何东西。
打个比方:这就像医院。医生(判断)先看检查报告决定"能不能做手术",护士(动作)再去执行。你想考核医生的判断水平,给他几份病例看就行,不用真把病人推进手术室。判断和动作搅在一起,等于让医生边判断边开刀,没法单独检验任何一环。
💡 别矫枉过正:三行就能搞定、又不会变的简单逻辑,不用硬拆。但凡这个判断关系到钱、关系到权限,就给它一个名字、一个测试。
📌 先看个场景
用户注册时邮箱重复了。小白的报错:
{ "message": "出错了" }
对谁有用? 用户一脸懵不知道哪错了;客服收到投诉不知道咋回事;半夜看日志的你,更是一头雾水。
更坑的是,前端为了区分错误,会去"猜"报错文字:
// 人质式代码:后端哪天把文案改成"邮箱已注册",这里立刻失效
if (error.message.includes("已存在")) {
showEmailTakenError();
}
这就埋了个雷——后端改个文案、翻译改个措辞,前端逻辑就崩了,而且崩得悄无声息。
高级工程师怎么做? 把错误当成「给系统看的信号」,而不只是"给人看的一句话":
{
"code": "USER_EMAIL_ALREADY_EXISTS", // 给程序判断用的错误码,永远不变
"message": "该邮箱已被注册", // 给人看的提示,随便改
"details": { "field": "email" }, // 错在哪个字段
"requestId": "req_8f91a2" // 追踪 ID,查日志一搜就到
}
前端只认 code,后端怎么改文案都不影响。日志里也带上结构化信息(但千万别记密码、token、身份证、银行卡这些敏感数据):
logger.warn("退款被拒", { invoiceId, customerId, reason: eligibility.reason, requestId });
打个比方:这就像看病。一句"我不舒服"医生没法治;但"我昨晚 8 点开始右下腹疼、伴随发烧、化验单编号 xxx",医生立刻能下手。好的报错,就是把"我不舒服"变成"可追踪的病历"。
💡 文案是给人看的,错误码是给系统看的。别指望一句模糊的人话,能同时喂饱用户、客服、日志和前端。
📌 先看个场景
你做完一个计费功能,本地跑得飞起,开心地提交代码,一个 PR(合并请求)里塞了一大堆改动:
反面教材的提交(活像一张"绑匪勒索信"):
feat: 更新计费流程
├─ 重构了 invoice 服务
├─ 重命名了支付字段
├─ 改了退款逻辑
├─ 顺手改了看板 UI
├─ 加了 webhook 处理
├─ 改了重试行为
├─ 又顺手修了个状态 bug
└─ 更新了测试
审查的同事:哪些是必须的?哪些是你顺手改的?bug 藏哪了?😵
问题在哪? 功能是好的,但这堆改动混在一起,审查的人根本看不清。bug 藏在一堆噪音里。更糟的是——万一这功能上线出问题要回滚,一撤销,把你顺手做的清理、改的 UI 全带走了。
高级工程师怎么做? 他们按「diff(改动差异)」来思考——一次只讲清楚一件事,拆成几个小 PR:
PR 1:重命名支付字段(不改任何行为)
PR 2:新增"退款资格判断" + 测试
PR 3:把退款判断接进计费流程
PR 4:看板 UI 展示退款原因
PR 5:加 webhook 重试
每个 PR 都在讲一个清楚的故事:改了什么、为什么改、行为有什么变化。审查快、出问题好定位、回滚也安全。
打个比方:这就像搬家。把书、衣服、厨具分箱打包、贴好标签,跟一股脑全塞进一个大箱子——搬的时候没差别,但找东西的时候,天壤之别。线上事故现场,一个小而清楚的改动是救命稻草;一个啥都改了的大改动,是灾难。
💡 记住:代码不是"在你电脑上跑通了"就算完成,而是"改动清楚、能审查、能测试、能安全撤回"才算完成。
咱们回头看这 7 条:提前返回、好命名、筑墙、状态建模、拆分判断、有用的报错、可审查的改动——表面五花八门,骨子里是同一个理念:
减少惊喜(Reduce surprise)。
高级工程师不是不会复杂的东西,而是他们明白一个道理:复杂度总得待在某个地方。 你要是不把它放进清晰的命名、明确的边界、诚实的类型、聚焦的函数里,它就会跑进每个人的脑子里——然后整个团队都得靠"记忆"和"猜"来干活。
那才是软件让人精疲力竭的根源。一个混乱的代码库,会让人改之前犹豫、疯狂手动测试(因为不信任系统)、在群里反复问同样的问题、绕着老代码走、照抄看不懂的写法、提个小改动却一身冷汗。
这不是因为他菜,而是代码库一直在让每一次改动都显得很冒险。
我共事过的最好的工程师,从不想证明自己多聪明。他们想的,是让下一次改动别那么蠢——而那个"下一个人",常常就是三个月后的自己。
所以别再以为高手赢在"会得多"。
好代码不是炫耀你懂多少,而是让下一个人少几个需要瞎猜的理由。
如果你刚入行,这 7 个习惯不用一次全学会。挑一个,从下一行代码开始用起来,半年后回头看,你会发现自己的代码"无聊"了很多——而这,正是变强的样子。