让后续开发者更易修改代码的七个习惯
保护性条款、能体现设计意图的名称、API边界、精确的类型定义、纯粹的决策函数、结构化的错误处理以及有针对性的拉取请求,附JavaScript示例。
来源: 7 Coding Habits That Separate Senior Developers From the Rest
学习另一种框架最终并不会让你成为更出色的工程师。真正能带来回报的,是你编写和修改代码的方式:经验丰富的开发者通常会写出更少的代码,这些代码意图更为清晰,也更容易阅读、测试以及在需求变化时进行修改。本文将介绍七种具体的习惯,每种习惯都配有JavaScript或TypeScript的改前后的示例,并说明每一种习惯能消除的具体问题。
这些习惯都不需要新的工具。它们都是在编写函数、命名变量、建模数据或提交拉取请求时做出的决策。
1. 用保护性代码让正常流程一目了然
假设一个函数需要先查找用户,确认账户处于活跃状态,再确认用户具有相应权限,之后才能执行操作。最初的实现方式往往是把一个条件嵌套在另一个条件之中:
function performAction(userId) {
const user = findUser(userId);
if (user) {
if (user.isActive) {
if (user.hasPermission) {
return executeAction(user);
}
}
}
return null;
}
虽然代码可以运行,但真正重要的操作位于三层嵌套深处,而最底部的回退语句 return null 在视觉上与触发它的条件毫无关联。
另一种方法是通过保护性语句立即拒绝无效情况,这样每个不满足的前提条件都会单独占一行结束。(此代码片段中的缩进不一致;查找操作之后的四条语句应处于同一层级。)
function performAction(userId) {
const user = findUser(userId);
if (!user) return null;
if (!user.isActive) return null;
if (!user.hasPermission) return null;
return executeAction(user);
}
现在的主流程是直线式的:检查、检查、检查,然后执行操作。这并不意味着嵌套逻辑总是错误的。当分支代表真正不同的结果而非前提条件时,if/else 结构往往是最清晰的表达方式。其目的是避免将核心业务逻辑埋藏在那些仅起过滤作用的条件下。
习惯:先处理异常情况,让正常流程保持原样。调用该函数的人应能直接看到通常会发生什么,而无需追踪五层嵌套的大括号。另一个好处是,每个保护机制日后都可以返回更具体的结果或错误信息,这在嵌套版本中很难做到。
2. 用恰当的名称体现其含义
几乎没有哪种改动能像使用更好的名称那样轻松提升可读性。看看这两行代码:
const data = getOrder();
process(data);
data 是什么?process 对它做了什么?你必须去阅读其他代码才能弄清楚。对比一下:
const pendingOrder = getPendingOrder();
processOrder(pendingOrder);
现在同样的两行代码就能让你明白涉及的是哪种排序方式以及其处理结果。好的名称能减少理解代码库所需的心力,因为读者不必再从上下文中推断含义。
函数名也应得到同样的重视。像这样的通用动词会掩盖其实际功能:
handle(order);
而这种写法则能明确说明其功能:
calculateOrderTotal(order);
同样地,仅使用 send 这样简短的名称无法说明发送的内容及接收对象:
send(order);
而具有描述性的名称则能同时回答这两个问题:
sendOrderConfirmation(order);
目标并非使用冗长的名称,而是让名称能够体现功能意图。一个有益的副作用是,模糊的名称往往会导致函数功能也不明确。如果 handle 这个名称难以修改,很可能意味着该函数正在执行多项无关的功能,此时就该将其拆分。
习惯建议:在确定名称之前,先思考仅阅读该行内容且不查看其他任何信息的人是否能理解该值或函数的含义。如果不能,就继续寻找更合适的名称。
3. 将外部系统隔离在边界之后
应用程序需要与许多外部系统进行交互:支付服务提供商、电子邮件服务、云存储、身份认证服务、第三方API以及内部微服务。一个常见的错误就是直接将这些系统的响应格式引入业务代码中:
const customer = await stripeCustomer.get();
console.log(customer.email_address);
console.log(customer.customer_name);
现在,凡是读取 email_address 或 customer_name 的代码行都依赖于供应商指定的字段名称。如果供应商更改了字段名称或调整了响应结构,这种变化就会波及整个代码库。
解决办法是创建一个简单的边界函数,该函数负责调用外部服务,并将响应转换为应用程序所使用的模型格式:
async function getCustomer(customerId) {
const response = await externalService.getCustomer(customerId);
return {
email: response.email_address,
name: response.customer_name
};
}
其余代码则使用这种内部定义的格式,而无需了解供应商使用的字段命名方式:
const customer = await getCustomer(customerId);
console.log(customer.email);
console.log(customer.name);
当外部 API 发生变化时,只需更新 getCustomer 函数内的映射逻辑即可。同一边界也是验证响应、规范化缺失值以及在测试时用虚拟数据替代真实数据的理想位置。这正是大型架构中适配器与防污染层设计理念在单个函数层面的应用。
习惯养成:将外部复杂性限制在边缘部分。业务逻辑无需了解每个第三方服务的特殊之处,而随着集成数量的增加,这种分离机制的价值也会愈发凸显。
4. 使无效状态难以表示
一种常见的建模方式是将所有字段设为可选,因为这样既快捷又能确保代码始终能成功编译:
type Order = {
id?: string;
paymentId?: string;
status?: string;
};
在模型被使用的任何地方都会出现这种成本问题。每个使用者都必须进行防御性检查,因为没有任何机制能保证某个字段一定存在:
if (order.id) {
// ...
}
if (order.paymentId) {
// ...
}
if (order.status === "paid") {
// ...
}
更深层次的问题并非冗长性。类型本身并不能说明哪些字段组合是有效的。对于编译器而言,带有支付ID却没有对应订单ID的订单,或是状态为"paid"却没有任何支付记录的情况,都是合法的。
更精确的模型能够描述具体的状态:
type PaidOrder = {
id: string;
paymentId: string;
status: "paid";
};
类型为PaidOrder的值能够保证三点:
- 它具有订单ID
- 它具有支付ID
- 它的状态恰好为
"paid"
该类型现在同时兼具文档说明与编译时安全保障的功能。在实际应用中,你可以为每种状态定义一个类型,例如没有支付ID的待处理订单和带有支付ID的已支付订单,然后通过status将它们组合成区分联合类型。在执行如order.status === "paid"这样的检查后,TypeScript可以自动缩小类型范围,并在遇到未处理的新状态时发出警告。如需该模式的实际示例,请参阅用区分联合类型替代加载状态标志。
习惯:不要仅仅因为可选字段能让初始版本更易于编写就直接使用它们。先确定哪些状态才是真正可行的,然后根据这些状态来设计数据类型。这虽然无法消除所有错误,但能大大降低生成错误数据的概率。
5. 将决策与操作分开
当业务规则与副作用混在一起时,就很难进行测试了。以只有经过验证的成年用户才能使用的功能为例:
async function processUser(userId) {
const user = await database.getUser(userId);
if (user.isVerified && user.age >= 18) {
await database.enableFeature(userId);
await emailService.send(user.email);
}
}
该规则本身——即用户需经过验证且年龄至少为18岁——与数据库读取、数据库写入以及发送邮件操作混在了一起。要测试这条规则,就必须对这三种操作进行模拟。
应将决策提取为一个纯函数,该函数接收数据并返回布尔值:
function canUseFeature(user) {
return user.isVerified && user.age >= 18;
}
编排函数随后获取用户信息,提交规则判断,仅当判断通过时才会执行相关操作:
async function processUser(userId) {
const user = await database.getUser(userId);
if (!canUseFeature(user)) {
return;
}
await database.enableFeature(userId);
await emailService.send(user.email);
}
现在可以直接使用普通对象来测试该规则:
expect(
canUseFeature({
isVerified: true,
age: 25
})
).toBe(true);
无需数据库、邮件服务或模拟对象:仅有决策逻辑及其输入参数。这样一来,处理诸如未经验证的30岁用户或已验证但恰好18岁的用户等边缘情况就变得非常简单。
习惯建议:将“是否应该这么做?”与“如何去做?”分开处理。适合这种分离的场景包括:
- 访问权限检查
- 价格计算
- 输入验证
- 决定是否通知某人
- 重试策略
- 资格判定与功能开关规则
某条规则对业务的重要性越高,它就越能从结构简洁、纯粹且经过充分测试的函数中受益。
6. 让错误对人和软件都有用
像这样的错误几乎无法提供任何有用的信息:
throw new Error("Something went wrong");
它没有说明是什么出了问题、在何处、为何出错,以及调用方能否采取任何措施。
更好的错误处理方式会同时提供稳定的代码标识和易于理解的消息。示例中假设存在一个继承自 Error 的 AppError 类,用于存储这两类信息;该类并非内置类。
throw new AppError(
"PAYMENT_FAILED",
"The payment could not be completed."
);
当被序列化为 API 响应时,它能为客户端提供可预测的结果:
{
"code": "PAYMENT_FAILED",
"message": "The payment could not be completed."
}
需要牢记的区别是:消息是为人类编写的,而代码则是为系统编写的。消息可以重新措辞或翻译,但代码值应保持稳定,以便客户端能够安全地基于其进行分支处理。例如,前端就可以针对特定的错误做出响应:
if (error.code === "PAYMENT_FAILED") {
showPaymentError();
}
日志也应得到同样的处理。在凌晨3点时,仅有一条简单的消息很难据此采取行动:
console.log("Payment failed");
结构化的上下文能让日志条目更易于搜索和诊断:
logger.error("Payment failed", {
orderId,
provider,
attempt
});
要谨慎决定将哪些内容放入该上下文中。绝不要记录:
- 密码
- 访问令牌
- API密钥
- 其他机密信息
- 敏感的个人数据
如果需要HTTP错误响应的标准格式,可以参考RFC 9457中的问题描述。
这个习惯:为每一次失败设定一种既便于人类理解又能让程序处理的格式。抛出错误只是容易的部分,真正的挑战在于让失败情况可预测且能快速诊断。
7. 保持每次变更的专注性
最后一个习惯与修改代码而非编写代码有关。想象一个包含以下内容的单一拉取请求:
- 一个新的结账功能
- 数据库迁移
- 支付服务的重构
- 前端更新
- 重试逻辑的修改
- 几项无关的错误修复
即便所有内容都能正常运行,也很难进行有效审查。审查者只能粗略浏览,风险会隐藏在大量信息中,而如果生产环境出现故障,要回滚某一部分就意味着必须回滚所有内容。
若将变更拆分成专注的独立部分,同样的工作看起来就会是这样:
PR #1 → Add checkout validation
PR #2 → Refactor payment service
PR #3 → Update checkout UI
PR #4 → Improve payment retry logic
每项变更都更易于:
- 进行审查
有针对性的修改也有助于协作:当出现回归问题时,需要排查的范围很小,而且像 git bisect 这样的工具能够定位到本身就具有意义的修改。
习惯要点:在将另一个修改合并到当前任务之前,先判断它是否属于同一个问题。如果不属于,则应为其创建独立的提交或拉取请求。相比那些虽然通过了测试但规模庞大的修改,有针对性的修改更值得信赖。
共同目标:让后续修改更加容易
这些习惯乍看之下似乎毫无关联,但实际上都服务于同一个目标。优秀的工程实践不仅在于让今天的代码正常运行,更在于让明天的修改更加轻松。每一个习惯都在为此贡献力量:
- 清晰的成功路径能让逻辑更易于理解
- 有意义的名称能明确功能意图
- 对外部系统的边界限制可控制其变更带来的影响
- 精确的类型能避免整类错误的出现
- 将决策与操作分开可使业务规则便于测试
- 结构化的错误信息有助于故障诊断
- 有针对性的修改能让代码库更易于演进
框架和库会不断更迭,但这些原则在 JavaScript、TypeScript、Python、Java、Go 或其他任何语言中都同样适用。
核心要点
- 完成函数、模型、集成或拉取请求后,不仅要确认其是否正常工作,还要确保后续开发者能够安全地对其进行修改。
- 保护性条款、精确的名称以及准确的类型能减少读者需要记忆的信息量。