可修改:代码的生存底线

那么可修改是什么呢?

只是说让维护者能够比较方便的修改,而不会引发新的bug吗?

感觉差不多是这个感觉,不过要准确描述它的话,应该是:

可修改的代码,是需求变化时,修改点集中、影响范围可预期、验证路径清楚的代码

它不只是"改起来方便",并且还是让维护者知道:

  • 我要改哪里
  • 哪些地方改可能受影响
  • 哪些地方不应该受影响
  • 改完后应该怎么验证
  • 如果出问题,问题大概率在哪里

一个变化原因,对应一个主要修改点

这里我拿一个现实的例子举例:

我负责开发和维护的软件代码是在一个 monorepo 仓库中的,它的架构大概如下

erlang 复制代码
repo
	apps
        remix-video
        remix-music
        next-foto
        ...
	packages
        ui
        utils
        ...

之前出问题的购物车在每个项目都有一份,这意味着,针对产品的一次较大的迭代,我需要更改很多项目,并且每个项目我需要测试,原因是,每个项目还存在不同的架构,部分用的是 Remix,部分用的是 next.js,因此我实现的代码也不同,需要针对不同的项目数据请求方式来完成功能。

这真是难以维护,不管是测试还是实现,加入AI coding之后,我每天都需要花大量的时间进行code review,这个也是之前那个初级问题出现的原因之一。到处都在改,改视频,改图片,测试也不知道怎么测,只能每个项目都去跑,每个项目都反复的测试,心智负担极大。

后来我把购物车相关的业务逻辑抽到了 packages/feat/cart 里,统一了数据请求层和状态管理,每个 app 只需要接入这个包,并实现自己框架层的 adapter 即可。从那以后,一次迭代只需要改一个地方,测试也只需要测这个包的变更,再也不用在四个项目里反复折腾了。

这就是可修改性的第一原则:

一个变化原因,最好对应一个主要修改点

不是说永远只改一个文件,而是修改应该有"主战场"。

如果每次需求变化都要全局搜索,到处修改,就说明边界不对。

可修改,是"改动路径"短,不是代码行数少

什么是"改动路径"?

它指的是:接到需求 → 找到修改点 → 完成修改 → 验证修改 → 确认没有影响到其他地方。这几个步骤越少、越清晰,代码的可修改性就越高。

有些代码看起来很简洁:

ini 复制代码
const visibleItems = items
	.filter(item => item.status === 1)
	.filter(item => item.count > 0)
	.filter(item => !item.disabled)

但是当规则发生变化时,比如"AI生成的素材虽然disabled,但仍要展示,只是不能购买",后续维护者就需要猜:

disabled在这里是什么意思?

它是UI禁用,还是业务不可用?

其他地方有没有也判断disabled

展示规则和购买规则是不是混在一起了?

item.status 到底代表是素材的什么状态,为什么需要 item.status === 1

更可修改的代码可能是:

ini 复制代码
const visibleItems = filterSearchVisibleItems(items)
const purchasableItems = filterPurchasableItems(items)

这不是为了多抽函数,而是为了把两个变化方向分开:

  • 现实规则会变
  • 购买规则会变

它们不应该共用一个模糊的 disabled 判断。

所以可修改的代码,往往不是行数最少的代码,而是让"从需求到改动完成"的路径最短的代码。

概念混乱的代码,没人敢改

很多代码难改,不是实现复杂,而是概念混乱。

比如一个字段叫:

lua 复制代码
status:
|'unpaid'
|'paid'
|'pay_refunded'
|'order_expired'
|'order_available'
|'order_not_allowed'
|'product_taken_off'
|'product_sold_out'

但它同时代表:

  • 订单是否可以支付
  • 订单是否已被支付
  • 商品是否变动
  • 商品是否有库存

那以后每次改状态逻辑都会很痛苦。

更可修改的设计是应该把概念拆开:

arduino 复制代码
paymentStatus: 'unpaid' | 'paid' | 'refunded'
orderStatus: 'not_found' | 'expired' | 'not_allowed'
productStatus: 'taken_off' | 'sold_out'

虽然字段变多了,但是修改更安全。

因为维护者知道:

我要改能够购买,看 paymentStatus

我需要了解订单的状态,看 orderStatus

我需要了解商品状态是否变化,看 productStatus

所以可修改性非常依赖"概念建模"。

概念越混乱,修改越困难。概念越清晰,修改越局部。

顺带一提,我们在可理解的章节里,提到了我们不应该弄很多状态,我当时提出了这样的例子

ini 复制代码
const isError = false
const isLoading = false
const isSuccess = false

这里我建议用这种方式来替代它

bash 复制代码
type CheckoutState = 
| { type: 'idle' }
| { type: 'loading' }
| { type: 'failed', reason: CheckoutError }
| { type: 'ready', preview: CheckoutPreview }

那么具体什么时候需要分出新的状态?

这里需要从业务上来看,这些业务状态是否处在同一变化维度,如果处在同一变化维度,那么它们适合合并成一个状态机,不同变化维度上的状态,应该分化

比如"加载中、加载成功、加载失败"这三个状态,描述的是同一件事"数据加载状态"的不同阶段,它们处在同一变化维度,适合合并成状态机。

但"是否登录"和"购物车是否有商品"是两个完全不同维度的事情,不应该被塞进同一个状态字段里。前者应该由 auth 模块管理,后者由 cart 模块管理。

警惕"隐形耦合"

隐形耦合是最容易制造新 bug 的东西。

比如下面这几行代码,单独看每一行都是对的:

scss 复制代码
setSelectedItems([])
setCoupon(undefined)
setTotalPrice(0)

但如果它们之间有隐含关系:

  • 选择项为空时,优惠券必须清空?
  • 优惠券清空后,总价必须重算?
  • 总价变化后,埋点要上报?
  • 某些商品不参与优惠?

那以后维护者可能只改了 selectedItems,忘了同步其他状态。

更好的方式时吧这些关系集中起来:

bash 复制代码
dispatch({ type: 'CLEAR_CART_SELECTION' })

或者:

ini 复制代码
const nextCartState = clearCartSelection(cartState)

这样 "清空选中项" 这个动作背后的连锁规则被封装起来了。

可修改性很重要的一点是:

不要让维护者记住一堆隐形耦合,让代码结构替他记住

新增分支,别污染主流程

很多业务已开始简单,后来逐渐变成:

  • 普通用户怎么走
  • 会员用户怎么走
  • 未登录用户怎么走
  • 个人/企业/企业PLUS 授权怎么走
  • AI生成内容怎么走
  • 历史订单怎么兼容

不可修改的代码会变成一堆:

scss 复制代码
if(...) {
	...
} else if(...) {
	...
} else if(...) {
	...
}

每加一个分支,主流程更难懂,也更容易影响老逻辑。

更可修改的方式是把变化点隔离出来:

ini 复制代码
const checkoutPolicy = getCheckoutPolicy({
 user,
 licenseType,
 itemType,
})

const result = checkoutPolicy.validate(selectedItems)

或者简单一点:

ini 复制代码
const validationResult = validateCheckout({
	selectedItems,
	user,
	licenseType
})

关键不在于用了什么设计模式,而是:

新增规则是,不要把主流程搅浑。

主流程应该保持稳定,变化规则应该进入专门的位置。

为"会变的东西"留个插口

比如支付。

一开始你可能只有支付宝,后来加微信支付,再后来加优惠劵、余额、企业月结。

如果一开始就这样写:

scss 复制代码
if(payType === 'alipay') {
	callAlipay()
} else if(payType === 'wechat') {
	callWechat()
}

小项目没问题。

但如果支付相关逻辑散在页面、订单、按钮、弹窗、回调里,后面就难改。

更可修改的方式是让支付方式成为可替换点:

scss 复制代码
const payment = getPaymentProvider(payType)
await payment.createPayment(order)

