关于 tanstack-frontend
面向使用 React 与 TanStack Start 的前端开发者,提供代码组织和实现约定。内容涵盖 FSD 分层、组件与数据层职责,以及 TanStack Query 的查询和变更模式。
主要功能
- 01TanStack Start 路由与服务端函数规范
- 02FSD 分层与目录约定
- 03组件、Hook 与模型职责划分
- 04查询和变更工厂模式
- 05页面状态与展示模型指导
适用场景
- 01规划新页面的路由、页面与功能层边界
- 02实现基于 TanStack Query 的数据读取与变更
- 03将请求状态整理为页面可用的展示模型
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ alexander-zuev/ agent-skills --skill tanstack-frontend安装后在 Claude Code 中输入/tanstack-frontend确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/data-access.mdperformance.mdphone-surfaces.mdtanstack-router-query.mdvisual-design.md详细介绍
前端 — React + TanStack Start
本技能负责前端布局、React、TanStack Query、状态和 UI 约定。 类型、架构、模块、注释和通用测试请使用 typescript-standards。 添加服务端函数(包括存根)时,请使用 cloudflare-backend。
标记为 [detect: …] 的部分指出需要阅读的证据。根据示例展示的关注点选择参考代码。
不要根据仓库的历史年限推断兼容性。在处理实体、查询或变更之前,请阅读 data access。
编辑前,追踪目标项目的工厂函数、服务端函数、中间件注册和错误载荷。
说明所选层、成功/错误契约、源文件和重大冲突。
TanStack Start
- 使用 TanStack Start,绝不使用 Next.js。采用传统 SSR + 水合,不使用 RSC,也不使用
'use client'。 - 仅服务端代码:使用
createServerFn()— 在src/server/entrypoints/functions/中定义 RPC 风格的服务端函数。 - Loader 可同构运行 — 在服务端和客户端都会执行。机密信息和数据库访问必须通过服务端函数完成。
- 需要时调整待处理状态的计时参数 — 使用原生路由选项,绝不使用人为延迟:
pendingMs: 0(立即显示待处理状态;默认值为 1000ms),pendingMinMs: 500(避免闪烁)。大多数路由则依赖由 loader 注入的数据和pendingComponent。
架构
心智模型
FSD 指 Feature-Sliced Design(特性切片设计)。实体表示用户、通话或余额等业务概念。 特性表示协调这些概念的用户操作。实体切片不负责屏幕工作流。
Route → Page → Feature → Entity → Shared
↘ Shared UI
导入依赖指向更底层的层。没有可复用特性时,页面可以组合共享 UI 和实体。
项目将 Shared 调整为 ui/、lib/ 和可在浏览器安全使用的工作区包。
目录
apps/<app>/src/ 下的共享核心:
src/
├── routes/ # TanStack Router files — thin, delegate to pages/
├── pages/ # orchestrators
├── entities/ # business concepts: types, factories, pure derivations
│ └── <entity>/ # <entity>-queries.ts, <entity>-mutations.ts, domain files
├── features/ # user actions and reusable workflows
│ └── <feature>/
│ ├── components/
│ ├── hooks/
│ ├── queries/ # feature-owned data, when no entity owns it
│ └── models/ # or store/, schema/ as needed
├── ui/ # app-specific presentation + stylesheets
├── lib/ # clients (rpc, auth, sentry), router, shared hooks
└── server/ # backend half (see cloudflare-backend skill)
[detect] 现有实体切片可能采用扁平结构,也可能使用 model/ 和 api/ 等 FSD 分段。
遵循项目现有布局。不要仅为匹配 FSD 图示而引入分段或 barrel 文件。
较早的项目会将模型放在特性或 lib 下。不要重构无关代码。
[detect] 设计系统位置:共享的 packages/ui 工作区包(纯基础组件 + token 样式表,由多个应用使用)和/或应用本地的 src/ui/components/。应用本地组件可以按领域(auth/、media/)或类型(controls/、overlays/、feedback/ 等)组织 — 遵循现有目录,不要自行设计分类体系。
层级规则
- 路由是精简的适配器 — 配置路由并渲染页面。
- 页面组合特性 — 不内联服务;营销/落地页可以拥有本地的
sections/和components/子目录树。 - 特性负责操作工作流、组件和钩子。实体负责可复用业务数据及其查询/变更工厂函数。
- UI 组件应保持纯粹 — 不调用 API、不包含业务逻辑、不导入特性。
- 按实体归属放置业务数据。根据复用次数决定展示代码保留在本地还是提取为共享代码。
✅ page → feature · feature → entity/model · feature → ui · entity → lib · hook → query factory
❌ ui component → feature · entity → feature · page → direct API call · circular feature pairs
实体切片不得导入同级实体切片。特性切片不得导入同级特性切片。
在使用方特性或页面中协调关系。适当时,将共享契约放入可在浏览器安全使用的核心包。
只有当项目已采用 FSD 显式的 @x 实体 API,且领域关系确实需要时,才使用该 API。
在添加或更改实体边界前,请阅读官方的 FSD layers 指南。
应用页面保持在 100 行以内。营销页面可以更长;除非任务范围要求,否则不要重构。
组件架构(Fowler 分层)
- 组件 — 精简的 JSX,仅包含 props 和处理函数。少于 100 行(目标为 50 行)。
- Hooks — 状态/副作用、合并两个或更多查询的领域对象,或表现模型(见下文)。仅转发一个查询及其调用处选项的 Hook 属于透传:直接在工厂函数或绑定的路由上下文对象上调用
useSuspenseQuery/useQuery,并在调用处添加额外选项(select、staleTime)。当一个 Hook 管理多个调用方共享的规则(例如何时轮询)时,封装一个查询是合理的;按该用途命名,并原样返回查询结果,让调用方保留isPending、isError和data。不要将UseQueryOptions作为 Hook 参数——这会破坏类型推断。来源:The Query Options API——如果包装 Hook「所做的只是」转发查询,那它「似乎有点多余」。
// ✅ extra options at the call site
const { data } = useQuery({
...productQueries.detail({ productId: id }),
select: (product) => product.title,
staleTime: 20_000,
})
// ❌ do not wrap the factory and forward UseQueryOptions
function useProduct(id: ProductId, options?: UseQueryOptions<Product>) {
return useQuery({
...productQueries.detail({ productId: id }),
...options,
})
}
- Models — 与框架无关的业务逻辑。不依赖 React。
- Services/queries — 获取数据、进行 API ↔ 领域转换。领域映射放在
queryFn中。视图映射是另一项工作——见「表现模型」。
表现模型
路由读取一个 Hook,并向下传递其结果。将查询状态映射为 props 不属于连接工作——这应放在负责屏幕视图模型的 Hook 中:如果只有一个屏幕读取它,则放在 pages/<page>/use-<thing>.ts;如果有两个或更多屏幕读取,则放在 features/<feature>/hooks/。entities/ 存放类型、查询工厂函数和纯派生逻辑,且不会导入 React。
领域模型、数据层与 Hook
来源:Modularizing React Applications(Fowler 和 Qiu)。每一层各司其职:
| 层 | 职责 | 本项目中的位置 |
|---|---|---|
| 视图 | 负责渲染并调用回调。它不知道数据来自哪里。 | components |
| Hook | “视图背后的状态机。”它持有状态、接收事件、调用数据层,并向视图提供领域对象和回调。 | features/*/hooks/、pages/*/use-*.ts |
| 领域模型 | 处理领域数据的业务规则,可表现为类型、纯函数或带 getter 的类。它“不知道视图的存在”,也可以在服务端运行。 | entities/ |
| 数据层 | 获取响应并将其转换为领域对象(“获取并转换”),这样外部数据结构发生变化时,改动就只需落在一个文件中。 | 查询和变更工厂(queryFn、mutationFn) |
由此得出的规则。文章支持使用获取的数据构建不含 UI 数据的领域对象。文章没有涵盖加载或错误状态,因此关于请求状态的规则 1 和 2 是本项目的约定。
只显示一个查询的组件会直接读取该查询,并根据 isPending、isError 和 data 进行分支(参见异步状态渲染)。只有当请求状态成为实体的输入时,它才会成为领域事实。
- 领域模型只接收领域值。 实体类型或类绝不能接收查询或变更的结构,无论是导入还是重新定义都不行:不能包含
status、variables、data、error、isPending或refetch。这些描述的是数据如何传递,而不是业务含义。 - 数据层转换负载;Hook 转换请求状态。
queryFn将响应转换为领域对象。Hook 在构建实体之前,将 TanStack 状态转换为领域事实:data → AdmittedCall、error → refused、status === "pending"→ 尚无已接纳的调用。 - Hook 负责汇合;领域模型负责决策。 当多个来源(查询、变更、SDK、本地状态)共同构成一个概念时,Hook 会将它们的领域值传给一个实体。实体推导出状态或权限等规则。Hook 自身不包含任何业务规则。
- 领域类是不可变的。 Hook 会在渲染期间根据当前值构建它。它不会被原地更新,也不会被订阅。
// ❌ mapping in the route — 20 lines, still wrong
const { data, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery(conversationQueries.list())
const conversationList = data
? { status: 'ready', conversations: data.pages.flatMap((p) => p.conversations), hasMore: hasNextPage, … }
: { status: 'failed', error, onRetry: () => void refetch() }
// ✅ pages/conversations/use-conversation-list.ts returns the view-ready shape
export function useConversationList(): ConversationList {
const query = useInfiniteQuery(conversationQueries.list())
if (query.status === 'pending') return { status: 'pending' }
if (query.status === 'error') {
return { status: 'failed', error: query.error, onRetry: () => void query.refetch() }
}
return { status: 'ready', conversations: query.data.pages.flatMap((p) => p.conversations), … }
}
这里有两个具体原因,与风格无关:
- 根据
data/error而不是status分支会遗漏pending。 ❌ 版本会在正常加载时渲染“失败”。只有status能区分全部三种状态。 - 页面可以使用 fixture 渲染。 每种状态都会成为故事可以传入的值——包括“失败”状态,否则就必须通过模拟一个会拒绝的 fetch 来诱发它。
返回的结构是一个判别联合类型,并为每个分支定义字段,因此每个分支都不会携带自身无法使用的内容:
export type ConversationList =
| { readonly status: 'pending' }
| { readonly status: 'ready'; conversations: readonly ConversationSummary[]; hasMore: boolean; isLoadingMore: boolean; onLoadMore: () => void }
| { readonly status: 'failed'; error: unknown; onRetry: () => void }
可复用的实体状态类型放在实体切片中。特定屏幕的展示类型放在对应页面 Hook 旁边。 绝不要在实体中导入页面类型。本项目保持实体模块不导入 React;FSD 本身允许实体包含 UI。
页面 Props
- 将处理函数归入一个
actions对象,而不是零散地传入一堆onXProps——之后添加操作时,只需修改一个调用点,而不必改动每个 story 和测试框架。 - 传递数据,而不是
ReactNode插槽,前提是页面已经拥有插槽所需的数据。只用于包裹<Header host={host} relay={relay} />的header: ReactNodeProp,应该改为传入host,并由页面自行渲染 Header。 - 导航应使用
Link,绝不能使用操作回调。onOpenThing: (id) => navigate(...)会失去 cmd/中键点击、“在新标签页中打开”、右键 → 复制地址,以及悬停时显示 URL 的功能。useNavigate用于命令式场景——例如变更后导航,而不是用于前往某处。
| 指标 | 限制 | 操作 |
|---|---|---|
| 行数 | <100(目标 50) | 提取组件/Hook |
| 使用的 Hooks | ≤5 | 提取自定义 Hook |
| Props | ≤7 | 组合或上下文 |
| 三元表达式嵌套深度 | 1 | 提前返回 |
重构方式:提取 Hook(移出状态/副作用)、提取 Model(将计算移至纯 TS)、提取 Service(移出 API 调用)。反模式:臃肿的组件、万能 Hook、在 JSX 中写逻辑、与框架耦合、将 Store 当作 Service。
数据层——查询与变更工厂
每个实体都提供可复用的 queryOptions、infiniteQueryOptions 和 mutationOptions 构造器。
工厂负责键、类型化请求、领域转换和缓存效果。消费者 Hook 负责页面状态和操作顺序。
示例、参考文件、API 检查和验证,请参阅数据访问。
在当前传输模式下,直接返回服务端函数的值。 只有在检查确认服务端函数返回封装对象时,才使用现有的解包辅助函数。 序列化和错误转换由后端技能负责。
对于路由预取查询,路由只绑定一次 URL 输入。 Loader 和页面使用相同的查询选项。
查询键
- 键必须包含
queryFn读取的每个输入。更改键即可重新获取数据。不要调用refetch({ newId })。 - 层级:
['products']→['products', 'list']→['products', 'list', filters]→['products', 'detail', input]。 - 使用前缀进行失效处理:
invalidateQueries({ queryKey: ['products', 'list'] })。 - 绝不要让
useQuery和useInfiniteQuery共用同一个键。 - 使用
productQueries.detail({ productId: id }).queryKey,以类型安全的方式调用getQueryData/setQueryData。
转换(select)
| 位置 | 用途 |
|---|---|
queryFn | 在需要转换时,将传输值转换为领域值。缓存存储此值。 |
调用处的 select | 选择单个字段或派生值。不要将 select 放入工厂中。 |
| 渲染 | 仅进行开销较小的派生计算。 |
默认不要使用 select。当较大的负载会导致小组件重新渲染时,才使用它。
变更
变更放在 <entity>-mutations.ts 中。
- 成功后,默认使用
invalidateQueries。 - 如果按钮必须保持待处理状态,直到列表刷新完成,则返回该 Promise。
- 优先使用
mutate。只有调用方必须组合或等待返回的 Promise 时,才使用mutateAsync。 - 工厂的
onSuccess用于缓存操作。调用处的mutate(variables, { onSuccess })用于提示消息/重定向。 - 使用一个变量对象:
mutate({ title, body })。 - 不要为表单、对话框或重定向编写乐观缓存更新。仅在即时切换时使用。变更后优先使用失效处理。
setQueryData是例外:用于即时切换,或变更恰好返回缓存中的完整行数据时。
无限查询
使用 infiniteQueryOptions。为其指定一个不同于常规列表键的键。设置 initialPageParam。
Router + Query 所有权
- Entity:拥有查询工厂、查询键、请求和领域转换。不导入 Router API。
- Route:验证 URL 状态,并绑定当前路由匹配的所有查询输入。
- Loader:使用绑定的查询选项启动缓存工作。**抛出还是吞掉错误是 UX 决策,而非偏好。**等待
queryClient.query(options)(或者在必须优先使用缓存数据、即使数据已过期时,使用query({ ...options, staleTime: 'static' }))。抛出错误会通过errorComponent替换整个路由。使用.catch(noop)吞掉错误,让失败留在页面布局中——此时组件也负责处理pending,因为 suspense 不再覆盖它。当读取失败时需要保留外壳(页眉、导航、操作),就选择吞掉错误。ensureQueryData/prefetchQuery/fetchQuery是已弃用的别名,将在 Query v6 中移除。 - Page:使用
useQuery或useSuspenseQuery观察绑定的查询。 - Feature:使用领域数据,并负责用户操作。
Router 和 React 使用同一个 QueryClient。当 Query 负责数据新鲜度时,设置 defaultPreloadStaleTime: 0。
为每个 SSR 请求创建一个新的 QueryClient。配置 setupRouterSsrQueryIntegration 以支持反序列化、水合和流式传输。
通过 Query hook 读取由 Query 管理的数据。不要通过 useLoaderData 读取。
有关绑定、Loader 时序、边界、订阅、示例和来源,请参阅 TanStack Router + Query。
页面中的路由参数 / 上下文
当路由需要验证、细化或转换时,在路由定义中使用 params.parse 解析路径参数。返回解析后的值,以便子路由继承这些值。解析器应保持确定性且无副作用。
页面通过 getRouteApi('/_authed/products/$productId') 读取参数和由 Loader 注入的上下文——无需从路由文件逐层传递属性。搜索参数通过 validateSearch 使用 Zod 验证(以及 @tanstack/zod-adapter)。
状态管理
| 状态类型 | 解决方案 | 适用场景 |
|---|---|---|
| 服务端数据 | TanStack Query | 始终使用,没有例外。 |
| 本地 UI | useState | 单个组件 |
| 全局客户端状态 | Zustand | 满足以下条件时 |
| 派生状态 | 就地计算 | 永远不要存储可以计算得出的内容 |
服务端状态
所有服务端数据都通过 Query 获取——不使用 useEffect+fetch,也不把查询结果复制到存储中。客户端缓存写入(queryClient.setQueryData)仅用于上述 mutation 例外,不用于本地 UI 状态。
**避免瀑布式请求:**设计服务端函数,使其返回页面所需的数据(在服务端进行联接,避免 N+1);无法避免多个请求时,使用 Promise.all。提高 staleTime 以减少重复获取。默认保留焦点变化时重新获取。只有在产品行为有明确记录时才更改。过期查询会在 refetchOnMount 允许时于挂载时重新获取。默认值为 staleTime: 0 和 refetchOnMount: true。
Zustand
仅在以下情况下使用:(1) 多个组件需要相同的客户端状态;(2) 需要持久化;(3) 需要在 React 树之外访问。否则使用 useState 或提升状态。实际应用只需要很少的存储(当前仓库:0–2 个)——应将新增存储视为设计决策,而非默认选择。
规则:
- 使用原子选择器(不要在每次渲染时创建新的
{},除非使用useShallow) - 查找操作应为 O(1)——使用查找映射,而非在选择器中调用
.find() - 将
actions子对象与数据分开 - 永远不要存储服务端数据
// ❌ new object every render → re-renders on ANY change
const { x, y } = useStore((s) => ({ x: s.x, y: s.y }))
// ✅ atomic
const x = useStore((s) => s.x)
const y = useStore((s) => s.y)
// ❌ O(n) per render // ✅ O(1)
useStore((s) => s.nodes.find((n) => n.id === id))
useStore((s) => s.nodeLookup[id])
原生存储变体——当非 React 代码需要访问存储(上传引擎、后台任务)时:使用 createStore(vanilla)+ zustand/react 中的 useStore + useShallow、devtools 中间件和 actions 子对象;非响应式句柄(例如引擎实例)应放在与存储并列的模块级 Map 中,而不是放在状态里。
Effects
Effect 必须在 setup → cleanup → setup 流程中保持正确。 绝不要使用 ref 或标记来抑制 Strict Mode 重放。请参阅 React 的重放指南。 用户操作(包括购买)应放在其发起事件对应的事件处理程序中。 若要在身份验证或外部重定向后自动继续,请在重定向前持久化一个稳定的尝试 ID。 在服务端或外部边界实施幂等性,确保重放仍然安全。
Lint 会强制执行这些规则:react/set-state-in-effect、react/no-deriving-state-in-effects 以及九条 react-you-might-not-need-an-effect/* 规则均为 error。每一行对应 You Might Not Need an Effect 的一个小节;中间列引用了该小节的总结。
| Effect 的用途 | react.dev 的建议 | 使用方式 |
|---|---|---|
| 根据 props/state 计算值 | “如果可以在渲染期间计算某个值,就不需要 Effect。” | 在渲染期间计算 |
| 缓存开销较大的值 | “要缓存开销较大的计算结果,请使用 useMemo,而不是 useEffect。” | useMemo |
| 根据 prop 重置所有状态 | “要重置整个组件树的状态,请为其传入不同的 key。” | key |
| 根据 prop 调整部分状态 | “要根据 prop 变更重置某个特定状态,请在渲染期间设置它。” | 在渲染期间使用 if (prev !== next) setState(...) |
| 响应用户操作 | “因组件被显示而运行的代码应放在 Effect 中,其余代码应放在事件中。” | 事件处理程序 |
| 在 setState 后接着调用 setState | “如果需要更新多个组件的状态,最好在单个事件中完成。” | 单个处理程序或 reducer 事件 |
| 仅运行一次的应用初始化 | 在模块级别运行,而不是在每个组件中运行 | 模块作用域 |
| 向父组件报告状态 | “每当尝试同步不同组件中的状态变量时,请考虑将状态提升到更高层。” | 提升状态;在处理程序中调用 onChange |
| 订阅 store 或浏览器 API | 有专门用于此用途的内置 Hook | useSyncExternalStore |
| 获取服务端数据 | “可以使用 Effect 获取数据,但必须实现清理,以避免竞态条件。” | TanStack Query(参见服务端状态) |
| 测量 DOM 节点 | 回调 ref 在挂载时运行,在卸载时清理 | ref={useCallback((node) => {...; return cleanup}, [])} |
| 与外部系统同步,并进行清理 | 这正是 Effect 的用途 | useEffect,且仅在其异步回调中调用 setState |
// ❌ redundant state + sync effect
const [filtered, setFiltered] = useState([])
useEffect(() => { setFiltered(items.filter((i) => i.active)) }, [items])
// ✅ derive inline
const filtered = items.filter((i) => i.active)
异步状态渲染
互斥状态 = 独立分支。绝不使用嵌套三元表达式。
| 数据来源 | 模式 |
|---|---|
| 路由 loader(关键数据) | pendingComponent / errorComponent 配置 |
组件中的 useQuery | 提前返回 |
| 多个异步数据源 | 组合 Suspense + ErrorBoundary |
function Component() {
const { data, isPending, isError } = useQuery(...)
if (isPending) return <Layout><Skeleton /></Layout>
if (isError) return <Layout><Error /></Layout>
return <Layout><Content data={data} /></Layout>
}
查询渲染与边界
等待加载器查询会阻塞路由,直到关键数据就绪。未等待的查询可以通过 Suspense 流式传输,也可以通过 useQuery 保持非阻塞。
路由查询使用 Router 的待处理状态和错误边界。独立小组件可以拥有本地 Suspense 和错误边界。
使用 getRouteApi 或严格的 from 路由。使用 select 订阅所需范围最小的 Router 状态。
更改路由预取或 Query SSR 行为前,请阅读 TanStack Router + Query。
轮询
使用 TanStack Query 的 refetchInterval;不要另外添加间隔循环。
对于有限时长的作业,在其进入终止状态时停止轮询。如果作业有完成期限,请使用项目的超时策略。
对于通话期间的余额等实时数据,由使用方功能控制何时启用轮询。
持续读取没有终止状态,因此无需自行设定卡住作业的超时。
根据产品需求选择轮询间隔。除非产品要求更改,否则保留 Query 的后台和焦点行为。
按钮中的待处理/加载状态
在不同状态下保持按钮尺寸和标签位置稳定。使用以下三种模式之一:
- 仅显示微调器,同时保留标签所占空间和无障碍名称
- 保持文本不变,并在左侧预留的空间中显示微调器
- 保持文本不变,仅禁用按钮
微调器不得移动标签或改变按钮尺寸。
列表与重复元素性能
较新的应用已启用 React Compiler(babel-plugin-react-compiler)[detect vite.config.ts]——它会处理记忆化,因此对于编译器覆盖的情况,不要手动编写 useMemo/useCallback。结构性开销仍会叠加;渲染 10 个或更多重复项时:
- 尽量减少每项的开销——每行使用 1-2 个组件,检查 DOM 节点,并移除多余的包装
<div>。 - 事件委托:达到 100 项或每项有 3 个以上处理器时,改为在容器上设置一个处理器并读取
data-item-id,而不是为每行创建闭包。 - 将复杂逻辑延迟到交互时——仅为当前活动行按需渲染一个菜单/工具提示,而不是为每行都挂载一个:
const [menuTarget, setMenuTarget] = useState<string | null>(null)
{items.map((item) => (
<Row key={item.id} onContextMenu={() => setMenuTarget(item.id)} />
))}
{menuTarget && <ContextMenu itemId={menuTarget} />}
- 不要在列表项中使用
useEffect——副作用会随项数增加;应提升到容器中。 - 达到 200 项或更多时使用虚拟化(TanStack Virtual)。少于 200 项时不要使用。
- CSS:重复元素使用扁平的基于类名的选择器;避免使用
:has()(会逐个父元素评估子元素)。
设计系统
技术栈:Tailwind CSS v4(@theme)+ @radix-ui/colors 色阶 + shadcn 风格组件(cva variants、tailwind-merge),基于 Base UI / Radix 原语。
- 设计令牌集中定义在样式表中:
ui/stylesheets/{colors,typography,radius,shadows,utilities}.css(跨应用共享时位于packages/ui)。colors.css导入 Radix 色阶,并将其映射为兼容 shadcn 的变量名——组件依赖这些名称。 [detect imports in ui components]原语库混用:较新的组件使用@base-ui/react,较旧的组件使用@radix-ui/react-*。遵循当前文件的用法;创建新组件时,遵循仓库中的主流做法。- 不要在 CSS 文件中编写组件样式——组件使用设计令牌,以 TSX 编写。
- 缺少设计令牌? 先检查样式表,再扩展设计系统——不要使用一次性的值或类。
- 切勿重新实现设计系统中已有的组件、图标或模式。
UI 文案与图标
UI 文案末尾不加句号:标签、按钮、提示、验证错误、Toast、引导文案和空状态。
用逗号连接两个分句,或只使用一个分句。电子邮件、法律页面、文章和 SEO 描述使用句末标点。
遵循项目的本地化系统。
导入 Phosphor 图标时使用带 Icon 后缀的名称,例如 PlusIcon 和 MagnifyingGlassIcon。
不要使用 || 隐藏缺失数据或错误状态。应完善类型或解析输入。
允许使用合理的产品默认值。
手机界面
对于手机产品、PWA 和手机流程,请阅读 手机界面。 这些规则不会自动应用于桌面界面或混合界面。
浏览器支持
最低支持版本是构建技术栈所针对的最旧浏览器。Tailwind CSS 4 和 Vite 8 决定了这一版本:Safari 和 iOS 16.4、Chrome 111、Firefox 128。项目的 .browserslistrc 中记录了该版本。不要面向低于此版本的浏览器构建。
代码必须在最低支持版本上正常运行:
- 每个新增的平台 API 都要查阅 MDN browser-compat-data,不能凭记忆判断。
URL.parse需要 Safari 18,因此在 iOS 16 和 17 上导致了登录崩溃。 - 高于最低支持版本的 API 必须配备 polyfill,或添加标记为
// FALLBACK: <who gets what>的回退分支。 - 低于最低支持版本时,应用应显示更新界面,不得启动。
[detect] 项目具备 browser-support/no-unsupported-api lint 规则时,该规则会强制检查第 1 和第 2 项。拒绝要求支持低于最低支持版本浏览器的审查意见。
视觉设计
关于字体排印、间距、界面层次和交互规则,请阅读 视觉设计。
Storybook
- 框架包:
@storybook/tanstack-react— 从中导入Meta/StoryObj;类型化 meta:const meta: Meta<typeof Component> = {...}。 - Story 集中放在
.storybook/下(design-system/、components/、features/、pages/),相对于.storybook/发现,并在.storybook/story-paths.ts中定义共享标题常量。涉及数据的 story 使用msw-storybook-addon配合 MSW。 - 以 Storybook 为先的 UI 工作流:先在 story 中试做新 UI,再编写应用代码;story 是 UI 规范——每次 UI 变更都要同步更新,并在完成后提供 story URL。
- Story 绝不使用本地化系统。Story 文件和 story 数据中的所有文案都使用纯文本。不要在 story 中导入消息函数,也不要为 story 添加消息。story 所渲染的生产组件仍使用各自的消息。唯一例外是展示国际化本身的 story,例如语言切换器。
查询测试
每项测试都使用新的 QueryClient。在测试默认配置中设置 retry: false。
测试网络边界时使用 MSW。在可行的情况下,通过缓存测试工厂行为。
请阅读 数据访问,根据变更的行为执行相应检查。
性能目标
关于性能工作,请阅读 性能目标。
表单
[detect package.json]:
- 较新的应用使用 TanStack Form,并搭配设计系统的
field.tsx原语。 - 较旧的应用使用 react-hook-form +
@hookform/resolvers,并搭配 shadcn 的form.tsx封装。
无论使用哪种方式:将 Zod schema 作为唯一验证来源(与服务端函数的 .validator 共享);使用设计系统中的字段原语;不使用临时拼凑的表单状态。
有意将服务端数据复制到表单状态,然后选择一种方式:
- 单人编辑:在查询上设置
staleTime: Infinity。使用快照,不进行后台覆盖。 - 多人协同编辑:保持 Query 实时更新。显示
field.value ?? serverValue,以便未触碰的字段仍能更新。
仅当数据存在时才渲染表单(必须定义 defaultValues)。保存后,等待失效处理完成并获取权威的已保存值。
- TanStack Form:
form.reset(savedValues)会重置表单并更新其默认值。 - React Hook Form:
reset(savedValues)会根据提供的值重置表单。实际可行时,请提供完整的值。
TanStack Form 规则
来源:shadcn TanStack Form、提交处理、表单组合。
- 在表单级别使用一个 Schema 进行校验:
validationLogic: revalidateLogic({ mode, modeAfterSubmission })和validators: { onDynamic: Schema }。根据表单选择校验时机,并在注释中说明原因。不要添加字段级校验器,也不要在onSubmit中手写校验。tanstacklint 插件会强制执行这些规则。 - 将
defaultValues类型标注为z.input<typeof Schema>。onSubmit接收输入值。如果输入类型和输出类型不同,请调用Schema.parse(value);否则直接使用value。 - 使用
useState(() => …)或模块常量,只计算一次时间或随机默认值。TanStack Form 会在不同渲染之间defaultValues发生变化时重置未操作过的表单。 - 为每个字段设置
id={field.name}、name={field.name}和onBlur={field.handleBlur}。isInvalid = field.state.meta.isTouched && !field.state.meta.isValid用于控制data-invalid、aria-invalid和错误提示。 - 表单元素使用
noValidate并调用event.preventDefault(),然后调用form.handleSubmit()。 - 表单组件接收
pending和onSubmit(output)。由调用方负责 mutation、toast 和关闭对话框。 - 异步校验器使用
onChangeAsync或onSubmitAsync。同步校验器不会等待 Promise。 - 服务端函数只接收
FormData格式的文件。在 mutation 工厂中构建它。
Auth(客户端)
lib/clients/auth-client 中的 better-auth 客户端由 lib/auth/ 层(hooks、models、services)封装——组件使用 useSession 风格的 hooks,绝不直接使用原始客户端。通过路由组(_auth/_authed layouts)保护路由,并由其 beforeLoad 重定向未认证用户。