监控网页内所有 JS、CSS 资源加载的三种状态:

  • 成功
  • 失败
  • 超时

目前浏览器并未提供一个可以直接实现以上需求的能力,我们可以通过如下方式来分别监控这三种状态:

我们来一条一条看(多留意代码注释):

成功检测

1
2
3
4
5
6
new PerformanceObserver((entryList) => {
// 任意资源加载完成基本都会回调(极少数情况不会,可忽略)
entryList.getEntries().forEach((entry) => {
// 我们可以通过 entry.name 后缀或 entry.initiatorType 来判断资源类型。代码略
})
}).observe({ entryTypes: ['resource'] })

如上代码注释中值得一提的首先是资源类型判断:我们可以通过 entry.name 后缀或 entry.initiatorType 来判断。具体怎么用得看你的业务:

  • 是否所有的静态资源请求地址都拥有正确的资源后缀?
  • 一个请求了 xxx.js 的 link 标签该资源算 js 还是 css?
  • 一个请求了 xxx.css 的 script 标签该资源算 js 还是 css?

另一个是「任意资源加载完成会回调」(2021-08-03 更正:script src 指向一个瞎写的 url,某些情况浏览器会直接使请求失败。这种就只触发 error,不触发 PerformanceObserver)。因此,你需要在回调中排除掉:

  • 已超时的 url
  • 已失败的 url

至于它们是怎么来的,自然是来自下面两节中 push 的资源 url 队列。 伪代码如下:

1
2
3
4
5
6
7
8
9
10
entryList.getEntries().forEach((entry) => {
const url = entry.name
if (!failedAssets.has(url) && !timeoutAssets.has(url)) {
// 这里执行资源加载成功逻辑
} else {
// 已超时或已失败的资源不代表会一直超时一直失败,所以在这个资源成功/失败都会回调的地方删除之,保不齐下次能成功呢
failedAssets.delete(url)
timeoutAssets.delete(url)
}
})

上面的 else 逻辑需要着重注意下:

  • 我们是用 url 来关联的资源加载的三个状态,但同一个 url 可能在实际业务中被请求多次,且每次的状态也可能不同
  • 之所以可以这么过滤,其实隐含了失败检测(GlobalEventHandlers.onerror)会先于成功检测(PerformanceObserver)被执行

失败检测

1
2
3
4
5
6
7
document.addEventListener(
'error',
(e) => {
// 类似成功检测逻辑,我们同样可以通过 e.target.tagName 或 e.target.src(script) 或 e.target.href(link 且 rel === 'stylesheet') 来判断资源类型
},
true, // 注意这里一定得传入 true
)

如上代码注释中值得一提的首先还是资源类型判断,除了类似成功检测逻辑中已经提到的部分,还有 Link 标签,因为它不仅仅可以用来加载 css,所以需要再加上 rel === ‘stylesheet’。且 e.target 一旦为 scriptNode 或 linkNode,就代表了这是一个资源加载错误。

另一个是你依然需要在失败检测中过滤掉已经超时的资源。伪代码如下:

1
2
3
if (!timeoutAssets.has(url)) {
// 这里执行资源加载失败逻辑
}

你应该会注意到,这里没有 else 了。为什么不需要像成功检测那样删除掉本次命中的超时资源?因为:

  • 触发了失败检测的资源一定还会继续触发成功检测(失败先,成功后)
  • 成功检测里会删除本次命中的超时资源,所以这里不需要多此一举

可能会导致数据不准的点:某个标签在请求资源的过程中被类似 removeChild 之类的方法给移除了,那样的话加载失败后就不会触发 error 事件了(因为本质上 error 事件是标签派发的)。而该情况却依然会被 PerformanceObserver 检测到,且一定会被判定成成功

超时检测

重头戏来了!关于静态资源加载成功或失败的检测方式其实网上可以搜到不少和本文类似的文章(虽然一定没有我写的细节),但超时检测我反正是没有搜到过真正靠谱的。

首先我们先明确一下超时的概念:对于请求来说,它一般是受服务端的控制,不同的请求,超时也可能不同。甚至有些请求都到不了后端,所以最稳妥的方式还是在前端来做这件事情。

那么请求的超时检测无非需要两个条件:

  1. 有明确的请求起点用来启动定时器
  2. 有明确的超时时长来触发定时器

第 2 点没什么好说的,看你自己的业务需求了。重点是 1,目前为止我没有找到任何 API 可以直接拿到请求开始,更精确点的描述是没有找到任何 API 可以拿到开发者工具中处于 pending 状态的请求。不过方法总比困难多,对于标签发起的请求来说,理论上标签被 dom 载入并解析的那一刻就代表了请求的发起!(被其他标签加载阻塞住的不算被解析)。

