禁止在客户端上下文中使用 Async 组件:深入解析 TanStack Start 的no-async-client-componentESLint 规则
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
在 TanStack Start 的 Server Components 架构中,Async 组件只能在服务端组件(Server Components)上下文中合法存在。一旦某个 Async 组件因为被路由选项引用、处于'use client'文件、或被客户端组件渲染而"变成"客户端组件,运行时就会直接崩溃——而且这类错误往往发生在页面渲染过程中,难以提前发现。TanStack Start 官方 ESLint 插件提供的@tanstack/start/no-async-client-component规则(文档见 no-async-client-component.md),正是为了在编译期、静态分析阶段就把这类隐患拦截下来。读完本文,你将掌握该规则的完整语义、两种报告位置的区别、如何用渲染图(render graph)机制追踪"客户端化"的组件,以及如何在实际项目中配置与修复这类问题。
规则背景:为什么 Async 组件不能出现在客户端上下文
Async React 组件(即用async关键字声明的组件函数)在标准客户端 React 中并不被支持——客户端 React 无法等待组件函数返回的 Promise 来继续渲染。它们只有在 React Server Components(RSC)的运行时中才是合法的:服务端渲染管线会解析组件的 Promise,把await到的数据渲染成最终的 UI。
在 TanStack Start 中,这意味着 Async 组件只能出现在服务端组件上下文里,具体有两种合法形态:
- 通过
renderServerComponent(<AsyncComp />)渲染(render-graph-builder.ts 中将其识别为服务端根节点); - 在
createCompositeComponent((props) => <AsyncComp />)的回调内部渲染(即复合服务端组件的回调体内)。
而问题在于:一个组件可能"直接或间接"地变成客户端组件。例如,文件路由的component选项本质上是一个客户端渲染入口,被它引用的组件会在浏览器端被 React 挂载;一旦 Async 组件被这样的客户端入口引用,代码虽然能通过编译,但运行时会抛出崩溃错误。no-async-client-component规则的价值,就是把这种"迟早会爆炸"的代码在 lint 阶段揪出来。
规则详情:正确与错误的代码形态
不正确的代码
下面的路由页面组件被声明为async,然后被createFileRoute的component选项引用——这正是最常见的错误形态。该组件在浏览器端会被当作客户端组件渲染,运行时会崩溃:
/* eslint "@tanstack/start/no-async-client-component": "error" */ import { createFileRoute } from '@tanstack/react-router' export async function Page() { return <div /> } export const Route = createFileRoute(undefined)({ component: Page, })正确的代码
方案一:去掉async。如果组件内部不需要await服务端数据,把它改回同步组件即可:
/* eslint "@tanstack/start/no-async-client-component": "error" */ import { createFileRoute } from '@tanstack/react-router' export function Page() { return <div /> } export const Route = createFileRoute(undefined)({ component: Page, })方案二:让 Async 组件留在服务端上下文。如果确实需要await数据,就把它放进createCompositeComponent回调,使其保持在服务端组件边界之内:
/* eslint "@tanstack/start/no-async-client-component": "error" */ import { createCompositeComponent } from '@tanstack/react-start/rsc' export const ServerPage = createCompositeComponent(async () => { const message = await Promise.resolve('hello') return <div>{message}</div> })值得注意的是,规则对 Async 组件的识别覆盖了两种常见声明形式:async function MyComponent() {}的函数声明,以及const MyComponent = async () => {}/const MyComponent = async function () {}的变量声明。源码中的 async-component-detector.ts 以 PascalCase 命名(首字母大写)+async修饰符两个条件来判定一个函数是不是"Async 组件"。
报告位置:Usage-site 与 Definition-site
该规则的报告点有两种,理解它们的区别有助于快速定位问题根源:
- Usage-site(使用点报告):当组件是因为被路由选项引用而变成客户端组件时(如
component、pendingComponent、errorComponent、notFoundComponent等),错误会报告在路由选项中引用该组件的位置(即路由文件里的 usage 处)。这样你能立刻看到"是哪个路由配置把 Async 组件拖进了客户端"。 - Definition-site(定义点报告):当组件是因为文件带有
'use client'指令或被某个客户端组件渲染而成为客户端组件时,错误会报告在组件定义处。
对应的两条报错消息分别以asyncClientComponentDefinition和asyncClientComponentUsage两个 messageId 定义在 no-async-client-component.rule.ts 中。判定逻辑在 context-analyzer.ts 中通过ClientReason区分三种原因:use-client(文件有'use client'指令)、route-option(被createFileRoute/createRootRoute等路由创建器的组件选项引用)、rendered-by-client(被客户端父组件渲染,报错信息中会带上父组件名与父文件路径)。
规则的推荐状态与配置方式
no-async-client-component被标记为Recommended(文档Attributes一节中标注 ✅ Recommended),意味着它被收录进插件的推荐配置,开箱即用。插件本身的安装与配置请参阅 eslint-plugin-start.md,这里给出与本规则直接相关的几种方式。
Flat Config(ESLint 9+,eslint.config.js)
启用全部推荐规则(包含本规则):
// eslint.config.js import pluginStart from '@tanstack/eslint-plugin-start' export default [ pluginStart.configs['flat/recommended'], // Any other config... ]只启用当前这条规则:
// eslint.config.js import pluginStart from '@tanstack/eslint-plugin-start' export default [ { plugins: { '@tanstack/start': pluginStart, }, rules: { '@tanstack/start/no-async-client-component': 'error', }, }, // Any other config... ]Legacy Config(.eslintrc)
{ "extends": ["plugin:@tanstack/eslint-plugin-start/recommended"] }或自定义:
{ "plugins": ["@tanstack/eslint-plugin-start"], "rules": { "@tanstack/start/no-async-client-component": "error" } }插件内部在 index.ts 中同时导出了recommended与flat/recommended两套配置,两者都把no-client-code-in-server-component与本规则设为error级别;而 rules.ts 则把两条规则统一注册进插件。
可选项:ignorePatterns
规则还支持一个可选的ignorePatterns选项(数组,元素为文件路径子串),用于忽略某些文件的检查。凡是文件路径包含列表中任意子串的文件都会被跳过(见 no-async-client-component.rule.ts 中的shouldIgnore实现)。例如:
rules: { '@tanstack/start/no-async-client-component': ['error', { ignorePatterns: ['legacy/', 'generated/'], }], }原理剖析:渲染图 + 上下文传播的静态分析
这条规则并不是简单的"看到 async 函数就报错",而是构建了一张跨文件的组件渲染图(render graph)来做可达性分析,其核心实现分布在规则目录下的 4 个文件中:
- render-graph-builder.ts:遍历 TypeScript Program 的所有源文件,索引组件(
fileName:ComponentName为 key)、JSX 渲染边(<Comp />形成的 from→to 边)、服务端根节点(renderServerComponent/createCompositeComponent)、客户端根节点(路由组件选项引用、'use client'文件内的组件)以及路由选项的使用位置信息。 - context-analyzer.ts:在渲染图上做 server/client 上下文的传播。算法分三个阶段:先从服务端根节点向下传播服务端上下文(遇到
'use client'边界即停止);再从客户端根节点向下传播客户端上下文;最后对'use client'文件中的组件强制标记为客户端。如果一个组件同时从两条路径可达,客户端上下文优先("tainted",被污染)——这正是"直接或间接变成客户端"的判定核心。 - async-component-detector.ts:负责识别文件中的 Async 组件(PascalCase 命名 +
async声明/初始化)。 - no-async-client-component.rule.ts:规则主入口,负责按上述报告策略输出诊断。
为兼顾性能,主规则文件做了多处缓存与门控(gating)优化:
- 每个
ts.Program只构建一次完整渲染图(perProgramCache,按需构建graphBuilt标记); - 对每个入口文件按需切片出可达子图(
sliceGraphForEntryFile)再进行分析,并通过onDemandCache缓存分析结果; - 在
Program()钩子中先做快速文本级门控(fileContainsRouteOptions/fileContainsServerComponentRoots/fileHasUseClientDirective),绝大多数不相关的文件直接跳过,避免无谓的全程序图构建; - 用
adjacencyCache缓存邻接表,避免每次切片都做全边扫描。
一个值得注意的细节是:路由创建器的识别不仅支持createFileRoute('/x')({...})这种柯里化调用,还支持createRootRoute()、createRootRouteWithContext<T>()以及链式调用(createFileRoute('/x').update(...)({...})),getRouteOptionsObject/findRouteCreatorFromCallee会沿着 callee 一路回溯寻找真正的路由创建器来源。
测试用例验证:规则行为一览
规则测试位于 no-async-client-component.rule.test.ts,覆盖了非常完整的行为矩阵,可以直接当作"规则行为规范"阅读:
合法(valid)场景:
- 路由中的同步组件(
component: SyncComponent); createCompositeComponent(async () => { ... })内的 Async 组件;'use client'文件中的同步组件;- 路由中内联的同步箭头组件(
component: () => <div>Hello</div>); - 同步
pendingComponent; createRootRoute中的同步组件。
非法(invalid)场景(均报asyncClientComponentDefinition):
- Async 组件直接作为
component; 'use client'文件中的 Async 组件(export async function AsyncClientComponent());- Async 箭头组件作为
component(const AsyncPage = async () => {...}); - Async
pendingComponent; - Async
errorComponent; createRootRoute中的 Async 组件;createRootRouteWithContext<MyRouterContext>中的 Async 组件;- 同一文件内多个 Async 组件被不同路由选项引用(每个各报一次错误)。
从测试可以看出规则的两大边界:"Async 组件 + 客户端入口 = 报错",而**"同步组件无论在哪、Async 组件只要在服务端边界内 = 放行"**。
常见问题与排查建议
Q1:报错说组件"cannot be used in client context",但我的组件没有写'use client'?答:很可能是被路由选项(component/pendingComponent/errorComponent/notFoundComponent)引用了,或是在某个客户端组件内部被<Comp />渲染。检查报错位置是 Usage-site(路由文件里)还是 Definition-site(组件定义处),前者会直接指向引用它的路由配置。
Q2:报错信息里的reason后缀是什么意思?答:规则消息会拼接一条原因说明:File has "use client" directive.、Component is referenced by a route option (createFileRoute/createRootRoute).或Rendered by client component "X" in <路径>.(由formatReason生成),帮助你一眼判断客户端化的根因。
Q3:我确实需要客户端渲染一个"看起来像异步"的组件,怎么办?答:客户端组件本质上无法await,请改用同步组件配合数据请求方案(如 TanStack Query、loader 数据预取等)在渲染前把数据准备好;只有服务端组件(renderServerComponent/createCompositeComponent)才允许 Async 组件,这是规则坚持的边界。
Q4:规则会误报构建产物或第三方文件吗?答:渲染图构建时会跳过.d.ts声明文件和node_modules(见 render-graph-builder.ts 的build()预过滤),若仍有特定目录需要豁免,可使用上文提到的ignorePatterns选项。
小结
@tanstack/start/no-async-client-component是 TanStack Start 客户端/服务端边界安全的重要防线:它把"Async 组件只能在服务端上下文使用"这条运行时约束提前到了 lint 阶段,并通过跨文件的渲染图分析精确识别"直接或间接客户端化"的组件。配合插件另一条推荐规则no-client-code-in-server-component(见 no-client-code-in-server-component.md),可以在编码阶段就系统性地守住 RSC 边界,避免把运行时崩溃留给用户。
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考