女王控的博客

缓存报错重试机制探究

背景

在线上部署期间,或者用户长时间没有访问网页等等各种情况,有一定概率会出现以下形式的报错,导致网页白屏

bash 复制代码
Uncaught ChunkLoadError: Loading chunk <CHUNK_NAME> failed.
(error: <WEBSITE_PATH>/<CHUNK_NAME>-<CHUNK_HASH>.js)

原因与方案

由于现代前端工具链打包出来的,尤其是 webpack 的项目,其主入口默认不缓存,其他文件长期缓存,缓存的文件通过改变文件名(一般是 hash)来更新

所以在部署期间或者用户没有长时间打开网页,可能会请求已被删除的对应的 chunk 或者 chunk 虽然路径,文件名没变,但是内容改变导致有概率的白屏,所以针对此情况,有以下方案

  1. 缓存所有版本的文件,但会造成空间浪费
  2. 对打包出来的文件都不做缓存,但对服务器有很大压力
  3. 可以通过一个 websocket 链接或者轮询来主动告知浏览器更新版本,但此方案过于繁重且需要后端支持
  4. 对报错的文件重试,超过一定次数白屏,或者主动告知用户刷新页面

综上,采用方案 4

技术选型

对于方案 4,同样有 2 大类,编译时或运行时,选型对比如下

类型 序号 选型 优点 缺点
运行时 1 assets-retry 以及 原理 接入简单 重试的元素种类太多,对于白屏问题,只需要保证 js 需要重试
2 重写 react lazy 方法,对报错进行重试 实现简单
  1. 不能监听到主入口的报错,需要用 window.error 补齐
  2. 需要改动原始代码
  3. 由于 webpack 的 chunk 加载机制,失败的 chunk 不能重试,原理见上面
编译时 3 webpack-retry-chunk-load-plugin 接入简单,很方便的实现 chunk 重试 不能让主入口报错重试
4 webpack-retry-chunk-load-plugin 以及 html-tag-attributes-plugin 接入简单 需要自己实现主入口报错重试逻辑

其他参考链接如下:

  1. handle-loading-errors-fallback-with-htmlwebpackplugin
  2. 如何解决 JS 脚本加载失败的问题
  3. webpack-code-splitting-chunkloaderror-loading-chunk-x-failed-but-the-chunk-e

最终采用选型 4

实现过程

预研过程包含选型 2 与 4,注意这里的重试需要有间隔时间以及需要带额外的 ? 参数防止缓存生效

重写 react lazy 方法

使用时可以直接替代 react 的 lazy 方法,但由于上面提到的缺点不采用此方案

importRetry.js

js 复制代码
import * as React from 'react';

const noop = () => {};
const strategies = {
   PARSE_ERROR_MESSAGE: (error, _) => {
      const url = new URL(error.request);
      return url.href;
   }
};

const defaultOpts = {
   strategy: 'PARSE_ERROR_MESSAGE',
   importFunction: (path) => import(/* @vite-ignore */ path),
   logger: noop
};

/**
 * Future improvements:
 * - cache successful variations to skip unnecessary lag on subsequent reloads
 */
// https://github.com/fatso83/retry-dynamic-import/blob/main/lib/retry.ts
function createDynamicImportWithRetry(maxRetries, opts = {}) {
   const resolvedOpts = {
      ...defaultOpts,
      ...opts
   };
   const { logger, importFunction, strategy } = resolvedOpts;

   return async (importer) => {
      try {
         return await importer();
      } catch (error) {
         logger(Date.now(), `Importing failed: `, error);

         const modulePath = strategies[strategy](error, importer);
         logger(`Parsed url using ${strategy}:${modulePath}`);

         if (!modulePath) {
            logger('Unable to determine path to module when trying to reload');
            // nothing we can do ...
            throw error;
         }

         // retry x times with 2 second delay base and backoff factor of 2 (1/2, 1, 2, 4, 8 seconds)
         //
         for (let i = -1; i < maxRetries; i++) {
            // add a timestamp to the url to force a reload the module (and not use the cached version - cache busting)
            let cacheBustedPath = `${modulePath}?t=${+new Date()}`;
            logger(Date.now(), `Trying re-import module using cache busted path: ${cacheBustedPath}`);

            try {
               return await importFunction(cacheBustedPath);
            } catch (e) {
               logger(`Import for ${cacheBustedPath} failed`);
               await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** i));
            }
         }
         throw error;
      }
   };
}

const MAX_RETRY_COUNT = 5;

