TanStack Start 中文文档
服务器与执行

执行模型

理解代码在哪里运行,是构建 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两个错误,而不只是一个:

  1. 安全:值可能被内联进客户端打包产物。
  2. 运行时正确性:在 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 则在需要时给你精确的控制力。

On this page