基于 React Query 的 CSR 到 SSG 改造方案

2026年07月15日·项目实战·#建站日志 #CICD #静态网页托管 #SEO优化·2次浏览

摘要:作者因 SPA 项目在搜索引擎中渲染异常且 API 国外访问延迟高,决定将其改造为静态站点生成(SSG)。通过编写构建脚本遍历路由,利用 React SSR 将数据注入 HTML 并完成水合,解决了 SEO 和首屏加载问题。同时,利用内容指纹和 Webhook 实现了自动化构建,确保更新后能自动触发部署。

基于 React Query 的 CSR 到 SSG 改造方案

前言

事情的起因是这样了,有天我心血来潮地打开必应,想看下我自己写的这个博客系统收录如何,然后搜索一下,满屏的“加载中...”,搞得我满脸黑线。

必应收录标题全是“加载中”
必应收录标题全是“加载中”

诶,我就纳闷了,不是说现代的搜索引擎大部分都支持 JS 渲染嘛?我开发的网站就是 SPA 呀,为啥会加载不出来呢?

此时,我突然打开我的监控页,发现虽然前端 SPA 的虽然很快,但是 API 接口的时延却很感人。

前端资源监控数据
前端资源监控数据

API监控数据
API监控数据

因为监控页是基于 Cloudflare Worker 搭建的,所以也能一定程度反应出国外的连接情况。虽然前端资源放到了 EO Pages,有全球分发,但是 API 服务是国内的,在国外访问响应耗时就不太理想了。

虽然说 SPA 项目不利于 SEO,但是没想到,我这个网站是以这种情况出现的。无奈,只能想到做 SSG 改造,而又正好博客、文档类系统是挺适合静态构建的。展示端只显示公开内容,动态的私有内容我在独立的管理端来承载,思路有了,开干。

首先声明,博客前端使用的技术栈是 Vite + React + React Query。

路由遍历

首先,新建一个静态构建脚本prerender.mjs,用于统筹构建流程。

在构建开始,需要获取到所有需要静态构建的页面路由,这里需要提供一个后端接口返回所有公开的内容,然后遍历获取并渲染即可。

js
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中:

js
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完成水合过程:

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方法)

tsx
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.tspackage.json命令

ts
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',
        },
  }
})
json
{
  "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属性标识,在客户端阶段先清理,再由客户端接管重新生成。

继续优化构建脚本:

js
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中清除:

tsx
function HeadSSGCleanup() {
  useLayoutEffect(() => {
    document.querySelectorAll('[data-ssg]').forEach(el => el.remove())
  }, [])
  return null
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  // ...
    <App />
    <HeadSSGCleanup />
  // ...

其他静态配置生成

在构建中,我们可以获取到所有希望公开的内容,在这又可以引申出几个静态配置:

  1. 重定向规则。SPA 项目的一大痛点就是网址变化后,无法在请求时回复301/302这类状态码。在此,就可以借助 Pages 平台提供的重定向规则,完成提前构建,让平台帮我们来把旧网址301/302到新网址。因为我使用的是 EegeOne Pages,只需要按照平台规则生成edgeone.json文件的redirects规则1即可。其他静态托管平台同理。
  2. 站点地图。即sitemap.xml文件,遍历路由生成即可。
  3. RSS订阅。同理,获取列表页前n篇文章,生成rss.xml文件在目标目录即可。

自动化构建

动态博客转 SSG 构建后,如果每次有内容更新,都要去 EO 触发手动构建,未免太繁琐。所以我引入了一个“内容指纹”,生成逻辑的逻辑也很简单。

  1. 把所有的参与构建的资源按类型、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,如果内容没变化,生成的字符串也一定不会变化。
  2. 对字符串取哈希,取前8位,如462163e9
  3. SSG 构建时,从接口获取当前的指纹,写入构建文件build-info.json,相关于固化整个页面的构建状态成一个哈希值。
  4. 启动定时器(可以在已有的后端服务,也可以用边缘函数定时),间隔几分钟或一小时,调用服务端获取最新的指纹,对比博客下build-info.json文件的指纹。如果不一致,则调用托管平台的 Webhook(钩子)来触发一次自动构建。例如 EO Pages 就提供了部署钩子2

结尾总结

完成上面的改造后,静态构建出来的 HTML 文件已经正常包含渲染内容。用户访问页面,首屏会很快,少了一次数据的获取。同时非首屏又能继续被原有的 CSR 逻辑接管,访问后续的第二、三页,只需要请求服务端 API 接口获取数据即可。

这样既能被“谷歌”、“必应”这些支持JS的爬虫正常渲染,又能被如“百度”这样不支持JS的爬虫获取。

下面看下成果图:

一次 SSG 构建日志截取
一次 SSG 构建日志截取

HTML源代码正常
HTML源代码正常

Footnotes

  1. 文档中心>边缘安全加速平台 EO>Makers>项目指南>edgeone.json

  2. 文档中心>边缘安全加速平台 EO>Makers>部署指南>触发部署