CSR 项目改造为请求期 SSR(React Router 7 data-mode)
你是代码迁移 Agent。任务:把当前 CSR 项目项目级 flip 到一个标准、自洽、能独立跑的 React Router 7 data-mode Vite SSR 项目。能力与边界(先读这一节)
这个 skill 让你做到:把 CSR 项目整体翻成标准 Vite SSR——单一routes.tsx + entry-server/entry-client 双入口 + IIFE server bundle + 项目自带的 server(部署成本站 Supabase 的 edge function,请求期直接产出完整 HTML)。产物是标准 Vite SSR 项目,不引 Next/Remix/vite-react-ssg、不自造数据层。方案依据:React Router 官方 ssr-data-router 示例 + Vite 官方 SSR(server.js 注模板)。
何时用:某页「公开 + 要 SEO/被索引」→ SSR(无论纯静态还是带真数据:静态页就是没有 loader 的 SSR,首屏照样预渲染)。私有/登录态页保持 CSR。SSG(vite-react-ssg)已退役(钉死 react-router v6、不再投入):禁止新用;已做过 SSG 的项目一律升级到 SSR(含纯静态页,别以”静态就留 SSG”为由保留),见 SSG→SSR 迁移。
心智模型:整体 flip → 局部深化
- 默认 = CSR。切 SSR = 项目级一次 flip。flip 后每条路由都被服务端渲染——但多数页此刻仍是壳(渲染外壳 + 客户端水合后取数),标准 React SSR 行为,无需特殊处理。
- 局部深化(按需逐页):要 SEO/首屏数据的页,给那条 route 加
loader(服务端取数),组件useLoaderData()读。
- 你的项目自带一个 server——标准 Vite SSR 里的
server.js。它 load server bundle + index.html 模板,render(url)出 app HTML、注进模板 → 吐完整 HTML 文档。这个 server 以 edge function 部署到本站自己的 Supabase(Deno):每站独立 = 强隔离、能用 service_role 直连本站库取数。它是你写的项目代码(见「模板·supabase fn」)。 - 平台的 container 只是多租户门控:转发请求给你的 server、拿回整页、注平台脚本(monitor/付费墙)、在真站点 URL 吐给访客。它不碰你的渲染逻辑。
- 核心原则:「模板」节的文件全由你写进项目源码,不依赖平台注入。平台只负责 CDN 上传、edge 部署、请求期门控(见 「平台负责的」一节)。
你要做的决策
flip 是机械的(照「模板」节写文件)。真正要你判断的就这几条:| 决策 | 怎么判断 | 落地 |
|---|---|---|
| 这页要不要 SSR | 公开 + 要 SEO/被索引 → SSR;私有/登录态 → 留 CSR | flip 后所有路由自动 SSR;私有页无需特殊处理 |
这页要不要 loader | 要 SEO/首屏就带真实数据 → 加 loader(服务端取数);否则是壳(客户端取数) | 「局部深化」 |
哪些库要从 server bundle external 剔除 | import 期跑浏览器初始化的客户端重库(图表/可视化/canvas/WebGL/数值精度:recharts/echarts/three/konva/decimal.js…) | 见下「关于 external」+ 「模板·vite.config.ssr」 |
| 要不要资源路由(按需) | 要请求期动态产出带扩展名的文件(sitemap.xml / robots.txt / feed.json) | 「局部深化·资源路由」 |
关于「external 剔除重库」(最常踩的 P0,必读)
纯客户端重库(图表/可视化/canvas/WebGL/数值精度,如 recharts、echarts、decimal.js、three 等)绝不能 import 进 SSR 渲染树(routes.tsx → 组件模块顶层)。它们常在 import 期就跑浏览器初始化、或用 typeof self/window 探测环境;而 edge 是 Deno(有 self)会被误判成浏览器分支直接崩(线上实测:decimal.js 抛 [DecimalError] Invalid argument: LN10 → edge 500 → 容器静默降级 CSR)。这类只做客户端展示、无 SEO 价值的能力,必须 React.lazy/动态 import() 或挂载后渲染,仅客户端加载。
⚠️ 但仅靠 React.lazy/动态 import() 在本指南的 IIFE server bundle 下不充分。 IIFE 无法代码分割,Vite lib 模式会强制 inlineDynamicImports,把 lazy 的目标模块连同它静态依赖的重库一起内联进唯一 bundle,并在 edge 加载(new Function eval)那一刻执行该重库的模块顶层副作用 → 仍然崩,且连不含该库的页(如首页 /)都会一起 500——这是 bundle 级 eval 崩,不是路由级渲染崩。所以这里有两层、缺一不可:
- 客户端懒加载 / 挂载守卫 —— 挡的是渲染期崩(组件别在服务端被渲染)。
vite.config.ssr.ts用build.rollupOptions.external把重库从 server bundle 物理剔除 —— 挡的是 eval 期崩。
vite.config.ts)不要 external,图表照常在浏览器加载。这份重库清单同时就是「执行步骤」的预扫描要标出、「模板·vite.config.ssr」 external 要剔除的库。
硬性规则(护栏,P0 真实踩过)
MUST- 只用 Vite + React Router 7 data-mode,不换栈。路由收敛成单一
src/routes.tsx导出RouteObject[](server/client 共用),用createStaticHandler/createStaticRouter/StaticRouterProvider+loader+useLoaderData;删<ScrollRestoration/>。 - CSR 项目零影响:没 flip 的项目构建/行为跟今天完全一致(仍用
main.tsx,没有entry-client/vite.config.ssr.ts/edge function)。 entry-server的render(path,ctx)只返回 app HTML(不拼完整文档);由你写的 server(edge function)注进 index.html 模板的<!--ssr-outlet-->才成完整 HTML。index.html改两处:入口脚本main.tsx→src/entry-client.tsx;<div id="root"></div>→<div id="root"><!--ssr-outlet--></div>(server 据此注入)。- 原
App的 providers 收进routes.tsx根 route 的element(providers 包<Outlet/>)。 vite.config.ssr.ts把react-router-domalias 到react-router(保证 react-router 单份)。- 仅客户端能力(Sonner/主题/window)做挂载后渲染/守卫(见「客户端能力约束」)。
- 不引入 Next/Remix/vite-react-ssg/框架式文件路由。
- 不在模块顶层执行依赖浏览器环境的逻辑:
window/document/localStorage→ 加typeof window==='undefined'守卫或惰性化(SSR bundle import 整棵树时会执行)。 - 不保留
manualChunks对react/react-dom的拆分(破坏自包含 IIFE bundle)。 - 不把客户端重库 import 进 SSR 渲染树(见 「关于 external」一节)。
- 不做平台职责的事:不要往 index.html 注
<meta name="x-ssr">、不要写render-manifest.json、不要做 CDN 上传——这些平台自动处理(见 「平台负责的」一节)。
执行步骤
1) SSR 安全全量预扫描(编码前必做)
改造前对src/ 全目录预扫描,汇总”待修改文件清单”:模块顶层/Hook 初始值/渲染路径里的 window/document/localStorage;非确定性值(时间戳/随机);client-only 组件(Sonner/useTheme);framer-motion 的 initial 隐藏态。另外标出所有 client-only 重库(图表/可视化/canvas/WebGL/数值精度,如 recharts/echarts/decimal.js)的 import 点——这些禁止进 SSR 树、必须改成客户端懒加载,否则在 Deno edge 直接崩(见 「关于 external」一节)。这份重库清单同时就是 「模板·vite.config.ssr」 rollupOptions.external 要剔除的库。清单收敛后再编码。
2) 按「模板」节写这些文件
src/routes.tsx · src/entry-server.tsx · src/entry-client.tsx · vite.config.ssr.ts · supabase/functions/ssr/index.ts,逐字照抄、按项目实际填空。
3) 改 index.html 两处
见上「硬性规则 MUST 4」。
4) 局部深化
要 SEO/首屏数据的页加loader;要动态文件加资源路由。见 「局部深化」。
模板(逐字照抄)
下面是 5 个要新建的文件 +index.html 的 2 处改动,全照抄、按项目实际填空。注释里的坑都是 P0 实战,别删。
1) src/routes.tsx —— 单一路由源
2) src/entry-server.tsx(render → app HTML · 固定模板)
3) src/entry-client.tsx(水合 · 固定模板)
4) vite.config.ssr.ts(自包含 IIFE + react-router 单份 · 固定模板)
5) index.html(改 2 处)
- 入口脚本:
<script type="module" src="/src/main.tsx">→/src/entry-client.tsx。 - 注入点:
<div id="root"></div>→<div id="root"><!--ssr-outlet--></div>(server 把 app HTML 注进这个占位)。
6) supabase/functions/ssr/index.ts(你项目的 SSR server · 固定模板,逐字照抄)
这是标准 Vite SSR 的 server.js,以 Deno edge function 形式部署到本站 Supabase。它 load 你的 server bundle + index.html 模板,render 出 app HTML、注进模板 → 返回完整 HTML。平台会用现成 deploy_edge_function 把它部署到本站 Supabase——你只写文件、不管部署(见 「平台负责的」一节)。
局部深化:loader 与资源路由
route loader(服务端取数)
const { product } = useLoaderData(); 读回。没 loader 的 route = 壳(客户端取数)。
动态文件路由(资源路由)
/sitemap.xml、/robots.txt、动态 feed.json 等。当你要请求期动态产出一个文件(生成 sitemap、robots、RSS/Atom、动态 manifest 等),在 routes.tsx 里加一条资源路由:
- 有
loader、但【不写element/Component】——entry-server据此用queryRoute取 loader 的原始Response(page 路由有element、走query渲染 HTML)。 loader直接return new Response(body, { headers: { 'content-type': '…' } }),且 Content-Type 必须是非 HTML(application/xml、text/plain、application/json…)——这是容器判「资源」(原样吐、不注平台脚本)还是「页面」的唯一依据;漏设 / 设成text/html会被当页面注脚本。也可只return字符串(按text/plain吐)。- path 带不带扩展名都行:容器靠 edge 返回的
Content-Type判定、不再靠扩展名——/sitemap.xml、无后缀的/sitemap/static都可。分片/动态文件名用动态段:/sitemap/:page.xml、/feeds/:slug.xml。
entry-server对资源路由用queryRoute取 loader 的原始Response(page 路由才用query渲染 HTML);你的index.tsserver 把它原样吐、不套 index.html 模板——见「模板」节。- 容器对 SSR 站不做路由白名单(已无
dynamicFileRoutes):带扩展名路径先精确查静态产物(命中且非空就走文件),不存在或为空则连同任意无扩展名路由一起转发给 edge。页面还是资源由 edge 返回的Content-Type决定——text/html→当页面(注平台脚本);其余→资源原样吐(不注入、用 edge 的Content-Type)。代价:带后缀的缺失请求(bot 探.php等)也会打一次 edge、由项目 router 的*兜底;真实静态产物因「有内容」已在前一步命中走文件、不碰 edge。 - 不要再去
public/放同名静态文件——静态文件优先级更高(精确命中即原样返回),会盖掉你的动态路由。
客户端能力约束
- Sonner/toast:保留在根布局,加
mounted守卫;toast(...)只在事件回调/useEffect。 - 主题:
useTheme()分支挂载后执行,未挂载用稳定 fallback。 - 浏览器 API:顶层/Hook 初始值/渲染路径里的
window/document/localStorage→useEffect或typeof window!=='undefined'守卫。 - 非确定性值:时间戳/随机改稳定值或移进
useEffect。
useMounted 参考:
平台负责的(你不写)
flip 时只写「模板」节的项目源码 + loaders(见「局部深化」)。下面平台自动处理,不要手搓:- edge 部署:平台用
deploy_edge_function把你写的supabase/functions/ssr/index.ts部署到本站 Supabase。代码你写、部署平台管。 - serving 标记 + bundle 指针:构建自动往 index.html 注
<meta name="x-ssr">、写render-manifest.json(存 server bundle 的 CDN URL),供 container 门控判 SSR + 把 bundle URL 用 header 传给你的 server。你不写这些。 - CDN 上传 / 请求期门控:平台上传 server bundle + index.html 到 CDN;container 转发请求给你的 server、拿回完整 HTML、注平台脚本、在真站点 URL 吐给访客。
dev 预览(可选)
- 文件进了源码后,项目
dev(vite)跑的就是真 SSR-shaped app(data router 客户端跑),结构与 prod 一致。 - 想 dev 真·服务端渲染:加官方
server.js(Node,vite.ssrLoadModule('src/entry-server')+ 同样注模板)——和「模板·supabase fn」的 edge 是同一套逻辑、不同 runtime。也是项目源码。 不加则 SSR 校验落在发布后 snapshot 预览(走平台 container→你的 edge = 真 SSR)。
交付验收(必须全部通过)
pnpm build成功 + 产出dist/server/entry-server.js(IIFE,暴露__SSR_BUNDLE__.render;edge 按模板「末尾 return 取回」加载,不依赖 globalThis)。- 客户端构建产出正常(index.html 入口指向
entry-client,#root 含<!--ssr-outlet-->)。 supabase/functions/ssr/index.ts已写(你的 SSR server)。- 运行后无
window/document is not defined;首屏 hydration 无显著 mismatch。 - 路由是单一
src/routes.tsx的RouteObject[]。 - 深化过的页 SSR 首屏带 loader 数据;未深化的页正常出壳 + 客户端接管。
- (若加了资源路由)发布后访问
/sitemap.xml、/sitemap/static等:返回 loader 设的 Content-Type(如application/xml)、内容是动态产出而非 HTML 壳,且响应里没被注入平台<script>(被注入 = 容器把它当成了页面,去查 loader Response 的 Content-Type 是否非 HTML)。 - 对一个不含任何客户端重库的公开页(通常是首页
/)单独发一次 SSR 请求,确认不出现任何重库的初始化错(如[DecimalError])。若这种页都报重库错,说明该库进了 eager bundle(IIFE 内联),需按 「关于 external」一节 用external物理剔除,而非仅 lazy。
失败排查(按顺序)
- edge 500 且报错栈来自
node_modules→ 某个包被打进 SSR bundle、在 Deno 上跑了不兼容的分支。两种典型,修法不同:- 栈是
Cannot read properties of undefined (reading 'prototype')(来自 react-dom)→react-dom/server被resolve.conditions:['node',…]解析成了server.node.js(Node stream 版),Deno 没有 NodeReadable。改 entry-server 的 import 为显式子路径react-dom/server.browser(见「模板·entry-server」;别动 conditions)。其它「带 node 分支又依赖 Node 内建」的库同理(显式 web 子路径 / 单独 alias)。 - 栈是
[DecimalError]、某图表/数值库 → 该库 import 期跑了浏览器初始化分支(typeof self/window把 Deno 误判成浏览器)。注意仅靠 React.lazy/动态 import 在 IIFE bundle 下排不掉(IIFE 强制内联,库的顶层副作用仍在 bundle eval 时执行)。正确修法是external+output.globals把它从 server bundle 物理剔除(见 「关于 external」一节),客户端继续用 lazy 加载。自检:若连一个不含该库的公开页(如首页/)都报这个错,就是 bundle 级 eval 崩、必须 external,而不是某条路由的渲染问题。
- 栈是
- edge 500 报
bundle did not expose __SSR_BUNDLE__.render,或现象是「每条路由返回同一份模板、/和子路由内容一模一样」 → bundle 能正常 eval 但服务端取不到 render 函数。根因:Rollup 的 IIFE 产物var __SSR_BUNDLE__=(...)()在new Function里是局部变量、不挂 globalThis;必须用「末尾return __SSR_BUNDLE__取回」的载入方式(见「模板·supabase fn」),别去读globalThis.__SSR_BUNDLE__。注意第 1 条会遮住这条——bundle 一 eval 崩就走不到取 render,所以先解第 1 条、再回来看这条是否仍在。 - 先查
vite.config.ssr.ts是否把react-router-domalias 到react-router、是否按模板加了resolve.conditions/footer/define。 - 再查
dist/server/entry-server.js是否产出。 - 再查 edge:
render返回的是{html}、server 注进<!--ssr-outlet-->(占位是否在 index.html 里)、x-ssr-bundle/模板 URL 是否能 fetch。 - 再查 SSR bundle import 期崩:定位模块顶层浏览器 API(回「执行步骤」的预扫描补齐)。
- 再查 hydration mismatch:主题/Sonner 挂载守卫、非确定性值。
- 资源路由(
/sitemap.xml、/sitemap/static等)返回 HTML 壳 / 被注入脚本 / 404 而非动态内容:① 该 route 是否误带了element/Component(带了 edge 就当 page 渲染、走不到queryRoute);② loader 的 Response 是否设了非 HTML 的Content-Type(漏设 / 设成text/html→ 容器当页面、会注平台脚本);③public/下是否放了同名非空静态文件(带后缀路径精确命中优先、会盖掉动态路由,删掉它)。
看日志:edge 现在会console.error出失败路径 + 栈(见「模板·supabase fn」),到本站 Supabase 的function_logs直接看真因——不再是「只进响应体、日志一片空」。 已知可接受噪音:function_logs里的Warning: useLayoutEffect does nothing on the server(Radix 等组件在服务端调useLayoutEffect触发)不影响 200 与内容产出,属预期,不必处理。function_logs为空 ≠ 没出错:edge 实例回收后日志有延迟、或查询时间窗没覆盖到失败请求时都会「空」。此时应 ① 主动发一次请求触发渲染再查;② 确认时间窗覆盖最近的 500;③ 务必拿到真实栈再下结论。切忌在拿不到栈时仅凭「先 200 后 500、未重部署」就推断成冷启动/远程依赖问题——确定性的 bundle eval 崩(同一路由稳定 500)和冷启动(时对时错)现象不同,靠状态码区分不了,必须看栈。

