金数据技术博客 · №20

跨 site 加载 JS 的代价 —— Chrome 139+ 的一个默认行为,和我们为它付的两次学费

· flanker · frontend / React / nextjs · English version

English Version

TL;DR

修复后对比(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 秒多。

此时想起了之前踩过的坑。

修复前:LCP 4.28s,scripting 2242ms

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 kLowPriorityAsyncScriptExecutionthird_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"

view-source:chunk 带上了 fetchPriority=high

修复后的效果(同一账号、同网络):LCP 从 4 秒多降到约 2 秒,scripting 从 2 秒多降到约 500ms:

修复后:LCP 2.25s,scripting 509ms

已知缺口:React 的 bootstrapScript(id="_R_" 的那个)无法标记 fetchPriority,但对结果影响不大。

9. 后续

  1. Patch 只是缓解,不是根治。我们在考虑给 Next.js 提 PR,让 chunk script 的属性可以配置。
  2. 继续给 chunk 瘦身——脚本执行总量降下来,对延迟调度的敏感度也会降低。
  3. 未来升级 Next.js 时需要同步维护这个 patch。

写在最后

两次学费的教训: