3789 words
19 minutes
TanStack Start 中的 Selective SSR:灵活控制路由渲染
2025-08-21 22:45:59
2025-08-21 23:19:04

TanStack Start 在短时间内取得了长足的进步,在开发者体验、稳健性和文档方面都有所改进。在这一演进过程中,最新且最具影响力的功能之一是 Selective SSR,这是一种灵活控制应用中路由渲染方式的新方法。

https://blog.logrocket.com/wp-content/uploads/2025/08/Untitled-design-5.png

传统上,SSR 框架对所有路由都采用相同的处理方式:先在服务器上渲染,然后在客户端进行 hydrate。但这种方法并不总是有效,特别是对于依赖浏览器专用 API、动态内容或缓慢数据获取的路由。这就是 Selective SSR 发挥作用的地方。

本文将详细介绍 Selective SSR 的工作原理、使用时机,以及如何构建一个真实的 TanStack Start 应用,该应用混合了服务器渲染、客户端渲染和仅数据路由,所有这些都能无缝协作。

为什么需要 Selective SSR?#

SSR 框架通过首先在服务器上生成路由的 HTML 内容来渲染应用的第一个请求路由。然后将生成的 HTML 内容发送到浏览器。这种方法使得使用 SSR 框架构建的应用具有更短的加载时间或更快的首次内容绘制 (FCP)

在将第一个请求路由的 HTML 内容发送到浏览器后,框架会使用 JavaScript 对其进行 hydrate。在后续的用户导航中,应用就像单页应用 (SPA) 一样工作。需要注意的是,当 SSR 框架对应用进行 hydrate 时,它会在浏览器上重新渲染。浏览器上的渲染必须与服务器上的渲染匹配,如果不匹配,大多数 SSR 框架会抛出错误。

传统的 SSR 框架渲染路由的方式可能不是 Web 应用每个路由的最有效策略,原因如下:

  • 显示基于时间或随机内容的路由在服务器和客户端上会有所不同

  • 使用浏览器专用 API(如 localStorage)的路由无法在服务器上渲染

  • 获取数据缓慢的路由最好在客户端渲染加载状态

在这些情况下,拥有更复杂的渲染策略非常重要,因为标准的 SSR 要么会抛出 hydration 不匹配错误,要么会太慢。为了避免遇到这些问题,Selective SSR 是在 SSR 应用中处理路由的一种简洁方法。

Selective SSR 是如何工作的?#

TanStack Start 中的每个路由都有类似以下的结构:

export const Route = createFileRoute("/")({ beforeLoad: ()=> beforeLoadFunc(), loader: ()=> loaderFunc(), component: RouteComponent, ssr: true, });

在上述路由中,beforeLoadloader 用于为路由加载数据beforeLoadloader 之前运行)。component 属性接受当用户导航到该路由时要渲染的 React 组件。最后,ssr 属性用于设置路由的渲染模式。正是这个属性实现了 Selective SSR。

TanStack Start 为渲染路由提供了三种不同的模式。这些模式是将 ssr 设置为 true"data-only"false

ssr: true

此选项允许 TanStack Start 在 服务器 上运行 beforeLoadLoader 函数,在服务器上渲染路由,然后在浏览器上运行这些 loader 函数并在浏览器上渲染路由。这是 SSR 框架渲染每个路由的默认方式。它确保快速加载时间和适当的 SEO。

ssr: "data-only"

使用 ssr: "data-only" 模式设置路由有点独特。在这里,loader 在服务器上运行,但组件本身不在服务器上渲染。相反,数据被发送到客户端,由客户端渲染组件。

在后续导航中,这些路由 loader 将在客户端运行。这对于需要快速数据获取但组件不适合服务器渲染的情况很有用(可能是因为它们需要动态显示或使用浏览器专用 API 或函数)。

ssr: false

这是 SPA 中所有路由的典型行为。在这里,loader 只在客户端运行,路由也只在客户端渲染。如果 loader 调用任何浏览器专用 API,这在 SSR 应用中很重要。

函数形式#

路由的 ssr 属性也接受函数。该函数的参数是一个具有两个属性的对象:paramssearch

这里的 params 参数是 TanStack Router 中的路径参数对象。通过它,可以访问路由中的所有动态参数。

另一方面,search 值提供对路由中 URL 查询参数的访问。它也是一个 TanStack Router 对象。使用这些参数,ssr 属性的函数值可以确定路由的渲染模式。

以下是一个示例:

export const Route = createFileRoute("/reports/$reportId")({ validateSearch: z.object({ docView: z.boolean().optional() }), ssr: ({ params, search }) => { if (params.status === 'success' && search.status === 'success') { return search.value.docView ? true : 'data-only' } }, beforeLoad: ()=> { console.log("Run beforeLoad function") }, loader: () => { console.log("Run loader function") }, component: ReportComponent, });

