- Published on
前端目录与状态的腐烂账:为什么"放哪里"和"归谁管"会决定代码库的寿命
- Authors

- Name
- Jack Qin
前端项目里有两个地方会以肉眼可见的速度腐烂:目录结构和状态管理。它们看起来是两件事,但腐烂的机制是同一个——某条边界本来应该被守住,结果在一次"图省事"里被悄悄打穿了。
这篇不想做一份"目录该怎么摆"的清单。清单谁都会抄,抄完照样腐烂。我想拆的是这两条边界各自在防什么类的错、为什么不守会出事,以及一条最容易被忽略、却直接关系到数据泄漏的进阶约定:在认证边界清空服务端缓存。理解了背后的受力,你就能在新场景里自己推出约定,而不是等着 code review 来抓。
目录腐烂:一次"它也是 React 代码"就够了
目录的腐烂往往始于一句听起来无可辩驳的话:"它也是 React 代码,丢进 src/components/ 呗。"
这句话的问题在于,它用文件类型回答了一个本该用归属回答的问题。一个只有 weekly-reports 页面会用到的表格,和一个三个功能都在用的 DataTable,在"是不是 React 代码"这个维度上完全一样——但在"谁拥有它、谁会因为它的改动而受影响"这个维度上,天差地别。按文件类型组织目录,等于主动丢弃了后一个维度的信息。半年后的结果是确定的:components/ 里堆满了实际只属于某个功能的东西,新人想读懂一段逻辑要在三个目录之间来回跳。
所以约定的第一性原理不是"按功能分目录好看",而是让目录结构承载"归属"这条信息。apps/web 的核心规则只有几条,每一条都在为归属服务:
- 路由入口放
src/app/。 - 领域逻辑放
src/features/<feature>/。 - 共享 UI 与跨功能部件放
src/components/、src/contexts/、src/hooks/、src/lib/。 - 优先就近放置(colocate),不要动不动新建顶层目录。
- 测试就近放在被测代码旁边。
整体布局:
src/
├── app/ # 路由分组与页面入口
│ ├── (admin)/
│ ├── (auth)/
│ ├── (others)/
│ └── (render)/
├── features/ # 功能本地的 api、hooks、components、types、config
├── components/ # 共享 UI、布局、包装器、跨功能构件
├── contexts/ # 跨切面客户端状态的 React Context Provider
├── hooks/ # 跨功能复用的共享 hooks
├── lib/ # API 客户端、query client、auth、时区、辅助函数
└── test/ # 共享测试初始化
路由分组本身也是一种归属声明:
(admin)—— 需要认证的产品页面(auth)—— 登录、重置密码、邀请、回调等流程(others)—— 错误页、维护页等独立页面(render)—— 由渲染令牌驱动、供自动化渲染使用的页面
一个功能目录通常长这样:
src/features/<feature>/
├── api/ # 请求函数与 query keys
├── hooks/ # React Query 包装与功能逻辑
├── components/ # 功能专属 UI
├── types/ # 功能本地类型
└── config/ # 需要时放功能常量
这里真正的判据是一条:只有当多个功能或路由都要用某段代码时,才把它提升到共享顶层目录。 反过来说,一段代码只要还只有一个功能在用,它就该待在那个功能目录里——哪怕它"看起来很通用"。提升的成本是真实的:一旦进了共享层,它的任何改动都要考虑所有潜在消费方,而此时可能根本没有第二个消费方。
命名约定也服务于同一个目标——让归属一眼可读:
- 组件用 PascalCase,如
DataTable.tsx、ProvidersWrapper.tsx。 - Hook 用
use*,如useEmailSchedules.ts、useCurrentUserQuery.ts。 - 路由文件通常是路由目录下的
index.tsx。 - 功能目录用 kebab-case,如
weekly-reports、email-schedules、user-management。 - 用稳定、有描述性的名字,别用
common2、helpers、misc这种含糊的兜底目录——misc这个名字本身就是"我懒得想它归谁"的自白。
状态腐烂:复制一份真相源,从此两边对不上
状态管理的腐烂比目录更要命,因为它直接产出数据 bug。最经典的反模式是:服务端数据明明已经在 React Query 缓存里了,有人又复制一份塞进 Context,理由是"这样更全局、更方便"。
要看清这件事错在哪,得先承认一个事实:同一份后端资源,只能有一个真相源。 一旦你把它复制进 Context,你就有了两份——一份在 Query 缓存里会随重新拉取更新,一份在 Context 里是某个时刻的快照。从复制那一刻起,它们就开始各走各的:缓存失效了,Context 不知道;mutation 成功刷新了缓存,Context 还是旧的。"数据不同步"的 bug 不是偶然,而是两个真相源存在时的必然。
所以状态约定的本质是按"真相源归谁"来切分。项目只用一小组工具,每个工具的职责边界很清楚:
| 状态类别 | 工具 | 典型场景 |
|---|---|---|
| 本地 UI 状态 | 组件本地 state | 开关、表格排序、临时表单、交互状态 |
| 跨切面客户端状态 | React Context | 认证/会话、全局筛选、布局级控制 |
| 服务端状态 | TanStack Query | API 数据、缓存、重新拉取、变更同步 |
项目里没有为常规业务流程引入独立的全局客户端状态库——因为大多数"全局状态"的需求,本质是服务端状态,它的真相源在后端,前端只该缓存而非重新托管。
- 本地 UI 状态:只服务视图本身的东西就放本地。比如
DataTable在外部没有提供排序时,内部自己维护排序状态。 - Context 状态:只有当某个客户端关注点真的横跨多个路由或主要布局区域时,才提升到 Context。例如
AuthContext管认证/会话状态和登录登出动作,ProvidersWrapper组合了AuthProvider、GlobalFiltersProvider和布局 Provider。 - 服务端状态:API 数据一律走 TanStack Query。模式很固定——通过功能 hook 拉取、通过功能 hook 变更、写操作成功后让对应 query key 失效或重新拉取、让查询的 loading/error 状态去驱动 UI。
一个值得抄的细节:AuthContext 依赖 useCurrentUserQuery,而不是自己维护第二个真相源。这一行选择就避免了"我有一份用户数据,Query 缓存里还有一份"的撕裂。它没有把用户数据复制进 useState,所以根本不存在两边对不上的可能——bug 不是被修掉的,是被设计掉的。
进阶约定:认证边界为什么必须清缓存
有一条约定值得单独讲,因为它防的不是"代码难看",而是一类真实发生过的数据泄漏。
做什么:在每一次认证边界跳转——login()、logout() 以及全局 401 处理器——都要在填充新用户数据之前,把 React Query 缓存里所有非认证类查询移除掉。唯独保留 auth-me / 当前用户那把 key,因为它是驱动 isAuthenticated 的受控信号。
为什么这是结构性的、而不是洁癖:React Query 缓存是按 query key 存活的,它不知道这些数据属于"哪个登录用户"。缓存里的标记点查询、站点列表、可访问站点查询,全都是上一个会话里、上一个用户作用域的数据。如果切换用户时这些缓存还活着,它们就会在新用户登录后的第一帧渲染出来。在共享移动设备上——驾驶室平板、一个班次多名司机轮换——这个泄漏窗口是真实存在的:用户 A 的数据出现在用户 B 的屏幕上。缓存的"按 key 存活"语义,和"数据属于某个用户"的业务语义之间,差了整整一层,而清缓存就是把这层差距显式补上。
function clearAuthenticatedQueryData(queryClient: QueryClient): void {
for (const query of queryClient.getQueryCache().findAll()) {
if (!isCurrentUserQueryKey(query.queryKey)) {
queryClient.removeQueries({ queryKey: query.queryKey, exact: true })
}
}
queryClient.setQueryData<CurrentUserDto | null>(AUTH_ME_QUERY_KEY, null)
}
// login():填充新用户前先清空
clearAuthenticatedQueryDataBeforeLogin(queryClient)
await authClient.login(email, password)
const user = await queryClient.fetchQuery({ ...currentUserQueryOptions, staleTime: 0 })
// logout() / 401 处理器:清空并把 auth key 重置为 null
clearAuthenticatedQueryData(queryClient)
这条约定有个容易漏的方向性:它必须双向应用。 Web 端的 ProvidersWrapper 已经装了一个 401 处理器,会清空查询状态并跳转——同一条约定,只是换了传输外壳。引入任何新的认证 Provider 时,login 和 logout/401 两个方向都要清。只清登录不清登出,泄漏窗口照样存在,只是换了个触发时机。
几个反例,以及它们共同的破绽
// 反例 1:把只属于某功能的组件放进共享目录,只因为它"是 React 代码"
// src/components/WeeklyReportTable.tsx ❌ 只有 weekly-reports 用到
// 正确:放在功能目录下
// src/features/weekly-reports/components/WeeklyReportTable.tsx ✅
// 反例 2:把服务端状态复制进 Context,让它"感觉上更全局"
const AuthProvider = ({ children }) => {
const { data } = useCurrentUserQuery()
const [user, setUser] = useState(data) // ❌ 第二个真相源,会和 Query 缓存不同步
// ...
}
// 正确:直接消费 Query 结果,不另存一份
const AuthProvider = ({ children }) => {
const { data: user } = useCurrentUserQuery() // ✅ 单一真相源
// ...
}
// 反例 3:在页面组件里直接 fetch,绕过既有的功能 hook 模式
function HeatmapPage() {
const [data, setData] = useState()
useEffect(() => {
fetch('/api/v1/heatmap')
.then((r) => r.json())
.then(setData) // ❌
}, [])
}
// 正确:逻辑下沉到功能 hook,路由文件保持轻薄
function HeatmapPage() {
const { data } = useHeatmapQueries() // ✅
}
把三个反例叠起来看,破绽是同一个:它们都在绕过一条本该守住的边界——反例 1 绕过归属边界,反例 2 制造第二个真相源,反例 3 绕过功能 hook 这层数据路径。其它要避免的也都是这个家族:不要在 src/components/ 里临时塞功能专属的 hook 或组件;不要在既有功能目录已经合适时引入新的顶层架构模式;不要把服务端状态逻辑复制进路由文件;不要在 TanStack Query 之外为同一份后端资源维护并行缓存。
落地建议
- 新建文件前先问归属:这段代码只有一个页面用吗?就近放。多个功能用吗?才考虑提升。
- 服务端数据默认进 React Query,除非有强理由需要在本地暂存或转换它。
- Context 是稀缺资源,只留给真正横跨多路由的关注点:认证、全局筛选、布局级控制。
- 认证边界清缓存要双向:新增任何认证 Provider 时,login 和 logout/401 都要清。
- 代码评审盯住目录归属与真相源:新增代码是否遵循功能化结构?API 调用是否走了共享客户端和功能 hook?有没有为同一份数据制造并行缓存?
可迁移的那一层
抛开 React 和 TanStack Query 的具体 API,这两条约定真正可迁移的认知是同一句:腐烂总是发生在边界被无声打穿的地方,而打穿它的往往是一句听起来无可辩驳的"图省事"。"它也是 React 代码"、"复制进 Context 更全局"、"在页面里直接 fetch 更快"——每一句单独看都成立,错的是它们都跨过了一条没人重新审视过的边界。
面对一个"放哪里 / 归谁管"的决定时,与其问"这样写方不方便",不如反过来问:这段代码的归属是谁?这份数据的唯一真相源在哪?我现在是不是正在制造第二个? 一个新人不用问就能猜对"代码放哪里"和"状态归谁管"的代码库,不是因为约定写得多,而是因为这两个问题在每一处都有唯一答案。