线上有个页面突然打不开了------白屏,什么都没有,只剩一个骨架屏在那儿转。
第一反应是服务挂了。但 curl 一看:HTTP 200,HTML 完整返回;页面引用的每一个 JS、CSS,状态码不是 200 就是 304,一个都不缺。更挠头的是,本地开发环境跑得好好的,怎么点都正常。
服务在、资源在、本地正常,可线上就是白屏。最后把浏览器控制台翻出来,才看到那行安静的报错:
javascript
Uncaught SyntaxError: The requested module
'https://cdn.example.com/pkg/crypto-js@4.2.0/index.esm.js'
does not provide an export named 'SHA256'
顺着报错摸回去,罪魁是一行再普通不过的代码------某个埋点工具里的 import { SHA256 } from 'crypto-js'。一个几十 KB 的加密小库,通过 CDN 外链加载时,说自己"没有 SHA256 这个导出"。而"把依赖外链到 CDN"这个做法,恰恰是很多年前被写进无数「前端性能优化最佳实践」清单里的经典招数。
一个曾经的最佳实践,把整个应用干白屏了。这件事真正值得聊的,不是 crypto-js 这个具体的坑,而是它背后的规律:最佳实践不是永恒真理,而是特定技术约束下的最优解。约束一旦消失,昨天的最优解会退化成今天的负优化,甚至故障源。
下面从这次事故复盘讲起,拆开「外链依赖」这个实践当年成立的前提,再看这些前提如何被技术演进一个个抽走,最后聊聊------怎么给你手里的「最佳实践」标上保质期。
一、事故现场:一个 HTTP 200 的白屏
先还原一下这个白屏有多「安静」。它没有 500、没有 404、没有网络中断,所有能想到的健康检查都是绿的:
- 页面文档:
200,HTML 结构完整; - 页面引用的十几个 JS / CSS:全部
200或304; - 出问题的那个
crypto-js:304,浏览器明明白白告诉你「这文件我拿到了」。
唯一的异常,是控制台里那行 SyntaxError。它说的是:crypto-js 这个模块,文件是加载成功了,但里面找不到名叫 SHA256 的导出。
这就诡异了------文件都下载下来了,怎么会没有它该有的东西?要回答这个问题,得先搞清楚这行 import 背后,构建工具到底替我们做了什么。
二、根因:打包器在模块边界上「撒手」了
这个项目的构建配置里,crypto-js 被标记成了 external,并映射到一个 CDN 地址。一句话解释 external 做了什么:
告诉打包器:这个依赖不要打进产物,运行时去外部拿。 然后打包器把代码里的
import ... from 'crypto-js'重写成指向那个 CDN 地址的 URL。
问题就出在「运行时去外部拿」这几个字。打包器把依赖踢出产物的同时,也把加载、版本、导出契约这些责任一并甩给了运行时和 CDN------它在这个边界上撒手了。而这次,CDN 那头的产物恰好不对付。
crypto-js 本身是个 CommonJS 库,没有原生的 ESM 版本。CDN(这次是 jsDelivr 那套自动转换)为了让它能被 import 语法加载,用 Rollup 把它自动转成了 ESM。但 crypto-js 有个特点:它的各个算法(SHA256、SHA224 等)是在运行时动态挂到主对象上 的,而不是静态写死的顶层导出。Rollup 做静态分析时,只认出了那些「明面上」的导出(AES、enc、mode、pad、MD5),把动态挂载的 SHA256 给漏了。
于是这个 CDN 文件的真实情况是:有 AES、有 MD5、有 default(完整对象),唯独没有 SHA256 这个命名导出 。代码却理直气壮地 import { SHA256 }。命名对不上,浏览器直接拒绝。
真正让它从「一个模块报错」升级成「整站白屏」的,是三重防线同时失守:
- 类型系统这层是瞎的。 项目里
@types/crypto-js是有SHA256的,所以 TypeScript 全程绿灯。因为类型走的是本地依赖,运行时走的是 CDN------两条路。类型系统压根不知道线上加载的是另一个东西。 - 打包器这层也不管。 依赖一旦被标成 external,打包器就不再校验外部产物的导出对不对得上。你
import什么它就重写成什么,至于 CDN 那头到底导没导出,它不负责。 - 运行时这层直接爆炸。 ESM 的命名导入失败,是模块 link 阶段 的
SyntaxError------它发生在模块执行之前 ,try/catch根本拦不住;更要命的是,它会中断整个依赖这个模块的加载图。这个埋点工具被打进了一个共享 chunk,SPA 启动时就要加载它。它一挂,整条链子跟着断,应用起不来,页面停在骨架屏。
一个不起眼的埋点小工具,import 了一个不存在的名字,就能让整个前端应用白屏。爆炸半径和代码的重要性完全不成比例。
而「本地正常、线上白屏」的根源,也在这条 external 边界上:
本地开发时,依赖走的是本地安装的 crypto-js(真正的 CommonJS 包,打包器的兼容处理很完善,SHA256 拿得到);只有线上构建才会把它换成 CDN 那个残缺的 ESM 产物。测试环境和生产环境跑的根本不是同一份 crypto-js------这才是它能一路绿灯滑到线上的原因。
三、回到起点:external 当年为什么算「最佳实践」
复盘到这儿,很容易一巴掌拍在「谁让你 external 的」上。但先别急。把时钟拨回单页应用(SPA)加 HTTP/1.1 的年代,你会发现把大依赖外链到 CDN,是实打实的优化,甚至是聪明做法。
那个年代 external 一个公共大库,能同时吃到好几份收益:
- 减小首屏 bundle。 纯 SPA 首屏要把 JS 一次性下完才能渲染,体积就是命。把 React、可视化库这些大块头抽出去,主包立刻瘦一圈。
- 跨站、跨页、跨版本共享缓存。 大家都从同一个公共 CDN 引同一个
react.min.js,用户在 A 站下载过,逛到 B 站就直接命中浏览器缓存,第二次访问近乎零成本。这是 external 最诱人的一条。 - 绕开 HTTP/1.1 的并发上限。 HTTP/1.1 下浏览器对同一个域名最多 6 个并发连接。把静态资源拆到独立的 CDN 域名,等于多开几条下载通道,首屏并行度更高。
- 加快构建。 大库不进打包流程,本地和 CI 的构建都能快一些。
这四条在当年的技术前提下全都成立。所以「把公共大库外链到 CDN」被写进各种性能优化清单,是名副其实的最佳实践------它精准地解决了那个时代的真问题。
四、时代变了:前提被一个个抽走
问题在于,上面每一条收益都挂着一个「当年才成立」的前提。这十来年,这些前提被技术演进一条条抽走了:
| 当年的前提 | 今天的现实 | external 那条收益 |
|---|---|---|
| HTTP/1.1 每域名并发 6 连接 | HTTP/2 多路复用,一个连接并发无数请求 | 「拆域名并行下载」失去意义 |
| 打包器分包能力弱 | 按路由自动 code split + 内容 hash 长效缓存 | 「减首屏体积」被打包器内建 |
| tree-shaking 不成熟 | tree-shaking 成熟,只打进用到的代码 | 本地打包体积往往比 CDN 整包更小 |
| SPA 首屏必须下完全部 JS | SSR / 流式渲染,首屏 HTML 服务端产出 | 「首屏减负」不再依赖 external |
| 跨站共享同一 CDN 缓存 | 浏览器按站点分区缓存 | 「跨站缓存复用」几近失效 |
前四条是打包器和协议自己进化的结果,这里重点说最后一条------它最反直觉,也最致命。
「大家都引同一个 CDN 的 React,缓存全网复用」曾经是 external 的头号卖点。但出于隐私和安全,主流浏览器已经把 HTTP 缓存按顶级站点做了分区:Chrome 从 86 版(2020 年)起默认启用,Safari 更是在大约八年前就这么做了。分区之后,缓存的键不再只是资源 URL,还叠加了「谁在用它」------具体说是当前顶级站点。结果就是:
哪怕 A 站和 B 站引用的是完全相同 的那个
react.min.js的 CDN URL,B 站也不会复用 A 站下载的缓存。跨站共享缓存,从架构层面被浏览器废掉了。
这不是我的推断,而是 Chromium 团队推动这项改动时就写明的权衡。他们在发布说明里直接提醒:这项改动会影响「跨大量站点分发高可缓存资源(比如字体和流行脚本)」的场景,并且补了一句------「站点们常常使用不同版本的流行库,本就削弱了这种缓存的收益」。
换句话说:external 公共库最标志性的那份收益,一半是被浏览器主动关掉的,另一半(版本各异导致命中率本来就低)从来也没那么美好。
五、于是「最佳实践」反转成了负优化
当四条收益里三条被内建、一条被废掉,再回头看 external,尤其是 external 一个像 crypto-js 这样的小包,账就完全算不过来了:
- 多一跳跨域往返。 external 是运行时才发起的请求,挂在独立 CDN 域名上还要多付 DNS 加 TLS 的成本;ESM 依赖之间还会链式请求,串成请求瀑布,拖慢关键路径。
- 丢掉 tree-shaking。 CDN 给的是整包。代码只用了
SHA256和AES两个函数,却要把整个crypto-js下下来。本地打包反而能只带走用到的部分。 - 凭空多出一类契约风险。 也就是本文这颗雷:导出名、版本、CJS 与 ESM 格式,任何一处对不上就炸,而且类型系统和打包器都保护不了你。
- 本地和线上跑两份代码。 dev 走本地依赖、prod 走 CDN,本地永远测不出线上的导出缺失。
收益归零,成本和风险却一条没少。这时候 external 已经不是优化,而是净负债------它从最优解变成了故障源。
最有说服力的反证,其实就在同一个团队内部:另一个用 SSR 加正常打包的应用,用的是同一批依赖、同一个 crypto-js 版本,因为依赖是正常打进产物的,从来没出过这类问题。它没做任何「妥协」,只是没有背上 external 这个历史包袱而已。
所以准确的定性是分层的:
- 当年对大公共库做 external ------ 合理实践,不是失误;
- 对
crypto-js这种小包也 external ------ 即便在当年也是负收益,属于「配置惯性」的执行失误; - 放到今天 ------ 整套 external 方案实践过时:收益被内建、被浏览器策略废掉,只剩成本。
六、给「最佳实践」标上保质期
crypto-js 只是个引子。真正想留下的,是一个可复用的判断框架:怎么识别你手里的「最佳实践」是不是已经过期。
任何最佳实践,本质都是某一组约束条件下的最优解。判断它是否还成立,就问三个问题:
- 它当年要解决的问题,今天还存在吗? 约束还在,实践大概率还有效。
- 它依赖的前提,今天还成立吗? 逐条列出来验证,别默认「一直都这样」。
- 有没有新能力把它的收益内建了? 一旦平台或工具默认帮你做了,你再手动做就是重复劳动甚至反效果。
拿这套框架扫一遍前端,会发现「正在过期或已经过期」的实践不止 external 一个:
- CSS 雪碧图(sprites)。 为省 HTTP/1.1 的请求数而生,HTTP/2 多路复用后基本没必要,维护雪碧图的成本反而更高。
- 域名分片(domain sharding)。 同样是绕 HTTP/1.1 并发上限的招,到了 HTTP/2 下不但没用,还因为多开握手、削弱多路复用而有害。
- 全量 polyfill。 早年一股脑把 polyfill 全引进来,如今按目标浏览器清单精准注入,全量引入纯属体积负担。
- 手动切 vendor chunk。 曾经要手写分包策略,现在打包器按路由自动分包加内容 hash,手动干预往往打乱它的优化。
注意,这里不是说这些做法「绝对错」------而是它们成立的前提可能已经不在了,用之前得先复检前提。有些在特定场景(比如还在维护 HTTP/1.1 老系统)依然合理,关键是别把「当年的结论」当「永远的结论」照抄。
落到团队层面,这件事还有一层含义:
技术债不只是「写错的代码」,更多是「曾经对、现在错」的决策沉淀。它们当初通过了评审、上线运行良好,然后就没人再回头看------直到某天一颗小雷把它炸成线上事故。
所以关键架构决策,尤其是那些「照着最佳实践清单抄来的」,值得定期做一次前提复检:当初为什么这么定?依据的前提还在吗?这比事后救火便宜太多。
七、写在最后
这次白屏,表面看是 crypto-js 少了个 SHA256 导出,往深一层是 external 的契约风险,再往深一层,是一个 SPA 时代的性能实践,在今天的技术栈里悄悄从资产变成了负债。
我并不想否定当年做 external 的人------在那个 HTTP/1.1、SPA、打包器还很稚嫩的年代,那是聪明的选择。真正危险的,是把「最佳实践」当成免检的口号:抄进来的时候不清楚它的前提,前提变了也没人复检,于是它就一直躺在配置里,等着一个命名导出把整站掀翻。
保持客观,其实只需要一句话------任何实践都带着它诞生那个时代的假设。用它之前,先问一句:这些假设,今天还成立吗?
参考与数据源
- Chrome for Developers · Gaining security and privacy by partitioning the cache(HTTP 缓存分区,Chrome 86 起默认启用)
- Chromium blink-dev · Intent to Ship: Partition the HTTP cache(官方权衡:跨站共享公共脚本 / 字体的缓存收益被削弱)
- RFC 9113 · HTTP/2(多路复用:单连接并发多请求)与 MDN · HTTP/2 术语
- MDN · JavaScript modules(ESM import / export 语义)
- webpack · Externals 与 Tree Shaking
- Rollup · external 配置
- jsDelivr · 文档(自动 ESM 转换 /npm/pkg/+esm)
- crypto-js(CommonJS 模块,算法运行时挂载)