一个脚本让任何框架无痛实现 SSR,无需转到 Next.js、Nuxt.js

SPA 静态化实战:无需替换框架,低成本地将 React、Vue... 应用转为 SSG 的方案

SPA 的甜蜜烦恼与性能优化的诉求

单页面应用(SPA)以其流畅的客户端路由和丰富的交互体验赢得了开发者的青睐。然而,这种架构也带来了一些挑战,尤其是在性能和爬虫可见性方面:

  1. 搜索引擎优化(SEO): 尽管搜索引擎爬虫能力在提升,但它们抓取和理解依赖 JavaScript 动态渲染内容的 SPA 页面,仍不如直接处理静态 HTML 那样高效和稳定。这可能影响网站在搜索结果中的排名,对内容驱动型网站尤其不利。
  2. 首屏加载性能(FCP/LCP): SPA 首次加载需下载并执行大量 JavaScript,然后才能渲染内容和请求数据。这个过程可能导致较长的白屏时间,影响用户体验,特别是在网络或设备性能受限时。

主流 SSR 方案的挑战与迁移成本

为了解决上述问题,社区涌现了像 Next.js (基于 React) 和 Nuxt.js (基于 Vue) 这样的优秀服务端渲染(SSR)或静态站点生成(SSG)框架。它们提供了开箱即用的解决方案,能有效改善 SEO 和首屏性能。

然而,将现有的、基于标准 React/Vue CLI 构建的 SPA 项目迁移到这些框架,并非总是轻松之举:

  • 学习成本: 需要学习新框架的约定、路由机制、数据获取方式(如 getServerSideProps, getStaticProps, asyncData 等)、配置和生命周期。
  • 项目重构: 往往需要对现有代码结构、路由逻辑、状态管理、甚至是 CSS 处理方式进行较大规模的调整,以适应框架的规范。这对于已具规模或历史悠久的项目来说,可能意味着巨大的工作量和潜在风险。
  • 构建与部署复杂性: SSR 方案通常需要运行一个 Node.js 服务器环境,对部署架构有特定要求。虽然它们也支持 SSG 模式,但整个开发和构建流程与传统 SPA 不同。

那么,有没有一种方式,可以在不颠覆现有 SPA 项目结构的前提下,低成本地享受 SSG 带来的好处呢?

这就是我开发这个小工具的初衷:提供一种非侵入式的、轻量级的解决方案,让你能够为现有 SPA 项目中的特定页面(如首页、关于页、产品页等)生成静态 HTML,从而快速优化这些关键页面的 SEO 和加载性能,而无需进行大规模的项目迁移或重构。

github.com/beixiyo/ssg...

使用示例

安装

bash 复制代码
npm i @jl-org/ssg puppeteer-core -D

使用

  1. 生成静态 HTML
ts 复制代码
const path = require('node:path')
const { ssg } = require('@jl-org/ssg')
const puppeteer = require('puppeteer-core')

const PORT = '4173'
main()

async function main() {
  await ssg({
    port: PORT,
    /**
     * 创建浏览器实例的函数,默认需要你自行下载 puppeteer-core 或者 puppeteer
     * ### 因为 puppeteer 会自动下载浏览器,所以提供此配置。你可以使用自己浏览器的路径,节省内存
     * @example
     * puppeteer.launch({ headless: true, ... })
     */
    createBrowser: () => puppeteer.launch({
      executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
      headless: true
    }),
    ssgPages: [
      {
        url: `http://localhost:${PORT}`,
        target: path.resolve(__dirname, '../dist/index.html')
      },
      {
        url: `http://localhost:${PORT}/pricing`,
        target: path.resolve(__dirname, '../dist/pricing.html')
      },
      {
        url: `http://localhost:${PORT}/about`,
        target: path.resolve(__dirname, '../dist/about.html')
      },
    ]
  })

  process.exit(0)
}
  1. 修改 Nginx 配置,并且重启服务
nginx 复制代码
server {
  listen 80;
  server_name yourdomain.com;

  # pages
  location /pricing {
    root /Your_Root_Dir;
    index pricing.html;
    try_files $uri $uri/ /pricing.html;
  }
  location /about {
    root /Your_Root_Dir;
    index about.html;
    try_files $uri $uri/ /about.html;
  }
}
  1. 测试生效
bash 复制代码
# 查看返回内容,是否为整个 HTML
curl http://yourdomain.com/pricing

核心原理与工作流程

这个工具的核心思路是在构建流程中,模拟真实用户访问应用的过程,捕获最终渲染好的 HTML,并将其保存为静态文件。