继承#

选择性渲染有一个顺序或层次结构。设置路由时,根路由的默认 ssr 值是 true。这也是其所有子路由继承的值。

现在,这个值只能更改为更严格的值。以下是从最严格到最不严格的渲染模式排名:

ssr: true > ssr: 'data-only' > ssr: false

继承 ssr: true 的路由只能更改为 data-onlyfalse,继承 ssr: 'data-only' 的路由只能更改为 false。即使子路由的 ssr 属性设置为限制较少的模式,TanStack Start 也会覆盖它:

root { ssr: true } // 根路由 child { ssr: false } // 路由设置为 `false` grandchild { ssr: 'data-only' } // 在继承 `false` 后不能使用 `"data-only"`,所以 TanStack 将使用 `false`

使用 Selective SSR 构建项目#

本节是一个教程,演示如何使用 Selective SSR 构建 TanStack Start 项目。示例项目是一个笔记应用,它以不同方式渲染其路由。项目的最终源代码可以在 GitHub 上找到

本指南使用 “start-basic” 模板引导 TanStack Start。

打开终端下载模板并将项目文件夹命名为 selective-ssr

npx gitpick TanStack/router/tree/main/examples/react/start-basic selective-ssr cd selective-ssr npm install npm run dev

下载框架模板并启动开发服务器后,清理项目文件夹,只保留与本教程相关的文件:

  1. 删除 /utils 文件夹中的所有文件

  2. 删除 /routes 目录中的所有文件和文件夹,只保留 /__root.tsx。该根路由是目前唯一相关的文件

  3. 最后,删除 /public 文件夹中除 /favicon.ico 文件之外的所有内容

接下来,配置应用的路由器,将 defaultSsr 设置为 true,并设置 defaultErrorComponentdefaultNotFoundComponent

// /src/router.tsx import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; import { DefaultCatchBoundary } from "./components/DefaultCatchBoundary"; import { NotFound } from "./components/NotFound"; export function createRouter() { const router = createTanStackRouter({ routeTree, defaultPreload: "intent", defaultErrorComponent: DefaultCatchBoundary, defaultNotFoundComponent: () => <NotFound />, scrollRestoration: true, defaultSsr: true, }); return router; } declare module "@tanstack/react-router" { interface Register { router: ReturnType<typeof createRouter>; } }

现在,这个项目的名称是 “Noteland”。在 /__root.tsx 路由中,添加 HTML <head> 内容以及路由的页面内容。请记住,TanStack Start 中的根路由将始终匹配,这意味着其内容将始终显示。

将以下内容添加到根路由:

/// <reference types="vite/client" /> import { HeadContent, Link, Scripts, createRootRoute, } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import * as React from "react"; import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; import { NotFound } from "~/components/NotFound"; import appCss from "~/styles/app.css?url"; export const Route = createRootRoute({ head: () => ({ meta: [ { charSet: "utf-8", }, { name: "viewport", content: "width=device-width, initial-scale=1", }, { title: "Noteland" }, { name: "description", content: "An app with a very original idea", }, ], links: [ { rel: "stylesheet", href: appCss }, { rel: "icon", href: "/favicon.ico" }, ], }), errorComponent: DefaultCatchBoundary, notFoundComponent: () => <NotFound />, shellComponent: RootDocument, ssr: true, }); function RootDocument({ children }: { children: React.ReactNode }) { return ( <html> <head> <HeadContent /> </head> <body> <header className='w-full px-4 py-3 border-b'> <div className='max-w-4xl mx-auto flex items-center justify-between'> <h1 className='text-lg font-semibold'> <Link to='/'>Noteland</Link> </h1> <Link to='/notes/$noteId' params={{ noteId: "new" }} className='px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800 transition' > Add note </Link> </div> </header> {children} <TanStackRouterDevtools position='bottom-right' /> <Scripts /> </body> </html> ); }

由于这不会是一个非常复杂的应用,只设置浅色模式样式。打开 /styles/app.css 文件并为应用添加简单而最小的样式:

@tailwind base; @tailwind components; @tailwind utilities; @layer base { html { color-scheme: only light; } * { /* border: 1px solid red; */ } html, body { @apply text-gray-900 bg-gray-50; } .using-mouse * { outline: none !important; } }

现在应用看起来是这样的:

https://blog.logrocket.com/wp-content/uploads/2025/08/image3.png

接下来,创建一些服务器函数来处理数据请求。我们需要三个函数:fetchNotesfetchNoteByIdupdateNote。在 /utils/notes.tsx 文件中创建这些函数:

