背景

静态资源的快速加载和高可用性对用户体验至关重要。随着 CDN 的推行,单一云服务商在遇到特殊情况时(如网络中断、服务故障等),会导致静态资源加载失败,从而影响用户体验和业务连续性。

所以,单靠一个云服务商已经不能满足我们的需求了,我们需要更强的容灾能力。需要对资源进行多云自动切换。

我们的最终目标是“多CDN架构”,具体实现方式如下

在源站上配置多个CDN域名,每个CDN域名对应一个CDN服务提供商的节点。这样,不同的CDN节点可以通过不同的域名访问同一个源站。

基于这种 “多 CDN 架构 ” 来应对单个云厂商在遇到特殊情况时 可以对资源进行自动切换,以保证网站的正常运行。

image-20240719104837190

如何实现

静态资源的分类

静态资源可以大致分为两种类型:

  1. 同步静态资源

    跟随页面组合好之后一起返回 | 接口返回的cdn地址

    特点:能够灵活控制资源cdn域名

  2. 异步静态资源

    由前端打包工具,建立关联关系后,构建工具异步拉取

    特点:由前端在构建时,根据不同的环境写死的cdn域名,在构建时已经确定,无法在运行时动态替换

原理

所以我们的实现主要分为三部分:

  1. 如何自动获取加载失败的静态资源(同步加载的 <script>, <link>, <img>)并重试
  2. 如何自动获取加载失败的异步脚本并重试
  3. 如何自动获取加载失败的背景图片并重试

最先,我想到的是动态去修改```__webpack_public_path__ ``(构建时修改,运行时生效)

伪代码如下:

1
2
3
4
5
6
// 代码中写死的publicCDN地址:
// publicCDN="https://assets.cdn.xxx.com"
if (process.env.VUE_APP_BUILD_TAG !== 'localhost') {
const target = cdn_replace.filter(i => i => i.origin_cdn === publicCDN)
__webpack_public_path__ = target ? target[0].target_cdn : publicCDN
}

优点:在运行时就会直接替换,不会触发error后再加载,过程比较平滑

缺点:需要修改构建配置,现在主流是webpack,后续构建工具多了之后,还要对不同的构建做不同的改动

显然,这不是最优选,不仅需要每个业务中都去修改配置,后续如果升级了其他构建工具,可能还会出现其他预想不到的问题。

方案二:

通过挂载全局error事件,捕获资源失败场景

1
window.document.addEventListener('error', errorHandler, true);

然后通过errorHandler来处理对应异步资源的cdn域名替换

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function errorHandler(e) {
// 拿到报错异步资源的src
let source_src = e.target.src;
// 获取协议
let protocol = source_src.split('//')[0]
// 获取domain
let cdn = source_src.split('//')[1].split('/')[0]
// 组合cdn地址
let full_domain = `${protocol}://${cdn}`
// 找到需要替换的目标地址
const target = cdn_replace.filter(i => i => i.origin_cdn === full_domain)
if (target) {
// 使用目标cdn替换当前cdn
source_src = source_src.replace(full_domain, target[0].target_cdn )
// 重新挂载script
loadScript(source_src)
}
}

function loadScript(src) {
const scriptEle = document.createElement('script')
scriptEle.crossOrigin = 'anonymous'
scriptEle.type = 'text/javascript'
scriptEle.src = src
scriptEle.addEventListener('load', () => {
console.log('成功')
})
scriptEle.addEventListener('error', err => {

优点:无需业务侧的配合,比较通用,既能兼顾老的页面也能支持新的页面

缺点:会触发资源的error事件之后,再次挂载对应的修改后的cdn

但这种方式无法对webpack构建的异步产物生效,因为在一个页面有多个异步chunk请求时,webpack是通过promise.all来处理异步chunk的,当一个失效后,Promise会进入rejected状态,导致再无法转为其他状态,会阻塞其他异步文件加载。

image-20240719111038151

image-20240719115621568

如何打破这种局面?摆在我们面前的只有两条路:

  1. 使用 webpack 插件,在编译期改写该段代码。
  2. 使用 monkey patch 对浏览器的原生方法进行改写。

为了降低集成成本,我们选择了第二种方案,即在运行时动态改写 document.createElement, Node#appendChild 等方法。

大体思路:

1
2
3
4
5
document.createElement

HTMLScriptElement(src, onload, onerror)

document.head.appendChild

为了阻止webpack在onerror回掉直接进入reject流程,可以直接对浏览器原生方法的document.createElement以及Node#appendChild等方法进行重写,使其流程变为:

1
2
3
4
5
document.createElement

virtualScriptElement(src, onload, onerror)

document.head.appendChild

代码流程图:

image-20240719115525527

优点:只针对script/link等标签的异步资源做拦截,无需担心掉入构建工具的rejected状态,从而影响后续异步资源的加载,也能配合最大重试次数的限制,能够及时终止循环重试。业务侧无需改动,公共模板挂载这段逻辑,并初始化即可。

看到这也许你会问了,

为什么要创建 虚拟 element 对象?

其实直接创建真实element,并且对真实element的onerror和onload做重写也ok,但是会被用户自定义的onerror和onload覆盖掉。

但是虚拟element就可以解决这个问题,可以将用户设置的onerror和onload挂载到虚拟对象上,通过ignore标识来判断,走用户设置的onerror还是默认重写的onerror。

那么 对页面上其他的document.createElement方法有没有影响?

没有影响,因为仅仅是在创建的时候多了一层包裹对象,本质上还是在调用原生createElement,并且在对创建的元素进行属性设置或者是插入节点动作时,都是取的真实的元素,而非虚拟对象。

1
2
3
4
5
6
// hookElement
{
originOnerror: fn, // 原生onerror
originOnload: fn, // 原生onload
realElement: script | link // 真实element
}

页面中可能使用到document.createElement的场景

  1. Vue中的render函数
  2. Vue中的自定义指令
  3. webpack中的动态css/js
  4. vite中的动态css/js
  5. 用户(开发)操作动态添加DOM元素

最终效果

a. 前置条件申请 3 个 c d n 域名

b. 插入重试逻辑代码,体积2.2kb,可以直接放到页面上,无需使用cdn加载

image-20240719111848137

c. 初始化重试方法

image-20240719111956627

d. 屏蔽原异步js地址

image-20240719112141930

e. 进入异步页面,验证重试

image-20240719115900918

如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
使用起来非常简单,只需要初始化并传入域名列表即可:

// assetsRetryStatistics 中包含所有资源重试的相关信息
var assetsRetryStatistics = window.assetsRetry({
// 域名列表,只有在域名列表中的资源,才会被重试
// 使用以下配置,当 https://your.first.domain/js/1.js 加载失败时
// 会自动使用 https://your.second.domain/namespace/js/1.js 重试
domain: ['your.first.domain', 'your.second.domain/namespace'],
// 可选,最大重试次数,默认 3 次
maxRetryCount: 3,
// 可选,通过该参数可自定义 URL 的转换方式
onRetry: function(currentUrl, originalUrl, statistics) {
return currentUrl
},
// 对于给定资源,要么调用 onSuccess ,要么调用 onFail,标识其最终的加载状态
// 加载详细信息(成功的 URL、失败的 URL 列表、重试次数)
// 可以通过访问 assetsRetryStatistics[currentUrl] 来获取
onSuccess: function(currentUrl) {
console.log(currentUrl, assetsRetryStatistics[currentUrl])
},
onFail: function(currentUrl) {
console.log(currentUrl, assetsRetryStatistics[currentUrl])
}
})

当使用以上代码初始化完毕后,以下内容便获得了加载失败重试的能力:

  • 所有在 html 中使用 <script> 标签引用的脚本
  • 所有在 html 中使用 <link> 标签引用的样式 (跨域 CSS 需要正确配置
  • 所有在 html 中使用 <img> 标签引用的图片
  • 所有使用 document.createElement('script') 加载的脚本(如 webpack 的动态导入
  • 所有 css 中(包含同步与异步)使用的 background-image 图片

配置

assetsRetry 接受一个配置对象 AssetsRetryOptions ,其类型签名为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface AssetsRetryOptions {
maxRetryCount: number // 最大重试次数
onRetry: RetryFunction // 每次重试时执行的函数
onSuccess: SuccessFunction // 在域名列表内的资源最终加载成功时执行
onFail: FailFunction // 在域名列表内的资源最终加载失败时执行:
domain: Domain // 域名列表
}
type RetryFunction = (
currentUrl: string,
originalUrl: string,
retryCollector: null | RetryStatistics
) => string | null
interface RetryStatistics {
retryTimes: number
succeeded: string[]
failed: string[]
}
type SuccessFunction = (currentUrl: string) => void
type FailFunction = (currentUrl: string) => void
type Domain = string[] | { [x: string]: string }