外部只关心"创建支付",不关心支付宝和微信细节。

可修改性不是一上来就搞复杂,而是要识别:

这个地方未来是不是大概率会长出第二种、第三种实现?

如果是,就要给她留一个"插口"。

容易被忽略的一点:可删除

这个点很多人忽略。

可修改不仅仅是功能方便新增,也包括功能删除。

比如一个活动需求、灰度逻辑、临时兼容,如果散得到处都是,后面没人敢删。

更好的方式是:

scss 复制代码
if(isEnabled('newCheckoutFlow')) {
	return renderNewCheckout()
}

return renderLegacyCheckout()

或者把活动逻辑集中在:

scss 复制代码
applyCampaignDiscount(cart)

以后活动结束,只需要删除一个模块或一个策略。

所以可修改代码还有个很实用的标准:

这个需求下线时,我能不能干净的删除它?

如果不能,说明它已经污染了主流程。

改完了,怎么验证?

代码应该是靠结构让人容易验证。

比如我改了 calculateTotalPrice ,如果它是纯函数:

scss 复制代码
calculateTotalPrice(items, coupon)

那你可以很容易写测试:

scss 复制代码
expect(calculateTotalPrice(items, coupon)).toBe(100)

但如果它内部还会读 URL、读 localStorage、发请求、改 React state、toast,那就很难验证。

所以越核心的业务逻辑,越应该尽量变成:

  • 输入明确
  • 输出明确
  • 副作用少
  • 可单独测试

可修改性和可测试性其实是一体的。

不能验证的修改,维护者只能靠祈祷。

稳定的直接写,会变化的要隔离

我们常常一听到可修改,就会过度设计:

接口、工厂、策略、适配器、领域服务一把梭。

但过度抽象会让代码更难改,因为代码维护者要跳很多层才能知道发生了什么。

比较好的原则是:

稳定的东西直接写。变化频繁的东西隔离。

比如一个按钮的 className,不需要抽象。

一个只用一次的静态文案,也不需要抽象。

一个确定不会复用的页面布局,也不一定要拆太细。

但下面这些东西通常值得隔离:

  • 价格计算
  • 权限判断
  • 订单状态流转
  • 搜索参数解析
  • 错误码转换
  • 支付方式差异
  • 业务埋点口径
  • AI 返回结构解析

因为它们要么复杂,要么会变,要么出错代价高。

前端代码的可修改结构长什么样

拿我们前端经常做的业务页面来说,一个比较可修改的结构通常是:

bash 复制代码
route/page
	负责 URL、loader/action、页面编排
	
domain/use-cases
	负责业务动作,比如 submitCheckout、loadSearchPage
	
service/api
	负责接口调用、错误转换、数据适配
	
model/policy
	负责业务规则,比如 canPurchase、calculatePrice
	
component
	负责展示和触发事件

我用常见的 remix 框架来举例:

typescript 复制代码
export const loader = async ({ params, request }: Route.LoaderArgs) => {
  const uid = requireUid(params.id)

  const searchParams = new URL(request.url).searchParams

  const queries = parseURLSearchParams(searchParams)

  try {
    const works = await api.getWorks({
      queries: {
        uid: uid.toString(),
        page: queries.page,
        size: queries.size,
        keyword: queries.keyword,
      },
    })

    return data({
      works,
      queries,
    })
  } catch (error) {
    if (api.getWorks.isError(error)) {
      if (error.response.data.code === 'SUB_ACCT_NO_ACCESS') {
        throw data(null, { status: 401 })
      }
      if (error.response.data.code === 'PARAM_INVALID') {
        throw data(null, { status: 400 })
      }
    }
    throw error
  }
}

export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
  if (isRouteErrorResponse(error)) {
    const status = error.status

    switch (status) {
      case 400:
        return <ServerErrorFallback />

      case 401:
        return <AuthForbiddenFallback />

      default:
        return <UnknowErrorFallback />
    }
  }

  return <UnknowErrorFallback />
}

