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

- Name
- Jack Qin
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 就绪的模型
命名要描述返回的行为,例如 useEmailSchedules、useUsersQuery、useWeatherMutations。读取型 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* 符号时,先确认这个库有没有命令式变体(常见命名是 *Async、fetch*,或服务单例上的方法)。一条可迁移的分界线是:use* 形式属于 React 渲染期;命令式形式属于事件处理器、异步函数和需要单次调用它的 effect 函数体。 看到 use* 出现在 async function 里,几乎总是搞错了管线。
组件:组合 vs 配置,是在哪根轴上付灵活性的账
组件的失控同样有迹可循。一个本来只服务某个页面工作流的组件被过早搬进共享目录;或者为了"灵活",做成一个塞满布尔开关的巨型可配置组件,结果谁也看不懂它到底渲染什么。
规则:
- 可复用基础件放
src/components/ui或其它共享src/components/*区域。 - 页面/功能专属的 UI,放在拥有它的功能或路由附近。
- 优先用组合 + 类型化 props,而不是一个大而全的一次性可配置抽象。
- 用可访问的 role、label 和 button 语义来构建交互 UI。
这里真正的取舍是"组合 vs 配置"。一个塞满 showHeader、enableSort、mode、density 的巨型组件,表面上很灵活,但它把灵活性付在了最糟的那根轴上:布尔开关的组合数随开关数指数增长,而其中绝大多数组合在现实里从不出现,却都要被实现、被测试、被维护。更糟的是,读代码的人无法从调用点推断出它会渲染成什么——所有分支逻辑都藏在组件内部。组合模式把这笔账换了根轴来付:用 slot 和 children 暴露扩展点,灵活性体现在"调用方往里塞什么",而调用点本身就是渲染结果的说明书。
共享组件(如 DataTable、Pagination、ProvidersWrapper)通常:接收类型化 props;暴露 toolbar、headerActions、children 这样的组合点;不嵌入功能专属的 API 逻辑。
Props 约定:
- 在组件附近用 TS 接口或类型别名定义 props。
- 优先用显式 props,而不是包罗万象的 config 对象。
- 用 render slot 或 React 节点作为扩展点,而不是堆一堆布尔开关。
- 把领域数据一路类型化到组件边界。
例如 DataTable 暴露类型化泛型和显式 props(columns、rowKey、toolbar、pagination);而 ProvidersWrapper 把 prop 表面收窄到只有 children——表面越窄,误用空间越小。
可访问性是测试契约,不只是用户体验
可访问性常被归类为"对用户好"的加分项。但在这套约定里,它还有第二重身份:它是测试的接口。
机制是这样的:一个语义化的交互元素带着 role 和可访问名称,测试就能用 getByRole("button", { name: "..." }) 这样稳定的选择器去定位它。而一个 div onClick 没有 role、没有名称,测试只能退回到脆弱的 DOM 遍历——按层级、按索引去摸,产品一改结构就红。换句话说,可访问性缺失不仅伤了用户,还逼测试用脆弱的方式去断言。把 role/label 加上,等于同时给了用户和测试一个稳定的抓手。
所以可访问性在组件代码和测试里都是被期待的:
- 用语义化交互元素,如
button。 - 需要时加上
aria-label、aria-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;不要过早把功能专属组件搬进共享目录;不要发布缺少测试所期待的可访问名称的对话框、按钮或表单输入。
落地建议
- 路由文件要薄:fetch 和 mutation 逻辑下沉到 Hook,页面只负责编排和渲染。
- 拿第三方
use*前先找命令式孪生:*Async、fetch*往往就在旁边,别把 Hook 拖进异步函数。 - Hook 归属看复用范围:多处共享才进
src/hooks/,否则留在功能目录。 - 组件优先组合而非配置:扩展点用 slot/节点,而不是布尔开关海。
- 可访问性当成测试契约:加 role/label 不只是为了用户,也是为了让测试能用语义选择器稳定断言。
可迁移的那一层
这三条约定拆到底,共享同一个认知:一个符号或一段结构的"形状",决定了它能被安全地放在哪里、被怎样消费。 use* 的形状决定它只能活在渲染期;巨型 config 的形状把复杂度藏进了内部、指数级放大;缺 role 的元素形状逼测试退回脆弱遍历。每次写 Hook 或组件前,与其问"这样写行不行",不如问一句:它的形状对它的使用场景诚实吗? 形状对了,约定就是多余的;形状错了,再多注释也守不住。