# 调研
| 方案 | 相关文章 | 关键点 | 评估结论 |
|---|---|---|---|
| 注入充实脚本 | 前端网站容灾-CDN主域重试方案 (opens new window) | 给html注入重试脚本 通过onerror触发重试 通过 document.write保证执行顺序 | 可行性较高 成本可控 风险低 |
| 自定义加载器 | 从0-1:美团侧CDN容灾解决方案 (opens new window) | 将标签加载改成xhr加载 loader处理重试、保证执行顺序 动态域名计算服务 | 可行性中 成本高 风险高 |
| 客户端拦截方案 | webview拦截js、css 加载 客户端处理重试、保证执行顺序 | 可行性高 成本高 风险高 | |
| 网关切留 | 不考虑 | ||
| 其他 | 得物CND域名收敛及多厂商容灾优化实践 (opens new window) B站SRE负责人亲述 713事故后的多活容灾建设 (opens new window) | 纯端方案 服务端方案 | 不可行 |
# 方案设计
模块关系视角

网络层视角

浏览器视角

# 具体实现
# 前端工程构建
引入插件
主要支持的插件包括
- vue-cli插件
- vue-cli 2.x(依赖webpack 4.x、html-webpack-plugin 3.x)
- vue-cli 5.x(依赖webpack 5.x、html-webpack-plugin 5.x)
- webpack 插件
- webpack 4.x(适用于 vue-cli 4.x)
- 【DONE】webpack 5.x(适用于 vue-cli 5.x)
- 【DONE】html-webpack-plugin 3.x(适用于 vue-cli 4.x)
- 【DONE】html-webpack-plugin 5.x(适用于 vue-cli 5.x)
- vite插件
- vite 3.x - 4.x
- SDK函数
- retryJS
- retryCSS
- 【DONE】retryImg
- 【DONE】retryAudio
- 【DONE】retryMedia
- vue-cli插件
新增构建配置
新增 vue.config.js 配置项 pluginOptions.cdnRetry全量拉取
module.export == { pluginOptions: { cdnRetry: { alias: 'CDN_SDK', // 配置页面中需要重试的cdn 资源类型,默认值为 js + css; dynamic-js为webbpack中动态导入的js types: [ 'js', 'css', 'dynamic-js', 'img', 'audio', 'video' ] } } }注入SDK代码(插件自动)
注入SDK逻辑和SDK初始化逻辑,在所有资源顶部注入
<!-- 注入代码到 html 顶部 --> <html> <head> <script> class CDNRetrySDK { ...函数逻辑 } window.__cdnRetrySDK__ = new CDNRetrySDK() window.__cdnRetrySDK__.init({ ...构建配置 }) </script> </head> <body></body> </html>注入JS检查代码(插件自动)
注入JS检查代码,当JS失败时,会使用document.write写入新脚本
<!-- 注入代码到每个 script 后面 --> <html> <head></head> <body> <script src="https://cdn-error.com/vue-swiper.js" onerror="javascript:__cdnRetrySDK__.retryJS(this, true)"></script> </body> </html>vue-cli 5+上默认引入 webpack 5+ 和 html-webpack-plugin 5+,脚本默认使用defer方式引入,此时无法阻塞js按顺序执行,**插件会自动将 defer 模式降级成 blocking(常规加载方式)**vue 3+上默认使用ESM引入JS,重试脚本也会以module script 方式插入
重写 webpack4 动态模块加载函数(插件自动)
重写 webpack mainTemplate 部分
重写 vite preload 部分
// 当配置了 dynamic-js 后,会重写 __webpack_require__.e 函数,仅支持 webpack4 + webpack5 __webpack_require__.e = function requireEnsureWithCDNRetry(chunkId) { var promises = []; // JSONP chunk loading for javascript var installedChunkData = installedChunks[chunkId]; if(installedChunkData !== 0) { // 0 means "already installed". // a Promise means "currently loading". if(installedChunkData) { promises.push(installedChunkData[2]); } else { ......此处省略代码 script.src = jsonpScriptSrc(chunkId); // create error before stack unwound to get useful stacktrace later var error = new Error(); onScriptComplete = function (event) { ......此处省略代码 }; ......此处省略代码 script.onerror = script.onload = onScriptComplete; // 新增 sdk 容错逻辑 if (window.__cdnRetrySDK__) { script.onerror = function() { script.onerror = null; var retry = window.__cdnRetrySDK__.retryJS(script); retry && retry.then(function() { onScriptComplete({ target: script }); }).catch(function() { onScriptComplete({ target: script }); }); }; }; document.head.appendChild(script); } } return Promise.all(promises); };
# 逻辑执行
主动监听
初始化后会执行全局监听代码
// 全局监听error事件(捕获阶段) window[__on]('error', (err) => { const target = err && err.target; const tagName = __getTagName(target); // 判断是否可重试加载 if(__canRetry(target)) { // 上报资源error __reportResourceError(err) if (tagName === 'script') { __errorScripts.push(target.src) // js为保证执行顺序,不立即处理 } else if (tagName === 'link') { this.retryCSS(target) } else if (tagName === 'img') { this.retryImg(target) } else if (tagName === 'audio') { this.retryAudio(target) } else if (tagName === 'video') { this.retryVideo(target) } } })主动重试
- JS资源
- 加载方式
- 同步脚本 document script:监听onerror
- 延迟脚本 defer script:改为同步脚本方式
- 异步脚本 async script: 不考虑不处理
- 动态脚本 webpack dynamic script:重写 webpack chunk loader
- ESM脚本 module script:监听 onerror
- 加载过程
- document loading 阶段:document.write,此方式风险较高需要手动控制写入
- document interactive 阶段:appendChild
- document complete 阶段:appendChild
- 重试队列
- 根据备用 domain 配置,会创建一个重试队列,成功则返回,失败则进行下一次重试
- 加载方式
/** * 重试加载 js * @param {*} target * @returns */ retryJS(target, isDocWrite) { const src = target.src const newUrls = __getBackupUrls(src) if (!newUrls) return return __retryQueue(newUrls, (newSrc) => { return new Promise((resolve, reject) => { const id = `retry_js_${__retryJSIndex++}` const isESM = target.type === 'module' const isDefer = target.hasAttribute && target.hasAttribute('defer') if (isDocWrite) { document.write(`<script id="${id}" src="${newSrc}" retry><\/script>`) const script = document.getElementById(id) script && __listenRetry(script, src, newSrc, resolve, reject) } else { const script = document.createElement('script') script.id = id isESM && (script.type = 'module') isDefer && script.setAttribute('defer', '') __resetSrc(script, src, newSrc, resolve, reject) __appendElem(script, target) } }) }) }- CSS 资源
- css 同js一致,重试会新建一个link style标签
- 为保证css解析执行顺序,新的CSS会插入到原来的老的css后面
- CSS Background
- 由于css background 不会触发error,此处解决方案为每当重试css插入时,会检查css rules中的backgrouindImage,将旧的替换成新的域名
/** * 处理 css background * @param {*} newSrc */ const __processStyles = (src, newSrc) => { const styleSheets = document.styleSheets for (let i in styleSheets) { const style = styleSheets[i] // 仅处理当前加载的 css if (style.href === newSrc) { const rules = style.cssRules for (let j in rules) { const rule = rules[j] const styleBackgroundImage = rule.style && rule.style.backgroundImage if (styleBackgroundImage && /^url\(/i.test(styleBackgroundImage)) { const host = __getHost(src) const newHost = __getHost(newSrc) const regex = new RegExp(host, 'g') // 替换 css 中的异常域名为新域名 rule.style.backgroundImage = styleBackgroundImage.replace(regex, newHost) } } } } }- 非 JS、CSS 资源
- 如果用户配置了该资源类型需要异常重试,会自动重置 src / href 触发 retry
- audio、video 通过 canplay 事件来回调,其他元素则通过 load 事件
- JS资源
# 更新和缓存备用域名规则
- 拉接口
- 入参:hosts 当前域名列表,即页面中可能出现的 cdn 域名
- 出参:备用域名规则,此规则是动态更新的
- 频率:每日更新,首次初始化立即更新
- 存储:数据返回后存储到 localStorage
- 读配置
- 页面打开时会初始化 SDK
- SDK 会从 localStorage 读取域名替换规则
- 配置基于页面 HOST 共享,本次写入、下次生效
- 域名替换
- 基于域名替换规则,支持多级重试
- 域名预埋
- 域名是动态的,且更新频率不高,暂不考虑预埋