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

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

  • 时效性 :当 CDN 出现问题时,SRE 会手动进行 CDN 切换,因为需要人为操作,响应时长就很难保证。另外,切换后故障恢复时间也无法准确保障。
  • 有效性 :切换至备份 CDN 后,备份 CDN 的可用性无法验证,另外因为 Local DNS 缓存,无法解决域名劫持和跨网访问等问题。
  • 精准性 :CDN 的切换都是大范围的变更,无法针对某一区域或者某一项目单独进行。
  • 风险性 :切换至备份 CDN 之后可能会导致回源,流量剧增拖垮源站,从而引发更大的风险。
  • 因此,前端侧需要寻求更好的解决方案,CDN容灾就能很好的解决以上问题,当前端CDN资源发生异常时,我们自动切换到备用CDN或者该用户切回源站。从而、解决由于核心资源异常,给用户带来的不好的用户体验,如白屏等。

    优化由于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> 标签即可

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

  • 解析HTML标签,绑定onerror、onload(用于打点上报成功率等)事件
  • HTML注入资源重载方法的脚本。
  • 对于手动绑定事件及注入重载脚本,较难维护和统一,应该考虑另外的方式自动绑定事件、注入脚本等。

    3.2.2、异步chunk资源

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

    // 源码
    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方法:

     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插件效果:

    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函数:

    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资源

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

  • 用户自己在创建脚本时,添加重载逻辑
  • html中注入全局动态加载函数,覆盖重载逻辑,由业务方进行调用。 此类重载逻辑简单,不做赘述。
  • 以上,对于三种资源加载类型的重载方案,也大概讲述完毕,下面我们看看详细代码设计。

    3.3 详细设计

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

    3.3.1、 webpack插件设计

    同步CDN资源

    主要目标如下:

  • 解析HTML标签,绑定onerror、onload(用于打点上报成功率等)事件
  • HTML注入资源重载方法的脚本。
  • 以上,均会对html进行解析或注入,HtmlWebpackPlugin插件目前提供了一系列钩子API,eg。 alterAssetTagsalterAssetTagGroups,具体可参考官方文档,以下是各API触发时机:

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

    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注入重载脚本

    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逻辑:

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

    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

    /******/ 	__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文件为例:

    (window["webpackJsonp"] = window["webpackJsonp"] || []).push([[27], {
    ...})
    

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

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

     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文件内容进行改写:

    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库进行分析。

     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(),
    

    异步chunk资源重载

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

    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
    

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