幸运的是,我们现在有了 MutationObserver 这个用于监听 dom 变化的 API,它恰好可以拿来做这件事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
new MutationObserver((mutationRecords) => {
// 我们在这里监听 script 和 styleLink 标签的动态
}).observe(
document.documentElement,
// 如下配置参数除了 attributeFilter 这个为了更好性能的参数,一个都不能动
{
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'href'],
attributeOldValue: true,
},
)

老规矩,我们再来看上面代码的重点注释部分。当 script 和 styleLink 在 dom 中发生了何种变化需要我们关注?

  1. script 或 styleLink 被创建的时候
  2. script 的 src 或 styleLink 的 href 属性发生变化的时候

先看 1。这个好办,直接:

1
2
3
4
5
6
7
8
9
mutationRecords.forEach((record) => {
if (record.type === 'childList') {
// addedNodes 里就是新创建的标签
record.addedNodes.forEach((node) => {
// 这里过滤出 script 和 rel === 'stylesheet' 的 link 标签。代码略
const url = isScript ? node.src : node.href // 如果判断 url 存在且合法的话,则可以认为该 url 已经开始发起请求了
})
}
})

再看 2。上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
mutationRecords.forEach((record) => {
if (record.type === 'attributes') {
// 因为有 attributeFilter 的存在,这里发生变化的属性一定是:src 或 href
const target = record.target
// 通过 target 来过滤出 script 和 rel === 'stylesheet' 的 link 标签。代码略
if (
(isScript && record.oldValue === null && target.innerHTML === '') || // 针对 script 标签的额外判断
isStyleLink
) {
const url = isScript ? node.src : node.href // 如果判断 url 存在且合法的话,则可以认为该 url 已经开始发起请求了
}
}
})

上面代码注释的重点在:「针对 script 标签的额外判断」。对于 script 标签来说,只有满足如下条件后,src 变化才会触发加载(link 标签没有这些限制)

  1. 之前没有设置过 src。可以通过 record.oldValue === null 来判断(这也是 MutationObserver 配置中需要 attributeOldValue 的原因)
  2. 不存在 innerHTML,换句话说一定得是一个连空格都不能有空的 script 标签。感兴趣的可以自己试一下动态改变一个已经有了 inline 脚本的 script 标签的 src 属性

关于第 1 条,逻辑缜密的你可能会想到,如果我这样:

1
2
3
scriptNode.src = 'http://x1.js'
scriptNode.src = null
scriptNode.src = 'http://x2.js'

会不会多误判一次 x2.js 的请求?不会的。因为标签的 src 属性值你没法设置为非 String 类型,所以上面代码的 null 会被转成 'null'。因此不会命中 record.oldValue === null。除非。。。

1
2
3
scriptNode.src = 'http://x1.js'
scriptNode.removeAttribute('src')
scriptNode.src = 'http://x2.js'

可能会导致数据不准的点:removeAttribute 确实可以把 script src 的 oldValue 变回 null,这会导致一个没有发生的请求被判定成超时。(我也替你试过 delete 了,delete 后的 src 不会发生变化)

对于 styleLink 标签也有个隐藏的坑是假设存在如下代码:

1
2
3
styleLinkNode.href = 'http://x1.css'
styleLinkNode.href = 'http://x2.css'
styleLinkNode.href = 'http://x3.css'

猜猜你在 MutationObserver 中会收到几次通知?每次取到的 href 值是?答案分别是:

  1. 3 次(符合预期)
  2. http://x3.css(不符合预期)

对于 2,我想你肯定能想明白是因为 MutationObserver 的触发是异步导致的。但我还是特别想吐槽一下 MutationObserver 的 api 设计,都提供 oldValue 了,为啥不顺便提供一下 newValue 呢?

同时,这种同步修改 href 的操作,会立即 cancel 掉之前的请求:

被 cancel 掉的请求不会触发 error 但会触发 PerformanceObserver

可能会导致数据不准的点:被 cancel 掉的请求进入到 PerformanceObserver 中,被误判为成功请求。


好了,已经能拿到请求起点了,接下来要做的主要事情就只有:

  1. 在起点处创建一个用于超时检测的定时器
  2. 在成功和失败检测被触发时停止定时器

是的,如此定时器必然要存在一个 Map 里,key 是资源 url。所以 1:

1
2
3
4
5
6
7
// 为每个 js、css 请求创建一个超时监控
const timeoutChecker = setTimeout(() => {
timeoutAssets.add(url)
timeoutCheckers.delete(url)
// 这里执行资源加载超时逻辑
}, TIMEOUT)
timeoutCheckers.set(url, timeoutChecker)

可能会导致数据不准的点:虽然 timeoutCheckers 具有 URL 唯一性,但可能会存在多个同 URL 的超时检测定时器在跑着。创建定时器之前是否要停掉上次创建的?其实停有停的不准,不停也有不停的不准

