Published on

Hook 的 use* 前缀不是命名习惯:从 React 渲染期契约看组件与 Hook 规范

Authors
  • avatar
    Name
    Jack Qin
    Twitter

Hook 和组件是前端的两块基石。它们一旦失控,代码库会迅速变成"每个人都有自己的一套"——而失控的方式往往不是显眼的大错,而是几条看起来无伤大雅的小习惯,叠加成无法预测的"形状"。

这篇想做的,是把三条最常被当作"风格偏好"的约定,还原成它们背后的硬机制:为什么在异步函数里调用一个 use* 函数会运行时直接崩、为什么巨型可配置组件是在错误的轴上付灵活性的账、为什么可访问性其实是一条测试契约而不只是用户体验。理解了机制,这些约定就从"团队要求你这么写"变成"不这么写它就会坏给你看"。

Hook 的归属与数据获取:让"形状"可预测

先把基本规则摆出来,它们的作用是让每个 Hook 的形状一眼可猜:

  • Hook 一律用 use* 前缀。
  • 共享 Hook 放 src/hooks/
  • 领域 Hook 放 src/features/<feature>/hooks/
  • API 支撑的服务端状态用 TanStack Query。
  • 把 fetch 和 mutation 逻辑下沉到 Hook,让路由文件保持轻薄。

功能 Hook 通常包装下面这些模式之一:

  • useQuery 做读取
  • useMutation 做写入
  • 写操作后让该功能的 query key 失效或重新拉取
  • 在返回数据前,把 DTO 映射成 UI 就绪的模型

命名要描述返回的行为,例如 useEmailSchedulesuseUsersQueryuseWeatherMutations。读取型 Hook 让读意图一目了然;变更型或混合工作流 Hook 描述它管理的资源。同一功能内,query key 命名和 Hook 命名要对齐——这条对齐不是为了整齐,而是为了让"哪个 mutation 该失效哪个 key"在阅读时不需要跳文件去确认。

数据获取上,TanStack Query 是默认的服务端状态层,仓库已在用的模式包括:用功能 API 层提供的、功能作用域的 query key;用 enabled 做条件拉取;在查询函数支持时传入 signal;写操作成功后让功能 query key 失效。

那个最容易踩的坑:use* 前缀冻结的是 React 渲染期

这是一个反复有人踩、且踩中就运行时崩溃的坑,值得从机制层讲清楚。

症状:某个命令式辅助函数(比如 signInWithMicrosoft())需要 OIDC discovery 元数据,顺手就写了 AuthSession.useAutoDiscovery(authority),然后运行时崩溃:Invalid hook call. Hooks can only be called inside of the body of a function component.

为什么必崩,而不是偶尔崩:关键在于理解 use* 前缀不是命名习惯。它是一份契约的标记,这份契约说的是"我内部用了 React 的 Hook 机制(useState + useEffect),所以我只能在 React 渲染期被调用"。React 的 Hook 依赖一个隐式的、由渲染流程维护的调用顺序状态——这个状态只在组件渲染或另一个 Hook 执行时存在。一个命令式异步函数根本不在渲染流程里,那个状态不存在,于是 React 检测到"在渲染上下文之外调用 Hook",立即抛错。这不是边界情况,是 Hook 机制的前提被违反,所以是确定性的崩溃。

修复:大多数"会用到 Hook"的库都提供一个命令式孪生函数。对 expo-auth-session 来说就是 AuthSession.fetchDiscoveryAsync(authority)——结果一样,返回 Promise,在哪里 await 都安全。

// 错误 —— useAutoDiscovery 是 React Hook,放在异步函数里必崩
export async function signInWithMicrosoft(): Promise<{ idToken: string }> {
  const discovery = AuthSession.useAutoDiscovery(authority)
  // ...
}

// 正确 —— 用命令式孪生函数
export async function signInWithMicrosoft(): Promise<{ idToken: string }> {
  const discovery = await AuthSession.fetchDiscoveryAsync(authority)
  // ...
}

预防:当你伸手去拿第三方库里任何 use* 符号时,先确认这个库有没有命令式变体(常见命名是 *Asyncfetch*,或服务单例上的方法)。一条可迁移的分界线是:use* 形式属于 React 渲染期;命令式形式属于事件处理器、异步函数和需要单次调用它的 effect 函数体。 看到 use* 出现在 async function 里,几乎总是搞错了管线。

组件:组合 vs 配置,是在哪根轴上付灵活性的账

组件的失控同样有迹可循。一个本来只服务某个页面工作流的组件被过早搬进共享目录;或者为了"灵活",做成一个塞满布尔开关的巨型可配置组件,结果谁也看不懂它到底渲染什么。

