基于 TanStack Router 的 Solid SSR 流式渲染实战:从文件式路由到自定义 Express 服务器
【免费下载链接】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
导读
本篇以仓库中examples/solid/basic-ssr-streaming-file-based示例为骨架,完整讲解如何在 Solid 应用中基于 TanStack Router 搭建支持SSR(服务端渲染)与流式响应的全栈页面:包括 Vite 双构建配置(客户端/服务端)、文件式路由(File-Based Routing)、renderRouterToStream流式渲染、客户端水合(Hydration)以及自定义 Express 服务器与请求管道对接。读完本文,你将能独立复刻一个"文件式路由 + SSR 流式输出 + 客户端交互"的最小可运行应用,并理解其中每一层代码的作用。
一、示例总览:一个麻雀虽小、五脏俱全的 SSR 应用
该示例的完整源码位于 examples/solid/basic-ssr-streaming-file-based,其目录结构如下:
examples/solid/basic-ssr-streaming-file-based/ ├── package.json # 脚本与依赖定义 ├── vite.config.js # 客户端/服务端双构建配置 ├── server.js # 自定义 Express 开发/生产服务器 ├── tsconfig.json └── src/ ├── entry-client.tsx # 客户端入口:水合(hydrate) ├── entry-server.tsx # 服务端入口:流式渲染 ├── fetch-polyfill.js # 服务端 fetch 兼容 ├── posts.tsx # 数据获取函数(JSONPlaceholder) ├── router.tsx # 路由器工厂(createRouter) ├── routerContext.tsx # 路由上下文类型定义 ├── routeTree.gen.ts # 由插件自动生成的路由树 └── routes/ # 文件式路由目录 ├── __root.tsx # 根路由(head/meta/布局) ├── index.tsx # 首页(含 deferred 数据) ├── counter.tsx # 计数器页面 ├── posts.tsx # 文章列表页 ├── posts.$postId.tsx # 文章详情页(动态参数) └── _pathlessLayout/ # 路径无关布局(Pathless Layout)项目依赖(见 package.json)的核心成员包括:
@tanstack/solid-router:Solid 版 TanStack Router 运行时;@tanstack/router-plugin:提供 Vite 插件,负责文件式路由扫描与routeTree.gen.ts代码生成;vite-plugin-solid:Solid 的 Vite 集成(开启ssr: true);express:作为自定义 SSR 服务器;solid-js、tailwindcss、zod等支撑库。
运行方式(与原文档一致,直接可复现):
# 开发模式(Vite 中间件 + HMR + SSR) npm install npm start # 等价于 node server # 生产构建与启动 npm run build # 依次执行 build:client 与 build:server npm run serve # NODE_ENV=production node server其中npm run build拆分为vite build(客户端产物到dist/client)与vite build --ssr(服务端产物到dist/server)两步,这是 SSR 应用的标准构建流程。
二、双构建配置:一份 Vite 配置服务端与客户端两端
vite.config.js 是理解整个示例的关键。它基于 Vite 的isSsrBuild标志在同一份配置中切换两套构建参数:
const ssrBuildConfig = { ssr: true, outDir: 'dist/server', ssrEmitAssets: true, // 服务端构建也输出静态资源 copyPublicDir: false, emptyOutDir: true, rolldownOptions: { input: path.resolve(__dirname, 'src/entry-server.tsx'), output: { entryFileNames: 'static/[name].js', chunkFileNames: 'static/assets/[name]-[hash].js', assetFileNames: 'static/assets/[name]-[hash][extname]', }, }, } const clientBuildConfig = { outDir: 'dist/client', emitAssets: true, copyPublicDir: true, emptyOutDir: true, rolldownOptions: { input: path.resolve(__dirname, 'src/entry-client.tsx'), output: { /* 同上的 static/ 目录规范 */ }, }, } export default defineConfig((configEnv) => { return { plugins: [ tailwindcss(), tanstackRouter({ target: 'solid', autoCodeSplitting: true }), solid({ ssr: true }), ], build: configEnv.isSsrBuild ? ssrBuildConfig : clientBuildConfig, } })几个值得注意的要点:
- 插件顺序与职责:
tanstackRouter({ target: 'solid', autoCodeSplitting: true })负责扫描src/routes生成routeTree.gen.ts,autoCodeSplitting: true会让路由组件按需代码分割;solid({ ssr: true })通知 Vite 插件 Solid 组件需要支持服务端渲染。 - 入口分离:客户端入口是
entry-client.tsx(负责水合),服务端入口是entry-server.tsx(负责渲染),两者通过input分别指定。 - 产物目录约定:服务端构建产物默认输出到
dist/server/static/entry-server.js,客户端产物输出到dist/client/static/entry-client.js。这个路径约定被 server.js 生产模式分支(import('./dist/server/static/entry-server.js'))和根路由的head脚本声明(/static/entry-client.js)共同引用,改动任意一端都要保持同步。
三、自定义 Express 服务器:开发与生产的统一入口
server.js 实现了一个同时兼容开发与生产模式的 Express 服务器,其核心逻辑如下:
开发模式下,服务器以middlewareMode: true创建 Vite 实例,并把 Vite 的 connect 中间件挂载到 Express 上,从而获得即时编译与 HMR:
if (!isProd) { vite = await (await import('vite')).createServer({ root, logLevel: isTest ? 'error' : 'info', server: { middlewareMode: true, watch: { usePolling: true, interval: 100 }, // 测试环境保证文件变更可靠 hmr: { port: hmrPort }, }, appType: 'custom', // 完全自定义 HTML 输出 }) app.use(vite.middlewares) }生产模式下,则挂载compression(含 Brotli 支持)压缩中间件与express.static('./dist/client')静态资源服务。
两种模式下,所有路由请求统一进入app.use('/{*splat}', ...)兜底处理器(Express 5 通配语法):
- 过滤带文件扩展名的无效路径(
path.extname(url) !== ''时返回 404); - 开发模式通过
vite.transformIndexHtml提取<head>片段,用于注入 Vite 处理后的资源引用; - 加载服务端入口(开发
vite.ssrLoadModule('/src/entry-server.tsx'),生产import('./dist/server/static/entry-server.js')); - 调用
entry.render({ req, res, head: viteHead })进入 TanStack Router 的渲染流程。
监听端口使用get-port在 3000–3100 范围内自动分配,便于并行测试。
四、服务端入口:从 Express 请求到流式 HTML 响应
entry-server.tsx 是整个 SSR 流程的枢纽,演示了如何把 Express 的请求对象转换为 Fetch API 的Request,再交给 TanStack Router 处理:
import { pipeline } from 'node:stream/promises' import { RouterServer, createRequestHandler, renderRouterToStream, } from '@tanstack/solid-router/ssr/server' import { createRouter } from './router' import './fetch-polyfill' export async function render({ req, res, head }) { // 1. Express req -> fetch Request const url = new URL(req.originalUrl || req.url, 'https://localhost:3000').href const request = new Request(url, { method: req.method, headers: (() => { const headers = new Headers() for (const [key, value] of Object.entries(req.headers)) { headers.set(key, value as any) } return headers })(), }) // 2. 创建请求处理器,每次请求都新建 router 实例 const handler = createRequestHandler({ request, createRouter: () => { const router = createRouter() router.update({ context: { ...router.options.context, head }, }) return router }, }) // 3. 使用默认流式处理器产出响应 const response = await handler(({ request, responseHeaders, router }) => renderRouterToStream({ request, responseHeaders, router, children: () => <RouterServer router={router} />, }), ) // 4. fetch Response 转回 express Response,并流式输出 res.statusMessage = response.statusText res.status(response.status) response.headers.forEach((value, name) => res.setHeader(name, value)) return pipeline(response.body as any, res) }这一层的设计要点:
- 每次请求独立创建 router 实例:
createRouter回调中调用createRouter()(定义于 router.tsx),并通过router.update({ context })注入来自 Vite 的head片段,避免服务端请求间状态污染; renderRouterToStream:来自@tanstack/solid-router/ssr/server,是 Solid 版 SSR 流式渲染的核心 API。它将路由树渲染为可写流,配合RouterServer组件输出流式 HTML——这也是"流式"(Streaming)的关键:首屏可以先输出已就绪的内容,Await/Suspense包裹的异步数据随后边解析边推送;node:stream/promises的pipeline:把响应体流安全地接到 Express 的res上,处理背压与错误传播;- fetch-polyfill:
import './fetch-polyfill'为 Node 环境补齐 Fetch API,保证createRequestHandler内部依赖的 Request/Response 可用(依赖node-fetch,见 package.json)。
五、客户端入口与路由器工厂
客户端入口entry-client.tsx 非常精简:
import { RouterClient } from '@tanstack/solid-router/ssr/client' import { hydrate } from 'solid-js/web' import { createRouter } from './router' const router = createRouter() hydrate(() => <RouterClient router={router} />, document.body)hydrate(来自solid-js/web)把客户端渲染挂接到服务端已输出的 DOM 上,而不是重新创建,这是 SSR 应用水合的标准写法;RouterClient负责接管路由器的客户端交互能力。
路由器工厂router.tsx:
import { createRouter as createSolidRouter } from '@tanstack/solid-router' import { routeTree } from './routeTree.gen' export function createRouter() { return createSolidRouter({ routeTree, defaultPreload: 'intent', // 悬停/聚焦链接时预加载 scrollRestoration: true, // 路由切换时恢复滚动位置 }) } declare module '@tanstack/solid-router' { interface Register { router: ReturnType<typeof createRouter> } }两点值得说明:
routeTree.gen.ts由@tanstack/router-plugin根据src/routes目录自动生成,不要手工编辑;新增/删除路由文件后插件会在开发时自动重新生成。- 类型注册(
declare module ... interface Register)为useLoaderData、Link等 API 提供全链路类型安全,这正是 TanStack Router "fully type-safe" 的体现。
六、文件式路由:从根路由到动态参数
6.1 根路由与 head 管理
__root.tsx 定义了应用的根布局。它通过head选项声明页面元信息——这是 SSR 场景下头部(<head>)注入的关键机制:
export const Route = createRootRoute({ head: () => ({ links: [ { rel: 'icon', href: '/images/favicon.ico' }, { rel: 'stylesheet', href: stylesCss }, ], meta: [ { title: 'TanStack Router SSR Basic File Based Streaming' }, { charSet: 'UTF-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1.0' }, ], scripts: [ { type: 'module', src: import.meta.env.PROD ? '/static/entry-client.js' : '/src/entry-client.tsx', }, ], }), component: RootComponent, notFoundComponent: () => (/* 全局 404 兜底 */), })notFoundComponent提供了全局未匹配路由的兜底 UI,导航栏中"This Route Does Not Exist"链接正是用来演示这一能力。同时根路由也展示了 TanStack Router 的导航组件Link及其activeProps(当前激活样式)、activeOptions={{ exact: true }}(精确匹配)等用法。
6.2 流式数据的标志性用法:Await + Suspense
index.tsx 是"流式渲染"最直观的演示——loader同时返回同步数据与一个延迟 1 秒的 Promise:
export const Route = createFileRoute('/')({ loader: () => ({ date: new Date(), deferred: new Promise<{ date: Date }>((r) => setTimeout(() => r({ date: new Date() }), 1000), ), }), component: Home, }) function Home() { const data = Route.useLoaderData() return ( <div class="p-2"> <h3>Welcome Home!</h3> <p>Data: {data().date.getDate()}</p> <Suspense fallback={<div>Loading...</div>}> <Await promise={data().deferred}> {(data) => <p>Deferred: {new Date(data.date).getDate()}</p>} </Await> </Suspense> </div> ) }这里Await(@tanstack/solid-router提供)与 Solid 的Suspense协作:已就绪的date立即渲染并随首屏流出,deferred数据在 Promise resolve 后通过流式通道增量推送,用户无需等待整页完成就能看到首屏内容——这正是"流式 SSR"相对传统 SSR 的核心优势。
6.3 布局、动态参数与错误处理
- 嵌套布局:posts.tsx 定义
/posts布局路由,loader: fetchPosts拉取文章列表(fetchPosts定义于 src/posts.tsx,从 JSONPlaceholder 获取数据并setTimeout 500ms模拟延迟),组件内通过Route.useLoaderData()消费数据并渲染<Outlet />承载子路由。 - 动态参数路由:posts.$postId.tsx 演示
$postId文件命名约定,loader: async ({ params: { postId } }) => fetchPost(postId)通过params读取 URL 参数;fetchPost在请求 404 时抛出notFound()(见 posts.tsx),触发路由级notFoundComponent。 - 错误边界:
errorComponent: PostErrorComponent(位于 routes/-components/PostErrorComponent.tsx)为详情页设置独立的加载失败 UI。 - 路径无关布局:_pathlessLayout.tsx 以下划线前缀命名,
createFileRoute('/_pathlessLayout')创建的布局不占用 URL 段,内部嵌套的_nested-layout与route-a/route-b用于演示多层无路径布局组合。
6.4 路由上下文
routerContext.tsx 定义了路由上下文类型{ head: string },与entry-server.tsx中router.update({ context: { ...router.options.context, head } })的注入相呼应——服务端从 Vite 提取的 head 片段正是通过这一机制传递给渲染层。
七、SSR 流式渲染的工作原理小结
综合上述源码,可以归纳出该示例的完整请求链路:
浏览器请求 → Express(开发: Vite 中间件 / 生产: compression + static) → app.use('/{*splat}') 兜底处理器 → 加载 entry-server(开发 ssrLoadModule / 生产 dist/server) → render({ req, res, head }) → Express req 转 fetch Request → createRequestHandler(新建 router 实例,注入 head 上下文) → renderRouterToStream(<RouterServer/>) → pipeline 把响应流接入 res → 浏览器收到流式 HTML → 客户端 entry-client hydrate(<RouterClient/>) → 交互与后续导航由客户端接管(defaultPreload、scrollRestoration 生效)从源码结构看,流式能力的三个支撑点缺一不可:renderRouterToStream的流式输出 API(@tanstack/solid-router/ssr/server)、Await/Suspense的异步分段渲染、以及pipeline的背压管理。若去掉Suspense或把 deferred 数据改为同步等待,应用便退化为传统的一次性 SSR 输出。
八、常见问题与调试提示
- 产物路径不匹配导致生产 404:
entry-server.tsx中生产模式动态导入路径./dist/server/static/entry-server.js、根路由head.scripts中的/static/entry-client.js与vite.config.js的entryFileNames: 'static/[name].js'必须保持一致。 - 开发模式 head 提取:
server.js中通过vite.transformIndexHtml并截取<head>与</head>之间的内容,若自定义了index.html之外的 HTML 结构,需同步调整这段提取逻辑。 - 文件式路由未生效:确认
@tanstack/router-plugin已在vite.config.js注册且target: 'solid',并留意routeTree.gen.ts是否在新增路由后被自动重新生成。 - 调试入口:
npm run debug以node --inspect-brk server启动,可配合 Chrome DevTools 断点调试服务端渲染流程。
延伸阅读
- 服务端渲染核心 API 文档:SSR 指南
- 文件式路由概念与约定:文件式路由
- 本示例的 React 对应版本:examples/react/basic-ssr-streaming-file-based
- Solid Router 源码与测试:packages/solid-router/src、packages/solid-router/tests
【免费下载链接】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),仅供参考