const defaultDynamicImportWithRetry = createDynamicImportWithRetry(MAX_RETRY_COUNT, {
   logger: console.log
});

export function lazy(importer) {
   return process.env.NODE_ENV === 'development'
      ? React.lazy
      : React.lazy(() => defaultDynamicImportWithRetry(importer));
}

webpack-retry-chunk-load-plugin + html-tag-attributes-plugin

plugins/html-tag-attributes-plugin.js

js 复制代码
const HtmlWebpackPlugin = require('html-webpack-plugin');

// For more information about plugin concepts, see: https://github.com/jantimon/html-webpack-plugin#events
class HtmlTagAttributesPlugin {
   static name = 'HtmlTagAttributesPlugin';

   constructor(options) {
      const defaultOptions = {
         script: {}
      };

      this.options = { ...defaultOptions, ...options };
   }

   apply(compiler) {
      compiler.hooks.compilation.tap(HtmlTagAttributesPlugin.name, (compilation) =>
         this._hookIntoHtmlAlterAssetTags(compilation)
      );
   }

   _hookIntoHtmlAlterAssetTags(compilation) {
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync(HtmlTagAttributesPlugin.name, (data, cb) =>
         cb(null, this._extendScriptTags(data))
      );
   }

   _extendScriptTags(data) {
      data.assetTags.scripts = data.assetTags.scripts.map(({ attributes, ...other }) => ({
         ...other,
         attributes: {
            ...attributes,
            ...this.options.script
         }
      }));

      return data;
   }
}

module.exports = { HtmlTagAttributesPlugin };

config-overrides.js

js 复制代码
const { RetryChunkLoadPlugin } = require('webpack-retry-chunk-load-plugin');
const { HtmlTagAttributesPlugin } = require('./plugins/html-tag-attributes-plugin');

const lastResortScript = "window.alert('Frontend Is Deploying, Please Wait!'); window.location.reload(true);";
const retryDelay = 4000;
const maxRetries = 5;

const isProd = env === 'production';

const localConfig = override(
   isProd &&
      addWebpackPlugin(
         new RetryChunkLoadPlugin({
            // optional stringified function to get the cache busting query string appended to the script src
            // if not set will default to appending the string `?cache-bust=true`
            cacheBust: `function() {
              return Date.now();
            }`,
            // optional value to set the amount of time in milliseconds before trying to load the chunk again. Default is 0
            // if string, value must be code to generate a delay value. Receives retryCount as argument
            // e.g. `function(retryAttempt) { return retryAttempt * 1000 }`
            retryDelay,
            // optional value to set the maximum number of retries to load the chunk. Default is 1
            maxRetries,
            // // optional list of chunks to which retry script should be injected
            // // if not set will add retry script to all chunks that have webpack script loading
            // chunks: ['chunkName'],
            // optional code to be executed in the browser context if after all retries chunk is not loaded.
            // if not set - nothing will happen and error will be returned to the chunk loader.
            lastResortScript
         })
      ),
   isProd &&
      addWebpackPlugin(
         new HtmlTagAttributesPlugin({
            script: {
               onerror: `
                // const isAsyncScript = event.target.defer || event.target.async
                // 暂时不包含对同步脚本的处理
                let retryCount = 0
                const retryFunc = async () => {
                  retryCount++
                  let cacheBustedPath = event.target.src + '?t=' + (+new Date())
                    const script = document.createElement('script')
                    script.src = cacheBustedPath
                    script.onerror = async () => {
                      await new Promise(resolve => setTimeout(resolve, ${retryDelay}))
                      if (retryCount === ${maxRetries}) {
                        ${lastResortScript}
                      } else {
                        retryFunc()
                      }
                    }
                    // webpack nonce for csp
                    const originalNonce = event.target.getAttribute('nonce')
                    if (originalNonce) {
                        script.setAttribute('nonce', originalNonce)
                    }
                    document.getElementsByTagName('head')[0].appendChild(script)
                }

                retryFunc()
              `
            }
         })
      )
);

效果展示

在主入口的 js 以及分包后的 chunk 报错后都会做重试 5 次的操作,超过 5 次弹窗提示用户在部署,点击确定后刷新页面再次重试

2023 12 05 11 29 43

plugin-assets-retry 插件的效果异曲同工

评论

阅读上一篇

深入理解 Python 虚拟机
2024-02-04 10:39:38

阅读下一篇

Python 后端 oom 处理过程
2023-11-30 17:46:51
0%