一个脚本让任何框架无痛实现 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} 已经关闭`)
        }
      })
    • 关键: 资源管理,避免进程残留。

相关推荐
mldong27 分钟前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排1 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60612 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角3 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!3 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi5 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马6 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端