金数据技术博客 · №20
跨 site 加载 JS 的代价 —— Chrome 139+ 的一个默认行为,和我们为它付的两次学费
TL;DR
- 我们的系统内页面首次打开会白屏 2~3 秒,LCP 高达 4 秒多
- Root cause 是 Chrome 139 起默认开启的
LowPriorityAsyncScriptExecution:它会延迟执行跨 site 的 async script,最长可达 1 秒 - 我们的网站在 jinshuju.net,JS chunk 在 jsjform.com 的 CDN 上,正好命中
- 被延迟的 chunk 让 React hydration 的调度器轮次从几十次暴涨到十万次,主线程被脚本占满,scripting 长达 2 秒多
- 修复:给 Next.js 的 JS chunk 都标记
fetchpriority="high",即可绕过延迟执行
修复后对比(home 页面):
| Scripting 时长 | LCP 时长 | |
|---|---|---|
| 修复前 | 大于 2000ms | ~4s |
| 禁用该特性启动 Chrome | ~700ms | ~1.6 ~ 2s |
| 修复后(同一账号、同网络) | ~700ms | ~2s |
背景
金数据是一款在线表单工具的 SaaS 产品。我们刻意把系统内(表单编辑、数据管理,跑在 jinshuju.net)和对外表单(填写者访问的公开页面)隔离在不同的域名上,而整个 Next.js 应用的 JS chunk 统一从 jsjform.com 的 CDN 加载。
这个「网站域名 ≠ 静态资源域名」的组合,让我们在同一个坑里交了两次学费。
1. 我们自己埋的坑
其实这个坑之前就踩到过一次。
之前对外表单冷加载时会有 1 秒白屏,当时初步定位到是 cross-site JS 执行有性能问题,于是把 JS chunk 的域名改成了和对外表单一致的 jsjform.com——对外表单变成了同 site 加载,问题解决。
当时也记录了副作用:系统内的域名从此和 chunk 域名不一致了(jinshuju.net vs jsjform.com)。但当时的判断是:系统内用户只有一次冷启动,之后都是热缓存,影响不大。
这个判断后来被证明是错的——热缓存同样受影响。
2. 系统内太慢了
随着功能增加,系统内的代码量越来越大。我们发现访问 home 页面,特别是冷启动时,会白屏 2~3 秒,体验极差。
做了一轮 chunk 瘦身后,Chrome 调试仍然显示 scripting 耗时长达 2 秒多。
此时想起了之前踩过的坑。
3. 用 agent 协助定位
第一次踩坑时并没有定位到真正的 root cause,这次我们在 Claude 的协助下重新排查。
Agent 用线上页面和 CDN,通过 CDP 做了控制变量测试——同一份 chunk,只改变加载它的网站域名:
| 网站域名 | 与 chunk 的关系 | ScriptDuration 冷 | ScriptDuration 热 | 调度器轮次 冷 | 调度器轮次 热 |
|---|---|---|---|---|---|
im.jsjform.com |
同 site | 241 ms | 51 ms | 5,527 | 36 |
zz.jsjform.com |
同 site | 260 ms | 48 ms | 6,710 | 23 |
im.jinshuju.net |
跨 site | 2,997 ms | 2,807 ms | 109,876 | 98,487 |
im.jsjform-x.net |
跨 site(不存在的域名) | 2,858 ms | 2,802 ms | 180,964 | 98,373 |
不论冷热,跨 site 都是同 site 的 12~55 倍。
注意:纯 JS script 不会有这个问题。它只在 React 协作式调度下被放大——被延迟的 chunk 让 hydration/Suspense 反复重试,调度轮次爆炸,才吃满了主线程。
4. 和上次不同的测试结果
这个问题不只影响冷启动:有热缓存时,差距倍数反而更大。
所以对系统内用户来说,这是必须修复的问题。
5. Root Cause
网络搜索没有找到任何相关的 issue 或文章。
Claude 先排查了 8 个方向:进程隔离、V8 code cache、时钟精度与 performance.now() 开销、MessageChannel RTT、裸 CPU 吞吐、H2 连接复用、字节数与压缩、资源域名白名单逻辑——都没有问题。
然后通过对 Chrome 版本做二分,定位到问题从 Chrome 139 开始出现。再通过源码分析,找到了 root cause:
Chrome 139 起默认开启 Blink feature kLowPriorityAsyncScriptExecution(third_party/blink/common/features.cc)。这个特性会让 async script 延迟执行,最长可达 1 秒。
它有几个关键参数:
| 参数 | 默认值 | 含义 |
|---|---|---|
cross_site_only |
true |
只对跨 site 的 script 生效 |
main_frame_only |
true |
只对主 frame 生效(iframe 不生效) |
exclude_non_parser_inserted |
false |
动态插入的脚本,也同样延迟执行 |
opt_out_high_fetch_priority_hint |
true |
fetchpriority=high 可以 opt out(不延迟) |
6. 方案一:换成同一个域名?
第一个解决方案,是让网站和 JS chunk 使用同一个域名。
但这对我们很难:系统内和对外表单是刻意隔离在不同域名上的,而 Next.js 一个代码库只能配置一个 assetPrefix。非要走这条路,就得把系统内和对外隔离成独立部署,或者用代理重写 HTML 返回值。
代价太高,放弃。
7. 方案二:让 chunk 带上 fetchpriority=high
从 Chrome 源码看,fetchpriority=high 可以让 script 完全绕过这个延迟机制。
但 Next.js 的 chunk script 是框架内部生成的,没有暴露配置接口。所以这个方案需要给 Next.js 打 patch。
我们最终采取了这个方案。
8. 打 patch
需要一个 patch 加一段运行时代码。
第一,patch Next.js 的 runtime:在 pushScriptImpl 向页面写入 script 时,判断是否是 chunk,自动添加 fetchPriority:
t.src && t.src.includes("/_next/static/chunks/") && null == t.fetchPriority
&& (t = {...t, fetchPriority: "high"}),第二,客户端侧,Turbopack 运行时 append script 时同样自动添加:
export function prioritizeNextChunkScript<T extends Node>(node: T): T {
if (node instanceof HTMLScriptElement && node.src) {
try {
if (new URL(node.src, location.href).pathname.includes(NEXT_CHUNK_PATH)) node.fetchPriority = 'high'
} catch {
// `src` reflects the raw attribute when it is not a parsable URL
}
}
return node
}上线后 view-source 可以看到 chunk script 都带上了 fetchPriority="high":
修复后的效果(同一账号、同网络):LCP 从 4 秒多降到约 2 秒,scripting 从 2 秒多降到约 500ms:
已知缺口:React 的 bootstrapScript(id="_R_" 的那个)无法标记 fetchPriority,但对结果影响不大。
9. 后续
- Patch 只是缓解,不是根治。我们在考虑给 Next.js 提 PR,让 chunk script 的属性可以配置。
- 继续给 chunk 瘦身——脚本执行总量降下来,对延迟调度的敏感度也会降低。
- 未来升级 Next.js 时需要同步维护这个 patch。
写在最后
两次学费的教训:
- 「网站域名 ≠ 静态资源域名」不再只是一个 DNS/缓存层面的决定——浏览器开始基于 site 边界调整脚本执行优先级,这个组合会直接影响运行时性能
- 性能问题的归因要做到 root cause 为止。第一次我们停在了「cross-site JS 执行慢」的现象层,选了一个恰好绕过问题的方案,于是在另一个域名组合上把坑重新踩了一遍


