前端CDN容灾(资源重载)方案

1、背景

在日常对前端核心站点性能分析过程中,不免遇到各种核心静态资源异常导致的白屏异常,目前应该绝大部分产线静态资源均发布到CDN。也就是,我们不免要经常跟各种CDN资源异常打交道,然而分析CDN异常,费时费力,还收效甚微。运维还需要我们提供各种节点信息,当然这些对于内嵌APP站点且用户分布全国的开发来讲,难度不是一星半点。

当然除此之外,SRE同学对于CDN异常也通常无法解决以下几个问题:

  • 时效性:当 CDN 出现问题时,SRE 会手动进行 CDN 切换,因为需要人为操作,响应时长就很难保证。另外,切换后故障恢复时间也无法准确保障。
  • 有效性:切换至备份 CDN 后,备份 CDN 的可用性无法验证,另外因为 Local DNS 缓存,无法解决域名劫持和跨网访问等问题。
  • 精准性:CDN 的切换都是大范围的变更,无法针对某一区域或者某一项目单独进行。
  • 风险性:切换至备份 CDN 之后可能会导致回源,流量剧增拖垮源站,从而引发更大的风险。

因此,前端侧需要寻求更好的解决方案,CDN容灾就能很好的解决以上问题,当前端CDN资源发生异常时,我们自动切换到备用CDN或者该用户切回源站。从而、解决由于核心资源异常,给用户带来的不好的用户体验,如白屏等。

2、目标

优化由于JS、CSS等资源异常,给用户造成的各种体验问题,如样式错乱,加载缓慢,白屏等等。需要做到以下几点:

  • 端侧 CDN 域名自动切换:在 CDN 异常时,端侧第一时间感知并自动切换 CDN 域名进行加载重试,减少对人为操作的依赖。
  • 更精准有效的 CDN 监控:建设更细粒度的 CDN 监控,能够按照项目维度实时监控 CDN 可用性,解决 SRE CDN 监控粒度不足,告警滞后等问题。并根据容灾监控对 CDN 容灾策略实施动态调整,减少 SRE 切换 CDN 的频率
  • 更完整的链路监控:当核心链路阻断时,前端监控平台上报更完整的链路日志,供研发侧分析问题根因

3、方案设计

3.1、资源重载流程图

3.2、资源重载设计概述

要进行资源重载,首先我们要了解在前端站点构建过程中,资源的加载方式,大概可以分为以下几种:

  • 情况1:同步CDN资源,如主文档中加载的vue、vueRouter、vuex等
  • 情况2:异步chunk资源,如路由的懒加载 () => import('./xxx.vue')
  • 情况3:动态加载的CDN资源,如动态创建script脚本,然后插入dom过程

对于以上三种资源加载方式,重载方案都不尽相同。

3.2.1、同步CDN资源

重载过程:

graph LR 资源异常 --> 触发该dom绑定onerror事件 --> 通过document.write追加新dom标签 --> 重载资源

这里我们就需要用到document.write 方法。他的特点是在文档流未关闭前,可以对文档流追加字符串。当浏览器一行一行加载HTML内的js,直到某个js失败时, 触发onerror,在onerror事件中立即写入一个该资源的CDN新地址的 <script> 标签即可

以上,我们可以看出,对与同步资源重载,我们需要做到:

  1. 解析HTML标签,绑定onerror、onload(用于打点上报成功率等)事件
  2. HTML注入资源重载方法的脚本。

对于手动绑定事件及注入重载脚本,较难维护和统一,应该考虑另外的方式自动绑定事件、注入脚本等。

3.2.2、异步chunk资源

webpack中,异步chunk资源加载,编译如下:

js 复制代码
// 源码
{ 
name: 'serviceCertificationList',
path: '/serviceCertificationList',
component: () => import('@/views/serviceCertification/serviceCertificationList.vue')
}

// webpack编译后
{
name: 'serviceCertificationList',
path: '/serviceCertificationList',
component: function component() { return Promise.all(/* import() */[__webpack_require__.e(0), __webpack_require__.e(3), __webpack_require__.e(12)]).then(__webpack_require__.bind(null, "0NEa"))
}

由上可知,对于webpack打包的项目,如果需要对异步chunk资源进行重载,我们需要对__webpack_require__.e方法进行重写,将资源加载失败重试逻辑封装进去。因此,我们需要了解异步chunk资源webpack加载原理。稍后说明。

然而,对于vite打包的项目,对于异步chunk资源是如何进行加载的呢?

我们知道,vite打包项目,构建目标是能支持 原生 ESM 语法的 script 标签原生 ESM 动态导入import.meta 的浏览器。

因此,vite加载chunk资源,是利用的原生esm天然支持的动态import,如果我们需要加入import()失败重载的逻辑,那么必然,我们需要将项目所有动态import的脚步解析出来,然后用我们重载函数给包装下。

所幸,vite模块会默认配置预加载:

vite内置插件vite:build-import-analysis,会对所有动态imports进行解析,然后给动态import拼接上preload方法:

js 复制代码
 const preloadCode = `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = ${preload.toString()}`;
    return {
        name: 'vite:build-import-analysis',
        resolveId(id) {
            if (id === preloadHelperId) {
                return id;
            }
        },
        load(id) {
            if (id === preloadHelperId) {
                return preloadCode;
            }
        },
        async transform(source, importer) {
            // ...提取imports
            // ...遍历imports,拼接preload方法
             for (let index = 0; index < imports.length; index++) {
                            const { s: start, e: end, ss: expStart, se: expEnd, n: specifier, d: dynamicIndex, a: assertIndex } = imports[index];
                            const isDynamicImport = dynamicIndex > -1;
                            // strip import assertions as we can process them ourselves
                            if (!isDynamicImport && assertIndex > -1) {
                                str().remove(end + 1, expEnd);
                            }
                            if (isDynamicImport && insertPreload) {
                                needPreloadHelper = true;
                                str().prependLeft(expStart, `${preloadMethod}(() => `);
                                str().appendRight(expEnd, `,${isModernFlag}?"${preloadMarker}":void 0${optimizeModulePreloadRelativePaths || customModulePreloadPaths
                                    ? ',import.meta.url'
                                    : ''})`);
                            }
                            // static import or valid string in dynamic import
                            // If resolvable, let's resolve it
                            // ...格式化import URL

                        // 拼接 preload引入
                        if (needPreloadHelper &&
                            insertPreload &&
                            !source.includes(`const ${preloadMethod} =`)) {
                            str().prepend(`import { ${preloadMethod} } from "${preloadHelperId}";`);
                        }

            }
    }

以上,主要功能就是给所有动态import拼接上preload方法,并在import preload函数时,返回合成好的代码。

对一个vite项目,关闭代码压缩后,我们可以观察最终dist产物,看看vite:build-import-analysis插件效果:

js 复制代码
{
    path: "/battery/stepInstructions",
    name: "stepInstructions",
    component: () => __vitePreload(() => import("./stepInstructions.917cdf0e.js"), true ? ["assets/stepInstructions.917cdf0e.js","assets/stepInstructions.4c659577.css"] : void 0),
    meta: {
      title: "\u7535\u74F6\u4E0A\u95E8\u88C5\u6B65\u9AA4\u8BF4\u660E"
    }
  }

再看看__vitePreload函数:

js 复制代码
const __vitePreload = function preload(baseModule, deps, importerUrl) {
  if (!deps || deps.length === 0) {
    return baseModule();
  }
  const links = document.getElementsByTagName("link");
  return Promise.all(deps.map((dep) => {
    dep = assetsURL(dep);
    if (dep in seen)
      return;
    seen[dep] = true;
    const isCss = dep.endsWith(".css");
    const cssSelector = isCss ? '[rel="stylesheet"]' : "";
    const isBaseRelative = !!importerUrl;
    if (isBaseRelative) {
      for (let i2 = links.length - 1; i2 >= 0; i2--) {
        const link2 = links[i2];
        if (link2.href === dep && (!isCss || link2.rel === "stylesheet")) {
          return;
        }
      }
    } else if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
      return;
    }
    const link = document.createElement("link");
    link.rel = isCss ? "stylesheet" : scriptRel;
    if (!isCss) {
      link.as = "script";
      link.crossOrigin = "";
    }
    link.href = dep;
    document.head.appendChild(link);
    if (isCss) {
      return new Promise((res, rej) => {
        link.addEventListener("load", res);
        link.addEventListener("error", () => rej(new Error(`Unable to preload CSS for ${dep}`)));
      });
    }
  })).then(() => baseModule());
};

由此,我们应该知道,如果对于vite import()资源异常进行重载,我们可以基于__vitePreload进行重写。 至此,webpack异步chunk加载、vite异步chunk加载思路应该有大概雏形。

3.2.3 动态加载的CDN资源

对于此类动态创建脚本添加的异步资源,一般有两种方式进行处理:

  1. 用户自己在创建脚本时,添加重载逻辑
  2. html中注入全局动态加载函数,覆盖重载逻辑,由业务方进行调用。 此类重载逻辑简单,不做赘述。

以上,对于三种资源加载类型的重载方案,也大概讲述完毕,下面我们看看详细代码设计。

3.3 详细设计

资源重载方案,会对webpack、vite等内部方法进行一些重写,因此,我们需要设计相应插件,来涵盖以上功能,而且也相对统一,利于以后维护。

3.3.1、 webpack插件设计

同步CDN资源

主要目标如下:

  1. 解析HTML标签,绑定onerror、onload(用于打点上报成功率等)事件
  2. HTML注入资源重载方法的脚本。

以上,均会对html进行解析或注入,HtmlWebpackPlugin插件目前提供了一系列钩子API,eg。 alterAssetTagsalterAssetTagGroups,具体可参考官方文档,以下是各API触发时机:

webpack插件开发规则,此处不赘述。对于以上两点目标,HtmlWebpackPlugin钩子API都能给我们很好解决:

js 复制代码
const pluginOptions = JSON.stringify(this.options);
let coreJsContent = `(${this.injectScript()})(${pluginOptions})`;
compiler.hooks.make.tapAsync(pluginName, async (compilation, callback) => {
    // 处理html里注入静态资源,添加onerror属性
    HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tap(pluginName, (options) => {
        const { scripts, styles } = options?.assetTags || {};
        if (scripts?.length) {
            scripts.map((js) => {
                !js.attributes.onerror && (js.attributes.onerror = `${this.options.globalReloadName}(this, event)`);
            })
        }
        if (styles?.length) {
            styles.map((css) => {
                !css.attributes.onerror && (css.attributes.onerror = `${this.options.globalReloadName}(this, event)`);
            })
        }
        return options;
    })
    HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tap(pluginName, (options) => {
        const { headTags } = options;
        // 在index.html注入脚本
        headTags.unshift({
            tagName: 'script',
            innerHTML: coreJsContent,
            attributes: {
                type: 'text/javascript'
            },
            voidTag: false
        });
        return options;
    })
    callback();
})

此处,我们通过coreJsContent向html注入重载脚本

js 复制代码
const load = (dom: HTMLElement, url: string, type: 'link' | 'js', retryTimes: number = 0) => {
    if (retryTimes < options.maxRetryTimes) {
        retryTimes++;
        const newUrl = win.__reloadRule__(url);
        if (type === 'link') {
            const newLink: any = dom.cloneNode();
            newLink.href = newUrl;
            newLink.onerror = `${options.globalReloadName}(this, event, ${retryTimes})`;
            newLink.onload = `${options.globalReloadName}(this, event, ${retryTimes})`;
            dom.parentNode?.insertBefore(newLink, dom);
        } else if (type === 'js') {
            var scriptText = '<scr' + 'ipt type=\"text/javascript\" src=\"' + newUrl
                + `\" onload=\"${options.globalReloadName}(this, event ` + ')\"'
                + `\" onerror=\"${options.globalReloadName}(this, event, ` + retryTimes + ')\" ></scr' + 'ipt>';
            document.write(scriptText);
        }
    }
}
win[options.globalReloadName] = (dom: HTMLElement, event: Event, retryTimes: number = 0) => {
    const url = (dom as any).src || (dom as any).href;
    if (event.type === 'load') {
        // 触发重载onload
        win.__report_reload__(url, 'success');
        return;
    }
    if (retryTimes > 0) {
        // 重载失败
        win.__report_reload__(url, 'error');
    }
    const tag = dom.tagName.toLowerCase();
    const type = tag === 'script' ? 'js' : tag === 'link' ? 'link' : '';
    if (type) {
        load(dom, url, type, retryTimes)
    }
}

逻辑较简单,同步CDN资源重载也大体结束了。

异步chunk资源重载

入口文件中比较重要的manifest文件,他包含了webpack模块加载的一些公共函数及维护了chunkid到模块的一些映射。我们需要改写的__webpack_require__.e函数就位于此文件中。

我们先看看() => import('xxx'), __webpack_require__.e逻辑:

我们知道路由文件最终会被编译为如下:

js 复制代码
{
  name: 'serviceCertificationList',
  path: '/serviceCertificationList',
  component: function component() {
    return Promise.all(/* import() */[__webpack_require__.e(0), __webpack_require__.e(1), __webpack_require__.e(4), __webpack_require__.e(17)]).then(__webpack_require__.bind(null, "0NEa")).then(function (m) {
      return m["default"] || m;
    });
  },
  meta: {
    title: '门店服务认证'
  }
}

当vue-router加载路由时,执行component函数,对返回的promise进行后续处理。 下面我们看看__webpack_require.e

js 复制代码
/******/ 	__webpack_require__.e = function requireEnsure(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 {
/******/ 				// setup Promise in chunk cache
/******/ 				var promise = new Promise(function(resolve, reject) {
/******/ 					installedChunkData = installedChunks[chunkId] = [resolve, reject];
/******/ 				});
/******/ 				promises.push(installedChunkData[2] = promise);
/******/
/******/ 				// start chunk loading
/******/ 				var script = document.createElement('script');
/******/ 				var onScriptComplete;
/******/
/******/ 				script.charset = 'utf-8';
/******/ 				script.timeout = 120;
/******/ 				if (__webpack_require__.nc) {
/******/ 					script.setAttribute("nonce", __webpack_require__.nc);
/******/ 				}
/******/ 				script.src = jsonpScriptSrc(chunkId);
/******/ 				if (script.src.indexOf(window.location.origin + '/') !== 0) {
/******/ 					script.crossOrigin = "anonymous";
/******/ 				}
/******/ 				// create error before stack unwound to get useful stacktrace later
/******/ 				var error = new Error();
/******/ 				onScriptComplete = function (event) {
/******/ 					// avoid mem leaks in IE.
/******/ 					script.onerror = script.onload = null;
/******/ 					clearTimeout(timeout);
/******/ 					var chunk = installedChunks[chunkId];
/******/ 					if(chunk !== 0) {
/******/ 						if(chunk) {
/******/ 							var errorType = event && (event.type === 'load' ? 'missing' : event.type);
/******/ 							var realSrc = event && event.target && event.target.src;
/******/ 							error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
/******/ 							error.name = 'ChunkLoadError';
/******/ 							error.type = errorType;
/******/ 							error.request = realSrc;
/******/ 							chunk[1](error);
/******/ 						}
/******/ 						installedChunks[chunkId] = undefined;
/******/ 					}
/******/ 				};
/******/ 				var timeout = setTimeout(function(){
/******/ 					onScriptComplete({ type: 'timeout', target: script });
/******/ 				}, 120000);
/******/ 				script.onerror = script.onload = onScriptComplete;
/******/ 				document.head.appendChild(script);
/******/ 			}
/******/ 		};
/******/ 		return Promise.all(promises);
/******/ 	};

简要概述,上述函数就是对chunkID对应的css, js资源进行动态脚本加载。返回一个promise,脚本加载失败,promise会reject, 加载成功,此处设计很精妙,在成功加载的文件中,会直接resolve掉。 具体细节,大家可以查看加载的文件,以下面27.js文件为例:

js 复制代码
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[27], {
...})

此处调用了window["webpackJsonp"].push方法,在此方法内,会resolve掉__webpack_require.e的promise.

要实现重载,此处我们对__webpack_require.e进行重写:

js 复制代码
 rewriteWepackE() {
        return (webpackRequire: any, options: Options) => {
            const oldWebpackE = webpackRequire.e;
            const oldWebpackP: string = webpackRequire.p;
            const newWepackE = (chunkId: string, retryTimes: number = 0) => {
                let resolveFn: any = null;
                let rejectFn: any = null;
                const win = window as any;
                const defer = new Promise((resolve, reject) => {
                    resolveFn = resolve;
                    rejectFn = reject;
                })
                const result = oldWebpackE(chunkId);
                if (retryTimes < options.maxRetryTimes) {
                    let hasError = false;
                    result.catch((e: any) => {
                        const newWebpackP = win.__reloadRule__(oldWebpackP);
                        webpackRequire.p = newWebpackP;
                        hasError = true;
                        newWepackE(chunkId, ++retryTimes).then(() => {
                            resolveFn();
                            // 重载成功打点
                            win.__report_reload__(`${webpackRequire.p}/${chunkId}`, 'success');
                            webpackRequire.p = oldWebpackP;
                        }, (e) => {
                            rejectFn(e);
                            // 重载失败打点
                            win.__report_reload__(`${webpackRequire.p}/${chunkId}`, 'error');
                            webpackRequire.p = oldWebpackP;
                        });
                    }).then(() => {
                        if (!hasError) {
                            resolveFn()
                        }
                    })
                } else {
                    result.then(() => {
                        resolveFn()
                    }, (e: any) => { rejectFn(e) })
                }
                return defer;
            };
            return newWepackE;
        }
    }

