软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

React微前端实战教程(长期缓存篇)

在初始构建React微前端时,我是想每个微前端APP版本如何兼容发布到K8s平台上面,像后端一样部署上去就可以路由到新版本,所以,在URL Path上添加Git Revision信息。但是,这样做造成微前端APP新版本发布之后,浏览器无法重用以前版本的任何资源,没有全部遵守“缓存为王”、“客户体验至上”的设计原则。

为了重用以前版本缓存的部分资源(JS/CSS/图片等),构建微前端APP时需固定发布的URL路径,只有文件内容Hash发生变化时才使用新的文件名。我们使用Webpack构建React微前端工程,相关配置项也是在Webpack配置脚本中修改。

文本演示如何尽可能地重用以前APP版本的资源文件。

1. Webpack配置脚本修改

Webpack配置相关内容主要在react-micro-frontend-scripts代码库中。

1.1. output.publicPath 改成固定值

output.publicPath 从原来的/rmf-{appName}/{gitRevision/改为/rmf-{appName}/。为了兼容以前构建脚本的输出路径,我们添加process.env.PUBLIC_DISABLE_REVISION控制是否禁用gitRevision。同时也添加process.env.PUBLIC_UR变量直接控制发布URL。

scripts/internal/getPublicUrlOrPath.js中,getPublicUrlOrPath函数改成如下:

// getPublicUrlOrPath.js
function getPublicUrlOrPath(isEnvDevelopment) {
  if (process.env.PUBLIC_URL || process.env.PUBLIC_UR === '') {
    return getPublicUrl(isEnvDevelopment);
  }

  let publicRootURL = process.env.PUBLIC_ROOT_URL || '';
  const disableRevision = process.env.PUBLIC_DISABLE_REVISION === 'true';

  // ensure last slash exists
  if (publicRootURL) {
    publicRootURL = publicRootURL.endsWith('/') ? publicRootURL : `${publicRootURL}/`;
  }

  if (isEnvDevelopment) {
    return publicRootURL.startsWith('.') ? '/' : (publicRootURL || '/');
  }

  const folderName = pkgJson.getMicroFrontendFolderName();
  // must add the end '/'
  return disableRevision ? `${publicRootURL}${folderName}/` : `${publicRootURL}${folderName}/${getRevisionPath()}/`;
}

1.2. optimization.moduleIds 改为 ‘hashed’

默认情况下,Webpack构建js文件模块时使用数字编号,如果依赖的内容发生改变,会造成多个chunk连锁变更。optimization.moduleIds设置成hash就可以避免该问题。详情见: https://webpack.js.org/guides/caching/

我们的变更:

// webpack.config.js
{
    optimization: {
        // Long-term caching
        moduleIds: isEnvProduction ? 'hashed' : false,
    }
}

1.3. filename、chunkFilename要使用[contenthash]

主要关注两个地方:1、Webpack本身output配置;2、MiniCssExtractPlugin参数选项配置。

Webpack本身output:

// webpack.config.js
{
    output: {
        publicPath: publicUrlOrPath,
        filename: (isEnvProduction && '[name].[contenthash:8].js') || (isEnvDevelopment && '[name].js'),
        chunkFilename: (isEnvProduction && '[name].[contenthash:8].chunk.js') || (isEnvDevelopment && '[name].chunk.js'),
    }
}

MiniCssExtractPlugin参数选项:

// webpack.config.js
isEnvProduction && new MiniCssExtractPlugin({
    // Options similar to the same options in webpackOptions.output
    // both options are optional
    filename: '[name].[contenthash:8].css',
    chunkFilename: '[name].[contenthash:8].chunk.css',
    }),

1.4. 拆分CommonChunk与RuntimeChunk

// webpack.config.js
{
    optimization: {
        splitChunks: shouldSplitChunks && {
            chunks: 'all',
            name: false,
        },
        // Keep the runtime chunk separated to enable long term caching
        runtimeChunk: shouldRuntimeChunk && {
            name: (entrypoint) => `runtime-${entrypoint.name}`,
        },
    }
}

对框架的公共库,我们还做了更详细的配置:

// helper.js
function getSplitChunksOptions() {
  return {
    cacheGroups: {
      'vendor-polyfill': {
        test: /[\\/]node_modules[\\/](core-js|object-assign|promise|raf|regenerator-runtime|whatwg-fetch)[\\/]/,
        name: 'vendor-polyfill',
        chunks: 'all',
      },
      'vendor-react': {
        test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
        name: 'vendor-react',
        chunks: 'all',
      },
      'vendor-redux': {
        test: /[\\/]node_modules[\\/](redux|react-redux|redux-thunk|redux-saga|redux-observable|rxjs)[\\/]/,
        name: 'vendor-redux',
        chunks: 'all',
      },
    },
  };
}

function webpackConfigCallback(config) {
  const newConfig = {
    ...config,
    optimization: {
      ...config.optimization,
      splitChunks: (process.env.SPLIT_CHUNKS !== 'false') && getSplitChunksOptions(),
    },
  };
  return newConfig;
}

2. Server-go修改

react-micro-frontend-server-go做了两处改变:1、在载入微前端元信息文件rmf-manifest.json判断规则放宽;2、载如runtime-framework.xxx.js的目录层级放宽到1~3级。

2.1. rmf-manifest.json文件名放宽

我们使用正则表达式^rmf-manifest([.\-_].+)?\.json$匹配到类似的文件就认为是元信息文件。原因,微前端APP多个版本部署到同一目录时,为了能够在启动时自动加载元信息,只能重命名为不同的文件名称。

// main.go
var manifestFileNameRegexp = regexp.MustCompile(`^rmf-manifest([.\-_].+)?\.json$`)

func matchManifestFileName(fileName string) bool {
    return manifestFileNameRegexp.MatchString(fileName)
}

func walkAppFiles(rootDir string) WalkAppsResult {
    ...
        // Find 'rmf-manifest.json' or 'rmf-manifest.xxx.json'
        if !isDir && matchManifestFileName(name) {
            // fmt.Printf("Find manifest: %+v\n", path)
            result.ManifestFiles = append(result.ManifestFiles, path)
        }
    ...
}

2.2. Runtime读取目录放宽1到3级深度

以前我们使用gitRevision添加到URL路径上面,JS资源文件是在第3级目录下面。现在放宽1、2级,即使所有的微前端APP的所有版本都部署到同一个路径上,也没有问题。

// manifest_cache.go
func readRuntimeContent(baseDir string, entry string) (string, error) {
    entryParts := strings.Split(entry, "/")
    partsLen := len(entryParts)
    var content []byte
    var err error

    readRuntime := func(validPathParts int) bool {
        start := 0

        if partsLen > validPathParts {
            start = partsLen - validPathParts
        }

        parts := append([]string{baseDir}, entryParts[start:partsLen]...)
        filename := path.Join(parts...)

        if exist, _ := pathExists(filename); exist {
            content, err = ioutil.ReadFile(filename)

            if err == nil {
                return true
            }
        }

        return false
    }

    ok := readRuntime(3) || readRuntime(2) || readRuntime(1)

    if !ok {
        log.Printf("[ERROR]  Cannot read runtime content for %s\n", entry)
        return "", fmt.Errorf("Cannot read runtime content")
    }

    return string(content[:]), nil
}

3. Nginx 配置

把JS/CSS等资源的过期时间设为max:

  # Expires map
  map $sent_http_content_type $micro_expires {
    default                    epoch;
    text/html                  epoch;
    text/xml                   epoch;
    text/css                   max;
    application/javascript     max;
    application/font-woff      max;
    application/octet-stream   60d;
    ~font/                     max;
    ~image/                    max;
  }

  server {
    # Cache control
    expires $micro_expires;
  }

4. 样例

我们支持以前带gitRevision路径,也支持当前的同一文件夹路径。以下tree .中没有rmf-manifest.json之类的版本表示已经下线,用户重新浏览时不会请求到。

样例下载,见: https://github.com/kinsprite/react-micro-frontend-server-go/releases/tag/v2.4.0

$ tree .
.
├── rmf-app-example-echarts
│   ├── app-example-echarts.3c31ae87.css
│   ├── app-example-echarts.3c31ae87.css.map
│   ├── app-example-echarts.befbc72a.js
│   ├── app-example-echarts.befbc72a.js.map
│   ├── app-example-echarts.dce2279f.css
│   ├── app-example-echarts.dce2279f.css.map
│   ├── app-example-echarts.df6483af.js
│   ├── app-example-echarts.df6483af.js.map
│   ├── rmf-manifest-v1.2.0.json
│   ├── rmf-manifest-v1.2.1.json
│   └── v1.1.0
│       ├── app-example-echarts.3c31ae87.css
│       ├── app-example-echarts.3c31ae87.css.map
│       ├── app-example-echarts.5ae95fb4.js
│       └── app-example-echarts.5ae95fb4.js.map
├── rmf-app-home
│   ├── 1ab03245
│   │   ├── app-home.740c5d55.js
│   │   ├── app-home.740c5d55.js.map
│   │   ├── app-home.83523b94.css
│   │   ├── app-home.83523b94.css.map
│   │   ├── assets
│   │   │   └── logo.103b5fa1.svg
│   │   └── rmf-manifest.json
│   ├── app-home.3f3c050e.css
│   ├── app-home.3f3c050e.css.map
│   ├── app-home.980dc961.js
│   ├── app-home.980dc961.js.map
│   ├── assets
│   │   └── logo.103b5fa1.svg
│   ├── rmf-manifest.json
│   └── v1.1.0
│       ├── app-home.77799b3a.css
│       ├── app-home.77799b3a.css.map
│       ├── app-home.8a61eaee.js
│       ├── app-home.8a61eaee.js.map
│       └── assets
│           └── logo.103b5fa1.svg
├── rmf-framework
│   ├── framework.383502c7.chunk.css
│   ├── framework.ada5bd48.chunk.js
│   ├── framework.ada5bd48.chunk.js.LICENSE.txt
│   ├── rmf-manifest.json
│   ├── runtime-framework.d8678361.js
│   ├── vendor-react.c027d328.chunk.js
│   ├── vendor-react.c027d328.chunk.js.LICENSE.txt
│   └── vendor-redux.57dd1261.chunk.js
├── rmf-polyfill
│   ├── polyfill.8920a887.js
│   ├── polyfill-ie11.fe6dce5e.js
│   ├── polyfill-ie11.fe6dce5e.js.LICENSE.txt
│   ├── polyfill-ie9.10d924a3.js
│   ├── polyfill-ie9.10d924a3.js.LICENSE.txt
│   └── rmf-manifest.json

浏览器重新打开页面,F12从开发者工具的网络中可以看到JS/CSS的响应头如下:

cache-control: max-age=315360000
expires: Thu, 31 Dec 2037 23:55:55 GMT