可理解:代码的第一性原理

68 阅读9分钟

ChatGPT Image 2026年7月2日 18_03_34.png

可理解:代码的第一性原理

那么可理解就代表着,我们需要把代码写的语意化,或者注释充分,代码简单吗?

这些都对,但是这只是表象,我们这样做的原因是什么呢?

我们是为了自己或者其他人看到这段代码时,能够快速的构建正确的心智模型

也就是他能知道:

  • 这段代码在解决什么问题?
  • 它依赖什么输入?
  • 它会产生什么输出?
  • 它有哪些特殊分支?
  • 失败时会发什么?
  • 我改这里,会影响哪里?

可理解首先是“意图清楚”

比如这段代码:

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)

这就更强了:读者知道它不是最终下单,只是结算预览。

很多时候,好的命名是在帮读者区分概念:

cartItemorderItem是否不同?

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、跳转、写缓存
  • 业务规则重复:多个地方都有类似判断,但略有不同
  • 异常路径缺失:只考虑成功,不知道失败会怎么样
  • 注释解释语法,不解释业务原因
  • 文件没有主流程,全是细节堆叠
  • 抽象太浅:函数很多,但每个函数知识转发一行代码
  • 概念不统一:同一个东西在不同地方叫不同名字