首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >为什么改一个 React 功能,要翻遍整个项目?

为什么改一个 React 功能,要翻遍整个项目?

作者头像
前端达人
发布2026-07-27 12:51:13
发布2026-07-27 12:51:13
20
举报
文章被收录于专栏:前端达人前端达人

为什么改一个 React 功能,要翻遍整个项目?

去年我接手一个生鲜电商项目,主要功能就是买菜下单(下面就叫它「买菜 App」,具体是哪家不重要)。

第一天翻代码,我心里还挺美的:目录干净得像样板间。组件全在 components,Hook 全在 hooks,请求全在 services,工具函数全在 utils。每个文件都待在「它该在的地方」,一眼就知道是什么类型。

我当时想:接手个这么规整的项目,稳了。

结果三个月后,我逢人就吐槽这套目录。倒不是它乱——它一点都不乱,是它规整得毫无用处。这三个月里,产品经理提了一连串需求,每一个都让我把整个项目从头翻到尾。这篇,我就把这三个月的踩坑经历,一个需求一个需求地讲给你听。

看完你大概率会心里一紧——因为你现在的项目,八成也长这样。

第一个需求:加个「企业客户」,我改崩了

产品经理发来第一条消息:「咱们要接企业客户了,下单时多一个『企业采购』选项,要填公司抬头、走对公结算。」

听着就是加个字段的活儿对吧?我也这么以为。真动手才发现,「下单」这一个功能,它的零件散落在六个目录里:

代码语言:javascript
复制
下单表单长啥样  → components/CheckoutForm.tsx
下单状态存哪    → contexts/CheckoutContext.tsx
下单怎么校验    → utils/validateCheckout.ts
下单怎么发请求  → services/orders.ts
下单的数据类型  → types/order.ts
把表单转成订单  → utils/formatPayload.ts

你发现问题了吗?这六个目录,没有一个叫「下单」。 一个完整的功能,被大卸八块,分别塞进了六个跟「下单」这个词八竿子打不着的抽屉里。

我改表单、改类型、改请求、改校验……自以为改全了,上线。当天晚上,运营在群里 @我:「企业客户全下不了单,报『缺少必填项』。」

我一查,脸都绿了——utils 里还躺着一个两年前写的老校验器,也在管下单,我压根不知道它的存在,自然没改。新表单允许的字段,被这个老古董一票否决了。

这就是这套「干净目录」的第一个坑:

我漏改了,不是因为我不认真,是因为我根本没法确定自己没漏

打个比方。这就像你要给一个孩子换季添衣服,结果他的衣服不是放在他自己的衣柜里,而是「所有短袖都挂客厅、所有裤子都堆卧室、所有袜子都塞玄关」——按种类分得清清楚楚。你想给这一个孩子备齐一套,得把整个屋子翻一遍,还总担心漏了双袜子。

「下单」这个功能,在整个项目里,没有一个属于自己的家

我做的第一件事:把「下单」搬回一个家

那次事故之后,我干的第一件事,就是把所有跟下单有关的文件,全都拎出来,塞进同一个目录:

代码语言:javascript
复制
features/
  checkout/          ← 「下单」终于有家了
    components/       表单、确认页
    api/             下单请求
    validation/      所有校验(就一处,再也不会有漏网的老古董)
    state/           下单状态
    types.ts         订单类型

道理特别朴素,一句话就能说清:

因为同一个原因才会一起改的代码,就该住在一起。

下单的校验,只在下单需求变的时候才改,那它就是下单的,凭什么放在全局 utils?那个 formatPayload,存在的唯一理由就是下单接口要那个格式,那它就该待在下单旁边。

搬完之后,还有个我没预料到的好处:耦合,一下子看得见了。

features/checkout/ 这个目录里现在躺着十来个文件,它们几乎总是一起改。这本身就是极有价值的信息——它在告诉我「这些东西是一伙的」。而在过去,同样这十几个文件散落在六个目录里,这种「它们是一伙的」关系一直存在,只是被目录结构藏起来了,我看不见,才会漏。

顺手还治了另一个病——假装通用

utils/formatPayload.ts 这名字,听着中立又高级,好像哪儿都能用。可它干的活是「把下单表单转成订单提交格式」,它满身都是下单的业务味儿,一点都不通用。名字在撒谎,位置也在撒谎。把它挪进 checkout/api/ 之后,谁看一眼都知道:哦,这是下单专用的,别乱调。

第二个需求:购物车数量,怎么老对不上?

搬完家没几天,产品又来了:「购物车要能刷新不丢;另外用户想拉同事一起拼单,得能把购物车分享出去。」

这需求本身不难。难的是我在排查一个诡异 bug 时,才发现「购物车里有几件」这个数字,在项目里存了整整六份

代码语言:javascript
复制
「购物车有 3 件」这个数字,同时存在于——
  接口返回里    ┐
  全局 store 里 │
  组件 state 里 ├─ 六个副本,
  URL 参数里    │   到底信哪个?
  本地缓存里    │
  角标组件里    ┘

bug 就出在这儿:用户在 A 页面删了一件菜,接口更新了、缓存也更新了,可顶部那个小红点角标,读的是另一份没更新的数据,还倔强地显示「3」。用户一脸懵:我明明删了啊?

这六份数据,每一份看起来都是「对的」,它们只是没商量好——到底哪一份才算数。

我以前以为,状态管理的核心是「选对工具」:用 Context 还是 Redux 还是 Zustand。踩完这个坑我才明白,该先问的根本不是「用什么工具」,而是:

这个状态,归谁管?谁才有权改它?

你拿它跟发消息类比就秒懂了:同一件事,你微信跟人说了一遍、又发短信说了一遍、还在便利贴上写了一遍。第二天信息变了,你改了微信,忘了短信,便利贴还贴在冰箱上——这时候「到底以哪个为准」就成了灾难。数据存六份,就是这么个灾难。

想清楚归属,你会发现每种状态其实各有各的家:

  • 下拉框开没开,纯粹是这个组件自己的事,组件 state 就行,别往外挪;
  • 搜索筛选想「刷新还在、还能发链接给同事」,那它的家是 URL
  • 从服务器拉回来的商品数据,是服务端的,就该交给 React Query 这类缓存去管它啥时候过期、啥时候刷新,别硬拷进全局 store;
  • 没提交的下单草稿,属于这一次下单流程,别因为几个字段要读它就升级成全局。

一旦「谁管、谁能改」想明白了,用哪个工具,答案自己就浮出来了。想不明白,再牛的状态库,也只是个「集中存放混乱」的仓库。

第三个需求:合规审查没过,不许下单

又过两周,产品经理拉了个紧急会:「监管要求,账户合规审查没通过的,一律不能下单。」

我心想这好办,加个判断呗。翻代码一看——完了,管「能不能下单」的逻辑,压根不在下单功能里,它躺在 utils 里,叫 canCheckout

它一开始很单纯,就一行:

代码语言:javascript
复制
export function canCheckout(user) {
  return user.isVerified
}

后来越长越大,判断条件加了账户类型、加了地区限制、加了余额……更要命的是,别的功能也开始 import 它,就因为名字听着「哪儿都能用」。我一查引用,倒吸一口凉气:连订单报表页都在调这个 canCheckout——可报表页的权限逻辑,跟下单能不能提交,根本是两码事!

这下我陷入两难:我一改 canCheckout 加上合规判断,报表页的行为也跟着变了,天知道会不会连带崩掉别的地方。

这就是把业务规则塞进 utils 的代价。utils 这个目录,就像家里那个「杂物抽屉」——一开始只放几节电池,后来钥匙、说明书、外卖优惠券、螺丝刀全往里塞。塞的时候图省事,等你急着找东西时,翻半天找不着,还不敢乱扔,生怕哪个是有用的。

业务规则根本不是工具函数。它编码的是产品决策——谁能下单、什么单能改、怎么定价。这种东西,就该待在拥有它的那个功能旁边:

代码语言:javascript
复制
features/checkout/policies/canCheckout.ts

光这个路径就在说话:这条规则是「下单」的,不是「全项目通用」的。报表页想用?不行,它得有它自己的规则。给前端达人的读者一份可以直接抄的本土化版本,TS / JS 两份都备好:

代码语言:javascript
复制
// 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」,那这个组件就会慢慢长出一堆配置项:可配置列、行操作、权限回调、加载态变体、分页模式、七八种空状态……

代码语言:javascript
复制
合并前:两段有点像的 JSX(有点重复)
        ↓ 图省事,捏成一个「万能组件」
合并后:重复没了,但你想给企业列表加个
        按钮,得先读懂一个「同时伺候两家」
        的组件,一动就怕碰坏另一家

        省了重复,欠下了「牵一发动全身」

这就像双胞胎——长得一模一样,但你不能因此给俩人办一张身份证。他们是两个独立的人,会有各自的人生。硬绑在一起,将来一个人想改名,另一个也被迫跟着改。

所以我现在把「长得像」当线索,不当证据。合并之前先问自己几句:这行为在两边含义真的一样吗?它俩会一起改吗?将来谁来维护这个共用的东西?——只要有一句答不清楚,就先让它们各写各的,留点「无害的重复」。

真该共享的,其实比你想的小得多。这俩列表可能不需要共用整个 Table,它们只需要共用同一个日期显示组件、同一个金额格式化函数。按钮、弹窗、输入框、格式化——这些稳定的「零件」尽管共享;而「哪个按钮出现、点了干嘛」这种产品决策,让每个功能自己拿主意。好的共享组件是帮你少做决定的;如果每次用都要传一大坨配置、还得研究它内部咋工作,那它只是被硬凑到了一起,根本没真正共享。

