可理解:代码的第一性原理
那么可理解就代表着,我们需要把代码写的语意化,或者注释充分,代码简单吗?
这些都对,但是这只是表象,我们这样做的原因是什么呢?
我们是为了自己或者其他人看到这段代码时,能够快速的构建正确的心智模型
也就是他能知道:
- 这段代码在解决什么问题?
- 它依赖什么输入?
- 它会产生什么输出?
- 它有哪些特殊分支?
- 失败时会发什么?
- 我改这里,会影响哪里?
可理解首先是“意图清楚”
比如这段代码:
const result = list.filter(item => item.status === 1 && item.type !== 3)
能看懂语法,但是不理解业务。
稍微好一点:
const availableItems = list.filter(
item => item.status === ItemStatus.Available && item.type !== ItemType.Deprecated,
)
这已经语意化了。
但是更好的肯呢个是:
const purchasableItems = getPurchasableItems(list)
因为它直接告诉读者:这里关心的不是 status 和 type,而是“哪些商品可购买”。
所以可理解代码的第一层是:代码表达业务意图,而不是暴露实现细节。
很多代码难懂,不是因为写的复杂,而是因为它一直在展示“怎么做”。却不告诉你“为什么这么做”
好名字不是长,而是能减少猜测
命名不是越详细越好,而是越能减少误解越好。
比如:
const data = await getData()
这基本没有信息量。
const cart = await getCart()
好一点。
const cartSnapshot = await getCartSnapshot()
更明确:这个是某个时刻的购物车快照,不一定是实时可变状态。
const checkoutPreview = await getCheckoutPreview(selectedItems)
这就更强了:读者知道它不是最终下单,只是结算预览。
很多时候,好的命名是在帮读者区分概念:
cartItem 和 orderItem是否不同?
price 是原价、现价、支付价、优惠后价格、还是展示价?
count 是总数、可购买数、接口返回数,还是分页总数?
status 是订单状态、支付状态、审核状态,还是UI状态?
这些命名不清楚,后面一定会出bug。
结构感:主流程像目录,细节像章节
结构感比局部语法更重要。
比如一个 loader/action/page 里混着:
- 解析参数
- 权限验证
- 请求接口
- 处理异常
- 组装UI数据
- 埋点
- 重定向
- 返回响应
哪怕每一行都不难,整体也很难理解,因为读者不知道主流程在哪里。
更可理解的写法是让代码呈现出阶段:
export async function loader({ request }: Route.LoaderArgs) {
try {
const query = parseSearchQuery(request)
const user = await requireUser(request)
const videos = await getSearchVideos(query, {
userId: user.id,
})
const searchResult = toSearchResult(videos)
return json({
query,
searchResult,
})
} catch (error) {
throwSearchLoaderError(error)
}
}
这里 reader 一眼就能看出主干:
解析参数 → 获取用户 → 查询结果 → 组装页面数据
细节被下沉了。不是为了炫技抽函数,而是为了让主流程可见。
我觉得可理解代码有一个很重要的特征:
主流程像目录,细节像章节。
读者先能看懂目录,再决定要不要点进去看章节。
警惕浅模块陷阱
顺带一提:如果过度追求代码结构化,会产生很多浅模块。
浅模块并不是所有都是坏的,也不是所有的浅模块都应该被反对。问题不在于“浅不浅”,而在于它有没有提供认知价值。
比如:
const query = parseSearchQuery(request)
它可能内部只是:
function parseSearchQuery(request: Request) {
const url = new URL(request.url)
const searchParams = new URL(request.url).searchParams
const queries = coerceByZodSchema(
searchParams,
z.object({
from: z.string().catch(VisionFromEnum.OTHER),
keyword: z.string().optional().catch(undefined),
clsIds: z.array(z.string()).optional().catch([]),
}),
)
return queries
}
从实现看不复杂,甚至很浅。
但是它有价值,因为它把这件事命名成了一个业务阶段:
这不是随便读取URL,而是在解析“搜索页查询条件”。
它还隐藏了几个细节:
URLSearchParams 怎么转对象;
schema 怎么校验;
默认值怎么处理;
参数错误怎么处理;
以后参数规则变化时该哪里。
所以 parseSearchQuery 虽然浅,但是不是坏抽象。
相反,这种封装就比较可疑:
const data = await fetchData(query)
里面只是:
fucntion fetchData(query) {
return getSearchVideo(query)
}
这种就没有新的概念,只是把 getSearchVideo改名叫fetchData。
读者点进去发现还是一行转发,就会烦。
这就是浅接口陷阱:
多了一层跳转,但没有多一层语义
简单 ≠ 代码少,而是概念少
很多人误解简单,以为简单就是行数少。
比如:
const total = items.reduce((sum, item) => sum + (item.checked ? item.price * item.count : 0), 0)
这种代码不多,但是它混了很多概念:
选中状态、价格、数量、求和、结算规则。
如果业务复杂一点,可能更应该写成:
const selectedItems = getSelectedItems(items)
const totalPrice = calculateTotalPrice(selectedItems)
行数变多了,但是概念更清楚了。
所以真正的简单是:
同一时间只让读者处理少量概念。
可理解代码不是“短”,而是“认知负担低”。
注释要解释 “为什么”,而不是 “是什么”
注释最不应该做的是重复代码:
// 设置 page 为 1
page = 1
这种注释没意义。
有价值的注释通常解释:
为什么不是另一种做法?
为什么这里有特殊逻辑?
为什么这个bug不能删?
这个历史兼容来自哪里?
比如:
// 这里不能直接使用接口返回的 total。
// AI 推荐会在经典搜索结果返回后异步追加,分页总数需要在客户端二次合并计算。
const totalPage = calculateMergedTotalPage(classicTotal, aiTotal)
这种注释很有价值,因为它解释了代码背后的业务约束。
尤其是生产代码里,我觉得最值得写注释的是这几类:
- 历史兼容逻辑
- 异常分支
- 和产品口径有关的规则;
- 看起来“多余”但不能删的代码
- 性能/缓存/SSR/浏览器兼容相关的特殊处理。
注释不是给“现在的我”看的,是给“三个月后忘了上下文的我”看的。
层级清晰,职责单一
前端里最容易难懂的代码,就是层级混乱。
比如一个组件里同时有:
UI 展示
请求接口
权限判断
错误 toast
埋点
URL 参数同步
业务状态计算
缓存更新
这时候组件就变成了“局部粪山”。
更好的结构是分层:
Route/Page 层:拿 URL、loader/action、页面级编排
Domain Hook 层:处理业务状态和业务动作
Service/API 层: 处理接口调用和错误转换
Component 层:展示 UI、触发事件
Utils 层: 纯计算
当然不是每个需求都要这么分。小需求可以简单写,但当一个文件开始变得难度时,就要问:
这里是不是混入太多不同层级的东西?
比如 React 组件里最危险的不是代码多,而是他同时承担太多角色:
它既是页面,
又是状态机,
又是接口适配器,
又是权限系统,
又是埋点系统,
又是异常处理中心。
这种代码很难理解,也很难测试。
副作用要显眼
一个函数最好让人知道:
- 它是否有副作用?
- 它是否会请求接口?
- 它是否会throw?
- 它是否会修改外部状态?
- 它是否会触发toast?
- 它是否会跳转页面?
比如:
validateCart(items)
这个名字让人以为只是校验。
但如果它内部会:
toast.error(...)
navigate('/cart')
throw new Error(...)
那就很可怕。因为你根本无法在函数语意上获得这些信息。
更清楚的命名可能是:
assertCartPurchasable(items)
表示可能会 thorw。
或者:
checkCartPurchasable(items)
返回结果,不产生副作用。
handleCartValidationFailure(error)
专门负责 toast 和跳转。
可理解代码很重要的一点是:副作用要显眼。
纯计算可以搞得深一点:
副作用代码要让人一眼知道它正在发生。
类型即文档
我们使用TypeScript,其实类型不只是防bug,它也是文档。
比如:
function submitOrder(data: any) {}
读者完全不知道 data 是什么。
更好的:
type CheckoutDraft = {
selectedItemIds: string[]
couponId?: string
invoice?: InvoiceInfo
}
function submitOrder(draft: CheckoutDraft) {}
类型把业务概念显式化了。
更进一步,有些状态最好不要用几个boolean表示:
const isLoading = false
const isError = false
const isSuccess = false
如果状态复杂,可以变成联合类型:
type CheckoutState =
| { type: 'idle' }
| { type: 'loading' }
| { type: 'failed', reason: CheckoutError }
| { type: 'ready', preview: CheckoutPreview }
异常路径也要可读
可理解的代码应该让“异常路径”一览无余。
太多生产事故源于主流程光鲜亮丽,异常路径却七零八落。读者看不到异常,就意味着异常发生时,系统处于失控状态。
比如:
try {
await api.postPayment(data)
toast.success('购买成功')
} catch {
toast.error('服务异常')
}
这看似简单,但读者不知道:
额度不足怎么办?
未登录怎么办?
商品失效怎么办?
接口超时怎么办?
可理解的代码不一定要处理所有异常,但至少应该让异常分类清晰:
switch (error.type) {
case 'UNAUTHORIZED':
return redirectToLogin()
case 'QUOTA_NOT_ENOUGH':
return showQuotaError()
case 'ITEM_UNAVAILABLE':
return showUnavialableItems(error.items)
default:
return showUnexpectedError()
这类代码虽然长一点,但它把业务边界讲清楚了。
尤其随着大家开发AI coding的量越来越大,异常路径的可理解性就特别重要。很多线上 bug 不是主流程错,而是异常路径没人看懂、没人覆盖。
检验标准:新人能不能安全改
有一个很现实的标准判断:
一个不熟悉这块业务的人,能不能在30分钟内知道该改哪里、风险在哪里、怎么验证?
如果不能,大概率就是不可理解。
不可理解的代码通常有这些特征:
- 名字很抽象:
handle,process,data,temp,flag - 状态分散:URL、React state、 ref、storage、接口缓存各管一部分
- 副作用隐藏:一个看似普通函数里发送请求、toast、跳转、写缓存
- 业务规则重复:多个地方都有类似判断,但略有不同
- 异常路径缺失:只考虑成功,不知道失败会怎么样
- 注释解释语法,不解释业务原因
- 文件没有主流程,全是细节堆叠
- 抽象太浅:函数很多,但每个函数知识转发一行代码
- 概念不统一:同一个东西在不同地方叫不同名字