【免费下载链接】react-snap
👻 Zero-configuration framework-agnostic static prerendering for SPAs
本文以 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)。
| 选项 | 默认值 | 说明 |
|---|---|---|
port | 45678 | 本地临时静态服务端口 |
source | "build" | 构建产物目录 |
destination | null(缺省同source) | 输出目录;若与 source 相同且saveAs: "html"且已存在200.html,直接报错 |
concurrency | 4 | 并发渲染的浏览器 tab 数 |
include | ["/"] | 入口路由列表(程序化路由发现) |
userAgent | "ReactSnap" | 可被应用代码识别的 UA |
headless | true | puppeteer headless 模式 |
puppeteer | { cache: true } | puppeteer 参数,cache: false用于 AJAX 缓存冲突场景 |
puppeteerArgs | [] | 如容器环境需["--no-sandbox", "--disable-setuid-sandbox"] |
puppeteerExecutablePath | undefined | 自定义 Chromium 可执行文件(Docker + Alpine 场景) |
puppeteerIgnoreHTTPSErrors | false | |
publicPath | "/" | 子目录部署时必填,如"/sub" |
minifyCss | {} | 传给 CleanCSS 的参数,也会并入minifyHtml.minifyCSS |
minifyHtml | 见源码 | 默认collapseWhitespace、decodeEntities、sortAttributes等开启;Semantic UI 需设sortClassName: false(默认已为false) |
viewport | { width: 480, height: 850 } | 移动端优先的视口 |
sourceMaps | true | 页面报错时映射回源 map |
fixWebpackChunksIssue | "CRA1" | 取值CRA1/CRA2/Parcel/false,CLI 下按依赖自动探测 |
removeBlobs | true | 清除blob:样式链接 |
fixInsertRule | true | 还原insertRule注入的 CSS-in-JS 样式 |
skipThirdPartyRequests | false | 阻断全部第三方请求 |
cacheAjaxRequests | false | AJAX JSON 存入window.snapStore |
http2PushManifest | false | 生成http2-push-manifest.json(Link 头 preload) |
ignoreForPreload | ["service-worker.js"] | 配合http2PushManifest的忽略列表 |
preconnectThirdParty | true | 注入第三方域<link rel="preconnect"> |
inlineCss | false | 实验特性:minimalcss 提取 critical CSS 内联,全量 CSS 以非阻塞方式加载 |
saveAs | "html" | 合法值html/png/jpeg(截图模式) |
crawl | true | 是否爬取页内链接;关掉则只渲染include列表 |
waitFor | false | puppeteer 额外等待条件 |
externalServer | false | 不启动内置静态服务,由外部提供服务 |
removeStyleTags/removeScriptTags | false | 激进特性:整体删除<style>/<script> |
preloadImages | false | 为本地图片注入<link rel="preload" as="image"> |
asyncScriptTags | false | 给<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
相关推荐
深入解析react-snap:JavaScript静态网站生成器的架构设计
深入解析react snap:JavaScript静态网站生成器的架构设计 前言 在现代前端开发中,单页应用 SPA 因其优秀的用户体验而广受欢迎。然而,SPA
Docusaurus架构设计深度解析:从静态站点生成到动态渲染
Docusaurus架构设计深度解析:从静态站点生成到动态渲染 一、Docusaurus核心架构概览 Docusaurus是一个现代化的静态站点生成器,其架构设
静态站点文档前端react-pdf 的 @react-pdf/ui 渲染架构:主线程渲染的取舍与 Web Worker 方案设计
react pdf 的 @react pdf/ui 渲染架构:主线程渲染的取舍与 Web Worker 方案设计 @react pdf/ui 是 react p
PDF生成后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考