第五个坑:下单去偷用了「个人中心」的私货

到这儿,我的目录已经很像样了,全按功能分。可我又栽了一跤——这次栽在「按功能分完,就万事大吉」的错觉上。

下单流程需要当前用户的信息(会员等级、收货地址)。我图快,直接从个人中心功能里 import 了一个 Hook:

代码语言:javascript
复制
// ❌ 下单功能里,伸手去掏了个人中心的私货
import { useProfileData } from '@/features/profile/hooks/useProfileData'

useProfileData 是个人中心页面为自己内部逻辑写的,属于它的「私人物品」。结果某天,负责个人中心的同事重构了这个 Hook——纯粹是他们自己页面的事——我的下单流程当场崩了。 他一脸无辜:我改的是我自己功能里的东西啊,怎么会影响到你下单?

因为我不该去掏他家的抽屉。

正确的做法是:功能之间打交道,要走正门,用对方明确对外提供的「契约」,而不是翻墙进去拿人家的私货。个人中心应该对外导出一个稳定的接口,比如 useCurrentUser(),我用这个;至于它内部叫 useProfileData 还是别的、怎么实现,是它自己的事,跟我无关,它随便改,只要正门不变,我就不会崩。

代码语言:javascript
复制
   ✅ 走正门:下单 → 用户功能对外的稳定契约
              (useCurrentUser)

   ❌ 翻墙:下单 → 个人中心的私有 Hook
              (对方一重构,你就跟着崩)

道理跟邻里相处一样:你要跟邻居借个东西,敲门、走正门;你不能翻窗户进去自己拿,那哪天人家重新装修,你就摔了。

为啥非得较这个真?因为功能的内部实现,比它的对外接口改得勤快多了。你依赖对方的正门(稳定接口),对方内部随便翻新你都不受影响;你一旦依赖了对方的私货,对方一动,你就跟着遭殃,改动像野火一样跨着功能到处烧。

三个月后我才想明白:怎么判断一个架构好不好

回头看这三个月,我对「好架构」的判断标准,彻底变了。

以前我判断一个 React 项目好不好,看的是它刚建好时有多干净——目录齐不齐、文件小不小、Hook 和组件分没分开。这个买菜 App 当初就完美符合这些标准,可它照样让我踩了一路的坑。

现在我只看一件事:一个新需求来的时候,会发生什么。

还记得那个「合规审查没过不许下单」的需求吗?

在我把下单搬回家、规则也归位之后,再来这么一个需求,我的操作是:打开 features/checkout/policies/canCheckout.ts,加一行判断,改一下界面提示,测一下,收工。改动稳稳地待在「下单」这一个功能里,我心里特别有底。

可要是放在三个月前那个「样板间」项目里,同一个需求,我得去动一个通用的 canCheckout、一个全局 Context、一个共享按钮、一个数据转换函数、还有一个路由守卫。每个文件看着都「可复用」,可没有任何一个地方能完整告诉我:这条「合规才能下单」的规则,到底归谁。

代码语言:javascript
复制
          「合规没过,禁止下单」

   归属清晰的项目          散架的项目
   ─────────────         ──────────
   checkout/policies      通用 canCheckout
     └ 加一行判断          全局 Context
   改动收敛在下单内         共享 Button
   → 5 分钟搞定            数据转换函数
                          路由守卫
                         → 翻 5 个文件,还怕漏

差别根本不在「文件夹有几个」,而在于——这个系统,能不能把一个决策,清清楚楚地讲给你听。

好架构不保证每次改动都很小,有些需求本来就会横跨好几块。但好架构至少能让这些边界看得见:你应该「知道」为什么这次要动好几个地方,而不是像我第一次那样,靠上线后运营的 @、靠一堆报错,才后知后觉。

所以,判断一个 React 结构好不好,从来不是「找个文件有多快」,而是——当需求来敲门时,你能多有底气地,一眼指出它的主人是谁。


文件夹,描述的是文件。归属,描述的才是系统。

其实没有哪套目录结构是完美的。这个买菜 App 要是只有三五个页面,按 components / hooks / utils 分,一点问题都没有。它是长大了,功能多了,那套分法才开始拖后腿。小项目、大项目、按路由分、按领域分——形状不重要,重要的是你心里得始终装着那句追问:

当这个需求变的时候,这次改动,到底该归到哪儿?

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-23,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 第一个需求:加个「企业客户」,我改崩了
    • 我做的第一件事:把「下单」搬回一个家
  • 第二个需求:购物车数量,怎么老对不上?
  • 第三个需求:合规审查没过,不许下单
  • 第四个需求:企业订单列表,跟个人的长得一样
  • 第五个坑:下单去偷用了「个人中心」的私货
  • 三个月后我才想明白:怎么判断一个架构好不好
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档