执行模型
理解代码在哪里运行,是构建 TanStack Start 应用的基础。本指南解释 TanStack Start 的执行模型,以及如何控制你的代码在何处执行。
核心原则:默认同构(Isomorphic)
TanStack Start 中所有代码默认都是同构的——除非显式约束,否则它们会运行,并同时被包含在服务端和客户端打包产物中。
// ✅ This runs on BOTH server and client
function formatPrice(price: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price)
}
// ✅ Route loaders are ISOMORPHIC
export const Route = createFileRoute('/products')({
loader: async () => {
// This runs on server during SSR AND on client during navigation
const response = await fetch('/api/products')
return response.json()
},
})关键理解
路由的 loader(加载器)是同构的——它们既在服务端运行(SSR 时),也在客户端运行(导航时),并不只在服务端运行。
执行边界
TanStack Start 应用运行在两种环境中:
服务端环境
- Node.js 运行时,可以访问文件系统、数据库、环境变量
- SSR 期间——首屏页面在服务端渲染
- API 请求——服务器函数在服务端执行
- 构建期间——静态生成和预渲染
客户端环境
- 浏览器运行时,可以访问 DOM、localStorage、用户交互
- 水合之后——首屏服务端渲染完成后,由客户端接管
- 导航期间——路由的加载器在客户端运行
- 用户交互——事件处理器、表单提交等
执行控制 API
仅服务端执行
| API | 用途 | 客户端行为 |
|---|---|---|
createServerFn() | RPC 调用、数据变更 | 向服务器发起网络请求 |
createServerOnlyFn(fn) | 工具函数 | 抛出错误 |
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
// RPC: Server execution, callable from client
const updateUser = createServerFn({ method: 'POST' })
.validator((data: UserData) => data)
.handler(async ({ data }) => {
// Only runs on server, but client can call it
return await db.users.update(data)
})
// Utility: Server-only, client crashes if called
const getEnvVar = createServerOnlyFn(() => process.env.DATABASE_URL)仅客户端执行
| API | 用途 | 服务端行为 |
|---|---|---|
createClientOnlyFn(fn) | 浏览器工具函数 | 抛出错误 |
<ClientOnly> | 需要浏览器 API 的组件 | 渲染兜底内容(fallback) |
import { createClientOnlyFn } from '@tanstack/react-start'
import { ClientOnly } from '@tanstack/react-router'
// Utility: Client-only, server crashes if called
const saveToStorage = createClientOnlyFn((key: string, value: any) => {
localStorage.setItem(key, JSON.stringify(value))
})
// Component: Only renders children after hydration
function Analytics() {
return (
<ClientOnly fallback={null}>
<GoogleAnalyticsScript />
</ClientOnly>
)
}useHydrated Hook
要对依赖水合的行为做更细粒度的控制,可以使用 useHydrated hook。它返回一个布尔值,表示客户端是否已经完成水合:
import { useHydrated } from '@tanstack/react-router'
function TimeZoneDisplay() {
const hydrated = useHydrated()
const timeZone = hydrated
? Intl.DateTimeFormat().resolvedOptions().timeZone
: 'UTC'
return <div>Your timezone: {timeZone}</div>
}行为:
- SSR 期间:总是返回
false - 客户端首次渲染:返回
false - 水合之后:返回
true(并且在后续所有渲染中保持true)
当你需要根据客户端数据(比如浏览器时区、语言或 localStorage)有条件地渲染内容,同时又要为服务端渲染提供合理兜底时,这个 hook 非常有用。
按环境区分实现
import { createIsomorphicFn } from '@tanstack/react-start'
// Different implementation per environment
const getDeviceInfo = createIsomorphicFn()
.server(() => ({ type: 'server', platform: process.platform }))
.client(() => ({ type: 'client', userAgent: navigator.userAgent }))架构模式
渐进增强
构建在无 JavaScript 时也能工作的组件,再用客户端功能增强体验:
function SearchForm() {
const [query, setQuery] = useState('')
return (
<form action="/search" method="get">
<input
name="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ClientOnly fallback={<button type="submit">Search</button>}>
<SearchButton onSearch={() => search(query)} />
</ClientOnly>
</form>
)
}环境感知的存储
const storage = createIsomorphicFn()
.server((key: string) => {
// Server: File-based cache
const fs = require('node:fs')
return JSON.parse(fs.readFileSync('.cache', 'utf-8'))[key]
})
.client((key: string) => {
// Client: localStorage
return JSON.parse(localStorage.getItem(key) || 'null')
})RPC 与直接函数调用
理解何时使用服务器函数 vs 仅服务端函数:
// createServerFn: RPC pattern - server execution, client callable
const fetchUser = createServerFn().handler(async () => await db.users.find())
// Usage from client component:
const user = await fetchUser() // ✅ Network request
// createServerOnlyFn: Crashes if called from client
const getSecret = createServerOnlyFn(() => process.env.SECRET)
// Usage from client:
const secret = getSecret() // ❌ Throws error常见反模式
模块作用域读取 process.env
在模块作用域读取 process.env 有两个错误,而不只是一个:
- 安全:值可能被内联进客户端打包产物。
- 运行时正确性:在 Cloudflare Workers 和其他边缘 SSR 运行时中,环境变量是按请求注入的。模块作用域的代码在模块加载时就执行了,此时环境变量还不存在,因此读取结果即使是服务端也是
undefined。
// ❌ Leaks to client AND is undefined under Worker SSR
const apiKey = process.env.SECRET_KEY
// ✅ Wrap in a server-only function — read happens per call, on the server
const apiKey = createServerOnlyFn(() => process.env.SECRET_KEY)
// ✅ Or read directly inside `.handler()` / middleware `.server()` / server-route handlers
const fetchData = createServerFn().handler(async () => {
const apiKey = process.env.SECRET_KEY
// ...
})译者注:模块作用域读取环境变量的坑
这是初学者最容易踩的坑。const apiKey = process.env.SECRET_KEY 写在模块顶层,打包器可能把这个值直接内联进客户端代码,导致密钥泄露;在边缘运行时(比如 Cloudflare Workers)中,模块加载时环境变量还不存在,读到的是 undefined。正确的做法是总是在请求级回调里读取(.handler()、中间件 .server()、服务器路由处理器),或包一层 createServerOnlyFn。
错误的加载器假设
// ❌ Assuming loader is server-only
export const Route = createFileRoute('/users')({
loader: () => {
// This runs on BOTH server and client!
const secret = process.env.SECRET // Exposed to client
return fetch(`/api/users?key=${secret}`)
},
})
// ✅ Use server function for server-only operations
const getUsersSecurely = createServerFn().handler(() => {
const secret = process.env.SECRET // Server-only
return fetch(`/api/users?key=${secret}`)
})
export const Route = createFileRoute('/users')({
loader: () => getUsersSecurely(), // Isomorphic call to server function
})水合不一致(Hydration Mismatch)
// ❌ Different content server vs client
function CurrentTime() {
return <div>{new Date().toLocaleString()}</div>
}
// ✅ Consistent rendering
function CurrentTime() {
const [time, setTime] = useState<string>()
useEffect(() => {
setTime(new Date().toLocaleString())
}, [])
return <div>{time || 'Loading...'}</div>
}译者注:什么是水合不一致?
水合(Hydration)是指客户端用 React 接管服务端渲染出来的 HTML 并挂上事件监听的过程。如果服务端渲染的 HTML 与客户端首次渲染的内容不一致(比如这里直接渲染当前时间,两次结果必然不同),React 在接管时就会报「Hydration mismatch」错误并被迫丢弃服务端内容重新渲染。所以任何依赖浏览器环境才能确定的值(时间、时区、随机数等),都要等到水合后用 useEffect 设置。
手动检测 vs API 驱动的环境检测
// Manual: You handle the logic
function logMessage(msg: string) {
if (typeof window === 'undefined') {
console.log(`[SERVER]: ${msg}`)
} else {
console.log(`[CLIENT]: ${msg}`)
}
}
// API: Framework handles it
const logMessage = createIsomorphicFn()
.server((msg) => console.log(`[SERVER]: ${msg}`))
.client((msg) => console.log(`[CLIENT]: ${msg}`))把整个文件标记为仅服务端或仅客户端
.server.* 和 .client.* 文件名后缀会让文件自动启用 Start 的导入保护(import protection)。当你无法或不想重命名文件时,可以在文件顶部加一个副作用导入来达到同样的效果:
// src/lib/secrets.ts (filename can't be *.server.ts)
import '@tanstack/react-start/server-only'
export function getApiKey() {
return process.env.API_KEY
}// src/lib/storage.ts
import '@tanstack/react-start/client-only'
export function savePreferences(prefs: Record<string, string>) {
localStorage.setItem('prefs', JSON.stringify(prefs))
}同一文件中同时出现两个标记是错误的。仅类型(type-only)导入会被忽略。完整的参考(包括开发/构建时的不同行为、配置拒绝规则、阅读违规追踪)请看导入保护。
架构决策框架
以下情况选择仅服务端:
- 访问敏感数据(环境变量、密钥)
- 文件系统操作
- 数据库连接
- 外部 API 密钥
选择仅客户端,当:
- DOM 操作
- 浏览器 API(localStorage、geolocation)
- 用户交互处理
- 分析/追踪
选择同构,当:
- 数据格式化/转换
- 业务逻辑
- 共享工具函数
- 路由加载器(它们天生就是同构的)
安全考量
打包产物分析
始终验证仅服务端的代码没有进入客户端打包产物:
# Analyze client bundle
npm run build
# Check dist/client for any server-only imports环境变量策略
- 暴露给客户端:用
VITE_前缀标记客户端可访问的变量 - 仅服务端:通过
createServerOnlyFn()或createServerFn()访问 - 绝不暴露:数据库 URL、API 密钥、密钥
错误边界
优雅地处理服务端/客户端的执行错误:
function ErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundaryComponent
fallback={<div>Something went wrong</div>}
onError={(error) => {
if (typeof window === 'undefined') {
console.error('[SERVER ERROR]:', error)
} else {
console.error('[CLIENT ERROR]:', error)
}
}}
>
{children}
</ErrorBoundaryComponent>
)
}理解 TanStack Start 的执行模型,对构建安全、高性能、易维护的应用至关重要。「默认同构」的方式带来了灵活性,而执行控制 API 则在需要时给你精确的控制力。