Published on

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

Authors
  • avatar
    Name
    Jack Qin
    Twitter

前端项目里有两个地方会以肉眼可见的速度腐烂:目录结构和状态管理。它们看起来是两件事,但腐烂的机制是同一个——某条边界本来应该被守住,结果在一次"图省事"里被悄悄打穿了

这篇不想做一份"目录该怎么摆"的清单。清单谁都会抄,抄完照样腐烂。我想拆的是这两条边界各自在防什么类的错、为什么不守会出事,以及一条最容易被忽略、却直接关系到数据泄漏的进阶约定:在认证边界清空服务端缓存。理解了背后的受力,你就能在新场景里自己推出约定,而不是等着 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.tsxProvidersWrapper.tsx
  • Hook 用 use*,如 useEmailSchedules.tsuseCurrentUserQuery.ts
  • 路由文件通常是路由目录下的 index.tsx
  • 功能目录用 kebab-case,如 weekly-reportsemail-schedulesuser-management
  • 用稳定、有描述性的名字,别用 common2helpersmisc 这种含糊的兜底目录——misc 这个名字本身就是"我懒得想它归谁"的自白。

状态腐烂:复制一份真相源,从此两边对不上

状态管理的腐烂比目录更要命,因为它直接产出数据 bug。最经典的反模式是:服务端数据明明已经在 React Query 缓存里了,有人又复制一份塞进 Context,理由是"这样更全局、更方便"。

要看清这件事错在哪,得先承认一个事实:同一份后端资源,只能有一个真相源。 一旦你把它复制进 Context,你就有了两份——一份在 Query 缓存里会随重新拉取更新,一份在 Context 里是某个时刻的快照。从复制那一刻起,它们就开始各走各的:缓存失效了,Context 不知道;mutation 成功刷新了缓存,Context 还是旧的。"数据不同步"的 bug 不是偶然,而是两个真相源存在时的必然

所以状态约定的本质是按"真相源归谁"来切分。项目只用一小组工具,每个工具的职责边界很清楚:

状态类别工具典型场景
本地 UI 状态组件本地 state开关、表格排序、临时表单、交互状态
跨切面客户端状态React Context认证/会话、全局筛选、布局级控制
服务端状态TanStack QueryAPI 数据、缓存、重新拉取、变更同步

项目里没有为常规业务流程引入独立的全局客户端状态库——因为大多数"全局状态"的需求,本质是服务端状态,它的真相源在后端,前端只该缓存而非重新托管。

  • 本地 UI 状态:只服务视图本身的东西就放本地。比如 DataTable 在外部没有提供排序时,内部自己维护排序状态。
  • Context 状态:只有当某个客户端关注点真的横跨多个路由或主要布局区域时,才提升到 Context。例如 AuthContext 管认证/会话状态和登录登出动作,ProvidersWrapper 组合了 AuthProviderGlobalFiltersProvider 和布局 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 之外为同一份后端资源维护并行缓存。

落地建议

  1. 新建文件前先问归属:这段代码只有一个页面用吗?就近放。多个功能用吗?才考虑提升。
  2. 服务端数据默认进 React Query,除非有强理由需要在本地暂存或转换它。
  3. Context 是稀缺资源,只留给真正横跨多路由的关注点:认证、全局筛选、布局级控制。
  4. 认证边界清缓存要双向:新增任何认证 Provider 时,login 和 logout/401 都要清。
  5. 代码评审盯住目录归属与真相源:新增代码是否遵循功能化结构?API 调用是否走了共享客户端和功能 hook?有没有为同一份数据制造并行缓存?

可迁移的那一层

抛开 React 和 TanStack Query 的具体 API,这两条约定真正可迁移的认知是同一句:腐烂总是发生在边界被无声打穿的地方,而打穿它的往往是一句听起来无可辩驳的"图省事"。"它也是 React 代码"、"复制进 Context 更全局"、"在页面里直接 fetch 更快"——每一句单独看都成立,错的是它们都跨过了一条没人重新审视过的边界。

面对一个"放哪里 / 归谁管"的决定时,与其问"这样写方不方便",不如反过来问:这段代码的归属是谁?这份数据的唯一真相源在哪?我现在是不是正在制造第二个? 一个新人不用问就能猜对"代码放哪里"和"状态归谁管"的代码库,不是因为约定写得多,而是因为这两个问题在每一处都有唯一答案。