规则:

  • 可复用基础件放 src/components/ui 或其它共享 src/components/* 区域。
  • 页面/功能专属的 UI,放在拥有它的功能或路由附近。
  • 优先用组合 + 类型化 props,而不是一个大而全的一次性可配置抽象。
  • 用可访问的 role、label 和 button 语义来构建交互 UI。

这里真正的取舍是"组合 vs 配置"。一个塞满 showHeaderenableSortmodedensity 的巨型组件,表面上很灵活,但它把灵活性付在了最糟的那根轴上:布尔开关的组合数随开关数指数增长,而其中绝大多数组合在现实里从不出现,却都要被实现、被测试、被维护。更糟的是,读代码的人无法从调用点推断出它会渲染成什么——所有分支逻辑都藏在组件内部。组合模式把这笔账换了根轴来付:用 slot 和 children 暴露扩展点,灵活性体现在"调用方往里塞什么",而调用点本身就是渲染结果的说明书。

共享组件(如 DataTablePaginationProvidersWrapper)通常:接收类型化 props;暴露 toolbarheaderActionschildren 这样的组合点;不嵌入功能专属的 API 逻辑。

Props 约定:

  • 在组件附近用 TS 接口或类型别名定义 props。
  • 优先用显式 props,而不是包罗万象的 config 对象。
  • 用 render slot 或 React 节点作为扩展点,而不是堆一堆布尔开关。
  • 把领域数据一路类型化到组件边界。

例如 DataTable 暴露类型化泛型和显式 props(columnsrowKeytoolbarpagination);而 ProvidersWrapper 把 prop 表面收窄到只有 children——表面越窄,误用空间越小。

可访问性是测试契约,不只是用户体验

可访问性常被归类为"对用户好"的加分项。但在这套约定里,它还有第二重身份:它是测试的接口。

机制是这样的:一个语义化的交互元素带着 role 和可访问名称,测试就能用 getByRole("button", { name: "..." }) 这样稳定的选择器去定位它。而一个 div onClick 没有 role、没有名称,测试只能退回到脆弱的 DOM 遍历——按层级、按索引去摸,产品一改结构就红。换句话说,可访问性缺失不仅伤了用户,还逼测试用脆弱的方式去断言。把 role/label 加上,等于同时给了用户和测试一个稳定的抓手。

所以可访问性在组件代码和测试里都是被期待的:

  • 用语义化交互元素,如 button
  • 需要时加上 aria-labelaria-current 等属性。
  • 让测试选择器对齐可访问名称和 role。

例如 Pagination 用了 nav aria-label="Pagination"、按钮标签和 aria-current;登录页测试则直接用 role 和 label 查询 UI。

反例

// 反例 1:在页面里直接 fetch,而既有功能 hook 模式完全适用
function ReportPage() {
  const [data, setData] = useState()
  useEffect(() => {
    fetch('/api/v1/reports').then(/* ... */)
  }, []) // ❌
}
// 反例 2:把功能专属 hook 放进 src/hooks/
// src/hooks/useWeeklyReportQueries.ts  ❌ 只有 weekly-reports 用
// 应放在 src/features/weekly-reports/hooks/useWeeklyReportQueries.ts
// 反例 3:为单个页面工作流造巨型可配置组件
<MegaPanel
  showHeader showFooter showToolbar enableSort enableFilter
  variant="weekly" mode="edit" density="compact" /* ...还有 12 个布尔 */
/> // ❌ 没人看得懂它会渲染成什么样

// 正确:组合 + slot
<Panel toolbar={<WeeklyToolbar />} headerActions={<ExportButton />}>
  <WeeklyReportTable />
</Panel>
// 反例 4:交互元素没有可访问名称,测试只能靠脆弱的 DOM 遍历
<div onClick={goNext}></div> // ❌

// 正确
<button aria-label="Next page" onClick={goNext}></button> // ✅

其它要避免的:不要在既有功能 hook 模式适用时,在页面组件里直接 fetch;不要把功能专属 hook 放进 src/hooks/;不要绕过功能 query key 去做临时的缓存失效;不要把 React Query 的数据镜像到另一份 state,除非 UI 正在编辑/暂存它;不要在 API 和功能类型已存在时返回松散类型的 any payload;不要过早把功能专属组件搬进共享目录;不要发布缺少测试所期待的可访问名称的对话框、按钮或表单输入。

落地建议

  1. 路由文件要薄:fetch 和 mutation 逻辑下沉到 Hook,页面只负责编排和渲染。
  2. 拿第三方 use* 前先找命令式孪生*Asyncfetch* 往往就在旁边,别把 Hook 拖进异步函数。
  3. Hook 归属看复用范围:多处共享才进 src/hooks/,否则留在功能目录。
  4. 组件优先组合而非配置:扩展点用 slot/节点,而不是布尔开关海。
  5. 可访问性当成测试契约:加 role/label 不只是为了用户,也是为了让测试能用语义选择器稳定断言。

可迁移的那一层

这三条约定拆到底,共享同一个认知:一个符号或一段结构的"形状",决定了它能被安全地放在哪里、被怎样消费。 use* 的形状决定它只能活在渲染期;巨型 config 的形状把复杂度藏进了内部、指数级放大;缺 role 的元素形状逼测试退回脆弱遍历。每次写 Hook 或组件前,与其问"这样写行不行",不如问一句:它的形状对它的使用场景诚实吗? 形状对了,约定就是多余的;形状错了,再多注释也守不住。