前端如何用webpack做好资源的容灾处理?

前言

网页在加载资源时,可能会因为某些未知因素导致资源加载失败或不可访问, 这其中的因素可能就有部分区域的cdn异常、用户环境的网络问题等等。一旦资源加载过程中出现了异常,就会导致用户看到的页面布局混乱、功能异常甚至是直接白屏。而人工去处理切换cdn总是伴随着一定风险,并且关键人员可能无法及时响应,切换后也需要相当长的预热时间才能生效。这就需要我们在前端侧来实现切换CDN域名的工作。

这篇文章就讲讲如何对这些资源异常做一个有效的自动化的容灾处理,做到当资源加载发生异常时如何自动切换至备份CDN域名并且重试加载资源。并带大家开发一个webpack插件。

区分资源

想处理资源,我们就得先搞清楚有哪些形式的资源。前端最主要的资源主要是js、css、image。 image 资源怎么处理大家应该都知道,也比较好控制,只需要监听 onerror 即可,在项目内我们是可控的。 所以我们重点关注剩下两个不太好控制的js, css。 然后我个人按加载形式给他们可以分为:同步的CDN资源、异步的CDN资源、异步的chunk资源。下面我们仔细来说一下。

同步的CDN资源

这个比较好理解,指的的声明式写在HTML文件内,一般写在head/body标签末尾,传统开发方式比较常见,比如jquery, 还有现代项目内的webpack内使用externals暴露出来的改为CDN的资源,从而可以不参与打包。比如处理vue

// webpack.config.js
externals: {
  vue: 'Vue'
}
复制代码

而这时候vue就必须保证在整个项目执行前久加载好vue的js, 否则项目就会因为找不到vue的实例而报错。 这些资源我将其称为同步的CDN资源。

异步的chunk资源

异步的chunk资源很好理解, 现代的spa项目的路由是通过前端实现的,而用户访问时只需要某一路由的所对应数据,如果全部加载那将是极其庞大,所以需要将其进行分割,让用户只加载到当前页面的资源即可。vue项目里我们一般是通过

() => import('./xxx.vue')
复制代码

来配置路由,同样的也可以这样去配置异步组件。 或者是这样动态的引用某个模块:

import('lodash').then(_ => {
    // ... 
})
复制代码

import()函数是webpack提供的, 它会返回一个promise,这就是在告诉webpack这是一个异步的chunk资源。这些资源就会在真正被使用到的时候才被webpack去请求加载过来。所以这也是优化网页加载速度的一种手段。 当然webpack还不光是这些,更可以通过optimization去配置更细节的chunk数量、大小、复用策略,这里就不展开讨论了。

资源形式如下: image.png

那是不是chunk只有js的呢?不是的,当然也会有css的。什么情况下会产生呢? 比如我们使用mini-css-extract-plugin 插件将css从js中抽离,webpack就会生成和js chunk 与之对应的css chunk,css具体是怎么被抽离和加载的如果大家有兴趣以后可以再仔细讲讲。当webpack根据某个id去加载chunk时,它们就会被同时加载:

如下图可以看到加载了两对id为16df319c6ee91441的chunk:

image.png

异步的CDN资源

有异步的 chunk 资源后,为什么还需要异步的CDN资源?这是因为 chunk 资源有一个问题,那就是没办法很好的卸载已加载到的资源。当chunk a被加载后, 又去加载了chunk b此时chunk a的js和css都还在dom里生效着并不会删除。那这样会有什么问题?

举个例子,在某个项目内有这样两个路由/a/b/a/c, 因为某些原因 /a/b 这个路由需要antd.css, 但 /a/c 因为历史原因无法使用 antd.css, 引入antd.css它的全局样式就会破坏原有/a/c,引起布局崩坏。 这时候就算你是只在/a/b中引入antd.css,只要用户的访问路径是 /a/b -> /a/c 的话,布局样式就会崩。

所以这时候就需要脱离webpack自己手动去加载和卸载这份antd.css,这样做还有一个好处,如果一个依赖的库比较大 , 当然这只是其中一个场景。这种情况我们接下来都会称为异步的CDN资源

