ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

解剖 JavaScript 静态网站生成器:预渲染框架的七个设计维度与 react-snap 的取舍

解剖 JavaScript 静态网站生成器:预渲染框架的七个设计维度与 react-snap 的取舍

【免费下载链接】react-snap

👻 Zero-configuration framework-agnostic static prerendering for SPAs

项目地址:https://gitcode.com/gh_mirrors/re/react-snap
点击查看免费下载

本文以 react-snap 仓库中的 anatomy-of-js-static-website-generator.md 为蓝本,从 DOM 实现、路由发现、数据层、数据生成器、数据再水化、Webpack 关系、数据源七个维度解剖 JavaScript SPA 静态预渲染器(prerenderer / static site generator)的设计空间,并结合 index.js、src/puppeteer_utils.js、run.js 等源码印证 react-snap 在每个维度上的具体选型与实现。读完后你将掌握:如何按这七个维度评估任意预渲染方案,以及 react-snap "零配置"背后的工程细节(puppeteer 爬取、window.snapSaveState状态注入、webpack chunk 修正等)。

一、为什么要预渲染 SPA

预渲染(prerendering)的动机在原文开篇即说得很清楚:假设我们已有一个能跑在客户端的 JavaScript 应用,为了加快首屏绘制(first paint)、并让页面可被搜索引擎爬虫与社交网络爬虫抓取,我们需要把它预渲染为静态 HTML。

react-snap 自身就是这一目标的实现:它用 Headless Chrome 从根路由开始爬取所有可发现链接,把每个路由渲染后的 DOM 保存为静态 HTML 文件(参见 Readme.md 与 doc/behind-the-scenes.md)。理解了预渲染器的七个设计维度,就能看懂它为什么长成现在这个样子。

二、维度一:DOM 实现——用谁来执行你的 JavaScript

预渲染一个 JavaScript 应用,需要一个能够承载 DOM 的执行环境。原文将可选方案归纳为三类:

2.1 虚拟 DOM:renderToString路线

代表项目:react-static使用 React 的renderToString和renderToStaticMarkup。

缺点:

  • 只能服务于选定的框架——react-static只能服务于 React;
  • 只支持具备 SSR 能力的组件,部分组件没有这种支持。

优点:

  • 可以做缓存,例如react-component-caching这类方案,可以缓存组件渲染结果。

2.2 Node.js DOM 库(JSDOM 路线)

代表项目:react-snapshot使用 React 的renderToString配合 JSDOM;prerender-loader使用 JSDOM。

缺点:

  • 不支持一些现代 DOM 特性,例如Blob;
  • 带有 SSR 专属逻辑的组件大概率行为不符合预期,例如react-ideal-image在服务端与客户端渲染方式不同;
  • 需要写专门的逻辑来剔除一些仅客户端的逻辑,例如 cookie consent(Cookie 同意横幅)或 Google Analytics。

2.3 Headless 浏览器(react-snap 的选择)

代表项目:react-snap使用puppeteer。

缺点:

  • 同样存在 SSR 专属逻辑组件行为不一致的问题(如react-ideal-image服务端/客户端渲染不同);
  • 同样需要写专门逻辑来排除仅客户端的逻辑(如 cookie consent、Google Analytics)。

react-snap 选择真实浏览器,换来的是"所有浏览器特性都能用"——Readme.md 在 Features 一节明确写道:背后是真实浏览器,因此不存在 WebGL、Blob 等 HTML5 特性的兼容性问题。但真实浏览器执行也引入了一些"事后修正"逻辑,这在源码中清晰可见:

  • blob URL 清除:运行时生成的blob:URL 只存在于当前浏览器会话中,写进静态 HTML 后是死链。defaultOptions中removeBlobs: true默认开启,removeBlobs 实现 会删除所有href以blob:开头的<link rel="stylesheet">。对应测试用例在 tests/run.test.js 的removeBlobs描述块中。
  • CSS-in-JS 的insertRule修正:JSS 等库通过styleSheet.insertRule注入样式,此时<style>标签的 innerHTML 是空的、只有运行时sheet.rules有内容。fixInsertRule会把style.sheet.rules逐条cssText序列化回<style>内部,否则预渲染产物会丢失这些样式(默认fixInsertRule: true,测试见fixInsertRule描述块)。
  • 表单控件状态回写:JS 动态改变 radio/checkbox/option 的选中态并不会体现在 HTML 属性上,fixFormFields会强制把checked/selected写回为属性,保证爬虫与无 JS 环境下看到一致的 DOM(测试见saves state of form elements changed via JS)。

三、维度二:路由发现——预渲染器如何知道应用里有哪些页面

既然是客户端应用,大概率在用react-router这类客户端路由。那么预渲染器怎么知道要渲染哪些页面?原文给出三种选项:

3.1 手动列出路由

代表项目:prerender-loader、prerender-spa-plugin。

缺点:需要人工创建并维护路由清单,页面一多就是负担。

3.2 程序化生成器

代表项目:

  • gatsby(gatsby-node.js中的createPages);
  • react-static(static.config.js中的getRoutes);
  • react-snap——可以把它 require 成库,把页面数组传给生成函数。

缺点:需要为页面编写一定配置。

3.3 爬取(Crawl)

代表项目:react-snap、react-snapshot。

缺点:

  • 有些路由爬虫发现不了,需要手动补充;
  • 如果不想渲染某些路由,需要显式忽略。

react-snap 同时支持"程序化列表"(include选项)和"爬取"(crawl: true默认开启),二者互补。源码中爬取机制的要点:

  • 链接提取:getLinks 收集a标签、link[rel='alternate'],以及iframe的src;对 SVG 元素(anchor.href.baseVal分支)也做了处理,即 SVG 内的<a xlink:href>也会被爬取(对应测试svgLinks)。
  • 入队过滤:addToQueue 会剥掉 query 与 hash,且只爬同端口的localhost链接(测试don't crawl localhost links on different port验证了这一点);当队列中已入队 2 个 URL 时会自动追加/404.html——即只要存在多页,404 页面也会被预渲染(测试many pages验证"if there is more than page it crawls 404.html")。
  • 404 标题校验:saveAsHtml 会检查/404.html的<title>是否包含 "404" 字符串、普通页面的 title 是否误含 "404",不符则输出警告,帮助运营 SEO。
  • 浏览器内跳转:如果页面加载后通过history.pushState等发生了前端重定向(location变了),afterFetch 中的重定向处理 会把新路由加入队列继续渲染(测试history.pushState系列)。
  • 静态服务与 history fallback:run 函数 先用 express +serve-static把source目录(默认build)挂载到publicPath下,再用express-history-api-fallback把非文件请求回退到200.html——200.html是复制自原始index.html的未预渲染副本,保证 SPA 的 history 路由在本地静态服务下也能正常启动。若sourceDir中已存在200.html(说明已跑过一次),run 会直接报错终止,"不能对同一目录跑两次"(测试You can not run react-snap twice验证了该保护)。
  • 等待策略:fetchPage 以waitUntil: "networkidle0"打开页面,再可选地执行options.waitFor(puppeteer 的等待条件)。超时诊断由 src/tracker.js 负责:augmentTimeoutError会把导航超时时仍未完成的请求 URL 附加到报错信息里,方便定位是哪一个资源卡住了渲染。

路由清单的最小示例即include选项:默认值["/"](defaultOptions),测试 tests/examples/many-pages/index.html 展示了爬取器对尾斜杠、hash、query、rel="alternate"等各类链接的归一化行为(最终生成 7 个文件,含404.html)。

四、维度三:数据层——预渲染器是否绑定数据层

原文提出:预渲染器是"数据层无关(data layer agnostic)"还是"绑定数据层"?

4.1 数据层无关

代表项目:react-snap、react-static。

优点:你惯用什么都行(Redux、MobX、Recoil、无状态 fetch……)。

4.2 绑定数据层

代表项目:gatsby。

缺点:必须使用框架专属的数据层——以 Gatsby 为例,就是 GraphQL。

react-snap 属于前者:源码里不存在任何数据层的依赖或约定(package.json 的 dependencies 中只有 express、puppeteer、html-minifier、clean-css、minimalcss 等运行时工具)。应用怎么取数,预渲染时它就怎么取数。相关的三个配套能力:

  • userAgent: "ReactSnap":默认 User-Agent 就叫 "ReactSnap"(defaultOptions)。Readme.md 的 userAgent 一节给出了经典用法——应用代码可据此区分"正在被爬取"与"真实用户访问",例如给 API 用相对路径还是绝对路径:
const BASE_URL = process.env.NODE_ENV == "production" && navigator.userAgent != "ReactSnap" ? "/" : "http://xxx.yy/rest-api";
  • skipThirdPartyRequests:爬取时阻断所有非本地请求,避免污染渲染或拖慢构建。skipThirdPartyRequests 通过page.setRequestInterception(true)对不以basePath开头的请求直接abort()。
  • preconnectThirdParty(默认开启):爬取过程中发现第三方域名时,向<head>注入<link rel="preconnect">(preloadResources 实现,测试preconnectThirdParty断言生成了<link href="https://fonts.googleapis.com" rel="preconnect">)。

五、维度四:数据生成器——由谁在构建期取数

5.1 不带数据生成器

代表项目:react-snap。

5.2 带数据生成器

代表项目:

  • react-static(static.config.js中的getData);
  • gatsby(gatsby-node.js中的createNodes)。

不带数据生成器意味着:react-snap 不在构建期替你拉数据,而是让应用在真实浏览器里自己跑一遍取数逻辑——page.goto使用networkidle0(src/puppeteer_utils.js#L241),即等到网络空闲,AJAX 数据自然已经进入页面。这也是"数据层无关"的自然延伸:构建期不需要知道你的数据长什么样。

但 AJAX 取数在客户端再水化时会产生"二次加载"与闪烁。react-snap 的对策是AJAX 缓存:开启cacheAjaxRequests: true后,preloadResources 监听response事件,把content-type含json的响应体按路径收集进ajaxCache;afterFetch 阶段 将其序列化为window.snapStore={"\/js\/test.json":{"test":1}};注入 HTML(测试cacheAjaxRequests验证了注入内容,示例页面见 tests/examples/other/ajax-request.html)。应用启动时优先读window.snapStore[<path>]即可跳过重复请求。注意 Readme.md 提示该特性可能与浏览器缓存冲突,必要时设置"puppeteer": { "cache": false }。

六、维度五:数据再水化——如何复用预渲染的 HTML

原文明确指出:预渲染完成后,关键在于再水化(rehydration)——核心技巧是"精确重建 HTML 渲染那一刻的应用状态",这样再水化才能最大程度(理想情况下全部)复用已有标记(markup)。要做到这一点,必须把应用状态的序列化结果传给客户端。原文列出三种手段:

6.1 Redux

示例:react-snap(Readme.md 的 Redux 一节)。完整可复制的代码:

// Grab the state from a global variable injected into the server-generated HTML const preloadedState = window.__PRELOADED_STATE__; // Allow the passed state to be garbage-collected delete window.__PRELOADED_STATE__; // Create Redux store with initial state const store = createStore(counterApp, preloadedState || initialState); // Tell react-snap how to save Redux state window.snapSaveState = () => ({ __PRELOADED_STATE__: store.getState() });

机制细节(源码证据):

  • window.snapSaveState是 react-snap 约定的"状态保存钩子":页面渲染结束、保存 HTML 之前,框架在页面上下文里调用它(index.js#L811-L820)。
  • 返回值按 key 逐个序列化为window["<key>"]=<json>;,并由snapEscape对<、>、/、\u2028、\u2029做 Unicode 转义,防止 XSS 式逃逸。
  • 生成的<script>被插入到页面第一个 script 之前,保证应用主 bundle 执行时状态已就绪。
  • 限制:源码注释(index.js#L800-L802)明确"目前仅能 stringify 基础数据类型",Date、Set、Map、NaN不会被正确处理——Readme.md 的 Caution 与测试snapSaveState(tests/examples/other/snap-save-state.html)均以此为据,non-JSON与 XSS 用例的断言以test.skip形式固化了当前限制。

6.2 专用数据访问器

示例:react-static(RouteProps、SiteProps);next.js(getInitialProps)。这类框架把"构建期数据"与"页面组件"绑定在一起,由框架自身完成注入与再水化——react-snap 没有对应物,它把这件事交给snapSaveState通用钩子。

6.3 缓存 AJAX 请求

示例:react-snap(即上一节cacheAjaxRequests特性)——把构建期抓取到的 JSON 存进window.snapStore[<path>],客户端直接复用,避免再水化时的二次请求。

再水化还有另一半发生在应用代码里:拿到预渲染 HTML 后要 hydrate 而非重渲染。Readme.md 给出的标准写法(React 16+):

import { hydrate, render } from "react-dom"; const rootElement = document.getElementById("root"); if (rootElement.hasChildNodes()) { hydrate(<App />, rootElement); } else { render(<App />, rootElement); }

Preact 因 API 差异需要用"以firstElementChild为参考节点"的变通写法;Vue 则依赖data-server-rendered属性标记再水化,Readme.md 甚至演示了如何借window.snapSaveState在渲染末尾给根元素补上该属性(原文称之为 "hack",因为该钩子本意是存状态而非改 DOM)。这些跨框架技巧正是"数据层无关"理念在再水化阶段的落地。

七、维度六:与 Webpack 的关系

原文把预渲染器与打包工具的关系分为三类:

7.1 Webpack 无关(react-snap 的选择)

代表项目:react-snap。

优点:

  • 可以配合其他打包器使用,例如 Parcel;
  • 无需改动 webpack 配置即可使用——例如可以不 eject 直接配合 create-react-app。

7.2 Webpack 插件

代表项目:prerender-loader、prerender-spa-plugin。

7.3 内建于框架

代表项目:next.js、react-static、gatsby。

缺点:除非一开始就用它,否则需要重写现有代码。

"无关"不等于"零集成"。react-snap 的入口形态决定了这一维度:

  • 库形态:run(userOptions)导出在 index.js 末尾(exports.run = run),任何 Node 脚本都可以调用,构建管线是什么不重要;
  • CLI 形态:run.js 是 npm 包bin入口(package.json 中"react-snap": "./run.js"),它读取当前项目package.json的reactSnap字段作为配置。典型接入即postbuild: react-snap。

run.js 还做了一件"Webpack 无关但理解 webpack 产物"的事:自动探测构建工具并修正 chunk 加载问题:

const publicUrl = process.env.PUBLIC_URL || homepage; const reactScriptsVersion = parseInt( (devDependencies && devDependencies["react-scripts"]) || (dependencies && dependencies["react-scripts"]) ); // react-scripts 1.x → "CRA1",2.x → "CRA2" // 依赖中出现 parcel-bundler → "Parcel"

问题背景:CRA 类构建会把路由级 lazy chunk 以<script>直接写进 HTML,预渲染后的静态页在再水化前这些 chunk 会阻塞加载。react-snap 的修正逻辑(fixWebpackChunksIssue1、fixWebpackChunksIssue2、fixParcelChunksIssue)用正则识别main.8位hash(.chunk).js形式的脚本,把 lazy chunk 的<script>替换为<link rel="preload" as="script">,只保留主脚本阻塞执行;开启http2PushManifest时则改由 Link 头推送。测试 tests/run.test.js 的fixWebpackChunksIssue块基于 tests/examples/cra/index.html 验证了"生成 preload 链接 + 保留 root script + 移除 chunk script"三条断言。该选项的默认值是"CRA1"(兼容旧版本),合法取值为CRA1、CRA2、Parcel、false(defaults 的校验逻辑)。

八、维度七:数据源

原文强调:数据源"不一定会是预渲染器的一部分,但可以是"。归纳出三种路线:

8.1 Markdown、Front Matter、Git

Jekyll 开创的路线。

示例:react-static、gatsby。

8.2 GraphQL 作为文件系统的接口

Gatsby 开创的路线。

示例:gatsby。

8.3 其他

可以是任何东西:JSON 文件、JSON API 等。

react-snap 落在这个维度的"其他"一侧,且完全解耦:它不读取任何内容文件,内容来自应用运行时自己获取的数据——Markdown、GraphQL、JSON API 对它是同构的,只要浏览器里能渲染出来,它就能抓下来。这也解释了为什么它被定位为"零配置":不需要理解你的内容管道。

九、配套参考:defaultOptions 全量配置速查

理解七个维度后,react-snap 的全部行为都可以映射到 defaultOptions 上。以下为当前仓库(react-snap 1.23.0,Node >= 8.6.0)中的默认值,经 defaults 校验函数 归一化后生效。配置方式:package.json的reactSnap字段(CLI 读取),或作为库调用时传入run(options)。

选项默认值说明
port45678本地临时静态服务端口
source"build"构建产物目录
destinationnull(缺省同source)输出目录;若与 source 相同且saveAs: "html"且已存在200.html,直接报错
concurrency4并发渲染的浏览器 tab 数
include["/"]入口路由列表(程序化路由发现)
userAgent"ReactSnap"可被应用代码识别的 UA
headlesstruepuppeteer headless 模式
puppeteer{ cache: true }puppeteer 参数,cache: false用于 AJAX 缓存冲突场景
puppeteerArgs[]如容器环境需["--no-sandbox", "--disable-setuid-sandbox"]
puppeteerExecutablePathundefined自定义 Chromium 可执行文件(Docker + Alpine 场景)
puppeteerIgnoreHTTPSErrorsfalse
publicPath"/"子目录部署时必填,如"/sub"
minifyCss{}传给 CleanCSS 的参数,也会并入minifyHtml.minifyCSS
minifyHtml见源码默认collapseWhitespace、decodeEntities、sortAttributes等开启;Semantic UI 需设sortClassName: false(默认已为false)
viewport{ width: 480, height: 850 }移动端优先的视口
sourceMapstrue页面报错时映射回源 map
fixWebpackChunksIssue"CRA1"取值CRA1/CRA2/Parcel/false,CLI 下按依赖自动探测
removeBlobstrue清除blob:样式链接
fixInsertRuletrue还原insertRule注入的 CSS-in-JS 样式
skipThirdPartyRequestsfalse阻断全部第三方请求
cacheAjaxRequestsfalseAJAX JSON 存入window.snapStore
http2PushManifestfalse生成http2-push-manifest.json(Link 头 preload)
ignoreForPreload["service-worker.js"]配合http2PushManifest的忽略列表
preconnectThirdPartytrue注入第三方域<link rel="preconnect">
inlineCssfalse实验特性:minimalcss 提取 critical CSS 内联,全量 CSS 以非阻塞方式加载
saveAs"html"合法值html/png/jpeg(截图模式)
crawltrue是否爬取页内链接;关掉则只渲染include列表
waitForfalsepuppeteer 额外等待条件
externalServerfalse不启动内置静态服务,由外部提供服务
removeStyleTags/removeScriptTagsfalse激进特性:整体删除<style>/<script>
preloadImagesfalse为本地图片注入<link rel="preload" as="image">
asyncScriptTagsfalse给<script src>加async

值得注意的校验行为(defaults):include必须是非空数组;saveAs只允许html/png/jpeg;preloadResources已废弃(应使用preloadImages或cacheAjaxRequests);minifyOptions/asyncJs会自动迁移为minifyHtml/asyncScriptTags。这些约束在 tests/run.test.js 的validates options块中有对应用例。

十、结语:七个维度的定位总结

把原文档的横评浓缩为一张 react-snap 的定位表:

维度react-snap 的选型换来的代价 / 备注
DOM 实现Headless 浏览器(puppeteer)全浏览器特性可用;需处理 blob URL、insertRule等事后修正
路由发现Crawl 为主 +include程序化列表不可发现的路由需手动补充
数据层数据层无关应用惯用什么都行
数据生成器无(应用自取数)以cacheAjaxRequests补偿二次请求
数据再水化window.snapSaveState钩子 +window.snapStore仅支持基础 JSON 类型
Webpack 关系无关(库 + CLI 双形态)可配合 Parcel;不 eject 可用 CRA
数据源解耦(Markdown/GraphQL/JSON API 均可)不介入内容管道

这一取舍的哲学是"零配置":框架、打包器、数据层、内容源统统不碰,只在真实浏览器里重放应用本身,再用最小钩子(snapSaveState、snapStore)桥接构建期与运行期。更完整的行为清单可参考 doc/behind-the-scenes.md 的 14 步流程,与其他方案(prerender-spa-plugin、react-snapshot、snapshotify 等)的特性对比见 doc/alternatives.md 中的表格。

【免费下载链接】react-snap

👻 Zero-configuration framework-agnostic static prerendering for SPAs

项目地址:https://gitcode.com/gh_mirrors/re/react-snap
点击查看免费下载
上一篇:FastAPI-SQLAlchemy终极指南:10分钟快速集成Python Web应用与数据库
下一篇:Flat 开源课堂环境变量参考:从部署域名到 macOS / Windows 签名公证的完整配置指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进