mcpField
E
execution-model

execution-model

github.com/tanstack开发15.3k204 次安装4 天前更新
代码仓库

关于 execution-model

介绍 TanStack Start 代码在服务端与客户端的执行方式,以及如何划定环境边界。帮助开发者避免将密钥和服务端逻辑放入客户端包,并妥善处理浏览器专属代码与 hydration。

主要功能

  1. 01仅服务端与仅客户端函数 API
  2. 02按环境使用不同实现
  3. 03文件导入环境保护
  4. 04环境变量处理
  5. 05基于 hydration 的渲染指导

适用场景

  1. 01将数据库访问和密钥保留在服务端
  2. 02仅在客户端运行浏览器 API
  3. 03按运行环境选择不同实现
  4. 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()依赖水合的逻辑水合后为 truefalse
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 是错误的,原因有两个:

  1. 安全性: 值可能会被内联到客户端构建产物中,导致机密泄露。
  2. 运行时正确性(边缘运行时): 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):

  • 数据格式化、业务逻辑
  • 共享工具
  • 路由加载器(它们本质上是同构的)

交叉引用

评论

支持 Markdown