监控网页内所有 JS、CSS 资源加载的三种状态:
- 成功
- 失败
- 超时
目前浏览器并未提供一个可以直接实现以上需求的能力,我们可以通过如下方式来分别监控这三种状态:
我们来一条一条看(多留意代码注释):
成功检测
1 | new PerformanceObserver((entryList) => { |
如上代码注释中值得一提的首先是资源类型判断:我们可以通过 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 | entryList.getEntries().forEach((entry) => { |
上面的 else
逻辑需要着重注意下:
- 我们是用 url 来关联的资源加载的三个状态,但同一个 url 可能在实际业务中被请求多次,且每次的状态也可能不同
- 之所以可以这么过滤,其实隐含了失败检测(GlobalEventHandlers.onerror)会先于成功检测(PerformanceObserver)被执行
失败检测
1 | document.addEventListener( |
如上代码注释中值得一提的首先还是资源类型判断,除了类似成功检测逻辑中已经提到的部分,还有 Link 标签,因为它不仅仅可以用来加载 css,所以需要再加上 rel === ‘stylesheet’。且 e.target 一旦为 scriptNode 或 linkNode,就代表了这是一个资源加载错误。
另一个是你依然需要在失败检测中过滤掉已经超时的资源。伪代码如下:
1 | if (!timeoutAssets.has(url)) { |
你应该会注意到,这里没有 else
了。为什么不需要像成功检测那样删除掉本次命中的超时资源?因为:
- 触发了失败检测的资源一定还会继续触发成功检测(失败先,成功后)
- 成功检测里会删除本次命中的超时资源,所以这里不需要多此一举
可能会导致数据不准的点:某个标签在请求资源的过程中被类似
removeChild
之类的方法给移除了,那样的话加载失败后就不会触发error
事件了(因为本质上 error 事件是标签派发的)。而该情况却依然会被 PerformanceObserver 检测到,且一定会被判定成成功
超时检测
重头戏来了!关于静态资源加载成功或失败的检测方式其实网上可以搜到不少和本文类似的文章(虽然一定没有我写的细节),但超时检测我反正是没有搜到过真正靠谱的。
首先我们先明确一下超时的概念:对于请求来说,它一般是受服务端的控制,不同的请求,超时也可能不同。甚至有些请求都到不了后端,所以最稳妥的方式还是在前端来做这件事情。
那么请求的超时检测无非需要两个条件:
- 有明确的请求起点用来启动定时器
- 有明确的超时时长来触发定时器
第 2 点没什么好说的,看你自己的业务需求了。重点是 1,目前为止我没有找到任何 API 可以直接拿到请求开始,更精确点的描述是没有找到任何 API 可以拿到开发者工具中处于 pending 状态的请求。不过方法总比困难多,对于标签发起的请求来说,理论上标签被 dom 载入并解析的那一刻就代表了请求的发起!(被其他标签加载阻塞住的不算被解析)。
幸运的是,我们现在有了 MutationObserver 这个用于监听 dom 变化的 API,它恰好可以拿来做这件事情:
1 | new MutationObserver((mutationRecords) => { |
老规矩,我们再来看上面代码的重点注释部分。当 script 和 styleLink 在 dom 中发生了何种变化需要我们关注?
- script 或 styleLink 被创建的时候
- script 的
src
或 styleLink 的href
属性发生变化的时候
先看 1。这个好办,直接:
1 | mutationRecords.forEach((record) => { |
再看 2。上代码:
1 | mutationRecords.forEach((record) => { |
上面代码注释的重点在:「针对 script 标签的额外判断」。对于 script 标签来说,只有满足如下条件后,src 变化才会触发加载(link 标签没有这些限制)
- 之前没有设置过
src
。可以通过record.oldValue === null
来判断(这也是 MutationObserver 配置中需要 attributeOldValue 的原因) - 不存在
innerHTML
,换句话说一定得是一个连空格都不能有空的 script 标签。感兴趣的可以自己试一下动态改变一个已经有了 inline 脚本的 script 标签的 src 属性
关于第 1 条,逻辑缜密的你可能会想到,如果我这样:
1 | scriptNode.src = 'http://x1.js' |
会不会多误判一次 x2.js
的请求?不会的。因为标签的 src 属性值你没法设置为非 String 类型,所以上面代码的 null
会被转成 'null'
。因此不会命中 record.oldValue === null
。除非。。。
1 | scriptNode.src = 'http://x1.js' |
可能会导致数据不准的点:removeAttribute 确实可以把 script src 的 oldValue 变回 null,这会导致一个没有发生的请求被判定成超时。(我也替你试过 delete 了,delete 后的 src 不会发生变化)
对于 styleLink 标签也有个隐藏的坑是假设存在如下代码:
1 | styleLinkNode.href = 'http://x1.css' |
猜猜你在 MutationObserver 中会收到几次通知?每次取到的 href 值是?答案分别是:
- 3 次(符合预期)
http://x3.css
(不符合预期)
对于 2,我想你肯定能想明白是因为 MutationObserver 的触发是异步导致的。但我还是特别想吐槽一下 MutationObserver 的 api 设计,都提供 oldValue
了,为啥不顺便提供一下 newValue
呢?
同时,这种同步修改 href 的操作,会立即 cancel 掉之前的请求:
被 cancel 掉的请求不会触发 error
但会触发 PerformanceObserver
:
可能会导致数据不准的点:被 cancel 掉的请求进入到 PerformanceObserver 中,被误判为成功请求。
好了,已经能拿到请求起点了,接下来要做的主要事情就只有:
- 在起点处创建一个用于超时检测的定时器
- 在成功和失败检测被触发时停止定时器
是的,如此定时器必然要存在一个 Map 里,key 是资源 url。所以 1:
1 | // 为每个 js、css 请求创建一个超时监控 |
可能会导致数据不准的点:虽然 timeoutCheckers 具有 URL 唯一性,但可能会存在多个同 URL 的超时检测定时器在跑着。创建定时器之前是否要停掉上次创建的?其实停有停的不准,不停也有不停的不准
2 要注意的是,由于超时检测依赖的 MutationObserver 是异步触发的,当某个请求命中缓存的时候就很可能会出现 PerformanceObserver 先于 MutationObserver 被触发的情况,从而出现请求已经结束了,但超时检测没有被关闭的情况。解决办法就是给终止超时检测的方法来个喜闻乐见的 hack 操作:
1 | const destroyTimeoutChecker = (url) => { |
2021-08-16 更:方案上线后发现用了上面的 hack 方案也还是会有 timeout 误上报的情况,大多会发生在安卓设备:已经上报 timeout 的资源里会有 20% 也上报了 success。这意味着某些情况下低端机型 100ms 的延时关闭也不够,再去增大延时数可能又会引发新的问题。故最后在定时器创建和执行的时候都再过滤掉已成功和失败的资源(虽然同一份资源曾经成功/失败不代表之后不会超时,但现有的基于 MutationObserver 的超时机制时机比较靠后,且大多数情况下页面根本就不会重复请求同一份静态资源。所以这算是一个两害相权取其轻的手段)
1 | const isCompleted = () => successAssets.has(url) || failedAssets.has(url) |
进一步优化
上文中各小节内存在的「可能会导致数据不准的点」 ,本质上就两个原因:
- 我们的成功、失败、超时检测都依赖请求 url 来关联,且因为同一个 url 可能被请求多次,所以它作为 id 就很可能带来各种问题
- 任意请求完结状态都会触发
PerformanceObserver
,但我们没办法在回调内精确地过滤出成功和失败的请求(2021-08-03 更正:script src 指向一个瞎写的 url,某些情况浏览器会直接使请求失败。这种就只触发 error,不触发 PerformanceObserver)
对于 1,或许可以设计一个更靠谱一些的 id?这个我倒是还没有动手去干,因为目前我的实际业务中基于 url 的其实已经很准了,这里我只说个可能可行的 id 生成办法:url + 请求发生时的时间戳。这个是利用了无论请求成功还是失败,PerformanceObserver 基本都会被触发且回调参数中可以拿到 startTime 。那么如果请求时间戳相差无几且 url 匹配,我们就可以做唯一关联了。
对于 2,或许可以通过 PerformanceResourceTiming 的 decodedBodySize 、 transferSize 等属性间接判断出失败请求?但感觉同样存在误判的可能,只是把原本的小概率变成了更小概率。还是期待下浏览器对相关 API 的完善吧!
最后,对于监控脚本自身应当是一个位于 head 首位的 inline 脚本,得先保证自身的高可用。
后记性能优化(2021-08-03 更)
针对监听 JS 和 CSS 资源加载监控这个场景来说,其实上文中的 MutationObserver.observe
的配置过于「重」了,它事实上会监听所有 dom 节点的变化及所有 dom 部分 attributes 的变化:
1 | { |
我基本没见过有人闲的蛋疼把 script 和 styleLink 节点乱塞和动态更改 src
和 href
,所以根据实际业务场景,我们其实只需要监听 head 和 body 的一级子节点的创建就够了。
上文提到过,监控脚本本身是一个位于 head 节点且处于所有 JS、CSS 之前的内联脚本,该位置有个尴尬之处是那时候 dom 中的 body 节点还未创建。所以你没法直接:
1 | // 代码会报错,因为 document.body === null |
但我们可以「迂回」一下,先监听 document.documentElement ,当 body 这个属于它的一级子节点出现的时候就开始监听,并停止 document.documentElement 的监听。
那么理论上我们只需要两个 MutationObserver 实例:
- 监听 head 和 body(是的,一个实例可以监听多个 target,多次调用 observe() 就好)
- 监听 document.documentElement (监听到 body 出现的时候就可以销毁了)
然鹅,事情还没有结束。还是因为 MutationObserver 的回调时机很靠后,当监听到 body 出现的时候,body 中一些靠前的 script、styleLink 可能已经被创建了,所以真正监听 body 子节点的 MutationObserver 必然会漏掉它们。
解决办法是在监听到 body 节点出现的时候,手动通过 dom 获取一波 body 中已经创建的节点作为「请求开始」的补充:
1 | document.body.querySelectorAll('script, link').forEach(...); |