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

重载过程:

 

 

 

 

 

 

 

资源异常

 

触发该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: "u7535u74F6u4E0Au95E8u88C5u6B65u9AA4u8BF4u660E" } }

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

原创文章,作者:速盾高防cdn,如若转载,请注明出处:https://www.sudun.com/ask/49585.html

(0)
速盾高防cdn's avatar速盾高防cdn
上一篇 2024年5月6日 下午6:57
下一篇 2024年5月6日 下午6:58

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注