从一次 Safari 白屏,聊聊 CDN 外置 React 的坑与前端监控盲区

一个周五下午收到反馈:用 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 definedCan't find variable: React。结果:0 条

0 条不等于没发生。这里藏着一个监控盲区

  • 前端错误监控 SDK 通常打包在主 bundle 内,在应用入口处初始化。
  • 而 React 全局缺失时,主 bundle 在最顶层就崩了,监控 SDK 的初始化代码根本还没轮到执行。
  • 于是最严重的那类崩溃(整站白屏)压根上报不到监控平台

反证也很有力:在同一个"全局依赖缺失"家族里,那些发生在具体路由懒加载 chunk 的错误------比如 useEffect is not definedjQuery is not defined、某 3D 库 is not defined------在监控里比比皆是。因为它们发生时,主 bundle 早就跑起来了,SDK 也已经初始化。

flowchart TD A[CDN 脚本加载失败] --> B[全局 React 缺失] B --> C[主 bundle 顶层执行即崩溃] C --> D[监控 SDK 尚未初始化] D --> E[最严重白屏 无法上报] C --> F[某些路由懒加载 chunk 报错] F --> G[此时监控 SDK 已初始化] G --> H[同源错误 可以上报]

洞察就在这里:任何"监控 SDK 随主 bundle 加载"的前端监控,都存在这个结构性盲区------它抓不到"应用还没起来就崩了"的情况。 你在监控面板上看到的错误分布,很可能系统性地漏掉了最严重的一类。"监控里没有"绝不能推出"线上没发生"。

一个容易被忽略的细节:措辞差异会撕裂告警

同一个逻辑错误(比如 useEffect 未定义),因为浏览器措辞不同(is not definedCan'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>,影响就大了------它会真正发起请求并阻塞解析,严重推迟预热。只有内联脚本才谈得上"影响很小"。别把这两种情况混为一谈。

小结:可迁移的几条经验

  1. "CDN 外置基础库"减包有隐藏代价:应用可用性被绑定到 CDN 脚本的加载成功率上,必须配套 onerror 回退、就绪门、降级 UI,否则一次 CDN 抖动就是一次整站白屏。
  2. 前端监控存在结构性盲区:当 SDK 随主 bundle 加载时,它抓不到"应用起来之前就崩"的最严重崩溃。用一段独立于 bundle 的内联探针把盲区补上。
  3. 别被浏览器措辞带偏Can't find variableis not defined 是同一类错误;措辞差异还会把同一问题撕裂成多个 issue,导致低估。
  4. 资源提示尽量靠前 :内联脚本影响小、外部脚本影响大,dns-prefetch / preconnect 放最前。
  5. 容灾要验证:备用源先确认可达,就绪门要有降级兜底,别做"看起来很稳"的假容灾。

排查的价值往往不在那行报错本身,而在于它逼你看清一整条你以为"没问题"的链路。

参考与数据源

相关推荐
lichenyang4533 小时前
从 Demo 到本地 HAR 包:把 HarmonyOS JSBridge 运行时做成可复用库
前端
不简说3 小时前
拖拽DIY表格插件来了!sv-print 七月更新:这次真的能自己拼表格了
前端·javascript·前端框架
ssshooter3 小时前
小程序分包页面报 "has not been registered yet" 的隐蔽根因:一个动态 import 拖垮整个分包
前端·javascript·微信小程序
鹏多多3 小时前
Web浏览器后台离线通知方案实现以及兼容指南
前端·javascript·vue.js
yangmu32033 小时前
Vue3 响应式系统深度解析:从 Proxy 到依赖追踪
前端·javascript·vue.js
还有多久拿退休金4 小时前
AI 写了代码,谁替你"试毒"?
前端·架构·ai编程
大E帝国子民14 小时前
OpenCloudOS Server 9 安装 Nginx 完整指南
前端·网络·nginx
少年白马醉春风丶4 小时前
【AI Agent面试题】Agent 的自主性分级,怎么理解?
前端
yingyima4 小时前
Docker Compose 紧急救援:凌晨3点服务器报警的解决方案
前端