
那么可修改是什么呢?
只是说让维护者能够比较方便的修改,而不会引发新的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 到处改?
如果会,说明缺少适配层。
第五问:改完之后怎么验证?
如果只能手点页面,说明核心规则不够可测试。
第六问:这个功能下线时,能不能删干净?
如果不能,说明它污染了主流程。
所以,"可修改"不是简单的"维护者方便改"。更深一点,它是:
代码结构和业务变化方向是对齐的。
业务怎么变,代码就在哪里边,业务不相关的地方,不应该被迫跟着变,变化产生的风险,应该被限制在一个可理解、可验证的范围内。
可修改的代码,不是让修改没有风险,而是让风险有边界。