JavaScript 的加载时间经常被简化成一个数字:从 script 标签被解析到脚本执行完成。但真实的时间线要复杂得多:DNS 解析、TCP 握手、TLS 协商、请求排队、首字节等待、响应体下载、JavaScript 解析与编译,每一步都会影响页面可交互时间。如果监控只记录 load 事件或脚本 onload 回调,会丢掉大量可定位的细节。量化 JavaScript 加载时间的关键,是把这些阶段拆开,并让监控数据能够回答“慢在哪个环节”。

一、JavaScript 加载时间的阶段拆解
一个外链脚本从 HTML 解析器遇到 <script> 标签开始,到脚本真正执行完毕,至少包含以下阶段。首先是资源定位:浏览器需要把域名解析成 IP,如果当前页面已经解析过该域名,则可能跳过或缩短这一步。接着建立网络连接:HTTP/1.1 下每个源通常有连接数上限,脚本可能需要排队等待空闲连接;HTTPS 还会增加 TLS 握手。连接建立后,请求被发送到服务器,浏览器等待首字节到达,这段时间通常用 TTFB(Time To First Byte)表示。首字节之后是响应体下载,下载耗时取决于文件体积、网络带宽和压缩策略。
下载完成并不等于加载完成。浏览器拿到 JavaScript 字节后还要进行解析和编译,主线程在这一步可能被阻塞,尤其是大型脚本。编译完成后才进入执行阶段,执行过程中可能再次阻塞渲染。由此可见,网络耗时只占整个加载时间的一部分,解析与编译耗时同样不可忽略。对性能监控而言,如果只采集总 duration 或 load 时间,无法区分是网络慢还是解析慢,后续优化就缺少方向。
此外,脚本的加载方式会改变这些阶段的可见性。普通同步脚本会阻塞 HTML 解析;带 async 属性的脚本下载不阻塞解析,但下载完成后会立即执行;带 defer 的脚本则等到文档解析完成后按顺序执行。监控埋点需要知道脚本是同步、异步还是延迟,否则同一份数据可能被错误解读。
二、通过 Resource Timing 获取脚本耗时
浏览器原生提供了 Resource Timing API,可以通过 performance.getEntriesByType('resource') 拿到页面上所有资源的时间信息。每个条目都带有 initiatorType 字段,脚本资源的值是 script。还可以使用 performance.getEntriesByName 直接按 URL 过滤。下面这段代码展示如何取出当前页面的所有脚本资源,并输出几个关键时间点。
const scriptEntries = performance.getEntriesByType('resource')
.filter(entry => entry.initiatorType === 'script');
scriptEntries.forEach(entry => {
console.log({
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
transferSize: entry.transferSize,
encodedBodySize: entry.encodedBodySize,
decodedBodySize: entry.decodedBodySize,
responseEnd: entry.responseEnd
});
});
entry.duration 是从 startTime 到 responseEnd 的差值,但它可能为 0。当资源命中强缓存或跨域响应没有暴露时间信息时,duration 会失真。transferSize 表示传输字节数,encodedBodySize 是压缩后大小,decodedBodySize 是解压后大小。通过这三个字段可以判断是否命中缓存:如果 transferSize 为 0 而 decodedBodySize 大于 0,说明资源来自本地缓存。若资源来自跨域且响应头中没有 Timing-Allow-Origin,则这些大小字段可能都是 0,只剩 startTime 和 responseEnd 可用。
要获得更细的阶段时间,需要使用 PerformanceResourceTiming 对象上的专用字段:domainLookupStart、domainLookupEnd、connectStart、connectEnd、secureConnectionStart、requestStart、responseStart、responseEnd。这些字段在不同浏览器中可能为 0,例如缓存资源会跳过网络阶段,某些字段不适用。计算阶段耗时时必须做非零判断。
三、量化指标与计算方法
把 Resource Timing 的字段做差,可以得到一组可操作的指标。DNS 查询耗时 = domainLookupEnd - domainLookupStart;TCP 连接耗时 = connectEnd - connectStart;TLS 协商耗时 = connectEnd - secureConnectionStart(仅在 HTTPS 下有意义);请求等待耗时 = responseStart - requestStart;内容下载耗时 = responseEnd - responseStart。JavaScript 解析与编译时间无法直接从一个资源条目获得,通常需要结合主线程长任务或使用 PerformanceObserver 观察 longtask,或者用 Navigation Timing 的 domContentLoadedEventEnd 与 loadEventStart 之间的差值做粗略估算。
function getScriptTiming(url) {
const entry = performance.getEntriesByName(url).find(
item => item.initiatorType === 'script'
);
if (!entry) return null;
return {
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
tls: entry.secureConnectionStart ? entry.connectEnd - entry.secureConnectionStart : 0,
waiting: entry.responseStart - entry.requestStart,
download: entry.responseEnd - entry.responseStart,
total: entry.responseEnd - entry.startTime
};
}
其中 total 是不少监控平台直接展示的脚本加载耗时,但它混合了网络与调度等待。如果要区分网络与解析,可以额外记录 PerformanceNavigationTiming 的 domContentLoadedEventEnd 和脚本执行结束时间。对于同步脚本,执行结束近似于 responseEnd 加上解析执行耗时;对于异步脚本,需要单独包装执行逻辑或在脚本末尾打点。实际工程中通常不追求单脚本解析时间的绝对精确,而是将同源脚本的解析耗时汇总,再结合 CPU 长任务定位。
另一个关注点是首屏性能。JavaScript 加载时间需要和用户可交互时间(TTI)关联。监控时可以记录每个脚本的 responseEnd 相对 navigationStart 的位置,若某个脚本在 DOMContentLoaded 之后才返回,说明它可能延迟了页面可交互。对于关键渲染路径上的脚本,应把 requestStart 和 responseEnd 上报,便于分析是否因为资源排队或服务端响应慢导致首屏阻塞。
四、缓存、异步脚本和跨域的影响
缓存会让 Resource Timing 数据出现很多 0 值。强缓存命中时,请求不会到达服务器,requestStart、responseStart 通常为 0,transferSize 为 0,但 decodedBodySize 仍可能是解压后体积。此时 duration 可能是一个很小的本地读取时间,不能代表网络加载。监控系统需要根据 transferSize 与 encodedBodySize 的组合判断资源来源,缓存命中与网络请求分开统计,否则平均值会掩盖真实网络问题。
async 和 defer 脚本的时间点位置含义不同。同步脚本的 startTime 到 responseEnd 能直接反映阻塞时长;async 脚本则可能很早就发出请求,但直到某个时间点才执行,单看资源耗时看不出对解析的影响。监控时应同时上报脚本的加载方式,并在执行开始处打点。动态创建的脚本没有出现在初始 HTML 中,performance.getEntriesByType 在脚本插入后可以拿到,但时机可能晚于脚本执行。使用 PerformanceObserver 并设置 buffered: true 可以回放已经发生的资源条目。
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.initiatorType === 'script') {
// 上报 entry.name、entry.duration 等字段
console.log(entry.name, entry.duration);
}
});
});
observer.observe({ type: 'resource', buffered: true });
跨域脚本的资源时间数据受 Timing-Allow-Origin 响应头控制。如果脚本来自第三方 CDN,服务端没有返回这个头,浏览器会把 resource timing 的详细属性置为 0,只保留 startTime、responseEnd 等少数值,duration 也可能为 0 或不准。要获得完整时间,需要脚本提供方在响应中添加 Timing-Allow-Origin,或者将脚本同源化。否则监控端只能退化为用请求发出和 onload 回调时间做粗略估计。
五、监控落地与常见误区
实际落地时,不建议直接采集所有脚本的全部字段。页面可能加载几十个脚本,每条 Resource Timing 的完整字段会带来较大数据量。更合理的做法是只对关键脚本或超过体积阈值的脚本收集阶段耗时,其余脚本仅上报数量、总耗时和缓存命中情况。上报前可以在客户端先做聚合,按页面和脚本源统计 P50、P75、P95 延迟,避免把原始时间序列全量发送到服务端。
常见误区之一是只看 window.onload。window.onload 是所有资源(包括图片、样式、脚本)加载完成的事件,它被最慢的资源拖累,不能准确反映 JavaScript 加载时间。第二个误区是用资源条目的 duration 当作总加载时间,实际上 duration 不包含解析和编译。第三个误区是忽略异步脚本对主线程的抢占:一个 async 脚本虽然下载不阻塞,但执行时仍可能阻塞渲染和交互,监控时需要单独记录执行阶段的耗时。
更完善的量化方案会把 JavaScript 加载时间拆成网络、解析执行、对页面影响三个维度。网络维度用 Resource Timing 的阶段差值;解析执行维度结合长任务和脚本执行包裹打点;页面影响维度通过对比禁用该脚本或延迟加载后的 Speed Index、TTI 变化来评估。这样监控数据不仅能回答“脚本慢不慢”,还能回答“慢在哪里”以及“是否值得优化”。

