前段时间转载了一篇文章《前端实现自动切换CDN,让资源高可用》,当时留了一个小尾巴,非 JavaScript 内容(例如图片等静态资源)如何实现高可用:

CDN自动切换

现在找到的解决方案也许能解决问题:

本文转载自前端白屏问题分析——CDN资源失效、JS代码不兼容、css代码不兼容_魔侠的沙滩-CSDN博客,遵循 CC 4.0 BY-SA 版权协议规范转载。

识别资源加载失败

对于第一个问题,上面头条的同学给一个简单通用的方案,就是这个:

1
2
3
4
5
6
7
8
<script>
var errors = [];
window.addEventListener('error', function (e) {
if (!(e instanceof ErrorEvent)) {
errors.push(e);
}
}, true);
</script>

利用addEventListener在捕获阶段获得错误,通过判断e不是ErrorEvent来判断是资源加载错误。但是这个错误不能判断资源是404还是网络问题,都统一返回一个error。如果需要知道具体的问题,还需要通过ajax请求一次这个出错的资源,才能知道具体问题(是404、域名解析不了、服务不可用、还是加载超时等)

捕获到错误以后,应该如何加载资源呢,重新加载哪些资源呢?js、css、image?

重新加载失败的资源

这个问题可以利用策略来解决。这里有两种策略:

页面内重新加载,在页面里获得当前页面失败到或者所有到资源,重新切换线路进行加载。
提示用户资源加载失败,然后reload页面,在url上给一个标示,让这个重新reload的页面采用新的资源路径。

第二种策略耗时会更久,这里就不讨论了,具体在业务中笔者也没有真的实现。我们来聊一聊页面内重新加载的思路。

上面头条的方案中,讨论了js重新加载的方案,这里我们会覆盖讨论css和背景图片的问题。

css资源和js资源

css资源和js资源都是通过dom标签加载的,所以实现方案上可以统一,流程都是识别所有的dom,然后把对应链接里的CDN域名提高为新的域名,再把得到的新链接生成dom重新插入到页面中,利用浏览器的并行加载,顺序执行重新执行一遍这些资源。

Tips: 如果出现部分js资源失败,部分成功。重新加载全部资源,可能会导致某些js执行出错,如果这些js文件不支持多次执行的话。我们这里不对这种情况做讨论,指考虑统一失败的情况,加载所有资源都使用了相同的CDN,且这些js都支持多次重复执行。

看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function cndReloadAll (nodeName, urlAttr) {
var domNodeList = document.getElementsByTagName(nodeName)
for (var i = 0; i < domNodeList.length; i++) {
var onedom = domNodeList[i];
if (!onedom[urlAttr] || (onedom[urlAttr].indexOf('a.cdn.com'))) {
continue;
}
var newNode = document.createElement(nodeName);
for (var key in onedom.attributes) {
if (onedom.attributes.hasOwnProperty(key)) {
var nodeAttr = onedom.attributes[key]
var name = nodeAttr.name
if (nodeAttr.name === 'crossorigin') {
newNode.crossOrigin = 'crossorigin'
} else if (['onerror', 'onload', 'onabort'].indexOf(name) === -1) {
newNode[nodeAttr.name] = nodeAttr.value
}
}
}
newNode[urlAttr] = onedom[urlAttr].replace('a.cdn.com', 'b.cdn.com')
onedom.parentNode.insertBefore(newNode, onedom)
onedom.parentNode.removeChild(onedom)
}
}

代码里的函数cdnReloadAll有两个参数,nodeName表示标签名字,比如script或者link,urlAttr表示需要替换的属性,script对应src,link对应href。

a.cdn.com表示当前的CDN域名,b.cdn.com表示切换后的域名。这段代码还贴心的,复制了原来script标签里一些常用属性。

背景图片

把css文件上传到多个CDN以后,css文件里的背景图URL可能还是同一个,切换的时候需要同时进行切换。看如下代码:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
function reloadBackgroundImage () {
var isSupportStyleSheets = document.styleSheets && document.styleSheets[0]
if (!isSupportStyleSheets) return false

var styleSheetsLength = document.styleSheets.length
for (var i = 0; i < styleSheetsLength; i++) {
var styleSheets = document.styleSheets[i]
try {
if (!(styleSheets.rules && styleSheets.rules.length > 0)) {
continue
}
for (var j = 0; j < styleSheets.rules.length; j++) {
var item = styleSheets.rules[j]
// is back null
var backStr = item.selectorText && item.style && item.style.backgroundImage
if (!backStr) {
continue
}
// is has real url
var matchRes = backStr.match(/url\(["|'](.*)["|']\)/)
var backgroundImage = matchRes && matchRes[1]
if (!backgroundImage) {
continue
}
// base64 not replace
if (backgroundImage.indexOf('data:image/') !== -1) {
continue;
}
var nodeName = "BACKGROUNDIMAGE"
if (backgroundImage.indexOf('a.cdn.com') === -1) {
continue;
}
var newUrl = backgroundImage.replace('a.cdn.com', 'b.cdn.com')
if (!newUrl) {
continue
}
var cssText = item.selectorText + "{ background-image: url(" + newUrl + ")}"
var cssTextKey = i + cssText
if (window.reloadCache[nodeName].indexOf(cssTextKey) === -1) {
window.reloadCache[nodeName].push(cssTextKey)
styleSheets.insertRule(cssText, styleSheets.rules.length)
}
}
} catch (err) {
throw Error("CdnAssetsSwitch replaceBackGroundImage " + err)
}
}
}

上面这段代码遍历了页面里所有的样式表,并且提取里面的background-image属性,如果发现是a.cdn.com的,就生成一个b.cdn.com的新样式插入到这个样式表中。里面还做了一个缓存,稍微提高一下性能。

这样我们就把重新加载资源搞定了。第一步和第二部要如何联合在一期呢,在不侵入代码的情况下。我们可以在后,业务js代码前插入一段js代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
window.addEventListener('error', function (e) {
if (!(e instanceof ErrorEvent)) {
if (!isCdnError) {
isCdnError = true
var script = window.document.createElement('script');
script.type = 'text/javascript';
script.addEventListener('error', function () {
// TODO 继续切换其他线路
})
script.src = 'reload.js';
window.document.body.appendChild(script);
}
}
}, true)

其中reload.js里写的是上面重载css、js、和背景图片的代码,定义两个函数,并且执行。