React微前端实战教程(APP篇)
在设计React微前端方案时,已经考虑了对微前端APP的开发没有入侵。原有SPA的代码要上React微前端,可以做到原有JS代码改动不到100行,唯一的前提条件是已经使用CSS模块化避免全局污染、全局冲突。开发人员在本地开发微前端APP时,仍然有原先SPA般的开发体验。
本文主要从两个样例中讲解:
- react-micro-frontend-app-example,只依赖框架引入的公共库,使用Redux,也使用子APP做子路由。讲解原有APP如何搬迁到微前端。
- react-micro-frontend-app-example-echarts,相比app-example,还引入ECharts。讲解如何把ECharts也当作为一个微前端。当然也可以把ECharts包含在自身内部,但是与别的微前端APP就无法共用一份ECharts JS代码,用户浪费加载时间。
1. App-example
原有SPA搬迁到React微前端的改动主要有前4处:
1.1. Package.json
name
要符合我们的统一规范,方便统一构建脚本识别。rmfManifest
中添加微前端元信息清单。renderId
表示当前微前端APP的渲染位置,routePath
表示要渲染路由(用在Route组件中比较有用),componentKey
表示要使用当前APP注册的哪个React组件去渲染。- 遵守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.js
与scripts/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
组件来加载。主要传入appId
与componentKey
就可以渲染哪个组件了。最简单的用法是:<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的一样,需做如下的适配:
- 在index.html中使用JSONP的方式请求本地提供的微前端元信息。
- 本地提供的微前端元信息中不需要提供入口文件列表,因为本地开发时已经把第三方库与框架都打包到一起了。
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
即可开始本地开发调试,看到如下界面:
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-echarts
、app-example
与app-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
开始本地开发调试,看到如下界面:
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技术并行网络请求,提升用户体验。