软件技术学习笔记

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

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

在设计React微前端方案时,已经考虑了对微前端APP的开发没有入侵。原有SPA的代码要上React微前端,可以做到原有JS代码改动不到100行,唯一的前提条件是已经使用CSS模块化避免全局污染、全局冲突。开发人员在本地开发微前端APP时,仍然有原先SPA般的开发体验。

本文主要从两个样例中讲解:

  1. react-micro-frontend-app-example,只依赖框架引入的公共库,使用Redux,也使用子APP做子路由。讲解原有APP如何搬迁到微前端。
  2. react-micro-frontend-app-example-echarts,相比app-example,还引入ECharts。讲解如何把ECharts也当作为一个微前端。当然也可以把ECharts包含在自身内部,但是与别的微前端APP就无法共用一份ECharts JS代码,用户浪费加载时间。

1. App-example

原有SPA搬迁到React微前端的改动主要有前4处:

1.1. Package.json

  1. name要符合我们的统一规范,方便统一构建脚本识别。
  2. rmfManifest中添加微前端元信息清单。renderId表示当前微前端APP的渲染位置,routePath表示要渲染路由(用在Route组件中比较有用),componentKey表示要使用当前APP注册的哪个React组件去渲染。
  3. 遵守React微前端开发规则,把dependencies中的依赖移到peerDependencies与devDependencies中。再新增微前端APP的子APP时就有用,避免子APP本地开发时出现小问题,生产环境构建时不会出现问题。
{
  "name": "react-micro-frontend-app-example",
  "rmfManifest": {
    "dependencies": [],
    "renders": [
      {"renderId": "root", "routePath": "/app-example", "componentKey": "default"}
    ],
    "extra": {}
  },
  "scripts": {
    "start": "node scripts/start.js",
    "build": "node scripts/build.js",
    "lint": "yarn run eslint \"./**/*.@(tsx|ts|jsx|js)\""
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "dependencies": {},
  "peerDependencies": {
    "@types/jest": "^26.0.0",
    "@types/node": "^12.0.0",
    "@types/react": "^16.9.38",
    "@types/react-dom": "^16.9.8",
    "@types/react-redux": "^7.1.9",
    "@types/react-router-dom": "^5.1.5",
    "react": "^16.13.1",
    "react-app-polyfill": "^1.0.6",
    "react-dom": "^16.13.1",
    "react-micro-frontend-framework": "^2.3.0",
    "react-redux": "^7.2.0",
    "react-router-dom": "^5.2.0",
    "redux": "^4.0.5",
    "redux-observable": "^1.2.0",
    "redux-saga": "^1.1.3",
    "redux-thunk": "^2.3.0",
    "rxjs": "^6.5.5",
    "sanitize.css": "^11.0.1"
  },
  "devDependencies": {
    "@types/jest": "^26.0.0",
    "@types/node": "^12.0.0",
    "@types/react": "^16.9.38",
    "@types/react-dom": "^16.9.8",
    "@types/react-redux": "^7.1.9",
    "@types/react-router-dom": "^5.1.5",
    "react": "^16.13.1",
    "react-app-polyfill": "^1.0.6",
    "react-dom": "^16.13.1",
    "react-micro-frontend-framework": "^2.3.0",
    "react-micro-frontend-scripts": "^2.5.0",
    "react-redux": "^7.2.0",
    "react-router-dom": "^5.2.0",
    "redux": "^4.0.5",
    "redux-observable": "^1.2.0",
    "redux-saga": "^1.1.3",
    "redux-thunk": "^2.3.0",
    "rxjs": "^6.5.5",
    "sanitize.css": "^11.0.1"
  }
}

1.2. 构建脚本

新增scripts/build.jsscripts/start.js两个构建脚本。随着业务的复杂,依赖的内容可能会改变,把构建的启动脚本放到微前端APP内部,扩展性比较强。

我们的构建脚本都是差不多的,复制一份过来再定制即可。PUBLIC_DISABLE_REVISION表示不添加Git修订版本Tag或Hash到URL中;ENSURE_NO_EXPORTS表示当前APP我们保证不导出任何内容,避免构建之后多出一个全局变量。SPLIT_CHUNKS = 'false'RUNTIME_CHUNK = 'false'表示比Framework多引入的依赖包不独立拆分,微前端APP内容比较小的时候不需要拆分成多个网络请求。

// scripts/build.js
const scripts = require('react-micro-frontend-scripts');

function build() {
  // --- ENV for 'production' only ---
  process.env.PUBLIC_DISABLE_REVISION = 'true';
  process.env.PUBLIC_ROOT_URL = '/';
  // process.env.PUBLIC_ROOT_URL = '/rmf-app-example/';

  // --- ENV for ALL ---
  process.env.ENSURE_NO_EXPORTS = 'true';
  process.env.SPLIT_CHUNKS = 'false';
  process.env.RUNTIME_CHUNK = 'false';

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

build();
// scripts/start.js
const scripts = require('react-micro-frontend-scripts');

function start() {
  // --- ENV for ALL ---
  process.env.ENSURE_NO_EXPORTS = 'true';

  scripts.runWebpack(scripts.envDevelopment, scripts.helper.webpackConfigCallback);
}

start();

1.3. App注册

在APP的主入口文件src/index.ts中,完成APP的注册,就一个函数registerApp()。这也是实际JS代码中唯一有变更的地方。

代码中注册APP的核心是React组件注册,确保框架或其它APP能够找到跨APP的组件。components字段支持多个组件,一个APP可以注册多个组件用在不同的地方。

在这个例子中,我们使用框架提供的全局Redux Store,在注册APP时把相关的reducer、saga或epic也注册了。不需要跨APP共享数据或通信时,就不需要使用全局Redux Store,只需在当前APP内部自己新建Redux Store,做到APP之间数据隔离。

从这个例子中,我们可以发现:即使import框架已经包含的第三方库,APP中的代码仍像原先SPA一样import,没有代码入侵。统一构建脚本帮我们完成了“JS符号导入/导出”的问题。

// `src/index.ts`
import { combineReducers } from 'redux';
import { combineEpics } from 'redux-observable';
import { all } from 'redux-saga/effects';
import { registerApp } from 'react-micro-frontend-framework';

import ExampleApp from './ExampleApp';

import {
  counterReducer,
  counterSaga,
  counterEpic,
} from './counter';

const appReducers = {
  counter: counterReducer,
};

function* appSagas() {
  yield all([
    counterSaga(),
  ]);
}

const appEpics = [
  counterEpic,
];

registerApp('app-example', {
  components: { default: ExampleApp },
  reducer: combineReducers(appReducers),
  saga: appSagas,
  epic: combineEpics(...appEpics),
});

1.4. CSS模块化

CSS模块化可以避免样式全局冲突、全局污染的问题。在React微前端APP中,我们必需使用import styles from './ExampleApp.module.css';引入CSS Class名称,CSS文件的名称要符合{xxx}.module.css的格式。

/* ExampleApp.module.css */

.container {
  color: #822;
  background-color: burlywood;
  padding: 0.5rem;
}

.linkList {
  position: absolute;
  top: 0;
  right: 1rem;
}
// ExampleApp.tsx
import styles from './ExampleApp.module.css';

function ExampleApp() : JSX.Element {
  return (
    <div className={styles.container}>
      <a
        href="/"
        rel="noopener noreferrer"
      >
        Goto Home
      </a>
      <div>This is App Example.</div>
      <ul className={styles.linkList}>
        <li>
          <Link to="/home">Home</Link>
        </li>
        <li>
          <Link to="/app-example">App Example</Link>
        </li>
        <li>
          <Link to="/app-example/sub">
            {'App Example\'s Sub'}
          </Link>
        </li>
        <li>
          <Link to="/app-example/echarts">Echarts from CDN</Link>
        </li>
      </ul>
      <CounterContainer />
      <SubAppRouters />
    </div>
  );
}

1.5. 使用其它APP中定义的组件

使用其它APP组件的方法,还是需要框架提供的AsyncApp组件来加载。主要传入appIdcomponentKey就可以渲染哪个组件了。最简单的用法是:<AsyncApp appId="app-example-sub" componentKey="default" />

// ExampleApp.tsx

import { AsyncApp, getRegister } from 'react-micro-frontend-framework';

function SubAppRouters() {
  const renderItems = getRegister().filterRenderItems('app-example-sub');

  return (
    <Switch>
      {renderItems.map((item) => (
        <Route path={item.render.routePath} key={item.render.routePath}>
          <AsyncApp
            appId={item.app.id}
            renderId={item.render.renderId}
            routePath={item.render.routePath}
            componentKey={item.render.componentKey}
            disableRedirect
          />
        </Route>
      ))}
    </Switch>
  );
}

1.6. 提升本地开发体验

为了确保本地开发体验如同原有SPA的一样,需做如下的适配:

  1. 在index.html中使用JSONP的方式请求本地提供的微前端元信息。
  2. 本地提供的微前端元信息中不需要提供入口文件列表,因为本地开发时已经把第三方库与框架都打包到一起了。

public/index.html的body部分:

  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <script>
      var rmfMetadataJSONP = {apps:[], extra: {}};
      function rmfMetadataCallback(data) { rmfMetadataJSONP = data }
    </script>
    <script src="/api/metadata/info?callback=rmfMetadataCallback"></script>
  </body>

public/api/metadata/info的内容:

/* eslint-disable */
"use strict";

(function(callback) {
  var data = {
    apps: [
      {
        id: 'app-example',
        dependencies: [],
        entries: [],
        renders: [{renderId: 'root', routePath: '/app-example', componentKey: 'default'}],
      },
    ],
    extra: {
      defaultRoute: '/app-example',
    }
  };

  if (callback) callback(data);
})(rmfMetadataCallback);

执行yarn start即可开始本地开发调试,看到如下界面:

Example App本地开发

2. App-example-echarts

App-example-echarts也是一个微前端APP,我们重点关注于App-example有差异地方。

2.1. Package.json

元信息新增对"3rd-echarts"微前端的依赖;引入"echarts""react-micro-frontend-app-example"包,其中的app-example只是为了我们本地开发调试方便看到页面全貌。也可以不引入"react-micro-frontend-app-example",只需在本地开发调试时把当前APP的组件渲染到root节点上。

相对App-example,package.json有如下的改动:

{
  "name": "react-micro-frontend-app-example-echarts",
  "rmfManifest": {
    "dependencies": [
      "3rd-echarts"
    ],
    "renders": [
      {
        "renderId": "app-example-sub",
        "routePath": "/app-example/echarts",
        "componentKey": "default"
      }
    ],
    "extra": {}
  },
  "peerDependencies": {
    "@types/echarts": "^4.6.3",
    "echarts": "^4.8.0"
  },
  "devDependencies": {
    "@types/echarts": "^4.6.3",
    "echarts": "^4.8.0",
    "react-micro-frontend-app-example": "^1.1.0"
  }
}

2.2. 构建脚本

build.js脚本新增:把echarts定义为外部依赖。因为echarts从CDN引入,导出在全局变量echarts上面。

// scripts/build.js
const scripts = require('react-micro-frontend-scripts');

function build() {
  // --- ENV for 'production' only ---
  process.env.PUBLIC_DISABLE_REVISION = 'true';
  process.env.PUBLIC_ROOT_URL = '/';

  // --- ENV for ALL ---
  process.env.ENSURE_NO_EXPORTS = 'true';
  process.env.SPLIT_CHUNKS = 'false';
  process.env.RUNTIME_CHUNK = 'false';

  scripts.runWebpack(scripts.envProduction, (config) => ({
    ...config,
    externals: {
      ...scripts.helper.getExternalsOptions(),
      echarts: {
        commonjs: 'echarts',
        commonjs2: 'echarts',
        amd: 'echarts',
        root: 'echarts',
      },
    },
  }));
}

build();

start.js脚本新增:引入'react-micro-frontend-app-example'包。只在本地开发时使用,不需要在实际代码中import。

// scripts/start.js
const scripts = require('react-micro-frontend-scripts');

function start() {
  // --- ENV for ALL ---
  process.env.ENSURE_NO_EXPORTS = 'true';

  scripts.runWebpack(scripts.envDevelopment, (config) => {
    const newConfig = scripts.helper.webpackConfigCallback(config);
    const key = Object.keys(newConfig.entry)[0];
    // Include parent route's project to develop easy
    newConfig.entry[key] = ['react-micro-frontend-app-example', newConfig.entry[key]];
    return newConfig;
  });
}

start();

2.3. App注册

没有使用全局Redux Store,只需要注册React组件。

// src/index.ts
import { registerApp } from 'react-micro-frontend-framework';

import ExampleEcharts from './ExampleEcharts';

registerApp('app-example-echarts', {
  components: { default: ExampleEcharts },
});

2.4. 代码中使用ECharts

我们像原先SPA一样使用Echarts,代码中无感知微前端的存在。

// EChartComponent.tsx
import React, { useState, useEffect, useCallback } from 'react';
import * as echarts from 'echarts';

interface EChartComponentProps {
  option: echarts.EChartOption;
  className?: string;
}

function EChartComponent({ className, option } : EChartComponentProps) : JSX.Element {
  const [chartInst, setChartInst] = useState(null as echarts.ECharts);

  // Init instance
  const echartRef = useCallback((node) => {
    if (node) {
      const inst = echarts.init(node);
      setChartInst(inst);
    }
  }, []);

  // Set option
  useEffect(() => {
    if (chartInst && option) {
      chartInst.setOption(option);
    }
  }, [chartInst, option]);

  // Clean up
  useEffect(() => () => {
    if (chartInst) {
      echarts.dispose(chartInst);
      setChartInst(null);
    }
  }, [chartInst]);

  return (
    <div className={className} ref={echartRef} />
  );
}

export default EChartComponent;

2.5. 提升本地开发体验

public/index.html与Example-app的一样。

public/api/metadata/info的内容需包含3rd-echartsapp-exampleapp-example-echarts

// file: public/api/metadata/info
"use strict";

(function(callback) {
  var data = {
    apps: [
      {
        id: '3rd-echarts',
        dependencies: [],
        entries: [
          // We use npm's echarts in development
          // "https://cdn.bootcdn.net/ajax/libs/echarts/4.8.0/echarts.min.js",
        ],
        renders: [],
      },
      {
        id: 'app-example',
        dependencies: [],
        entries: [],
        renders: [{renderId: 'root', routePath: '/app-example', componentKey: 'default'}],
      },
      {
        id: 'app-example-echarts',
        dependencies: ['3rd-echarts'],
        entries: [],
        renders: [
          {renderId: 'app-example-sub', routePath: '/app-example/echarts', componentKey: 'default'},
          // {renderId: 'root', routePath: '/echarts-at-root', componentKey: 'default'}
        ],
      },
    ],
    extra: {
      defaultRoute: '/app-example/echarts',
    }
  };

  if (callback) callback(data);
})(rmfMetadataCallback);

执行yarn start开始本地开发调试,看到如下界面:

Example App ECharts本地开发

2.6. 部署时定义ECharts元信息

ECharts的微前端元信息需要单独定义。线上rmf-3rd-echarts/rmf-manifest-4.8.0.json的内容如下:

{
  "entrypoints": [
    "https://cdn.bootcdn.net/ajax/libs/echarts/4.8.0/echarts.min.js"
  ],
  "files": {
  },
  "gitRevision": {
    "tag": "4.8.0"
  },
  "libraryExport": "echarts",
  "publicPath": "https://cdn.bootcdn.net/ajax/libs/echarts/4.8.0/",
  "serviceName": "3rd-echarts",
  "dependencies": [],
  "renders": [
  ],
  "extra": {}
}

线上时,微前端框架先加载3rd-echarts,再加载app-example-echarts,当然已使用preload技术并行网络请求,提升用户体验。