软件技术学习笔记

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

React微前端实战教程(工程篇)

工程化的内容很多,但是本文只关注与微前端构建相关的内容:统一构建脚本,Webpack按规则导入/导出JS模块符号,生成元信息(入口文件路径、微前端定义信息、GitRevision等)。

虽然Create React App可以构建SPA,但是满足不了我们微前端的构建需求。同时,Create React App没有提供可回调的方式让我们扩展,于是只能新建构建脚本:react-micro-frontend-scripts。该项目脚本内容大部分来源于Create React App,目前只支持TypeScript + ESLint + PostCSS。在实际项目中,可以调用该项目脚本,使用函数回调的方式定制自己的内容。在几个微前端App样例中,也是这种方式使用,有的已添加自身的定制参数。

1 导入/导出JS模块符号

微前端加载到浏览器中,仍是以SPA的方式运行,很多第三方库都不允许加载多份代码;同时,为了减少网络传输、提升用户体验,对公共库我们只加载一份代码。所以,需要让各个微前端在构建时能够按照规则找到对应的符号。

1.1 导出微前端模块符号

所有的微前端,都构建成umd Library,导出符号规则为:比如微前端app-home的package.json name为react-micro-frontend-app-home导出全局变量rmfAppHome。其中,微前端framework在全局变量rmfFramework中还导出第三方库ReactReactDOMRedux等内容。

// webpack.config.js
module.exports = (env) => {
  const libraryName = pkgJson.getLibraryName();

  return {
    // ...
    output: {
      // ...
      library: libraryName,
      libraryTarget: 'umd',
    },
  };
}
// pkgJson.js
function getPkgNamePrefix() {
  return process.env.REACT_MICRO_FRONTEND_PKG_NAME_PREFIX || 'react-micro-frontend';
}

function getReactMicroFrontendShort() {
  return process.env.REACT_MICRO_FRONTEND_SHORT || 'rmf';
}

/**
 * @param {string} [pkgName] Optional pkgName
 */
function getMicroFrontendFolderName(pkgName) {
  const pkgJson = getPkgJson();
  const shortRmf = getReactMicroFrontendShort();
  const names = `${pkgName || pkgJson.name}`.replace(getPkgNamePrefix(), shortRmf).split('/');
  const lastName = names[names.length - 1];
  return paramCase(lastName);
}

/**
 * @param {string} [pkgName] optional pkg name
 */
function getLibraryName(pkgName) {
  return camelCase(getMicroFrontendFolderName(pkgName));
}

1.2 导入JS模块符号

在构建production环境时,使用Webpack配置的externals导入外面模块的符号,微前端framework例外。而本地开发构建development环境时,仍然使用正常的导入,确保开发体验与SPA一样。

// helper.js
const frameworkPkgName = 'react-micro-frontend-framework';

const frameworkVendorExportsDefault = {
  // [module]: Variable
  react: 'React',
  'react-dom': 'ReactDOM',
  'react-router-dom': 'ReactRouterDOM',
  redux: 'Redux',
  'react-redux': 'ReactRedux',

  rxjs: 'RxJS',
  // 'rxjs/ajax': 'RxJSAjax',
  // 'rxjs/fetch': 'RxJSFetch',
  'rxjs/operators': 'RxJSOperators',
  // 'rxjs/testing': 'RxJSTesting',
  // 'rxjs/webSocket': 'RxJSWebSocket',

  'redux-observable': 'ReduxObservable',

  'redux-saga': 'ReduxSaga',
  'redux-saga/effects': 'ReduxSagaEffects',
};

/**
 * @param {*} [frameworkVendorExports]
 */
function getExternalsOptions(frameworkVendorExports) {
  const frameworkVarName = pkgJson.getLibraryName(
    process.env.FRAMEWORK_PKG_NAME || frameworkPkgName,
  );

  return Object.entries(frameworkVendorExports || frameworkVendorExportsDefault).reduce(
    (acc, x) => Object.assign(acc, {
      [x[0]]: {
        commonjs: x[0],
        commonjs2: x[0],
        amd: x[0],
        root: [frameworkVarName, x[1]], // indicates global variable
      },
    }), {
      [frameworkPkgName]: {
        commonjs: frameworkPkgName,
        commonjs2: frameworkPkgName,
        amd: frameworkPkgName,
        root: frameworkVarName,
      },
    },
  );
}
// 各微前端App中的build.js
function build() {
  process.env.PUBLIC_ROOT_URL = '/';

  process.env.SPLIT_CHUNKS = 'false';
  process.env.RUNTIME_CHUNK = 'false';

  scripts.runWebpack(scripts.envProduction, (config) => ({
    ...config,
    externals: scripts.helper.getExternalsOptions(),
  }));
}

