关于 execution-model
介绍 TanStack Start 代码在服务端与客户端的执行方式,以及如何划定环境边界。帮助开发者避免将密钥和服务端逻辑放入客户端包,并妥善处理浏览器专属代码与 hydration。
主要功能
- 01仅服务端与仅客户端函数 API
- 02按环境使用不同实现
- 03文件导入环境保护
- 04环境变量处理
- 05基于 hydration 的渲染指导
适用场景
- 01将数据库访问和密钥保留在服务端
- 02仅在客户端运行浏览器 API
- 03按运行环境选择不同实现
- 04避免渲染组件时出现 hydration 不匹配
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tanstack/ router --skill execution-model安装后在 Claude Code 中输入/execution-model确认已加载。
详细介绍
执行模型
了解代码运行的位置是 TanStack Start 的基础。本指南介绍同构执行模型,以及如何控制环境边界。
CRITICAL:TanStack Start 中的所有代码默认都是同构的——会同时运行在服务端和客户端代码包中。路由加载器在服务端(SSR 期间)和客户端(导航期间)都会运行。服务端专属操作必须使用
createServerFn。 CRITICAL:在模块级别访问process.env会导致两个方面的问题——安全性(值会泄露到客户端代码包)和运行时正确性(在 Cloudflare Workers 和其他边缘运行时中,环境变量会按请求注入,因此即使在服务端,模块级读取的结果也会是undefined)。应在.handler()或其他按请求执行的函数中读取环境变量,绝不要在模块作用域中读取。 CRITICAL:带VITE_前缀的环境变量会暴露到客户端代码包中。服务端密钥绝不能使用VITE_前缀。 CRITICAL:相对 URL 仅适用于浏览器代码。同构加载器也会在 SSR 期间运行,此时fetch('/api/...')可能没有基础 URL。请在加载器中调用服务端函数,或将 fetch 放在明确的环境边界内。
执行控制 API
| API | 用途 | 客户端行为 | 服务端行为 |
|---|---|---|---|
createServerFn() | RPC 调用、数据修改 | 向服务端发起网络请求 | 直接执行 |
createServerOnlyFn(fn) | 工具函数 | 抛出错误 | 直接执行 |
createClientOnlyFn(fn) | 浏览器工具函数 | 直接执行 | 抛出错误 |
createIsomorphicFn() | 为不同环境提供不同实现 | 使用 .client() 实现 | 使用 .server() 实现 |
<ClientOnly> | 仅限浏览器的组件 | 渲染子元素 | 渲染备用内容 |
useHydrated() | 依赖水合的逻辑 | 水合后为 true | false |
import '@tanstack/<fw>-start/server-only' | 将整个文件标记为仅服务端 | 禁止导入 | 允许导入 |
import '@tanstack/<fw>-start/client-only' | 将整个文件标记为仅客户端 | 允许导入 | 禁止导入 |
仅服务端执行
createServerFn(RPC 模式)
运行服务端专属代码的主要方式。在客户端上,调用会变成 fetch 请求:
// Use @tanstack/<framework>-start for your framework (react, solid, vue)
import { createServerFn } from '@tanstack/react-start'
const fetchUser = createServerFn().handler(async () => {
const secret = process.env.API_SECRET // safe — server only
return await db.users.find()
})
// Client calls this via network request
const user = await fetchUser()
createServerOnlyFn(在客户端上抛出错误)
适用于绝不能在客户端运行的工具函数:
// Use @tanstack/<framework>-start for your framework (react, solid, vue)
import { createServerOnlyFn } from '@tanstack/react-start'
const getSecret = createServerOnlyFn(() => process.env.DATABASE_URL)
// Server: returns the value
// Client: THROWS an error
仅客户端执行
createClientOnlyFn
// Use @tanstack/<framework>-start for your framework (react, solid, vue)
import { createClientOnlyFn } from '@tanstack/react-start'
const saveToStorage = createClientOnlyFn((key: string, value: string) => {
localStorage.setItem(key, value)
})
ClientOnly 组件
// Use @tanstack/<framework>-router for your framework (react, solid, vue)
import { ClientOnly } from '@tanstack/react-router'
function Analytics() {
return (
<ClientOnly fallback={null}>
<GoogleAnalyticsScript />
</ClientOnly>
)
}
useHydrated Hook
// Use @tanstack/<framework>-router for your framework (react, solid, vue)
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)。
特定于环境的实现
// Use @tanstack/<framework>-start for your framework (react, solid, vue)
import { createIsomorphicFn } from '@tanstack/react-start'
const getDeviceInfo = createIsomorphicFn()
.server(() => ({ type: 'server', platform: process.platform }))
.client(() => ({ type: 'client', userAgent: navigator.userAgent }))
导入保护:文件标记
实验性功能。
.server.* 和 .client.* 文件名后缀(例如 db.server.ts)会让文件启用 Start 的导入保护——禁止从错误的环境导入该文件。如果无法或不想重命名文件,可以在文件顶部添加副作用导入,通过标记应用相同的保护:
// src/lib/secrets.ts (filename can't be *.server.ts)
import '@tanstack/react-start/server-only'
// (or @tanstack/solid-start/server-only, @tanstack/vue-start/server-only)
export function getApiKey() {
return process.env.API_KEY
}
// src/lib/storage.ts
import '@tanstack/react-start/client-only'
// (or @tanstack/solid-start/client-only, @tanstack/vue-start/client-only)
export function savePreferences(prefs: Record<string, string>) {
localStorage.setItem('prefs', JSON.stringify(prefs))
}
规则:
- 同一文件中同时使用两种标记会报错。
- 类型导入会被忽略(它们在运行时会被擦除)。
- 默认情况下,生产构建中为
error,开发环境中为mock。模拟对象会返回递归 Proxy,让开发环境在你修复导入关系图时仍能继续运行。
选择合适的工具:
- 文件绝不能在错误的一侧运行,并且没有客户端 API → 使用
*.server.ts文件名或import '@tanstack/<fw>-start/server-only'。 - 某个符号需要根据环境表现不同 → 使用
createIsomorphicFn().client(...).server(...)。 - 某个函数在错误的一侧被调用时应报错 → 使用
createServerOnlyFn/createClientOnlyFn。 - 组件仅在水合后渲染 → 使用
<ClientOnly>或useHydrated()。
环境变量
服务端(在 createServerFn 内)
通过 process.env 访问任何变量:
const connectDb = createServerFn().handler(async () => {
const url = process.env.DATABASE_URL // no prefix needed
return createConnection(url)
})
客户端(组件)
只有带 VITE_ 前缀的变量可用:
// Framework-specific component type (React.ReactNode, JSX.Element, etc.)
function ApiProvider({ children }: { children: React.ReactNode }) {
const apiUrl = import.meta.env.VITE_API_URL // available
// import.meta.env.DATABASE_URL → undefined (security)
return (
<ApiContext.Provider value={{ apiUrl }}>{children}</ApiContext.Provider>
)
}
客户端运行时变量
如果需要在客户端使用服务端变量,又不想添加 VITE_ 前缀,请通过服务端函数传递:
const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
return process.env.MY_RUNTIME_VAR
})
export const Route = createFileRoute('/')({
loader: async () => {
const foo = await getRuntimeVar()
return { foo }
},
component: () => {
const { foo } = Route.useLoaderData()
return <div>{foo}</div>
},
})
环境变量的类型安全
// src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_NAME: string
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string
readonly JWT_SECRET: string
}
}
}
export {}
常见错误
1. 严重:误以为加载器只在服务端运行
// WRONG — loader runs on BOTH server and client
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const secret = process.env.API_SECRET // LEAKED to client
return fetch(`https://api.example.com/data`, {
headers: { Authorization: secret },
})
},
})
// CORRECT — use createServerFn
const getData = createServerFn({ method: 'GET' }).handler(async () => {
const secret = process.env.API_SECRET
return fetch(`https://api.example.com/data`, {
headers: { Authorization: secret },
})
})
export const Route = createFileRoute('/dashboard')({
loader: () => getData(),
})
2. 严重:在模块作用域读取 process.env
在模块级别读取 process.env 是错误的,原因有两个:
- 安全性: 值可能会被内联到客户端构建产物中,导致机密泄露。
- 运行时正确性(边缘运行时): Cloudflare Workers 和其他边缘 SSR 运行时会在请求时注入环境变量。模块级代码会在模块加载时运行,此时环境变量尚不存在,因此即使在服务端,读取结果也会是
undefined。这个问题只有部署后才会显现。
// WRONG — leaks to client AND is undefined on Workers
const apiKey = process.env.SECRET_KEY
export function fetchData() {
/* uses apiKey, which is undefined under Worker SSR */
}
// CORRECT — read per-request, inside the handler
const fetchData = createServerFn({ method: 'GET' }).handler(async () => {
const apiKey = process.env.SECRET_KEY
return fetch(url, { headers: { Authorization: apiKey } })
})
同样的规则也适用于中间件 .server() 回调、服务端路由处理程序,以及任何按请求运行的函数——应在那里读取环境变量,而不是在文件顶部读取。
3. 严重:使用 VITE_ 前缀存储服务端机密
# WRONG — exposed to client bundle
VITE_SECRET_API_KEY=sk_live_xxx
# CORRECT — no prefix for server secrets
SECRET_API_KEY=sk_live_xxx
# CORRECT — VITE_ only for public client values
VITE_APP_NAME=My App
4. 高:水合不匹配
// WRONG — different content server vs client
function CurrentTime() {
return <div>{new Date().toLocaleString()}</div>
}
// CORRECT — consistent rendering
function CurrentTime() {
const [time, setTime] = useState<string>()
useEffect(() => {
setTime(new Date().toLocaleString())
}, [])
return <div>{time || 'Loading...'}</div>
}
架构决策框架
仅服务端(createServerFn / createServerOnlyFn):
- 敏感数据(环境变量、机密)
- 数据库连接、文件系统
- 外部 API 密钥
仅客户端(createClientOnlyFn / <ClientOnly>):
- DOM 操作、浏览器 API
- localStorage、地理位置
- 分析/跟踪
同构(默认 / createIsomorphicFn):
- 数据格式化、业务逻辑
- 共享工具
- 路由加载器(它们本质上是同构的)
交叉引用
- start-core/server-functions — 主要的服务端边界
- start-core/deployment — 部署目标会影响执行方式