TanStack Solid Start 执行模型全解:同构优先、执行边界控制与安全实践
【免费下载链接】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
本文基于仓库文档 execution-model.md 展开,系统讲解 TanStack Start 在 Solid 生态中的执行模型:为什么"所有代码默认同构"、如何通过
createServerFn/createServerOnlyFn/createClientOnlyFn/createIsomorphicFn/<ClientOnly>/useHydrated精准控制代码在服务端与客户端的执行位置,并结合仓库源码说明这些 API 的底层实现原理,最终给出环境变量安全、水合一致性等实战反模式与决策框架。读完本文,你将能正确判断一段业务代码应该运行在哪一侧,并能独立排查服务端代码泄漏到客户端 bundle、水合不匹配等典型问题。
核心原则:默认同构(Isomorphic by Default)
理解 TanStack Start(Solid 版)应用的第一个关键认知是:所有代码默认同构——除非被显式约束,否则代码会同时被打进服务端 bundle 和客户端 bundle,并在两端运行。
// ✅ 这段函数同时运行在服务端和客户端 function formatPrice(price: number) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(price) } // ✅ 路由 loader 也是同构的 export const Route = createFileRoute('/products')({ loader: async () => { // SSR 期间在服务端运行,客户端导航时在浏览器运行 const response = await fetch('/api/products') return response.json() }, })关键理解:路由
loader是同构的——它既在服务端运行,也会在客户端运行,而不是只在服务端运行。这一点是整个执行模型中最容易出错、也最容易引发安全事故的认知盲区。
执行边界:两个运行时环境
TanStack Start 应用运行在两个环境中,它们拥有不同的能力与资源:
服务端环境(Server)
- Node.js 运行时,可访问文件系统、数据库、环境变量;
- SSR 期间——首次页面渲染在服务端完成;
- API 请求——服务端函数(server functions)在服务端执行;
- 构建期——静态生成与预渲染(pre-rendering)。
客户端环境(Client)
- 浏览器运行时,可访问 DOM、localStorage、用户交互;
- 水合之后——客户端接管首次服务端渲染的页面;
- 导航期间——路由 loader 在客户端侧运行;
- 用户交互——事件处理器、表单提交等。
两个环境的能力差异决定了执行控制 API 的取舍:服务端有敏感数据与资源,客户端有 DOM 与交互,而纯逻辑(格式化、业务计算)则可以在两端安全运行。
执行控制 API 全景
仓库文档给出了两类控制 API 的速查表,本文将其合并为一张完整对照表,并补充服务端行为列:
| API | 使用场景 | 客户端行为 | 服务端行为 |
|---|---|---|---|
createServerFn() | RPC 调用、数据变更 | 通过网络请求发往服务端 | 直接执行 |
createServerOnlyFn(fn) | 工具函数(仅服务端) | 抛出错误 | 直接执行 |
createClientOnlyFn(fn) | 浏览器工具函数 | 直接执行 | 抛出错误 |
createIsomorphicFn() | 按环境提供不同实现 | 使用.client()实现 | 使用.server()实现 |
<ClientOnly> | 依赖浏览器 API 的组件 | 渲染子节点 | 渲染 fallback |
useHydrated() | 依赖水合状态的行为 | 水合后返回true | 始终返回false |
服务端专属执行
import { createServerFn, createServerOnlyFn } from '@tanstack/solid-start' // RPC:服务端执行,客户端可调用(客户端调用会变成网络请求) const updateUser = createServerFn({ method: 'POST' }) .validator((data: UserData) => data) .handler(async ({ data }) => { // 只在服务端运行,但客户端可以调用它 return await db.users.update(data) }) // 工具函数:仅服务端,客户端调用即崩溃 const getEnvVar = createServerOnlyFn(() => process.env.DATABASE_URL)createServerFn是服务端代码的主要入口:在客户端 bundle 中,调用它会转换为对服务端的网络请求;在服务端,它被直接执行。这是 TanStack Start 实现"同构调用服务端逻辑"的核心机制。
客户端专属执行
import { createClientOnlyFn } from '@tanstack/solid-start' import { ClientOnly } from '@tanstack/solid-router' // 工具函数:仅客户端,服务端调用即崩溃 const saveToStorage = createClientOnlyFn((key: string, value: any) => { localStorage.setItem(key, JSON.stringify(value)) }) // 组件:水合之后才渲染子节点 function Analytics() { return ( <ClientOnly fallback={null}> <GoogleAnalyticsScript /> </ClientOnly> ) }useHydrated Hook
useHydrated返回一个 Solid accessor(signal),用于判断客户端是否已完成水合,为依赖水合状态的行为提供更细粒度的控制:
import { useHydrated } from '@tanstack/solid-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)。
这在需要根据浏览器端数据(时区、locale、localStorage)做条件渲染、同时为服务端渲染提供合理回退时非常有用。
源码级原理:查看 packages/solid-router/src/ClientOnly.tsx 可以看到useHydrated的实现——模块级globalHydrated变量配合Solid.createSignal(globalHydrated && !Solid.sharedConfig.context)初始化,并在onMount中置为true。其中Solid.sharedConfig.context正是服务端渲染上下文的标志,因此在 SSR 时初始值恒为false,这正是文档所述行为的直接来源。而ClientOnly组件本身(同文件 ClientOnly.tsx)就是基于useHydrated()配合Solid.Show实现:水合为真时渲染 children,否则渲染fallback ?? null。
按环境提供不同实现:createIsomorphicFn
import { createIsomorphicFn } from '@tanstack/solid-start' // 每个环境使用不同的实现 const getDeviceInfo = createIsomorphicFn() .server(() => ({ type: 'server', platform: process.platform })) .client(() => ({ type: 'client', userAgent: navigator.userAgent }))createIsomorphicFn允许你为同一函数定义两份实现:服务端 bundle 中编译为.server()的实现,客户端 bundle 中编译为.client()的实现。从源码看(packages/start-fn-stubs/src/createIsomorphicFn.ts),其类型系统通过ServerOnlyFn/ClientOnlyFn接口保证链式调用的类型安全:调用.server()后返回的类型只剩client()方法可接,反之亦然。
架构模式实战
渐进增强(Progressive Enhancement)
构建"无 JavaScript 也能工作"的组件,再用客户端功能增强:
function SearchForm() { const [query, setQuery] = createSignal('') 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> ) }服务端与未水合时渲染原生提交按钮(表单仍可用),水合后替换为交互式搜索按钮。这正是<ClientOnly>的典型用法:服务端渲染 fallback、客户端渲染完整交互组件。
环境感知存储(Environment-Aware Storage)
const storage = createIsomorphicFn() .server((key: string) => { // 服务端:基于文件的缓存 const fs = require('node:fs') return JSON.parse(fs.readFileSync('.cache', 'utf-8'))[key] }) .client((key: string) => { // 客户端:localStorage return JSON.parse(localStorage.getItem(key) || 'null') })RPC 与直接函数调用的取舍
理解何时使用 server function、何时使用 server-only function 是正确建模的关键:
// createServerFn:RPC 模式 —— 服务端执行,客户端可调用 const fetchUser = createServerFn().handler(async () => await db.users.find()) // 客户端组件中的用法: const user = await fetchUser() // ✅ 网络请求 // createServerOnlyFn:客户端调用即崩溃 const getSecret = createServerOnlyFn(() => process.env.SECRET) // 客户端用法: const secret = getSecret() // ❌ 抛出错误常见反模式(Anti-Patterns)
环境变量泄漏
// ❌ 泄漏到客户端 bundle const apiKey = process.env.SECRET_KEY // ✅ 仅服务端可访问 const apiKey = createServerOnlyFn(() => process.env.SECRET_KEY)对 Loader 的错误假设
// ❌ 错误地假设 loader 只在服务端运行 export const Route = createFileRoute('/users')({ loader: () => { // 这段代码在服务端和客户端都会运行! const secret = process.env.SECRET // 已暴露给客户端 return fetch(`/api/users?key=${secret}`) }, }) // ✅ 用 server function 承载服务端专属操作 const getUsersSecurely = createServerFn().handler(() => { const secret = process.env.SECRET // 仅服务端 return fetch(`/api/users?key=${secret}`) }) export const Route = createFileRoute('/users')({ loader: () => getUsersSecurely(), // 同构地调用 server function })值得强调的是,即使不涉及密钥,在 loader 中直接使用相对 URL(如fetch('/api/...'))也是危险的:同构 loader 在 SSR 阶段没有可靠的 base URL。正确做法是在 loader 中调用 server function,或把 fetch 放进显式的环境边界内。
水合不匹配(Hydration Mismatch)
// ❌ 服务端与客户端渲染内容不同 function CurrentTime() { return <div>{new Date().toLocaleString()}</div> } // ✅ 渲染保持一致 function CurrentTime() { const [time, setTime] = createSignal<string>() createEffect(() => { setTime(new Date().toLocaleString()) }) return <div>{time() || 'Loading...'}</div> }水合不匹配的根源是"同一组件在服务端与客户端渲染出不同内容"。解法是让首帧渲染内容确定(如占位符),待水合后再通过 effect 更新为真实值——与服务端渲染输出保持一致,避免 React/Solid 水合时丢弃或重渲染 DOM。
手动检测 vs API 驱动的环境判断
// 手动:自行处理逻辑分支 function logMessage(msg: string) { if (typeof window === 'undefined') { console.log(`[SERVER]: ${msg}`) } else { console.log(`[CLIENT]: ${msg}`) } } // API:框架处理环境分流 const logMessage = createIsomorphicFn() .server((msg) => console.log(`[SERVER]: ${msg}`)) .client((msg) => console.log(`[CLIENT]: ${msg}`))手动typeof window检测虽然有效,但无法让编译器做死代码消除(dead code elimination)——两端 bundle 都会保留完整的分支逻辑。而createIsomorphicFn让编译器能按环境裁剪掉另一侧实现,这正是"从源码结构看"官方推荐 API 驱动方式的深层原因。
架构决策框架
选择 Server-Only(createServerFn/createServerOnlyFn)当:
- 访问敏感数据(环境变量、密钥);
- 文件系统操作;
- 数据库连接;
- 外部 API key。
选择 Client-Only(createClientOnlyFn/<ClientOnly>)当:
- DOM 操作;
- 浏览器 API(localStorage、geolocation);
- 用户交互处理;
- 分析/追踪(analytics/tracking)。
选择 Isomorphic(默认 /createIsomorphicFn)当:
- 数据格式化与转换;
- 业务逻辑;
- 共享工具函数;
- 路由 loader(天然同构)。
安全考量
Bundle 分析
始终验证服务端专属代码没有被包含进客户端 bundle:
# 分析客户端 bundle npm run build # 检查 dist/client 中是否存在服务端专属的 import环境变量策略
- 客户端可见:使用
VITE_前缀,例如import.meta.env.VITE_API_URL; - 服务端专属:通过
createServerOnlyFn()或createServerFn()的 handler 内访问process.env; - 永不暴露:数据库 URL、API key、任何密钥。
补充一个常被忽略的坑:不要在模块顶层读取
process.env。这有两个层面的错误——一是安全层面,模块级读取可能被内联进客户端 bundle;二是运行时正确性层面,在 Cloudflare Workers 等边缘运行时中,env 是按请求注入的,模块加载时读取会得到undefined(即使在服务端)。正确的做法是始终在.handler()或其他按请求执行的函数内部读取环境变量。
错误边界
优雅地处理服务端/客户端执行错误:
function ErrorBoundary(props) { 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) } }} > {props.children} </ErrorBoundaryComponent> ) }底层原理:Start 编译器如何实现执行边界
了解"为什么这些 API 能按环境裁剪"有助于写出更正确的代码。仓库中 packages/start-plugin-core/src/start-compiler/config.ts 定义了编译器对四类工厂函数的查找配置(lookup config):
| 工厂函数 | Kind | 说明 |
|---|---|---|
createServerFn | Root | 编译为 RPC 调用(客户端)/直接执行(服务端) |
createIsomorphicFn | IsomorphicFn | 按环境替换为.server()/.client()实现 |
createServerOnlyFn | ServerOnlyFn | 服务端保留原实现,客户端替换为抛错 |
createClientOnlyFn | ClientOnlyFn | 客户端保留原实现,服务端替换为抛错 |
编译器在两端 bundle 中做差异化改写,这也是为什么 packages/start-fn-stubs/src/envOnly.ts 中createServerOnlyFn/createClientOnlyFn的运行时实现只是恒等函数(fn) => fn——真正的边界逻辑在编译期完成(客户端 bundle 中调用会抛错,服务端保留原样)。
同样,<ClientOnly>的 JSX 变换(ClientOnlyJSX)只在服务端构建时启用(见 compiler.ts 与 config.ts):服务端渲染时移除 children、渲染 fallback,客户端则保留 children。这与上文useHydrated的运行时行为相互印证,构成了"编译期裁剪 + 运行时信号"的双层保障。
如果希望以更粗粒度约束整个文件,还可以使用文件名后缀约定(如db.server.ts)或在文件顶部添加import '@tanstack/solid-start/server-only'/import '@tanstack/solid-start/client-only'标记,让 import protection 在构建期拒绝跨环境导入(详见 start-plugin-core 的 envOnly 测试)。
小结
TanStack Solid Start 的执行模型可以浓缩为一条原则与一组工具:同构优先(isomorphic by default)提供了灵活性与开发效率,而createServerFn/createServerOnlyFn/createClientOnlyFn/createIsomorphicFn/<ClientOnly>/useHydrated这套执行控制 API 在需要精准控制时提供了明确边界。理解 loader 的同构本质、环境变量的暴露规则、水合一致性的要求,是构建安全、高性能、可维护的 TanStack Start 应用的基石。本文涉及的执行模型文档位于 docs/start/framework/solid/guide/execution-model.md,Solid Router 侧的水合组件实现可继续阅读 packages/solid-router/src/ClientOnly.tsx,编译期边界机制可深入 packages/start-plugin-core/src/start-compiler/compiler.ts。
【免费下载链接】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),仅供参考