前端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的资源重载方案设计完毕,投入实际项目中,会发现很大效率提高了资源加载成功率。有兴趣的同学,不妨一试。

相关推荐
Pedantic12 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘13 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆13 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师14 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆14 小时前
VSCode自动格式化三要素
前端
爱勇宝15 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen15 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user205855615181317 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode17 小时前
Redis 在生产项目的使用
前端·后端