以下是详细的工作流程及关键代码原理:

  1. 启动本地预览服务器:

    • 动作: 执行命令(如 npx vite preview --port <端口>)启动 SPA 的预览服务。默认使用 vite,你可自定义命令

    • 原理: 需要一个 HTTP 服务器来提供 SPA 资源。我们使用 Node.js 的 child_process.spawn 异步启动这个服务。

      typescript 复制代码
      /** 伪代码示意 */
      const proc = spawn('npx', ['vite', 'preview', '--port', '4173'], { shell: true })
      /** 通过监听 stdout 确认服务器启动成功 */
      proc.stdout.on('data', (data) => {
        if (data.toString().includes('server running at')) {
          resolve(killServerFunction) /** 服务器就绪,继续下一步 */
        }
      })
    • 关键: 确保服务器完全启动后再进行下一步。

  2. 启动无头浏览器实例:

    • 动作: 利用 Puppeteer 库启动一个无头(headless)浏览器实例。

    • 原理: 无头浏览器提供 API 控制浏览器行为。通过 createBrowser 配置,允许用户指定本地浏览器路径,避免额外下载。

    • 注意: 为了节省用户内存,启动浏览器需要用户从外部传入,你可以选择下载 puppeteer-core ,传入自己浏览器路径来节省内存;或者下载 puppeteer ,直接用自动下载的浏览器

      typescript 复制代码
      const puppeteer = require('puppeteer-core')
      
      /** 伪代码示意 - createBrowser 函数内 */
      const browser = await puppeteer.launch({
        executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', /** 用户指定的路径 */
        headless: true /** 在后台运行 */
      })
      return browser
    • 关键: 使用 headless: true 在后台执行,executablePath 提供灵活性。

  3. 访问目标页面:

    • 动作: 无头浏览器导航到配置中指定的每个本地 URL(如 http://localhost:4173/about)。

    • 原理: 调用 Puppeteer Page 对象的 goto 方法。

      typescript 复制代码
      /** 伪代码示意 - genHtml 函数内 */
      const page = await browser.newPage()
      await page.goto('http://localhost:4173/about', { /* ... */ })
  4. 等待页面完全加载:

    • 动作: 等待页面资源加载完毕且网络活动静默。

    • 原理: 使用 goto 方法的 waitUntil: 'networkidle0' 选项。这指示 Puppeteer 等待,直到在 500ms 内网络连接数少于或等于 0,通常意味着异步加载和渲染已完成。

      typescript 复制代码
      /** 伪代码示意 - page.goto 调用时 */
      await page.goto(url, {
        waitUntil: 'networkidle0', /** 等待网络空闲 */
        timeout: 100000 /** 设置较长超时以防页面加载慢 */
      })
    • 关键: networkidle0 是捕获完整动态内容的核心。

  5. 捕获 HTML 内容:

    • 动作: 获取当前页面的完整 DOM 结构,并序列化为 HTML 字符串。

    • 原理: 调用 Page 对象的 content() 方法。

      typescript 复制代码
      /** 伪代码示意 - genHtml 函数内 */
      const html = await page.content()
  6. (可选)HTML 优化与压缩:

    • 动作: 若启用 needMinify,使用 html-minifier-terser 库压缩 HTML。

    • 原理: 调用 minify 函数,传入 HTML 内容和压缩选项。

      typescript 复制代码
      /** 伪代码示意 - genHtml 函数内 */
      if (needMinify) {
        html = await minify(html, {
          collapseWhitespace: true,
          removeComments: true,
          // ... 其他压缩选项
        })
      }
    • 关键: 减小文件体积,提升加载速度。

  7. 路径处理:

    • 原因: 通过开发服务器启动的页面,里面的资源是 https://localhost:PORT/xxx.png 之类的路径,这在服务器里不可用,应该替换掉 http 路径,移除 HTML 中指向本地预览服务器的绝对 URL 前缀。

    • 原理: 使用字符串的 replace 方法配合正则表达式,将 http://localhost:PORT 替换为空字符串,使资源引用变为相对路径或根相对路径。

      typescript 复制代码
      /** 伪代码示意 - genHtml 函数内 */
      const curUrl = `http://localhost:${PORT}` /** 获取当前基础 URL */
      html = html.replace(new RegExp(curUrl, 'g'), '') /** 全局替换 */
    • 关键: 保证部署后的资源路径正确。

  8. 写入静态文件:

    • 动作: 将处理后的 HTML 内容写入指定的文件路径。

    • 原理: 使用 Node.js 的 fs.writeFileSync

      typescript 复制代码
      /** 伪代码示意 - genHtml 函数内 */
      writeFileSync(targetPath, html) // targetPath 是配置中指定的输出路径
  9. 循环与清理:

    • 动作: 对所有配置的页面完成 SSG 后,关闭浏览器页面和实例,并终止预览服务器进程。

    • 原理: 使用 page.close() 关闭单个页面,browser.close() 关闭浏览器。最后还需要关闭命令行(npx vite preview),使用 tree-kill 库确保预览服务器及其所有子进程被彻底关闭,释放资源。

      typescript 复制代码
      import treeKill from 'tree-kill'
      
      treeKill(pid, 'SIGTERM', (err) => {
        if (err) {
          console.error(`关闭服务器失败 ${pid}:`, err)
        }
        else {
          console.log(`Process ${pid} 已经关闭`)
        }
      })
    • 关键: 资源管理,避免进程残留。

相关推荐
涵信3 分钟前
2024年React最新高频面试题及核心考点解析,涵盖基础、进阶和新特性,助你高效备战
前端·react.js·前端框架
mmm.c5 分钟前
应对多版本vue,nvm,node,npm,yarn的使用
前端·vue.js·npm
混血哲谈10 分钟前
全新电脑如何快速安装nvm,npm,pnpm
前端·npm·node.js
天天扭码11 分钟前
项目登录注册页面太丑?试试我“仿制”的丝滑页面(全源码可复制)
前端·css·html
桂月二二40 分钟前
Vue3服务端渲染深度实战:SSR架构优化与企业级应用
前端·vue.js·架构
萌萌哒草头将军40 分钟前
🚀🚀🚀 这六个事半功倍的 Pinia 库,你一定要知道!
前端·javascript·vue.js
_一条咸鱼_41 分钟前
深入剖析 Vue 状态管理模块原理(七)
前端·javascript·面试
rocky1911 小时前
谷歌浏览器插件 录制动态 DOM 元素
前端
谁还不是一个打工人1 小时前
css解决边框四个角有颜色
前端·css
海晨忆2 小时前
【Vue】v-if和v-show的区别
前端·javascript·vue.js·v-show·v-if