一个周五下午收到反馈:用 Safari 打开线上某个页面,白屏,控制台只有一行红字------ReferenceError: Can't find variable: React。同一个页面,Chrome 一切正常。
这行报错看起来平平无奇,但顺着它挖下去,牵出了一条完整的链路:一个"减小首屏体积"的常见架构选择,如何在特定条件下把整站带崩;以及一个更反直觉的事实------我们的前端错误监控,恰恰监控不到这类最严重的崩溃。
这篇文章复盘整个排查过程,并给出一套不推翻现有架构、可直接复制的容灾方案。为方便讨论,下文把出问题的应用称为"某单页应用(SPA)",它把 React 等基础库通过 CDN 外置。
现象:只有 Safari 白屏,一行 ReferenceError
现象很干净:Safari 打开白屏,控制台就一行 Can't find variable: React;Chrome、Firefox 正常。
第一反应很容易是"Safari 兼容性问题",然后开始翻 Safari 的各种坑。但先别急着甩锅浏览器------这一步的误判会让你在错误的方向上耗很久。
根因:React 被"外置"到了 CDN
为了减小主 bundle、复用 CDN 缓存,很多项目会把 React 全家桶从打包产物里"外置"(external),改成运行时从 CDN 以全局变量的形式加载。典型配置是这样:
ts
// vite.config.ts(示意)
export default defineConfig({
build: {
rollupOptions: {
external: ['react', 'react-dom', 'react-router-dom'],
output: {
globals: { react: 'React', 'react-dom': 'ReactDOM' },
},
},
},
})
再配合一个 CDN 注入插件,最终 HTML 里大致长这样:
html
<!-- 构建产物注入的经典脚本:注意此时没有任何失败兜底 -->
<script src="https://cdnA.example.com/react@18/react.production.min.js"></script>
<script type="module" src="/assets/index-[hash].js"></script>
这套方案的效果是:编译后所有 import React from 'react' 都被改写成引用全局 window.React。代价也随之而来------页面能否运行,强依赖那个 CDN 脚本先于主 bundle 执行成功。
而当时的实现,既没有 onerror、也没有备用源、更没有就绪校验。一旦这个 CDN 脚本加载失败、被拦截或超时,window.React 就不存在,主 bundle 一执行到 React.xxx 立刻抛 ReferenceError,整页白屏。
为什么偏偏是 Safari?先厘清一个误区
Safari 的 Can't find variable: X 和 Chrome/Firefox 的 X is not defined,其实是同一个错误,只是不同引擎的措辞不同。所以这压根不是 Safari 独有的 bug。
真正的触发点是"CDN 脚本没能在主 bundle 之前就绪",这在任何浏览器、任何弱网或被拦截的场景下都可能发生。Safari 用户更常安装内容拦截器/隐私保护插件,命中率或许略高;但监控数据显示,Chromium 系(含 Edge、Windows 用户)同样大量中招。
经验一:别被"只有某浏览器复现"带偏,先分清这是"浏览器 bug"还是"通用问题的浏览器措辞差异"。
反转:错误监控里,这个崩溃查不到
按常规套路,去错误监控平台(Sentry 这类)搜 React is not defined 和 Can't find variable: React。结果:0 条。
0 条不等于没发生。这里藏着一个监控盲区:
- 前端错误监控 SDK 通常打包在主 bundle 内,在应用入口处初始化。
- 而 React 全局缺失时,主 bundle 在最顶层就崩了,监控 SDK 的初始化代码根本还没轮到执行。
- 于是最严重的那类崩溃(整站白屏)压根上报不到监控平台。
反证也很有力:在同一个"全局依赖缺失"家族里,那些发生在具体路由懒加载 chunk 的错误------比如 useEffect is not defined、jQuery is not defined、某 3D 库 is not defined------在监控里比比皆是。因为它们发生时,主 bundle 早就跑起来了,SDK 也已经初始化。
洞察就在这里:任何"监控 SDK 随主 bundle 加载"的前端监控,都存在这个结构性盲区------它抓不到"应用还没起来就崩了"的情况。 你在监控面板上看到的错误分布,很可能系统性地漏掉了最严重的一类。"监控里没有"绝不能推出"线上没发生"。
一个容易被忽略的细节:措辞差异会撕裂告警
同一个逻辑错误(比如 useEffect 未定义),因为浏览器措辞不同(is not defined 与 Can't find variable),会被监控平台按 message 拆成两个独立 issue。
后果是:每个 issue 单独看都"没几条",很容易被判定为长尾忽略掉;但把同义不同文的错误合并统计,规模往往不小。排查同类问题时,记得先做一次"措辞归并"。
容灾:四层加固(可直接复制思路)
目标先说清楚:不推翻现有架构(不动 external、不动模块联邦、不动业务代码),只在"资源加载层 + HTML 入口"做冗余和观测。主源正常时零副作用。
第一层:独立于 bundle 的探针,先把盲区补上
既然"SDK 在 bundle 内"是盲区的根源,那就在 HTML 的 <head> 放一段不依赖 bundle 的内联脚本,尽早注册全局错误监听和关键全局变量就绪检测,直接上报:
html
<script>
(function () {
var REQUIRED = ['React', 'ReactDOM'];
function report(kind, detail) {
// 上报走独立端点(如日志服务的 Web Tracking,匿名写入)
// 数据最小化:只报 pathname,别带 query,避免 token 进日志
var url = 'https://<your-log-endpoint>/track?kind=' + encodeURIComponent(kind)
+ '&detail=' + encodeURIComponent(String(detail).slice(0, 300))
+ '&path=' + encodeURIComponent(location.pathname);
if (window.fetch) fetch(url, { mode: 'no-cors', keepalive: true }).catch(function () {});
else new Image().src = url;
}
window.addEventListener('error', function (e) {
var t = e && e.target;
if (t && t.tagName === 'SCRIPT' && t.src) return report('script_error', t.src);
var msg = (e && e.message) || '';
if (/is not defined|Can't find variable/.test(msg)) report('reference_error', msg);
}, true);
// 兜底:load 后关键全局仍缺失即上报(覆盖"顶层崩溃、SDK 未 init"的盲区)
window.addEventListener('load', function () {
var missing = REQUIRED.filter(function (k) { return typeof window[k] === 'undefined'; });
if (missing.length) report('globals_missing_after_load', missing.join(','));
});
})();
</script>
关键点:它必须是内联(不走 bundle、不依赖任何库),才能在"应用根本没起来"时依然工作。
第二层:关键 CDN 脚本 onerror 回退备用源
给 React 全家桶的 CDN <script> 加上 onerror,加载失败时回退到备用 CDN------换域名、同版本、同全局名,保持 React 单例:
js
window.__cdnFallback = function (el) {
if (!el || !el.src || el.getAttribute('data-fb')) return;
el.setAttribute('data-fb', '1');
var fb = el.src.replace('//cdnA.example.com/', '//cdnB.example.com/');
if (fb === el.src) return;
var s = document.createElement('script');
s.src = fb;
s.async = false; // 尽量保序
s.crossOrigin = 'anonymous';
(el.parentNode || document.head).insertBefore(s, el.nextSibling);
};
有个坑必须提醒:上线前先验证备用源真的可达(对备用 CDN 的对应资源发个请求确认返回 200),否则就是"假兜底"------看起来做了容灾,真出事时备用源也 404。
第三层:就绪门 + 降级 UI,彻底消除时序竞态
onerror 回退是异步的,不保证在主入口执行前完成。要 100% 消除竞态,就在入口做一道"就绪门":轮询关键全局变量就绪后,再动态 import 真正的应用入口;超时仍缺失,就渲染一个降级 UI,而不是让用户对着白屏:
js
import './white-screen-check';
(function () {
var tries = 0;
function ready() {
return typeof window.React !== 'undefined' && typeof window.ReactDOM !== 'undefined';
}
function tick() {
if (ready()) return void import('./bootstrap'); // 就绪后再加载应用入口
if (tries++ >= 100) return showFallbackUI(); // 约 10s 超时 → 降级 UI
setTimeout(tick, 100);
}
tick();
})();
代价是入口从静态变成动态 chunk(首屏多一跳),换来的是"宁可降级、绝不白屏"。
第四层:别急着"内联 React"------模块联邦的单例约束
排查到这里,很多人会问:干脆取消 external、把 React 打进 bundle,不就没这问题了?
如果项目用了模块联邦(Module Federation) ,事情没这么简单。host 和 remote 必须共享同一个 React 实例,否则 hooks 会抛 Invalid hook call;而当前正是靠全局 window.React 来共享单例的。简单内联会打破这个单例约定。React 的 Hooks 使用规则本质上要求整个应用解析到同一份 React------一旦出现多份,hook 的内部 dispatcher 对不上就会报错。所以"内联"并不是免费的,要连带处理联邦的 shared 机制------这也是为什么本次选择"加固加载层"而非"重构架构"。
顺带回答:探针脚本放在 dns-prefetch 前,会拖慢预热吗
这是我加探针时被同事问到的问题,值得单独说清楚。
我放的是内联脚本 (没有 src,不发起网络请求),执行是微秒级,但它确实会同步阻塞主 HTML 解析器 极短时间,理论上让后面的 dns-prefetch / preconnect 稍晚一点点发起。
好在现代浏览器有预加载扫描器(preload scanner):它独立于主解析器向后扫描 HTML,会提前发现并触发资源提示,所以实际影响很小。web.dev 对 preload scanner 的工作方式有专门说明。
但最佳实践仍然是------把资源提示放在任何脚本之前 ,最大化预热窗口。所以我把 dns-prefetch / preconnect 提回了 charset 之后、探针之前。
这里有个关键区分:如果放的是外部脚本 <script src>,影响就大了------它会真正发起请求并阻塞解析,严重推迟预热。只有内联脚本才谈得上"影响很小"。别把这两种情况混为一谈。
小结:可迁移的几条经验
- "CDN 外置基础库"减包有隐藏代价:应用可用性被绑定到 CDN 脚本的加载成功率上,必须配套 onerror 回退、就绪门、降级 UI,否则一次 CDN 抖动就是一次整站白屏。
- 前端监控存在结构性盲区:当 SDK 随主 bundle 加载时,它抓不到"应用起来之前就崩"的最严重崩溃。用一段独立于 bundle 的内联探针把盲区补上。
- 别被浏览器措辞带偏 :
Can't find variable与is not defined是同一类错误;措辞差异还会把同一问题撕裂成多个 issue,导致低估。 - 资源提示尽量靠前 :内联脚本影响小、外部脚本影响大,
dns-prefetch/preconnect放最前。 - 容灾要验证:备用源先确认可达,就绪门要有降级兜底,别做"看起来很稳"的假容灾。
排查的价值往往不在那行报错本身,而在于它逼你看清一整条你以为"没问题"的链路。
参考与数据源
- MDN:rel=dns-prefetch、rel=preconnect
- MDN:script 元素的 defer / async
- web.dev:Don't fight the browser preload scanner
- React:Rules of Hooks(Hooks 使用规则)
- 阿里云 SLS:使用 Web Tracking 采集日志