解决思路

区分了类型,接下来我们一个个来介绍解决方案。

  1. 同步的CDN资源

这种资源一般作为一种前置依赖, 必须在核心逻辑代码执行前成功被加载。如果这种资源加载出了异常,想让他重新加载新的资源,你监听 onerror 然后重新appendChild是不行的,无法保证加载顺序, 这里我们就需要用到document.write 方法。他的特点是在文档流未关闭前,可以对文档留追加字符串。当浏览器一行一行加载HTML内的js,直到某个js失败时, 触发onerror,在onerror事件中立即写入一个该资源的CDN新地址的 <script> 标签即可。

image.png

  1. 异步的chunk资源

这种资源加载是被webpack接管的,原理其实也很简单,对于js资源就是创建了一个<script>去加载的。整个加载逻辑就封装在了 __webpack_require__.e 上。要做到对异步的 chunk 资源的的容灾,那就需要对该方法做一定的改造,要让他能够做到能监测资源的失败并且能够发起重试。

image.png

  1. 异步的CDN资源

这个就很简单了,我们只需要封装一个统一的加载函数,通过通过js创建scriptlink标签然后监听相应事件就可以了。

面临的问题

  1. 那该怎么去管理具体的备用CDN域名呢? 我们需要有一个管理这些资源的地方,尤其是同步的CDN资源是最早就要被加载的,这也就导致了我们不能把维护的代码写在项目内。难道要直接写在HTML里?那不要太难维护吧。

  2. 如何修改__webpack_require__.e

  3. 封装的这些资源的加载逻辑如何被更好复用?

为了解决上述的这些问题,我们就需要来动手开发一个webpack插件。

插件开发

webpack插件开发语法、tapable钩子等细节本文不过多阐述,有兴趣的可以自行搜索了解。这里我分析插件的重点逻辑。

处理同步的CDN资源

首先是要处理 同步的CDN资源

首先我们在HTML内先编写好容灾逻辑

        window.__CDN_RELOAD__ = (function () {
          // 所有备份资源的地址列表
          var cdnAssetsList = [
              // 比如当 cdnjs的地址失败后,会切换至 unpkg的地址, 备份地址可以无限增长
              // 同一资源写在同一数组, 它们会按顺序被注入到 HTML模版上
              [ 'https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.min.js', 'https://www.unpkg.com/[email protected]/dist/vue.min.js' ]
              [ 'https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js', 'https://www.unpkg.com/[email protected]/dist/axios.min.js' ]
          ]
          // 记录cdn重试次数
          var cdnReloadTimesMap = {};
          // 接收两个参数,dom实例 和 cdn资源在 cdnAssetsList 中的索引
          return function (domTarget, cdnIndex) {
            var tagName = domTarget.tagName.toLowerCase()
            var getTimes = cdnReloadTimesMap[cdnIndex] === undefined ? ( cdnReloadTimesMap[cdnIndex] = 0 ) : cdnReloadTimesMap[cdnIndex]
            var useCdnUrl = cdnAssetsList[cdnIndex][getTimes++]
            cdnReloadTimesMap[cdnIndex] = getTimes
            
            // 没有更多的备份地址,只能失败了
            if( !useCdnUrl ) {
              return
            }
            // 构建
            if( tagName === 'script' ) {
              var scriptText = '<scr' + 'ipt type=\"text/javascript\" src=\"' + useCdnUrl + '\" onerror=\"__CDN_RELOAD__(this, ' + cdnIndex + ')\" ></scr' + 'ipt>'
              document.write(scriptText)
            }
            else if( tagName === 'link' ) {
              // css 对加载时机没有要求可以不使用 document.write
              var newLink = domTarget.cloneNode()
              newLink.href = useCdnUrl
              domTarget.parentNode.insertBefore(newLink, domTarget)
            }
          }
        })();
复制代码

使用方式:

<!-- 当 www.xxx.com/vue.min.js 失败后就会调用 __CDN_RELOAD__, 进入上面的js逻辑 -->
<script src="https://www.xxx.com/vue.min.js" onerror="__CDN_RELOAD__(this, 0)" ></script>
复制代码

虽然容灾逻辑有了但得手动维护,这时候就需要webpack把它自动化了。

我们把这段代码写进入webpack插件内部,通过inlineAssets 变量动态生成实际的代码。

// 因为会使用数组第一个地址作为 html模版内的地址, 所以容灾逻辑里不需要第一个地址
const cdnAssetsList = inlineAssets.map(_url => _url.slice(1))
const coreJsContent = `
        window.__CDN_RELOAD__ = (function () {
          var cdnAssetsList = ${JSON.stringify(cdnAssetsList)}
          var cdnReloadTimesMap = {};

          return function (domTarget, cdnIndex) {
            var tagName = domTarget.tagName.toLowerCase()
            var getTimes = cdnReloadTimesMap[cdnIndex] === undefined ? ( cdnReloadTimesMap[cdnIndex] = 0 ) : cdnReloadTimesMap[cdnIndex]
            var useCdnUrl = cdnAssetsList[cdnIndex][getTimes++]
            cdnReloadTimesMap[cdnIndex] = getTimes
            if( !useCdnUrl ) {
              return
            }
            if( tagName === 'script' ) {
              var scriptText = '<scr' + 'ipt type=\"text/javascript\" src=\"' + useCdnUrl + '\" onerror=\"__CDN_RELOAD__(this, ' + cdnIndex + ')\" ></scr' + 'ipt>'
              document.write(scriptText)
            }
            else if( tagName === 'link' ) {
              var newLink = domTarget.cloneNode()
              newLink.href = useCdnUrl
              domTarget.parentNode.insertBefore(newLink, domTarget)
            }
          }
        })();
      `
复制代码

resources哪里来呢,自然是通过插件参数接收进来的

class AssetsReloadWebpackPlugin {
    onstructor({ 
        // ...
        inlineAssets = []
        //...
    }) {
        // ...
        this.inlineAssets = inlineAssets
        // ...
    }
}
复制代码

代码模版是生成了,但按加载形式来说,我们必须把容灾的逻辑放在加载的时机控制放js开始前被加载。这就需要我们去修改 html-webpack-plugin 所暴露的一个 tapable钩子,叫做htmlWebpackPluginAlterAssetTags

compiler.hooks.make.tap( pluginName, ( compilation ) => {
    compilation.hooks.htmlWebpackPluginAlterAssetTags.tap(pluginName, (pluginArgs) => {
    // do someting
    })
 })
复制代码

这个钩子接收一个参数,是所有最后会注入到HTML文件内的标签数据;

它长这样:

我们想注入容灾逻辑就必须修改这个参数, 像这样

compiler.hooks.make.tap( pluginName, ( compilation ) => {
    compilation.hooks.htmlWebpackPluginAlterAssetTags.tap(pluginName, (pluginArgs) => {
        const injectScripts = []
        let inejctTarget = pluginArgs.head

        injectScripts.push({
            tagName: 'script',
            closeTag: true,
            innerHTML: coreJsContent,
            attributes: {
              type: 'text/javascript'
            }
        })
  
        inlineAssets && inlineAssets.forEach((_url, cdnIndex) => {
          // 挨个生成cdn资源 tag
          if( /\.js$/.test(_url[0]) ) {
            injectScripts.push({
              tagName: 'script',
              closeTag: true,
              attributes: {
                type: 'text/javascript',
                src: _url[0],
                // 绑定异常函数 和 资源索引
                onerror: `__CDN_RELOAD__(this, ${cdnIndex})`
              }
            })
          }   
          else if( /\.css$/.test(_url[0]) ) {
            injectScripts.push({
              tagName: "link",
              selfClosingTag: false,
              voidTag: true,
              attributes: {
                href: _url[0],
                rel: "stylesheet",
                onerror: `__CDN_RELOAD__(this, ${cdnIndex})`
              }
            })
          }
        })

        inejctTarget.unshift(...injectScripts)
    })
 })
复制代码

然后我们使用一下插件, 这里我故意弄了几个错误地址方便做测试

// webpack.config.js使用插件
plugins: [
    new AssetsReloadWebpackPlugin({
      inlineAssets: [
        ['https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min1.css', 'https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css'],
        ['https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue2.js', 'https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.js'],
        ['https://cdnjs.cloudflare.com/ajax/libs/vuex/2.5.0/vuex.js', "https://cdnjs.cloudflare.com/ajax/libs/vuex/2.5.0/vuex.js"],
        ['https://cdnjs.cloudflare.com/ajax/libs/vue-router/3.0.7/vue-router.js', "https://cdnjs.cloudflare.com/ajax/libs/vue-router/3.0.7/vue-router.js"]
      ]
    }),
}
复制代码

看看生成效果

image.png

实际加载, 先失败后成功

image.png

这样同步的资源重载就实现了自动基本的容灾。

处理异步的chunk资源

解决了同步的,现在这位更是重量级。

上面说了,想处理chunk资源得修改 __webpack_require__.e, 而想修改该逻辑牵扯到的webpack 钩子就有点多了, 需要用到compilation.mainTemplate上的多个钩子:

  • mainTemplate.hooks.bootstrap 【控制 webpackJsonpCallback 函数的代码片段】
  • mainTemplate.hooks.requireExtensions 【控制在 _webpack_require__ 函数上扩展的 mec等等方法在内的整体代码片段】
  • mainTemplate.hooks.beforeStartup 【控制开始执行模块代码前的代码片段】
  • mainTemplate.hooks.requireEnsure 【控制__webpack_require__.e 执行逻辑的代码片段】

核心代码如下:

   compiler.hooks.compilation.tap(pluginName, compilation => {
      const { Template } = webpack;
      const { mainTemplate } = compilation;

      // 修改启动部分模版
      mainTemplate.hooks.bootstrap.tap(pluginName, source => {
        return Template.asString([
          source,
          // 保存原始 publicPath
          `var originPublicPath;`,
          // 用于生成重试请求时携带的query参数
          `var __webpack_url_format__ = ${this.chunkAssetsReloadQueryValue.toString()}`
        ]);
      });

      // 修改入参
      mainTemplate.hooks.requireExtensions.tap(pluginName, source => {
        // 让 requireEnsure 方法多接收一个重试次数参数
        return source.replace(
          "function requireEnsure(chunkId) {",
          "function requireEnsure(chunkId, times) {"
        );
      });
      
      mainTemplate.hooks.beforeStartup.tap(
        pluginName,
        (source, chunk, hash) => {
          var newRequireEnsure = `
          function newRequireEnsure (chunkId, options) {
            var url = jsonpScriptSrc(chunkId)
            var matched = url.match(/\.([0-9a-z]+)(?:[\?#]|$)/i)
            var type = matched[1] || 'js'
            if (options === undefined) {
              options = {};
            }
            var times = options[type] !== undefined ? ++options[type] : (options[type] = 0);
            // 根据当前重试次数调整 publicPath
            __webpack_require__.p = getPublicPath(times)
             // 第一次请求资源直接发起不用等待
            if( times === 0 ) {
              return __webpack_require__.oldE(chunkId, times).then(function () {}, function (err) {
                console.error(err);
                if (times < ${this.maxChunkAssetsRetries}) { 
                  return newRequireEnsure(chunkId, options);
                }
              })
            } else {
              // 资源重试
              var delayTime = typeof getRetryDelay === 'function' ? getRetryDelay(times) : getRetryDelay              
              return sleep(delayTime).then(function () {
                return __webpack_require__.oldE(chunkId, times).then(function () {}, function (err) {
                  console.error(err);
                  if (times < ${this.maxChunkAssetsRetries}) {
                    return newRequireEnsure(chunkId, options);
                  }
                })
              })
            }
          }`;
          
          return Template.asString([
            source,
            "__webpack_require__.oldE = __webpack_require__.e;",
            "originPublicPath = __webpack_require__.p",
            `var chunkPublicpath = ${JSON.stringify(this.chunkAssetsPublicpath)};`,
            `var publicPathpathFull = [ originPublicPath ].concat(chunkPublicpath);`,
            `function getPublicPath(times) {
              return publicPathpathFull[ Math.min(publicPathpathFull.length - 1, times) ];
            }`,
            `var sleep = function (delay) {
              return new Promise(function(resolve, reject) {
                setTimeout(resolve, delay)
              })
            }
            `,
            `var getRetryDelay = ${this.chunkAssetsRetryDelay};`,
            // 对原本的__webpack_require__.e 做一层包装
            `__webpack_require__.e = ${newRequireEnsure}`
          ]);
        }
      )

      // 修改加载函数 __webpack_require__.e 内部
      mainTemplate.hooks.requireEnsure.tap(
        pluginName,
        (source, chunk, hash) => {
          const cssHackReplace = "linkTag.href = fullhref;";
          source = source.replace(
            cssHackReplace,
            Template.asString([
              `linkTag.href = __webpack_url_format__(fullhref, times)`,
            ])
          );
          const jsHackReplace = "script.src = jsonpScriptSrc(chunkId);";
          source = source.replace(
            jsHackReplace,
            Template.asString([
              `var newSrc = jsonpScriptSrc(chunkId);`,
              `script.src = __webpack_url_format__(newSrc, times)`
            ])
          );
          return source;
        }
      );
    })
复制代码

上面这么多代码看的肯定很懵,其实就是做了这样一件事,将原本用于加载资源的 __webpack_require__.e 的方法先保存在__webpack_require__.oldE,我们自己外层包装一个能够记录每个资源请求次数的函数也就是newRequireEnsure, 当资源尝试请求前,会先修改 __webpack_require__.p ,也就是publicPath,能够做到根据重试次数调整publicPath。发起请求时实际的请求还是调用原本webpack自身的请求逻辑,也就是__webpack_require__.oldE方法, 并监听异常事件,非该资源的第一次请求进入时, 会调用sleep方法进行延迟,为了更可配置化所以具体的延迟时间通过参数传入。 最后就是修改 __webpack_require__.e 原始方法的内部代码片段,将script.srclinkTag.href 使用的资源路径做一层处理,主要是为了携带query参数(方便收集数据,也为了刷新缓存)。

实际效果如下:

处理异步的CDN资源

这种资源的处理就比较简单了, 我们只需要封装一个统一的加载函数,只不过为了方便管理我们同样需要交给webpack。 原理就是通过webpack的参数传入代码片段内, 再利用html-webpack-plugin注入HTML内,将方法挂在window的某个方法上, 思路和上面大同小异, 因为比较冗长所以这里就不贴代码了, 有兴趣的同学可以去插件源码里看。

具体用法呢大概就是这样:

插件配置:

// webpack.config.js使用插件
plugins: [
    new AssetsReloadWebpackPlugin({
    // ...
    dynamicAssets: {
    // 同样支持多个备份地址用于容灾
      animate: [
          'https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css', 
          'https://www.unpkg.com/[email protected]/animate.css'
      ],
    },
}
复制代码

页面内使用,默认挂在$cdn上:

// 挂载资源
window.$cdn.mount('animate', () => {
  console.log('加载成功')
}, (error) => {
  console.log('加载失败')
})

// 资源卸载
window.$cdn.destroy('animate', () => {
  console.log('卸载成功')
}, (error) => {
  console.log('卸载失败')
})
复制代码

总结

通过上述的这些手段,我们就能够做到对前端常见的几种资源的加载做好了有效的容灾手段,并通过webpack提供了一个统一、自动化的管理方式。整个逻辑我已经封装成了webpack插件,地址贴在下方,大家使用过程中如果遇到问题可以提issue我会及时处理,如果你觉得这片文章帮助到了你,希望能给我一个Star~

npm:assets-reload-webpack-plugin

github:nxl3477/assets-reload-webpack-plugin

相关参考

猜你喜欢

转载自juejin.im/post/7083306121733079054