Skip to main content

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() 读。
你写什么 vs 平台做什么(三层架构)
  • 你的项目自带一个 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 是机械的(照「模板」节写文件)。真正要你判断的就这几条:

关于「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 崩,不是路由级渲染崩。所以这里有 两层、缺一不可:
  1. 客户端懒加载 / 挂载守卫 —— 挡的是 渲染期 崩(组件别在服务端被渲染)。
  2. vite.config.ssr.ts 用 build.rollupOptions.external 把重库从 server bundle 物理剔除 —— 挡的是 eval 期 崩。
客户端构建(vite.config.ts) 不要 external,图表照常在浏览器加载。这份重库清单同时就是「执行步骤」的预扫描要标出、「模板·vite.config.ssr」 external 要剔除的库。

硬性规则(护栏,P0 真实踩过)

MUST
  1. 只用 Vite + React Router 7 data-mode,不换栈。路由收敛成单一 src/routes.tsx 导出 RouteObject[](server/client 共用),用 createStaticHandler/createStaticRouter/StaticRouterProvider + loader + useLoaderData;删 <ScrollRestoration/>。
  2. CSR 项目零影响:没 flip 的项目构建/行为跟今天完全一致(仍用 main.tsx,没有 entry-client/vite.config.ssr.ts/edge function)。
  3. entry-server 的 render(path,ctx) 只返回 app HTML(不拼完整文档);由你写的 server(edge function)注进 index.html 模板的 <!--ssr-outlet--> 才成完整 HTML。
  4. index.html 改两处:入口脚本 main.tsx → src/entry-client.tsx;<div id="root"></div> → <div id="root"><!--ssr-outlet--></div>(server 据此注入)。
  5. 原 App 的 providers 收进 routes.tsx 根 route 的 element(providers 包 <Outlet/>)。
  6. vite.config.ssr.ts 把 react-router-dom alias 到 react-router(保证 react-router 单份)。
  7. 仅客户端能力(Sonner/主题/window)做挂载后渲染/守卫(见「客户端能力约束」)。
DO NOT
  1. 不引入 Next/Remix/vite-react-ssg/框架式文件路由。
  2. 不在模块顶层执行依赖浏览器环境的逻辑:window/document/localStorage → 加 typeof window==='undefined' 守卫或惰性化(SSR bundle import 整棵树时会执行)。
  3. 不保留 manualChunks 对 react/react-dom 的拆分(破坏自包含 IIFE bundle)。
  4. 不把客户端重库 import 进 SSR 渲染树(见 「关于 external」一节)。
  5. 不做平台职责的事: 不要 往 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。
机制(你只写上面这条 route,其余平台自动):
  • entry-server 对资源路由用 queryRoute 取 loader 的 原始 Response(page 路由才用 query 渲染 HTML);你的 index.ts server 把它原样吐、不套 index.html 模板——见「模板」节。
  • 容器对 SSR 站 不做路由白名单(已无 dynamicFileRoutes):带扩展名路径先精确查静态产物(命中且 非空 就走文件), 不存在或为空 则连同任意无扩展名路由一起转发给 edge。 页面还是资源由 edge 返回的 Content-Type 决定——text/html→当页面(注平台脚本);其余→资源原样吐(不注入、用 edge 的 Content-Type)。代价:带后缀的缺失请求(bot 探 .php 等)也会打一次 edge、由项目 router 的 * 兜底;真实静态产物因「有内容」已在前一步命中走文件、不碰 edge。
  • 不要再去 public/ 放同名静态文件——静态文件优先级更高(精确命中即原样返回),会盖掉你的动态路由。

客户端能力约束

  1. Sonner/toast:保留在根布局,加 mounted 守卫;toast(...) 只在事件回调/useEffect。
  2. 主题:useTheme() 分支挂载后执行,未挂载用稳定 fallback。
  3. 浏览器 API:顶层/Hook 初始值/渲染路径里的 window/document/localStorage → useEffect 或 typeof window!=='undefined' 守卫。
  4. 非确定性值:时间戳/随机改稳定值或移进 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)。

交付验收(必须全部通过)

  1. pnpm build 成功 + 产出 dist/server/entry-server.js(IIFE,暴露 __SSR_BUNDLE__.render;edge 按模板「末尾 return 取回」加载,不依赖 globalThis)。
  2. 客户端构建产出正常(index.html 入口指向 entry-client,#root 含 <!--ssr-outlet-->)。
  3. supabase/functions/ssr/index.ts 已写(你的 SSR server)。
  4. 运行后无 window/document is not defined;首屏 hydration 无显著 mismatch。
  5. 路由是单一 src/routes.tsx 的 RouteObject[]。
  6. 深化过的页 SSR 首屏带 loader 数据;未深化的页正常出壳 + 客户端接管。
  7. (若加了资源路由)发布后访问 /sitemap.xml、/sitemap/static 等:返回 loader 设的 Content-Type(如 application/xml)、内容是动态产出而非 HTML 壳,且 响应里没被注入平台 <script>(被注入 = 容器把它当成了页面,去查 loader Response 的 Content-Type 是否非 HTML)。
  8. 对一个 不含任何客户端重库 的公开页(通常是首页 /)单独发一次 SSR 请求,确认不出现任何重库的初始化错(如 [DecimalError])。若这种页都报重库错,说明该库进了 eager bundle(IIFE 内联),需按 「关于 external」一节 用 external 物理剔除,而非仅 lazy。

失败排查(按顺序)

  1. 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 没有 Node Readable。 改 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,而不是某条路由的渲染问题。
  1. 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 条、再回来看这条是否仍在。
  2. 先查 vite.config.ssr.ts 是否把 react-router-dom alias 到 react-router、是否按模板加了 resolve.conditions/footer/define。
  3. 再查 dist/server/entry-server.js 是否产出。
  4. 再查 edge:render 返回的是 {html}、server 注进 <!--ssr-outlet-->(占位是否在 index.html 里)、x-ssr-bundle/模板 URL 是否能 fetch。
  5. 再查 SSR bundle import 期崩:定位模块顶层浏览器 API(回「执行步骤」的预扫描补齐)。
  6. 再查 hydration mismatch:主题/Sonner 挂载守卫、非确定性值。
  7. 资源路由(/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)和冷启动(时对时错)现象不同,靠状态码区分不了,必须看栈。