基于 React Query 的 CSR 到 SSG 改造方案
摘要:作者因 SPA 项目在搜索引擎中渲染异常且 API 国外访问延迟高,决定将其改造为静态站点生成(SSG)。通过编写构建脚本遍历路由,利用 React SSR 将数据注入 HTML 并完成水合,解决了 SEO 和首屏加载问题。同时,利用内容指纹和 Webhook 实现了自动化构建,确保更新后能自动触发部署。
前言
事情的起因是这样了,有天我心血来潮地打开必应,想看下我自己写的这个博客系统收录如何,然后搜索一下,满屏的“加载中...”,搞得我满脸黑线。
诶,我就纳闷了,不是说现代的搜索引擎大部分都支持 JS 渲染嘛?我开发的网站就是 SPA 呀,为啥会加载不出来呢?
此时,我突然打开我的监控页,发现虽然前端 SPA 的虽然很快,但是 API 接口的时延却很感人。
因为监控页是基于 Cloudflare Worker 搭建的,所以也能一定程度反应出国外的连接情况。虽然前端资源放到了 EO Pages,有全球分发,但是 API 服务是国内的,在国外访问响应耗时就不太理想了。
虽然说 SPA 项目不利于 SEO,但是没想到,我这个网站是以这种情况出现的。无奈,只能想到做 SSG 改造,而又正好博客、文档类系统是挺适合静态构建的。展示端只显示公开内容,动态的私有内容我在独立的管理端来承载,思路有了,开干。
首先声明,博客前端使用的技术栈是 Vite + React + React Query。
路由遍历
首先,新建一个静态构建脚本prerender.mjs,用于统筹构建流程。
在构建开始,需要获取到所有需要静态构建的页面路由,这里需要提供一个后端接口返回所有公开的内容,然后遍历获取并渲染即可。
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const WEB_ROOT = resolve(ROOT, '../..')
const DIST = resolve(ROOT, 'dist')
const SSR_ENTRY = resolve(ROOT, '.ssg-temp', 'ssg-entry.js')
const { default: dotenv } = await import('dotenv')
dotenv.config({ path: resolve(WEB_ROOT, '.env') })
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8001/api'
console.log(`[SSG] API_BASE_URL: ${API_BASE_URL}`)
// 接口请求失败则退出构建,避免构建出错误的内容
async function fetchOrFail(url) {
const res = await fetch(url)
if (!res.ok) {
throw new Error(`[SSG] ${url} → HTTP ${res.status} ${res.statusText}`)
}
const json = await res.json()
if (json.code !== 0) {
throw new Error(`[SSG] ${url} → API code=${json.code} msg=${json.msg}`)
}
return json.data
}
async function main() {
console.log('[SSG] Fetching sitemap...')
const sitemap = await fetchOrFail(`${API_BASE_URL}/sitemap`)
const routes = [
{ url: '/', type: 'home' },
...sitemap.posts.map(p => ({ url: `/posts/${p.identify}`, type: 'post-detail', identify: p.identify })),
// 其他需要支持的路由 ...
]
console.log(`[SSG] Routes to prerender: ${routes.length}`)
for (const route of routes) {
// 其他页面逻辑 ...
}
console.log('[SSG] Done!')
}
main().catch(err => {
console.error(err.message)
process.exit(1)
})数据注入与水合
因为我们在遍历路由然后构建每个页面的过程中,我们已经提前获取到了页面的接口数据,那么我们可以把这部分数据提前注入到生成的 HTML 源码中,当客户端接收到网页时,直接拿之前的数据完成水合,不需要再次请求数据。这可以避免用户访问先看到内容,突然变成加载中,又重新请求一遍数据重新渲染。
数据注入需要在构建脚本中完成,把 React Query 数据缓存到 HTML 的window中:
async function main() {
const ssrModule = await import(SSR_ENTRY)
const { renderPage, QueryClient } = ssrModule
const template = readFileSync(resolve(DIST, 'index.html'), 'utf-8')
// 上一步路由获取逻辑 ...
for (const route of routes) {
const queryClient = new QueryClient()
switch (route.type) {
case 'post-detail': {
const data = await fetchOrFail(`${API_BASE_URL}/posts/${route.identify}`)
queryClient.setQueryData(['post', route.identify], data)
break
// 其他页面 ...
}
const { appHtml, dehydratedState, headHtml } = renderPage(route.url, queryClient)
// 构建时数据在用户加载页面时已"过期",staleTime 检查会触发重请求
// 将 dataUpdatedAt 设为极大值使其永不过期,避免不必要的重请求
if (dehydratedState?.queries) {
for (const query of dehydratedState.queries) {
if (query.state) {
query.state.dataUpdatedAt = Number.MAX_SAFE_INTEGER
}
}
}
const outputPath = route.url === '/'
? resolve(DIST, 'index.html')
: resolve(DIST, route.url.slice(1), 'index.html')
mkdirSync(dirname(outputPath), { recursive: true })
let pageHtml = template
.replace('<div id="root"></div>', `<div id="root">${cleanedAppHtml}</div>`)
// 需要把“<”转义,避免JSON里有HTML标签导致页面的<script>提前闭合,导致出现异常(XSS)
const initDataScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(dehydratedState).replace(/</g, '\\u003c')}</script>`
pageHtml = pageHtml.replace('<script type="module"', `${initDataScript}\n<script type="module"`)
writeFileSync(outputPath, pageHtml, 'utf-8')
console.log(`[SSG] ✓ ${route.url}`)
}
}改造原来的 SPA 入口文件main.tsx完成水合过程:
declare global {
interface Window {
__INITIAL_DATA__?: DehydratedState
}
}
// ...
const dehydratedState = window.__INITIAL_DATA__
if (dehydratedState) {
hydrate(queryClient, dehydratedState)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
// ...
)页面生成
把 CSR 逻辑渲染成完整的 HTML,这里就需要借用到 React 的 SSR 能力renderToString了。这个核心 API 能把组件渲染成 HTML 字符串输出。
这里我新建了一个ssg-entry.tsx的入口文件,用于生成 SSR 代码,供后续进一步渲染成 SSG 静态文件。(上一步构建脚本中使用的renderPage方法)
export function renderPage(url: string, queryClient: QueryClient) {
const helmetContext: { helmet?: HelmetServerState } = {}
const appHtml = renderToString(
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="posts" element={<PostsPage />} />
<Route path="posts/:identify" element={<PostDetailPage />} />
<Route path="categories/:identify" element={<CategoryPage />} />
<Route path="tags/:identify" element={<TagPage />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>
</HelmetProvider>
)
const dehydratedState = dehydrate(queryClient)
const helmet = helmetContext.helmet
let headHtml = ''
if (helmet) {
const parts = [helmet.title.toString(), helmet.meta.toString(), helmet.link.toString()]
headHtml = parts.filter(Boolean).join('\n')
}
return { appHtml, dehydratedState, headHtml }
}改造 Vite 构建输出vite.config.ts和package.json命令
export default defineConfig(({ mode }) => {
const isSSR = process.env.SSG === 'true'
return {
// ...
build: isSSR
? { ssr: './src/ssg-entry.tsx', outDir: '.ssg-temp' }
: {
outDir: 'dist',
sourcemap: process.env.NODE_ENV === 'development',
},
}
}){
"scripts": {
"build": "vite build",
"build:ssr": "SSG=true vite build",
"prerender": "node scripts/prerender.mjs",
"build:ssg": "pnpm build && pnpm build:ssr && pnpm prerender",
}
}至此,启动pnpm build:ssg便可以遍历路由生成渲染后的 HTML 文件了。
Head 标签优化
你可能注意到了,上面代码中出现了headHtml这个变量。因为之前网页的标题、描述、OG信息等,都是使用react-helmet-async这个三方库在客户端阶段才会渲染到 DOM 树上。而通过上面的 SSR 过程,生成的head内容,实际处于<body>标签内,所以需要多一步,把它们迁移过去才符合规范。
需要注意,需要区分静态生成的head以及客户端阶段生成的head标签,react-helmet-async在客户端阶段,不会去动原有 DOM 中的标签,而是重新生成,会导致标签重复不准。
我的思路是把静态构建的head标签加上data-ssg属性标识,在客户端阶段先清理,再由客户端接管重新生成。
继续优化构建脚本:
async function main() {
for (const route of routes) {
// 上一步数据注入逻辑 ...
const { appHtml, dehydratedState, headHtml } = renderPage(route.url, queryClient)
// react-helmet-async v3 在 React 19 下将 title/meta/link 渲染为 DOM 元素
// React 19 也会自动插入 <link rel="preload">
// 这些标签应统一移到 <head>
const extractedHead = []
let cleanedAppHtml = appHtml
const headTagRegex = /<title[^>]*>.*?<\/title>|<meta[^>]*\/?>|<link[^>]*\/?>/gis
cleanedAppHtml = cleanedAppHtml.replace(headTagRegex, (match) => {
extractedHead.push(match)
return ''
})
if (extractedHead.length > 0) {
const headContent = extractedHead.join('\n')
.replace(/<(title|meta)(\s|>)/g, '<$1 data-ssg=""$2')
.replace(/<link(?!\s+rel="preload")/g, '<link data-ssg=""')
pageHtml = pageHtml.replace(/<title>.*?<\/title>/, '')
pageHtml = pageHtml.replace('</head>', `${headContent}\n</head>`)
}
// 上一步数据注入window.__INITIAL_DATA__逻辑 ...
writeFileSync(outputPath, pageHtml, 'utf-8')
console.log(`[SSG] ✓ ${route.url}`)
}
}main.tsx中清除:
function HeadSSGCleanup() {
useLayoutEffect(() => {
document.querySelectorAll('[data-ssg]').forEach(el => el.remove())
}, [])
return null
}
ReactDOM.createRoot(document.getElementById('root')!).render(
// ...
<App />
<HeadSSGCleanup />
// ...其他静态配置生成
在构建中,我们可以获取到所有希望公开的内容,在这又可以引申出几个静态配置:
- 重定向规则。SPA 项目的一大痛点就是网址变化后,无法在请求时回复301/302这类状态码。在此,就可以借助 Pages 平台提供的重定向规则,完成提前构建,让平台帮我们来把旧网址301/302到新网址。因为我使用的是 EegeOne Pages,只需要按照平台规则生成
edgeone.json文件的redirects规则1即可。其他静态托管平台同理。 - 站点地图。即
sitemap.xml文件,遍历路由生成即可。 - RSS订阅。同理,获取列表页前n篇文章,生成
rss.xml文件在目标目录即可。
自动化构建
动态博客转 SSG 构建后,如果每次有内容更新,都要去 EO 触发手动构建,未免太繁琐。所以我引入了一个“内容指纹”,生成逻辑的逻辑也很简单。
- 把所有的参与构建的资源按类型、id、更新时间升序拼接,如
category:2+2026/07/15 12:27:30|post:1+2026/07/15 12:27:30|post:9+2026/07/15 13:27:30|tag:6+2026/07/15 10:27:30,如果内容没变化,生成的字符串也一定不会变化。 - 对字符串取哈希,取前8位,如
462163e9。 - SSG 构建时,从接口获取当前的指纹,写入构建文件
build-info.json,相关于固化整个页面的构建状态成一个哈希值。 - 启动定时器(可以在已有的后端服务,也可以用边缘函数定时),间隔几分钟或一小时,调用服务端获取最新的指纹,对比博客下
build-info.json文件的指纹。如果不一致,则调用托管平台的 Webhook(钩子)来触发一次自动构建。例如 EO Pages 就提供了部署钩子2。
结尾总结
完成上面的改造后,静态构建出来的 HTML 文件已经正常包含渲染内容。用户访问页面,首屏会很快,少了一次数据的获取。同时非首屏又能继续被原有的 CSR 逻辑接管,访问后续的第二、三页,只需要请求服务端 API 接口获取数据即可。
这样既能被“谷歌”、“必应”这些支持JS的爬虫正常渲染,又能被如“百度”这样不支持JS的爬虫获取。
下面看下成果图: