一、先把"高质量"定义清楚
优化写起来爽,是优化错了对象
成本大头在读和改,不在写这是整页的地基
一段业务代码,写它可能花两小时,之后三年里会被读几十遍(排查、接需求、新人上手)、改几次、在事故中被紧急阅读一次。
所以真正该优化的是阅读成本和修改风险,而不是写的时候顺不顺手。很多看起来"高级"的写法——把三步压成一行、用一个特别巧妙的技巧——优化的是写的那两个小时,代价是之后三年的每一次阅读。
工作里的样子
那行特别聪明的三元嵌套。 写的时候很爽,半年后你自己都要读三遍。
"这个我知道,当时是为了少写几行。" 少写的几行,换来了每次读都要重新理解一次。
新人上手一周。 这一周的成本,直接反映的就是代码的阅读成本。
半夜排查线上问题。 这是阅读成本最贵的时刻——人困、有压力、没时间。代码在这个时刻好不好读,决定故障多长时间恢复。
接手别人的模块。 你的第一感受就是那个人的代码质量,不需要任何指标。
写完一段代码,问一句:半年后的我,或者一个刚来的同事,看这段要多久? 这个问题比任何规范都能提高质量。
一次性脚本和支付链路不该同一个标准
质量是分级的,别平均用力有限的预算花在刀刃上
把所有代码都按最高标准写,是一种浪费,而且做不到。质量预算要按"改动频率 × 出错代价"来分配。
- 两头都高(核心业务、支付、鉴权、数据迁移):值得下最大功夫,测试、审查、防御全都上。 - 两头都低(一次性脚本、临时分析、内部小工具):能跑就行,别过度设计。 - 改动频繁但出错便宜(内部管理页面):重点是好改,不必追求滴水不漏。 - 很少改但出错极贵(加密、结算、权限):重点是正确性和可审查性,宁可写得笨一点、直白一点。
工作里的样子
跑一次的数据修复脚本。 别给它做配置化和插件机制,它明天就没用了。
支付回调。 这里多花三倍时间是值得的,出一次错的代价远超三倍。
内部后台的列表页。 好改比完美重要,需求每周都在变。
鉴权逻辑。 宁可写得又长又直白,让每个人一眼看懂——这里的"聪明"是负资产。
日志和监控代码。 常被当成二等公民,但它决定了出事时你能不能看见。
动手前花十秒判断:这段代码会被改多少次?错了有多贵? 两个问题的答案决定你该投入多少。这一步能省下大量在无关紧要处的纠结。
二、杠杆最高的几条
起不出名字是设计有问题的信号
命名:名字不对,通常说明理解不对最高杠杆的一件事
命名之所以是最高杠杆的事,不是因为"可读性"这种笼统的好处,而是因为:你能不能给一个东西起出准确的名字,直接反映了你有没有真正理解它。
起名困难通常不是词汇量问题,是这个东西本身职责不清。 一个函数叫 handleData、processInfo、doStuff,往往说明它干了不止一件事。这时候正确的动作不是想个更好的名字,是把它拆开。
- 说清楚是什么,不是怎么做
getUserById 好过 queryUserFromMysqlByPrimaryKey——实现会变,意图不会。- 布尔量用肯定式
isEnabled 好过 isNotDisabled。双重否定是理解成本的重灾区。- 带上单位和量纲
timeoutMs、sizeInBytes。省下的是一整类低级事故。- 别用会骗人的名字
- 叫
save 却顺手发了通知,这比名字难听危险得多。 - 统一同一概念的叫法
- 同一个东西在三个模块里叫三个名字,是理解成本的主要来源之一。
- 名字长度配得上作用域
- 循环里的
i 没问题,跨越两百行的变量不能叫 d。
工作里的样子
utils.js 和 common 包。 名字没有含义,于是什么都往里塞,最后成为垃圾场。名字模糊会招来更多混乱。
data、info、result、temp。 这几个词几乎不携带信息,看到它们通常可以想想更准的说法。
flag、status = 3。 三个月后没人记得 3 是什么。用枚举。
给函数起名时卡住了。 这是个好信号——卡住往往意味着它做了太多事。
改名字改出一堆冲突。 说明这个概念渗透太广,这本身就是设计信息。
每次起名卡壳,别硬凑,先问自己"这东西到底负责什么"。答不上来就是设计没想清楚——这时候改设计比改名字有用。
靠纪律不如靠结构
让非法状态无法表示防错的最优解
最有效的防错不是"记得检查",而是让错误的写法根本写不出来。靠人记得,总有忘的时候;靠结构约束,一次做对永远有效。
防止"未支付的订单被发货"
靠结构未支付的订单类型上就没有 ship() 方法
工作里的样子
必填参数用类型强制。 而不是进函数后一堆 if 判空。
用枚举代替字符串。 拼错字符串编译器不管,拼错枚举它当场就说。
不可变对象。 一个东西创建后不能改,就永远不用担心它在别处被悄悄改掉——这消灭的是一整类最难查的 bug。
把校验放在入口。 数据一进系统就规整成合法形态,里面的代码就不用到处防御。
别用同一个类型表达两种含义。 比如金额有时候是元有时候是分,这类事故的根源是类型上分不出来。
出了一个 bug,别只修它。问一句:能不能改一下结构,让这类错误以后写都写不出来? 能做到的话,你消灭的是一整类 bug,不是一个。
能推理的代码才可能正确
bug 密度正比于"这段代码能被多少东西影响"减少状态和依赖
一段代码为什么难改?因为你不知道改了会影响谁。而"能影响它的东西"越多——全局变量、共享状态、隐藏的副作用、跨层调用——你能确定的事情就越少。
一个函数如果只依赖参数、只返回结果、不碰外面的任何东西,那它的正确性是可以被完整推理和测试的。反过来,一个读三个全局变量、写两张表、发一条消息的函数,没有人能真正说清它对不对。
工作里的样子
全局配置到处读。 改一个配置,影响范围没人说得清。
函数里偷偷改了传进来的对象。 调用方完全预料不到,这类 bug 极难查。
一个方法既算数又存库还发通知。 想单独用它的计算逻辑?不行,一用就发通知。
把计算和副作用分开。 纯计算的部分抽出来,好测、好复用、好推理。剩下的读写外部的部分薄薄一层。
单例和静态状态。 测试时互相污染的主要来源——测试难写,常常是设计耦合的症状,不是测试工具的问题。
下次觉得一段代码"不好测",别急着找测试技巧,先怀疑是设计问题。难测几乎总是意味着依赖太多或职责不清。"好测"和"好改"高度重合,所以可测性是很好的质量代理指标。
暴露得越少,未来越自由
模块的价值在于藏住了什么边界
划分模块的目的不是"整理得好看",是让一部分决定可以在不惊动其他人的情况下被改掉。
所以衡量一个模块好不好,不看它提供了多少功能,看它藏住了多少东西:藏住了数据库表结构、藏住了第三方接口的怪癖、藏住了具体算法。藏得越多,将来能自由改动的空间越大。
反过来,一个把内部细节全暴露出去的模块,等于把自己焊死了——任何改动都会波及所有调用方。
工作里的样子
接口返回了数据库实体。 于是表结构变成了对外契约,以后想改表就得改所有调用方。
把第三方 SDK 的对象传得到处都是。 换供应商时才发现它渗透进了几十个文件。
一个"薄薄的一层"包装。 看起来多余,但换实现的时候救命。
跨模块直接读对方的数据库表。 短期最快,长期是最难拆的耦合。
内部方法设成 public"方便调用"。 一旦被人用了,它就变成了不能改的契约。
写一个模块时问:如果哪天要换掉里面的实现,有多少人会被影响? 答案越接近"没人",这个边界划得越好。
顺利的情况人人都试过
事故大多发生在错误路径上,而错误路径最少被测试被系统性忽视的一块
正常流程每天被跑几万次,有问题早就发现了。真正出事的是异常分支:超时、重试、部分失败、并发冲突、依赖挂了。而这些路径恰恰是最少被测试、最少被 review、写得最随意的。
- 别吞异常
catch 里什么都不做,是在把问题藏到更远的地方爆炸。- 错误信息要能定位
- "操作失败"帮不了任何人。要带上是什么操作、哪个对象、什么原因。
- 想清楚重试的后果
- 网络超时了但对方其实成功了,重试一次就是重复扣款。幂等不是可选项。
- 部分失败怎么办
- 三步操作做完两步挂了,系统处在什么状态?这个问题必须有答案。
- 超时必须设
- 没有超时的调用,会在下游变慢时拖垮整个上游。
- 降级路径也要测
- 从没被执行过的兜底代码,出事时大概率也是坏的。
工作里的样子
catch (e) {}。 这一行制造的排查难度,可能是整个项目里最高的。
重复下单、重复扣款。 几乎都来自重试路径没做幂等。
下游变慢导致自己也挂了。 没设超时 + 线程池被占满,是典型的雪崩链条。
日志里只有"error"没有上下文。 半夜排查时,这等于没有日志。
兜底逻辑写完从没跑过。 真到用的时候才发现它自己有 bug。
写完一个功能,专门花五分钟只看异常分支:每个可能失败的地方失败了会怎样、能不能重试、重试安全吗、失败信息够不够定位。这五分钟的性价比高于其他任何质量动作。
三、几个常被做反的
等到第三次再抽
过早抽象,比重复更糟错误的抽象比重复难拆得多
"不要重复自己"被念得太多,以至于很多人看到两处相似就急着抽象。但相似不等于相同——两段代码今天长得像,可能是巧合,明天就会因为不同的原因分头演化。
而一旦你把它们合并成一个带三个开关的通用函数,拆开的成本远高于当初重复的成本。经验做法是等到第三次:出现三次,你才真正看清哪部分是共性、哪部分是差异。
工作里的样子
带五个布尔参数的"通用"函数。 每次新需求就加一个开关,最后没人敢动。
为了复用而复用。 两个业务今天逻辑一样,合并之后其中一个改需求,于是加 if——从此开始腐烂。
"以后可能会用到"的配置项。 大部分永远没用到,但一直在增加理解成本。
过早拆微服务。 边界还没稳定就拆,结果是分布式的单体,同时承担两边的缺点。
复制粘贴其实常常是对的。 尤其在你还不确定两者会不会分头走的时候。保留重复,是在保留选择权。
看到重复先忍住,记一笔就好。等第三次出现时再抽象——那时候你对"什么是真正的共性"的判断,会准得多。
要问它搬到哪儿去了
复杂度守恒:它不会消失,只会转移警惕"这个方案消除了复杂度"
业务本身有多复杂,是需求决定的,代码消灭不了它。所有的框架、模式、工具,做的都是把复杂度从一个地方搬到另一个地方。
所以听到"用了这个方案就简单了",正确的反应不是高兴,是问一句:那些复杂度现在在哪? 通常在:配置文件里、运维那里、调试的时候、或者新人的学习曲线上。
工作里的样子
引入一个框架,业务代码变清爽了。 但出问题时你得读框架源码——复杂度搬到了排查环节。
"零配置"工具。 约定优于配置的意思是,复杂度搬进了你必须记住的约定里。
微服务拆分。 单个服务简单了,但你多了服务发现、分布式事务、链路追踪——复杂度搬到了运维和调试。
ORM。 写起来简单,代价是生成的 SQL 你不知道长什么样,性能问题排查更难。
加一层缓存。 读变快了,但你多了一致性问题——这是最常被低估的一次搬运。
评估任何方案时问:它把复杂度搬到哪儿了?搬过去的地方,我们扛得住吗? 有时候答案是"搬得好"(搬到我们擅长的地方),那就该做;但"消除了复杂度"这种说法,基本可以直接怀疑。
风格之争几乎不值得吵
团队里,一致性大于个人品味统一的次优 > 各自最优
一个项目里有三种错误处理方式、四种命名风格、五种目录组织,即使每一种单独看都不差,合起来的理解成本也远高于统一用一种平庸的做法。
因为读代码的人需要不断切换心智模型。这个成本是隐形的、持续的、且随人数增长。
而且风格之争(tab 还是空格、大括号换不换行)本身就消耗团队精力,而收益接近零——交给工具自动格式化,把争论时间省下来。
工作里的样子
格式化工具入 CI。 一次配置,永久消灭这类争论。
"我习惯这么写。" 在个人项目里没问题,在团队项目里是成本。
新人带来了新框架的写法。 不是不能引入,但要么统一切,要么明确划出边界,别两种风格长期共存。
同一个项目里三种日期处理方式。 这类不一致最容易导致真实 bug。
把能自动化的全部自动化(格式化、lint、import 排序),剩下的写成一页约定文档。争论只发生一次,之后按文档执行。这一页文档同时也是给 AI 用的——它和新同事一样需要显式的规矩。
四、几个工程习惯
没测试的代码会腐烂
测试真正的价值,是让你敢改不是为了抓 bug
测试常被理解成"防止 bug",这个理解太窄了。测试最大的价值是给你修改的勇气。
一个没有测试的模块,所有人都不敢碰。不敢碰就会用打补丁的方式绕开它,补丁越堆越多,最后彻底不可维护。代码腐烂的机制,本质是恐惧。
- 先测关键路径
- 覆盖率数字没意义,重要的是最贵的那条路径有没有被测到。
- 测行为不测实现
- 测"输入这个得到那个",别测内部调了哪个私有方法——否则重构就会误报。
- 一个测试测一件事
- 失败时才能立刻知道哪里坏了。
- 改老代码前先补测试
- 哪怕当前行为是错的。你要的是别改坏,不是证明它对。
- bug 修复配一个测试
- 这是最划算的一类测试——它防的是已经证明会发生的事。
- 别追求 100%
- 边际收益递减很快,而维护成本一直在。
工作里的样子
"这块代码没人敢动。" 几乎总是等价于"这块没有测试"。
重构时心里没底。 有测试的重构是工程,没测试的重构是赌博。
一改就崩的测试。 通常是测了实现细节,而不是行为。
测试跑五分钟。 太慢就没人跑,等于没有。速度本身是测试的质量属性。
判断一段代码质量,有个很准的问法:"你敢不敢现在就重构它?" 敢,说明质量不错;不敢,原因通常就是缺测试或依赖太乱。
大改动等于没审
小步提交,可回滚性是最被低估的质量属性出事时唯一真正管用的东西
线上出问题时,最有效的手段永远是回滚,不是现场调试。而能不能快速回滚,取决于你的改动是不是足够小、足够独立。
同时,大 diff 等于没有 review:一次改二十个文件,人是审不动的,审查会退化成"看着还行"。
工作里的样子
一个 PR 改了三个不相干的事。 想回滚其中一个,只能整个回。
重构和功能改动混在一起。 这是最难审的一类 PR——看不出哪些是行为变化,哪些只是搬家。 分开提。
上线前一天合并的大改动。 风险叠加在最不该叠加的时间点。
"顺手把格式也调了。" 于是真正的逻辑改动淹没在两千行格式变化里。
一条规矩:一个提交只做一件事,重构和功能改动永远分开提。 这一条同时改善了 review 质量、回滚能力和排查效率——性价比极高。
留着"以后可能用"的成本是持续的
删代码是被低估的技能最好的代码是没有代码
每一行代码都是负债:要被读、要被理解、要在升级依赖时被考虑、会在搜索时干扰你。没有代码是最没有 bug 的状态。
而大多数团队只会加不会删:功能下线了代码还留着,开关早就固定了分支还在,注释掉的代码放了三年——因为删除有风险而收益不明显,没人愿意做。
工作里的样子
注释掉的代码块。 版本控制就是干这个的,直接删。留着只会让人猜"是不是还要用"。
下线功能的残骸。 常常还在被定时任务调用,成为幽灵逻辑。
永远为 true 的开关。 每个读代码的人都要多想一次。
"以后可能用得上"。 大部分永远用不上,而理解成本天天在付。
依赖里没用的包。 增加构建时间、增加攻击面。
每次做需求时顺手删掉一点死代码。删除比新增更需要勇气,但它是少数能让系统真正变简单的动作。
它解释不了什么
这一页给不了"什么时候该妥协"的答案。 所有条目在现实里都会撞上工期、人手、历史包袱。判断什么时候该守、什么时候该让,靠的是经验,这一页替代不了。
不同领域的权重差别很大。 嵌入式、游戏、数据分析脚本、金融系统,对性能、正确性、可读性的取舍完全不同。这一页偏向的是长期演进的业务系统。
代码质量解决不了产品方向的问题。 把一个没人要的东西写得很漂亮,收益是零。"该不该做"永远排在"做得好不好"前面。
团队问题不能靠个人代码质量解决。 如果 review 流于形式、没人写测试、上线全靠人肉,个人再努力也扛不住。这些是流程和文化问题。
它也不解决"祖传屎山"。 面对一个几十万行的老系统,上面这些原则大部分用不上,你需要的是另一套东西:先加测试保护、小步改、别大重构。这一页没展开讲。
- 高质量的唯一有用定义:将来改它的时候,不容易出错。
- 起不出名字,通常不是词穷,是这个东西职责不清。
- 最好的防错不是记得检查,是让错的写法根本写不出来。
- 复杂度不会消失,只会转移——听到"这个方案更简单了",先问它搬到哪儿去了。
- 代码腐烂的机制本质是恐惧:没有测试 → 没人敢改 → 只能打补丁 → 更没人敢改。
这一页和《AI 写代码》是配套的:那一页说"你的收益取决于验证能力",而这一页讲的很多东西——小 diff、好测试、显式约定、清晰边界——恰好就是验证能力的组成部分。AI 写得越快,这些基本功越值钱,不是越不值钱。