webpack提供了compilation.mainTemplate.hooks,允许我们对manifest文件内容进行改写:

js 复制代码
compiler.hooks.compilation.tap(pluginName, (compilation, callback) => {
    compilation.mainTemplate.hooks.requireExtensions.tap(pluginName, (chunk, name) => {
        const webpackE = this.rewriteWepackE();
        const code = `__webpack_require__.e = (${webpackE})(__webpack_require__, ${pluginOptions})`;
        return `${chunk};\n${code}`;
    })
    compilation.mainTemplate.hooks.requireEnsure.tap(pluginName, (chunk) => {
        const promiseAll = this.rewritePromiseAll();
        const code = `return (${promiseAll})(promises)`;
        return `${chunk};\n${code}`;
    })
})

由于__webpack_require.e是返回的promise.all,当一个资源异常时就返回异常,此处不符合我们重载逻辑,需要改写promise.all,当资源加载均异常时,才返回异常,触发重载逻辑。

到此,webpack 异步chunk重载逻辑也解释完毕。

3.3.2、 vite插件设计

同步CDN资源

理念跟webpack一致,只是找对应vite钩子函数,vite对html进行变更及插入脚本,可以利用transformIndexHtml钩子, 此处对html文本解析,用到了cheerio库进行分析。

js 复制代码
 transformIndexHtml(html: string) {
            const pluginOptions = JSON.stringify(realOptions);
            let coreJsContent = `(${injectScript})(${pluginOptions})`;
            const $ = load(html);
            const mapCheerio = (res: Array<any>, fn: any) => {
                for (let i = 0; i < res.length ; i++) {
                    if (res[i]) {
                        fn($(res[i]))
                    }
                }
            }
            const scripts: any = $('head script');
            mapCheerio(scripts, (cheerio: any) => {
                if (cheerio.attr('src')) {
                    cheerio.attr('onerror', `${realOptions.globalReloadName}(this, event)`)
                }
            })
        
            const links: any = $('head link[rel="stylesheet"]');
            mapCheerio(links, (cheerio: any) => {
                if (cheerio.attr('href')) {
                    cheerio.attr('onerror', `${realOptions.globalReloadName}(this, event)`)
                }
            })
        
            const tags = [
                {
                    tag: 'script',
                    attrs: {
                        type: 'text/javascript'
                    },
                    children: coreJsContent,    
                }
            ]
            return {
                html: $.html(),
                tags
            };
        },

异步chunk资源重载

由概述,我们知道,异步chunk资源重载可以基于preload方法,因此,参考webpack 重载逻辑,可在transform钩子中,改变preload内容,返回包含了重载逻辑的内容。

js 复制代码
transform(code: string, id: string) {
    const preloadHelperId = '\0vite/preload-helper';
    const pluginOptions = JSON.stringify(realOptions);
    if (id === preloadHelperId) {
        console.log(code);
        const newPreload = `(${rewritePreload})(assetsURL, seen, scriptRel, ${pluginOptions})`;
        const newCodeArr = code.split('const __vitePreload =');
        const newCode = `${newCodeArr[0]}const __vitePreload =${newPreload}`;
        return newCode
    }
},

4、结语

至此,基于webpack,vite的资源重载方案设计完毕,投入实际项目中,会发现很大效率提高了资源加载成功率。有兴趣的同学,不妨一试。

相关推荐
熊的猫几秒前
DOM 规范 — MutationObserver 接口
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
天农学子几秒前
Easyui ComboBox 数据加载完成之后过滤数据
前端·javascript·easyui
mez_Blog1 分钟前
Vue之插槽(slot)
前端·javascript·vue.js·前端框架·插槽
爱睡D小猪4 分钟前
vue文本高亮处理
前端·javascript·vue.js
开心工作室_kaic7 分钟前
ssm102“魅力”繁峙宣传网站的设计与实现+vue(论文+源码)_kaic
前端·javascript·vue.js
放逐者-保持本心,方可放逐7 分钟前
vue3 中那些常用 靠copy 的内置函数
前端·javascript·vue.js·前端框架
IT古董8 分钟前
【前端】vue 如何完全销毁一个组件
前端·javascript·vue.js
Henry_Wu00110 分钟前
从swagger直接转 vue的api
前端·javascript·vue.js
SameX19 分钟前
初识 HarmonyOS Next 的分布式管理:设备发现与认证
前端·harmonyos
M_emory_1 小时前
解决 git clone 出现:Failed to connect to 127.0.0.1 port 1080: Connection refused 错误
前端·vue.js·git