
为什么改一个 React 功能,要翻遍整个项目?
为什么改一个 React 功能,要翻遍整个项目?
去年我接手一个生鲜电商项目,主要功能就是买菜下单(下面就叫它「买菜 App」,具体是哪家不重要)。
第一天翻代码,我心里还挺美的:目录干净得像样板间。组件全在 components,Hook 全在 hooks,请求全在 services,工具函数全在 utils。每个文件都待在「它该在的地方」,一眼就知道是什么类型。
我当时想:接手个这么规整的项目,稳了。
结果三个月后,我逢人就吐槽这套目录。倒不是它乱——它一点都不乱,是它规整得毫无用处。这三个月里,产品经理提了一连串需求,每一个都让我把整个项目从头翻到尾。这篇,我就把这三个月的踩坑经历,一个需求一个需求地讲给你听。
看完你大概率会心里一紧——因为你现在的项目,八成也长这样。
第一个需求:加个「企业客户」,我改崩了
产品经理发来第一条消息:「咱们要接企业客户了,下单时多一个『企业采购』选项,要填公司抬头、走对公结算。」
听着就是加个字段的活儿对吧?我也这么以为。真动手才发现,「下单」这一个功能,它的零件散落在六个目录里:
code下单表单长啥样 → components/CheckoutForm.tsx
下单状态存哪 → contexts/CheckoutContext.tsx
下单怎么校验 → utils/validateCheckout.ts
下单怎么发请求 → services/orders.ts
下单的数据类型 → types/order.ts
把表单转成订单 → utils/formatPayload.ts你发现问题了吗?这六个目录,没有一个叫「下单」。 一个完整的功能,被大卸八块,分别塞进了六个跟「下单」这个词八竿子打不着的抽屉里。
我改表单、改类型、改请求、改校验……自以为改全了,上线。当天晚上,运营在群里 @我:「企业客户全下不了单,报『缺少必填项』。」
我一查,脸都绿了——utils 里还躺着一个两年前写的老校验器,也在管下单,我压根不知道它的存在,自然没改。新表单允许的字段,被这个老古董一票否决了。
这就是这套「干净目录」的第一个坑:
我漏改了,不是因为我不认真,是因为我根本没法确定自己没漏。
打个比方。这就像你要给一个孩子换季添衣服,结果他的衣服不是放在他自己的衣柜里,而是「所有短袖都挂客厅、所有裤子都堆卧室、所有袜子都塞玄关」——按种类分得清清楚楚。你想给这一个孩子备齐一套,得把整个屋子翻一遍,还总担心漏了双袜子。
「下单」这个功能,在整个项目里,没有一个属于自己的家。
我做的第一件事:把「下单」搬回一个家
那次事故之后,我干的第一件事,就是把所有跟下单有关的文件,全都拎出来,塞进同一个目录:
codefeatures/
checkout/ ← 「下单」终于有家了
components/ 表单、确认页
api/ 下单请求
validation/ 所有校验(就一处,再也不会有漏网的老古董)
state/ 下单状态
types.ts 订单类型道理特别朴素,一句话就能说清:
因为同一个原因才会一起改的代码,就该住在一起。
下单的校验,只在下单需求变的时候才改,那它就是下单的,凭什么放在全局 utils?那个 formatPayload,存在的唯一理由就是下单接口要那个格式,那它就该待在下单旁边。
搬完之后,还有个我没预料到的好处:耦合,一下子看得见了。
features/checkout/ 这个目录里现在躺着十来个文件,它们几乎总是一起改。这本身就是极有价值的信息——它在告诉我「这些东西是一伙的」。而在过去,同样这十几个文件散落在六个目录里,这种「它们是一伙的」关系一直存在,只是被目录结构藏起来了,我看不见,才会漏。
顺手还治了另一个病——假装通用。
utils/formatPayload.ts 这名字,听着中立又高级,好像哪儿都能用。可它干的活是「把下单表单转成订单提交格式」,它满身都是下单的业务味儿,一点都不通用。名字在撒谎,位置也在撒谎。把它挪进 checkout/api/ 之后,谁看一眼都知道:哦,这是下单专用的,别乱调。
第二个需求:购物车数量,怎么老对不上?
搬完家没几天,产品又来了:「购物车要能刷新不丢;另外用户想拉同事一起拼单,得能把购物车分享出去。」
这需求本身不难。难的是我在排查一个诡异 bug 时,才发现「购物车里有几件」这个数字,在项目里存了整整六份:
code「购物车有 3 件」这个数字,同时存在于——
接口返回里 ┐
全局 store 里 │
组件 state 里 ├─ 六个副本,
URL 参数里 │ 到底信哪个?
本地缓存里 │
角标组件里 ┘bug 就出在这儿:用户在 A 页面删了一件菜,接口更新了、缓存也更新了,可顶部那个小红点角标,读的是另一份没更新的数据,还倔强地显示「3」。用户一脸懵:我明明删了啊?
这六份数据,每一份看起来都是「对的」,它们只是没商量好——到底哪一份才算数。
我以前以为,状态管理的核心是「选对工具」:用 Context 还是 Redux 还是 Zustand。踩完这个坑我才明白,该先问的根本不是「用什么工具」,而是:
这个状态,归谁管?谁才有权改它?
你拿它跟发消息类比就秒懂了:同一件事,你微信跟人说了一遍、又发短信说了一遍、还在便利贴上写了一遍。第二天信息变了,你改了微信,忘了短信,便利贴还贴在冰箱上——这时候「到底以哪个为准」就成了灾难。数据存六份,就是这么个灾难。
想清楚归属,你会发现每种状态其实各有各的家:
- 下拉框开没开,纯粹是这个组件自己的事,组件 state 就行,别往外挪;
- 搜索筛选想「刷新还在、还能发链接给同事」,那它的家是 URL;
- 从服务器拉回来的商品数据,是服务端的,就该交给 React Query 这类缓存去管它啥时候过期、啥时候刷新,别硬拷进全局 store;
- 没提交的下单草稿,属于这一次下单流程,别因为几个字段要读它就升级成全局。
一旦「谁管、谁能改」想明白了,用哪个工具,答案自己就浮出来了。想不明白,再牛的状态库,也只是个「集中存放混乱」的仓库。
第三个需求:合规审查没过,不许下单
又过两周,产品经理拉了个紧急会:「监管要求,账户合规审查没通过的,一律不能下单。」
我心想这好办,加个判断呗。翻代码一看——完了,管「能不能下单」的逻辑,压根不在下单功能里,它躺在 utils 里,叫 canCheckout。
它一开始很单纯,就一行:
typescriptexport function canCheckout(user) {
return user.isVerified
}后来越长越大,判断条件加了账户类型、加了地区限制、加了余额……更要命的是,别的功能也开始 import 它,就因为名字听着「哪儿都能用」。我一查引用,倒吸一口凉气:连订单报表页都在调这个 canCheckout——可报表页的权限逻辑,跟下单能不能提交,根本是两码事!
这下我陷入两难:我一改 canCheckout 加上合规判断,报表页的行为也跟着变了,天知道会不会连带崩掉别的地方。
这就是把业务规则塞进 utils 的代价。utils 这个目录,就像家里那个「杂物抽屉」——一开始只放几节电池,后来钥匙、说明书、外卖优惠券、螺丝刀全往里塞。塞的时候图省事,等你急着找东西时,翻半天找不着,还不敢乱扔,生怕哪个是有用的。
业务规则根本不是工具函数。它编码的是产品决策——谁能下单、什么单能改、怎么定价。这种东西,就该待在拥有它的那个功能旁边:
codefeatures/checkout/policies/canCheckout.ts光这个路径就在说话:这条规则是「下单」的,不是「全项目通用」的。报表页想用?不行,它得有它自己的规则。给前端达人的读者一份可以直接抄的本土化版本,TS / JS 两份都备好:
typescript// features/checkout/policies/canCheckout.ts
import type { User } from '@/features/user/types'
export function canCheckout(user: User): boolean {
// 合规审查没过,一票否决(新加的监管要求)
if (user.complianceStatus !== 'passed') return false
// 账户没实名,不能下单
if (!user.isVerified) return false
// 企业客户还得对公账户可用
if (user.type === 'enterprise') {
return user.corporateAccount?.status === 'active'
}
return true
}javascript// features/checkout/policies/canCheckout.js
export function canCheckout(user) {
if (user.complianceStatus !== 'passed') return false
if (!user.isVerified) return false
if (user.type === 'enterprise') {
return user.corporateAccount?.status === 'active'
}
return true
}这么一放,连测试都变顺了。以前测一个通用 helper,是干巴巴地穷举各种输入组合;现在测这条政策,测的是有血有肉的业务场景:合规没过的用户不能下单、企业客户对公账户停用后不能下单、实名用户正常下单。测试读起来是「产品的话」,不是「代码的话」。
当然,utils 不是不能用。日期格式化、数字千分位、深拷贝这种纯机制,放 utils 天经地义。但一个函数只要沾上了业务含义——「谁能干什么」——把它藏进那个中立的名字里,就是在给自己埋雷。
第四个需求:企业订单列表,跟个人的长得一样
合规上线后,产品又说:「企业客户要一个专属的订单列表页。」
我一看现成的个人订单列表,心动了:这俩长得几乎一模一样啊,抽成一个「万能 Table」组件,两边共用,多优雅!
还好我忍住了。因为我想起上一个项目就是这么翻车的。
两个列表现在长得像,不代表它们将来会因为同一个原因改动。企业列表以后要加「批量开发票」按钮、要按部门筛选、要显示对公流水;个人列表要加「再来一单」、按收货地址分组。你真把它们捏成一个「万能 Table」,那这个组件就会慢慢长出一堆配置项:可配置列、行操作、权限回调、加载态变体、分页模式、七八种空状态……
code合并前:两段有点像的 JSX(有点重复)
↓ 图省事,捏成一个「万能组件」
合并后:重复没了,但你想给企业列表加个
按钮,得先读懂一个「同时伺候两家」
的组件,一动就怕碰坏另一家
省了重复,欠下了「牵一发动全身」这就像双胞胎——长得一模一样,但你不能因此给俩人办一张身份证。他们是两个独立的人,会有各自的人生。硬绑在一起,将来一个人想改名,另一个也被迫跟着改。
所以我现在把「长得像」当线索,不当证据。合并之前先问自己几句:这行为在两边含义真的一样吗?它俩会一起改吗?将来谁来维护这个共用的东西?——只要有一句答不清楚,就先让它们各写各的,留点「无害的重复」。
真该共享的,其实比你想的小得多。这俩列表可能不需要共用整个 Table,它们只需要共用同一个日期显示组件、同一个金额格式化函数。按钮、弹窗、输入框、格式化——这些稳定的「零件」尽管共享;而「哪个按钮出现、点了干嘛」这种产品决策,让每个功能自己拿主意。好的共享组件是帮你少做决定的;如果每次用都要传一大坨配置、还得研究它内部咋工作,那它只是被硬凑到了一起,根本没真正共享。
第五个坑:下单去偷用了「个人中心」的私货
到这儿,我的目录已经很像样了,全按功能分。可我又栽了一跤——这次栽在「按功能分完,就万事大吉」的错觉上。
下单流程需要当前用户的信息(会员等级、收货地址)。我图快,直接从个人中心功能里 import 了一个 Hook:
typescript// ❌ 下单功能里,伸手去掏了个人中心的私货
import { useProfileData } from '@/features/profile/hooks/useProfileData'useProfileData 是个人中心页面为自己内部逻辑写的,属于它的「私人物品」。结果某天,负责个人中心的同事重构了这个 Hook——纯粹是他们自己页面的事——我的下单流程当场崩了。 他一脸无辜:我改的是我自己功能里的东西啊,怎么会影响到你下单?
因为我不该去掏他家的抽屉。
正确的做法是:功能之间打交道,要走正门,用对方明确对外提供的「契约」,而不是翻墙进去拿人家的私货。个人中心应该对外导出一个稳定的接口,比如 useCurrentUser(),我用这个;至于它内部叫 useProfileData 还是别的、怎么实现,是它自己的事,跟我无关,它随便改,只要正门不变,我就不会崩。
code ✅ 走正门:下单 → 用户功能对外的稳定契约
(useCurrentUser)
❌ 翻墙:下单 → 个人中心的私有 Hook
(对方一重构,你就跟着崩)道理跟邻里相处一样:你要跟邻居借个东西,敲门、走正门;你不能翻窗户进去自己拿,那哪天人家重新装修,你就摔了。
为啥非得较这个真?因为功能的内部实现,比它的对外接口改得勤快多了。你依赖对方的正门(稳定接口),对方内部随便翻新你都不受影响;你一旦依赖了对方的私货,对方一动,你就跟着遭殃,改动像野火一样跨着功能到处烧。
三个月后我才想明白:怎么判断一个架构好不好
回头看这三个月,我对「好架构」的判断标准,彻底变了。
以前我判断一个 React 项目好不好,看的是它刚建好时有多干净——目录齐不齐、文件小不小、Hook 和组件分没分开。这个买菜 App 当初就完美符合这些标准,可它照样让我踩了一路的坑。
现在我只看一件事:一个新需求来的时候,会发生什么。
还记得那个「合规审查没过不许下单」的需求吗?
在我把下单搬回家、规则也归位之后,再来这么一个需求,我的操作是:打开 features/checkout/policies/canCheckout.ts,加一行判断,改一下界面提示,测一下,收工。改动稳稳地待在「下单」这一个功能里,我心里特别有底。
可要是放在三个月前那个「样板间」项目里,同一个需求,我得去动一个通用的 canCheckout、一个全局 Context、一个共享按钮、一个数据转换函数、还有一个路由守卫。每个文件看着都「可复用」,可没有任何一个地方能完整告诉我:这条「合规才能下单」的规则,到底归谁。
code 「合规没过,禁止下单」
归属清晰的项目 散架的项目
───────────── ──────────
checkout/policies 通用 canCheckout
└ 加一行判断 全局 Context
改动收敛在下单内 共享 Button
→ 5 分钟搞定 数据转换函数
路由守卫
→ 翻 5 个文件,还怕漏差别根本不在「文件夹有几个」,而在于——这个系统,能不能把一个决策,清清楚楚地讲给你听。
好架构不保证每次改动都很小,有些需求本来就会横跨好几块。但好架构至少能让这些边界看得见:你应该「知道」为什么这次要动好几个地方,而不是像我第一次那样,靠上线后运营的 @、靠一堆报错,才后知后觉。
所以,判断一个 React 结构好不好,从来不是「找个文件有多快」,而是——当需求来敲门时,你能多有底气地,一眼指出它的主人是谁。
文件夹,描述的是文件。归属,描述的才是系统。
其实没有哪套目录结构是完美的。这个买菜 App 要是只有三五个页面,按 components / hooks / utils 分,一点问题都没有。它是长大了,功能多了,那套分法才开始拖后腿。小项目、大项目、按路由分、按领域分——形状不重要,重要的是你心里得始终装着那句追问:
当这个需求变的时候,这次改动,到底该归到哪儿?
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