import { notFound } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; export interface Note { id: number; title: string; note: string; created: string; } const notes: Note[] = []; export const fetchNoteById = createServerFn() .validator((noteId: number) => noteId) .handler(({ data }) => { const storedNote = notes[data - 1]; if (storedNote) return storedNote; throw notFound(); }); export const fetchNotes = createServerFn().handler(() => { const reversedNotes = [...notes].reverse(); return reversedNotes; }); export const updateNote = createServerFn({ method: "POST", response: "data", }) .validator((note) => { if (!(note instanceof FormData)) throw new Error("Invalid form data"); let noteId = note.get("noteId"); let title = note.get("title"); let noteText = note.get("note"); if (!title || !noteText) throw new Error("Note must have title and content"); return { id: noteId ? Number(noteId) : undefined, title: title.toString(), note: noteText.toString(), }; }) .handler(({ data: { title, note, id } }) => { if (id) { let storedNote = notes[id - 1]; notes[id - 1] = { ...storedNote, ...{ title, note } }; return notes[id - 1]; } let inputNote: Note = { id: notes.length + 1, title, note, created: new Date().toISOString(), }; notes.push(inputNote); return inputNote; });

fetchNotes 是一个获取应用中存储的所有笔记的函数。对于这个项目,笔记存储在 notes 数组中,当服务器重启时数据不会持久化。

fetchNoteById 获取与传递给函数的值具有相同 id 的笔记。如果不存在具有该 id 的笔记,函数会抛出 notFound()notFound() 是 TanStack Router 中的内置函数,当在路由 loader 中抛出时,将显示路由配置的 notFoundComponent 或路由器的 defaultNotFoundComponent

updateNote 是一个服务器函数,它接受来自客户端的 FormData 并将该数据保存到 notes 数组,或更新数组中的现有笔记。

接下来,创建 /index.tsx 路由。此路由是 /__root.tsx 的索引路由。请记住,虽然 /__root.tsx 路由在应用的每个页面上都匹配,但 /index.tsx 路由只作为主页,并在必要时为其他路由卸载。

还要注意,/__root.tsx/index.tsx 路由的父级)被配置为具有 ssrtrue,这意味着 /index.tsx 将继承该值,除非明确更改为更严格的值。

对于 /index.tsx 路由,将 ssr 值设置为 "data-only",因为我们只需要来自服务器的数据,但不想在浏览器上渲染路由:

// /src/routes/index.tsx import { createFileRoute, Link } from "@tanstack/react-router"; import { fetchNotes } from "~/utils/notes"; export const Route = createFileRoute("/")({ component: NotesComponent, ssr: "data-only", loader: () => fetchNotes(), }); function NotesComponent() { const notes = Route.useLoaderData(); return ( <div className='max-w-2xl mx-auto p-4'> {!notes.length ? ( <p className='text-gray-500'>No notes</p> ) : ( <ul className='space-y-4'> {notes.map((n) => ( <li key={n.id}> <Link to='/notes/$noteId' params={{ noteId: n.id }} className='border p-3 block rounded shadow-sm bg-white hover:shadow-md' > <h2 className='font-semibold'>{n.title}</h2> <p className='text-sm text-gray-600'> {n.note} </p> <p className='mt-2 text-xs text-gray-400'> Created:{" "} {new Date(n.created).toLocaleString()} </p> </Link> </li> ))} </ul> )} </div> ); }

现在加载页面应该显示所有笔记,但由于还没有向应用添加笔记,所以你会看到这样:

https://blog.logrocket.com/wp-content/uploads/2025/08/image4.png

接下来,创建用户可以向应用添加笔记的页面。添加新笔记时此路由的 URL 是 /notes/new,编辑现有路由时是 /notes/${noteId}

此路由不会在服务器上渲染;相反,它将仅在客户端浏览器上渲染。这是因为路由的 loaderwindow.localStorage 获取数据。它需要这样做,因为应用在用户将数据保存到服务器之前,会在 localStorage 中离线保存用户输入。

前端保存用户输入,这样即使用户还没有将数据保存到服务器,他们也不会丢失数据。打开 /routes/notes.$noteId.tsx 文件并添加以下内容:

import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { fetchNoteById, updateNote } from "~/utils/notes"; interface Draft { id?: number; title?: string; note?: string; } const LOCAL_STORAGE_KEY = "draft_note"; const fetchLocalStorage = (): Draft => { const raw = localStorage.getItem(LOCAL_STORAGE_KEY); const localState = raw ? JSON.parse(raw) : {}; return localState; }; const updateLocalStorage = (update: Draft | null) => { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(update ?? {})); }; export const Route = createFileRoute("/notes/$noteId")({ params: { parse: ({ noteId }) => { if (noteId === ("new" as const)) { return { noteId }; } else if (!isNaN(+noteId)) { return { noteId: +noteId }; } throw new Error("Invalid Path"); }, }, loader: async ({ params: { noteId } }) => { if (noteId === "new") { return fetchLocalStorage(); } const { id, title, note } = await fetchNoteById({ data: noteId }); return { id, title, note }; }, component: RouteComponent, ssr: false, }); function RouteComponent() { const navigate = useNavigate(); const fetchedNote = Route.useLoaderData(); const [formValues, setFormValues] = useState(fetchedNote); useEffect(() => { setFormValues(fetchedNote); }, [fetchedNote]); const handleInputChange = ( event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement> ) => { const { name, value } = event.currentTarget; setFormValues((prevState) => { const nextState = { ...prevState, [name]: value }; if (!fetchedNote?.id) updateLocalStorage(nextState); return nextState; }); }; return ( <form onSubmit={async (event) => { event.preventDefault(); const formData = new FormData(event.currentTarget); formData.append("noteId", fetchedNote?.id?.toString() || ""); await updateNote({ data: formData }); updateLocalStorage(null); return navigate({ to: "/" }); }} method='post' > <div className='max-w-2xl mx-auto p-4 space-y-4'> <input type='text' name='title' placeholder='Untitled note' required value={formValues.title || ""} onChange={handleInputChange} className='w-full text-2xl font-bold p-2 border border-gray-300 rounded focus:outline-none focus:ring' /> <textarea name='note' placeholder='Start typing your note...' required value={formValues.note || ""} onChange={handleInputChange} className='w-full h-40 p-2 border border-gray-300 rounded focus:outline-none focus:ring' /> <button type='submit' className='px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700' > Save </button> </div> </form> ); }

有了这个,现在可以在应用中创建新笔记:

https://blog.logrocket.com/wp-content/uploads/2025/08/Nkc5_xRg.gif

也可以编辑创建的笔记:

https://blog.logrocket.com/wp-content/uploads/2025/08/2PCcIGiw.gif

最后,用户输入保存在 localStorage 中,这样用户即使在页面刷新后也能访问数据:

https://blog.logrocket.com/wp-content/uploads/2025/08/XowwaOpg.gif

将 TanStack Start 的 Selective SSR 与其他框架进行比较#

本节将探讨其他框架如何实现相同或类似的功能。要与 TanStack Start 比较的框架是 Next.js 和 React Router(以前称为 Remix),因为它们目前是最受欢迎的替代 React SSR 框架。

Next.js#

Next.js 默认将其所有组件渲染为 React Server Components。需要交互性或浏览器专用 API 的组件被渲染为客户端组件

但是,这与 Selective SSR 提供的功能不同。Selective SSR 是一个允许配置框架渲染路由位置的功能。在 Next.js 中,客户端组件和 React Server Components 都在某种形式上在服务器上渲染,然后发送到浏览器进行 hydration。

Next.js 有一个目前处于实验阶段的功能叫做部分预渲染 (PPR)。此功能使 Next.js 应用能够为请求的路由发送静态外壳。外壳有一个插槽,动态内容可能需要额外时间加载,最终会在那里渲染。这样,路由的动态部分根本不在服务器上渲染。

除此之外,Next.js 没有其他类似 Selective SSR 的功能。

React Router#

React Router 以三种不同模式工作:声明式、数据和框架。数据模式类似于 "data-only" 模式,因为它只关心为路由获取数据。但是,在 React Router 的数据模式中,数据在客户端加载,不涉及 SSR。

在 React Router 的框架模式中,在设置项目配置时,可以将 ssr 的值设置为 truefalse。这类似于 TanStack Start 中的 defaultSsr 设置。但是,在 React Router 中,不能为后代路由更改此 ssr 值。

React Router 没有任何功能可以密切复制 TanStack 的 Selective SSR 所做的事情。

总结#

TanStack Start 是一个用于构建全栈 Web 应用的可靠框架,它添加了 Selective SSR 功能,进一步为开发者提供了更多在应用中渲染布局的选项。Selective SSR 对于为路由选择适当的渲染模式很有用,无论它们显示什么以及加载需要多长时间。

本文重点介绍了 Selective SSR 有用的情况,解释了 Selective SSR 的工作原理,提供了使用 Selective SSR 构建全栈项目的教程,最后将该功能与其他框架的类似功能进行了比较。

TanStack Start 中的 Selective SSR:灵活控制路由渲染
https://0bipinnata0.my/posts/weekly-translate/selective-ssr-tanstack-start/
Author
0bipinnata0
Published at
2025-08-21 22:45:59