2 避免CSS冲突或全局污染

各App使用CSS Modules,只有framework使用sanitize.css修正浏览器默认差异。文件名定义为xxx.module.css或xxx.module.pcss即可。在Webpack配置脚本中,如下配置:

{
    test: cssModuleRegex,
    use: getStyleLoaders({
        importLoaders: 1,
        sourceMap: isEnvProduction && shouldUseSourceMap,
        modules: {
        getLocalIdent: getCSSModuleLocalIdent,
        },
    }),
    },
    {
    test: cssRegex,
    exclude: cssModuleRegex,
    use: getStyleLoaders({
        importLoaders: 1,
        sourceMap: true,
    }),
    sideEffects: true,
},

3 生成微前端元信息

构建时,我们把微前端相关的元信息添加到rmf-manifest.json中,使用ManifestPlugin插件。有了元信息,server-go提供服务时,就可以返回给前端,再由前端框架处理。

// webpack.config.js 片段
generate: (seed, files, entrypoints) => {
  return {
    entrypoints: entrypointFiles,
    files: manifestFiles,
    gitRevision: gitRev, // 代码库中Git修订版本,包含tag或short SHA-1
    libraryExport: libraryName,
    publicPath: publicUrlOrPath,
    serviceName: mainEntryName, // 也是微前端id
    ...pkgJson.getRmfManifest(),
    };
}

微前端APP的package.json样例:

{
  "rmfManifest": {
    "dependencies": [
      "3rd-echarts"
    ],
    "renders": [
      {
        "renderId": "app-example-sub",
        "routePath": "/app-example/echarts",
        "componentKey": "default"
      }
    ]
  }
}

一份完整的rmf-manifest.json(files字段不是微前端关注的内容):

{
  "entrypoints": [
    "/rmf-app-example-echarts/792735ad/app-example-echarts.3c31ae87.css",
    "/rmf-app-example-echarts/792735ad/app-example-echarts.67238f12.js"
  ],
  "files": {
    "app-example-echarts.css": "/rmf-app-example-echarts/792735ad/app-example-echarts.3c31ae87.css",
    "app-example-echarts.js": "/rmf-app-example-echarts/792735ad/app-example-echarts.67238f12.js",
    "app-example-echarts.js.map": "/rmf-app-example-echarts/792735ad/app-example-echarts.67238f12.js.map",
    "app-example-echarts.3c31ae87.css.map": "/rmf-app-example-echarts/792735ad/app-example-echarts.3c31ae87.css.map",
    "index.html": "/rmf-app-example-echarts/792735ad/index.html"
  },
  "gitRevision": {
    "short": "792735ad"
  },
  "libraryExport": "rmfAppExampleEcharts",
  "publicPath": "/rmf-app-example-echarts/792735ad/",
  "serviceName": "app-example-echarts",
  "dependencies": [
    "3rd-echarts"
  ],
  "renders": [
    {
      "renderId": "app-example-sub",
      "routePath": "/app-example/echarts",
      "componentKey": "default"
    }
  ]
}

4 其它杂项

  1. 构建微前端APP时,一般内容不大,使用SPLIT_CHUNKSRUNTIME_CHUNK控制不拆分runtime与chunk。
  2. 本地开发微前端APP时,在scripts/start.js可以把父路由的npm包添加到entry中,方便调试。
  3. 输出的文件名需满足长期缓存策略。JS/CSS每个版本生成到不同的文件夹。图片字体等不经常改变的静态资源可以输出到固定的文件夹,可以重用以前版本的资源。如果选择每个微前端的所有版本都输出到同一个文件夹的缓存重用方案,需要编写部署工具,计算哪些资源是当前的有效合集,才能进行文件增减。
  4. 元信息中包含GitRevision,方便线上版本溯源。优先使用精确匹配的Tag,再使用8字符的short SHA-1。
  5. 各个微前端把npm依赖挪到peerDependenciesdevDependencies,特别是大家都依赖的framework;使用yarn代替npm做包管理工具。避免APP自己引入不同的第三方包版本造成重复引入。