2 要注意的是,由于超时检测依赖的 MutationObserver 是异步触发的,当某个请求命中缓存的时候就很可能会出现 PerformanceObserver 先于 MutationObserver 被触发的情况,从而出现请求已经结束了,但超时检测没有被关闭的情况。解决办法就是给终止超时检测的方法来个喜闻乐见的 hack 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
const destroyTimeoutChecker = (url) => {
const destroy = () => {
clearTimeout(timeoutCheckers.get(url))
timeoutCheckers.delete(url)
}

if (timeoutCheckers.has(url)) {
destroy()
} else {
// 由于 MutationObserver 是异步的,所以需要如此来防止请求已经结束了但没人关闭超时检测的情况
setTimeout(destroy, 100) // 100 是测试很多次后得到的值
}
}

2021-08-16 更:方案上线后发现用了上面的 hack 方案也还是会有 timeout 误上报的情况,大多会发生在安卓设备:已经上报 timeout 的资源里会有 20% 也上报了 success。这意味着某些情况下低端机型 100ms 的延时关闭也不够,再去增大延时数可能又会引发新的问题。故最后在定时器创建和执行的时候都再过滤掉已成功和失败的资源(虽然同一份资源曾经成功/失败不代表之后不会超时,但现有的基于 MutationObserver 的超时机制时机比较靠后,且大多数情况下页面根本就不会重复请求同一份静态资源。所以这算是一个两害相权取其轻的手段)

1
2
3
4
5
6
7
8
9
10
const isCompleted = () => successAssets.has(url) || failedAssets.has(url)
if (isCompleted()) return // 过滤掉已成功和失败的
// 为每个 js、css 请求创建一个超时监控
const timeoutChecker = setTimeout(() => {
if (isCompleted()) return // 过滤掉已成功和失败的
timeoutAssets.add(url)
timeoutCheckers.delete(url)
// 这里执行资源加载超时逻辑
}, TIMEOUT)
timeoutCheckers.set(url, timeoutChecker)

进一步优化

上文中各小节内存在的「可能会导致数据不准的点」 ,本质上就两个原因:

  1. 我们的成功、失败、超时检测都依赖请求 url 来关联,且因为同一个 url 可能被请求多次,所以它作为 id 就很可能带来各种问题
  2. 任意请求完结状态都会触发 PerformanceObserver,但我们没办法在回调内精确地过滤出成功和失败的请求(2021-08-03 更正:script src 指向一个瞎写的 url,某些情况浏览器会直接使请求失败。这种就只触发 error,不触发 PerformanceObserver

对于 1,或许可以设计一个更靠谱一些的 id?这个我倒是还没有动手去干,因为目前我的实际业务中基于 url 的其实已经很准了,这里我只说个可能可行的 id 生成办法:url + 请求发生时的时间戳。这个是利用了无论请求成功还是失败,PerformanceObserver 基本都会被触发且回调参数中可以拿到 startTime 。那么如果请求时间戳相差无几且 url 匹配,我们就可以做唯一关联了。

对于 2,或许可以通过 PerformanceResourceTiming 的 decodedBodySizetransferSize 等属性间接判断出失败请求?但感觉同样存在误判的可能,只是把原本的小概率变成了更小概率。还是期待下浏览器对相关 API 的完善吧!

最后,对于监控脚本自身应当是一个位于 head 首位的 inline 脚本,得先保证自身的高可用


后记性能优化(2021-08-03 更)

针对监听 JS 和 CSS 资源加载监控这个场景来说,其实上文中的 MutationObserver.observe 的配置过于「重」了,它事实上会监听所有 dom 节点的变化及所有 dom 部分 attributes 的变化:

1
2
3
4
5
6
7
{
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'href'],
attributeOldValue: true
}

我基本没见过有人闲的蛋疼把 script 和 styleLink 节点乱塞和动态更改 srchref ,所以根据实际业务场景,我们其实只需要监听 head 和 body 的一级子节点的创建就够了。

上文提到过,监控脚本本身是一个位于 head 节点且处于所有 JS、CSS 之前的内联脚本,该位置有个尴尬之处是那时候 dom 中的 body 节点还未创建。所以你没法直接:

1
2
3
4
// 代码会报错,因为 document.body === null
observer.observe(document.body, {
childList: true,
})

但我们可以「迂回」一下,先监听 document.documentElement ,当 body 这个属于它的一级子节点出现的时候就开始监听,并停止 document.documentElement 的监听。

那么理论上我们只需要两个 MutationObserver 实例:

  1. 监听 head 和 body(是的,一个实例可以监听多个 target,多次调用 observe() 就好)
  2. 监听 document.documentElement (监听到 body 出现的时候就可以销毁了)

然鹅,事情还没有结束。还是因为 MutationObserver 的回调时机很靠后,当监听到 body 出现的时候,body 中一些靠前的 script、styleLink 可能已经被创建了,所以真正监听 body 子节点的 MutationObserver 必然会漏掉它们。

解决办法是在监听到 body 节点出现的时候,手动通过 dom 获取一波 body 中已经创建的节点作为「请求开始」的补充:

1
document.body.querySelectorAll('script, link').forEach(...);

 评论