export default function Index({ loaderData }: Route.ComponentProps) {
  const { works, queries } = loaderData

  const [, setSearchParams] = useSearchParams()

  const onSearchFormSubmit = (keyword?: string) => {
    setSearchParams((prev) => {
      if (keyword) {
        prev.set('keyword', keyword)
      } else {
        prev.delete('keyword')
      }

      return prev
    })
  }

  const onPaginationChange = (page: number) => {
    setSearchParams((prev) => {
      prev.set('page', page.toString())
      return prev
    })
  }

  return (
    <>
      <SearchForm
        key={queries.keyword}
        defaultValue={queries.keyword}
        onSubmit={onSearchFormSubmit}
      />

      <div className="flex flex-col w-full">
        <section className="flex flex-col gap-5">
          <div className="flex gap-2 items-center font-medium text-2xl">
            <span className="text-white">全部作品</span>
            <span className="text-neutral-50">{works.page.total}</span>
          </div>

          {works.data.length === 0 && <fallback.Empty colorScheme="white" />}

          {works.data.length > 0 && (
            <div className="grid grid-cols-min-[320px] gap-x-6 gap-y-8">
              {works.data.map((item) => (
                <Card key={item.id} item={item} />
              ))}
            </div>
          )}
        </section>

        <Pagination
          colorScheme="dark"
          limitPage={60}
          total={works.page.total}
          page={works.page.page}
          size={works.page.size}
          onChange={onPaginationChange}
        />
      </div>
    </>
  )
}

这不是固定模版,而是一种思路:

  • UI改动不要影响业务规则
  • 接口字段变化不要影响所有组件
  • 业务规则变化不要散落在UI里
  • 错误处理不要散落在每个按钮里

可修改的前端代码,不是"组件拆得多",而是"变化类型分得清"

可修改的六个检验问题

在以后的code review 或自测时,可以问:

第一问:这个需求变化时,主要改哪里?

如果答不上来,说明边界不清晰。

第二问:这个修改会修改哪些地方?

如果只能说"不知道,得全局搜",说明耦合太隐蔽

第三问:新增一个类似规则时,是加一个分支,还是改一堆旧逻辑?

如果总是在旧逻辑里硬塞,后面会越来越危险。

第四问:接口字段变了,会不会传染到 UI 到处改?

如果会,说明缺少适配层。

第五问:改完之后怎么验证?

如果只能手点页面,说明核心规则不够可测试。

第六问:这个功能下线时,能不能删干净?

如果不能,说明它污染了主流程。

所以,"可修改"不是简单的"维护者方便改"。更深一点,它是:

代码结构和业务变化方向是对齐的。

业务怎么变,代码就在哪里边,业务不相关的地方,不应该被迫跟着变,变化产生的风险,应该被限制在一个可理解、可验证的范围内。

可修改的代码,不是让修改没有风险,而是让风险有边界。

相关推荐
没落英雄2 小时前
3. DeepAgents 实战 - Memory Skills 与上下文工程
前端·人工智能·架构
Bigger2 小时前
对不起!我错怪你了,UnoCSS:一次和 AI 一起排查 DevTools 卡顿的经历
前端·css·人工智能
laowang3572 小时前
依赖故障时,日志体系需要埋哪些关键节点才能快速定位是安装失败、解析失败还是运行时缺失?
前端·javascript·vue.js·webpack·node.js
倾颜3 小时前
AI Mind 的单线程短期记忆设计:如何压缩、整理并控制当前会话上下文
前端·langchain·llm
u0143781083 小时前
行内块元素之间的空白
前端·html
Lsx_3 小时前
基于 Claude Code 的 AI 编程工作流探索
前端·ai编程·claude
yingyima3 小时前
Nginx 配置速查手册:核心语法与实战案例
前端
梨子同志3 小时前
开发工具
前端·后端
像我这样帅的人丶你还3 小时前
Java 后端详解(六):Maven 与本地开发指南
前端·后端