React微前端实战教程(框架篇)
大系统拆分成微前端之后,为了提升用户体验,都是按需延时加载相关的CSS/JS。微前端框架react-micro-frontend-framework的职责:
- 提供微前端APP注册接口。有了注册接口才能让框架反向找到微前端APP的React组件。
- 作为整个前端的底座,负责启动React框架、渲染root节点,提供按需渲染某个微前端APP的接口——React中以组件的方式。
- 找到相关微前端的资源位置,按照依赖次序加载CSS/JS。
- 提供多个微前端之间的通信机制——全局Redux Store。
- 提供前端公共库服务——符号导出。
选择React做微前端框架的其中一个原因是:React Router支持动态路由,都是在渲染每层路由组件时判断加载什么内容。而别的路由框架就不是这个样子,有的是静态配置路由,如果它支持Middleware或者Hook机制,还能玩微前端,否则就得重新开发一个路由框架。所以,统一技术栈选择React
+ React Router
,让微前端更加容易落地。
为了方便继续阅读,我们先对齐概念:在前端浏览器中跑的微前端,除了框架(framework),其它的我都称之为微前端APP或APP或SubApp。元信息、polyfill与framework是用户开始浏览时就被后端(比如我们的server-go)直接塞到返回的html文档中,它们不需要动态加载。
1 APP注册接口
注册接口分为两个:
registerFromMetadata()
, 在框架启动时,根据后端提供的Metadata进行注册。调用见:getRegister().registerFromMetadata(Util.getMetadataApps());
。该部分主要定义每个微前端APP的资源位置、微前端APP之间的依赖关系(一般是依赖第三方库,我们把第三方库也定义为微前端)。registerApp()
,在延时加载的微前端APP JS解析之后,在其JS文件中调用。
1.1 registerFromMetadata函数
APP注册信息AppRegisterInfo
主要记录后端返回的元信息、APP加载状态、APP注册的内部组件,如下:
// register.ts
export interface AppInfo {
// Components map for rendering, such as: { default: MyComponent }
components?: {
[key: string]: React.Component | React.FC;
};
/* Global redux store here, but recommend to use isolated store in every app */
reducer?: Reducer;
// Saga end Epic, run in registering only, not be saved in register
saga?: Saga;
sagaArgs?: Array<any>;
epic?: Epic;
}
interface AppRegisterInfo extends AppInfo {
id: string; // as 'serviceName' in manifest
dependencies: string[]; // dependencies ids
entries: string[]; // css/js entries files
renders: MetadataRender[];
promiseLoading?: Promise<boolean>;
loadState?: AppLoadState,
}
class AppRegister {
// appId to AppRegisterInfo
apps: {[id: string]: AppRegisterInfo} = {}
// use in framework to init apps info, or append apps which not be rendered on 'root' later
registerFromMetadata(apps: AppRegisterInfo[]) {
apps.forEach((app) => {
this.apps[app.id] = {
components: {},
...app,
promiseLoading: null,
loadState: AppLoadState.Init,
};
});
}
}
1.2 registerApp函数
在其它微前端APP内部调用的registerApp()
接口实现:
// registerApp.ts
function registerApp(id: string, appInfo?: AppInfo): RegisterResult {
const ok = getRegister().registerFromSubApp(id, {
...appInfo,
saga: null,
sagaArgs: null,
epic: null,
});
if (appInfo.reducer) {
// *** 这里比较特殊: 每次注册一个新APP的全局reducer,需要重新combine
combineReducersAndReplace();
}
if (appInfo.epic) {
getStore().runEpic(appInfo.epic);
}
return {
ok,
sagaTask: appInfo.saga ? getStore().runSaga(appInfo.saga, ...(appInfo.sagaArgs || [])) : null,
};
}
2 渲染底座
渲染底座分为两部分:
- 渲染root节点。微前端框架只提供一个白板,具体在什么路由显示什么APP,由APP的元信息定义。用户的默认路由也是在后端配置定义。
- 延时渲染微前端APP组件。只有动态加载APP入口的CSS/JS之后,我们才能渲染非框架中的React组件。
2.1 渲染Root节点
与渲染相关的元信息定义如下(可在rmf-manifest.json
或APP的package.json
中看到):
{
"renders": [
{
"renderId": "app-example-sub",
"routePath": "/app-example/sub",
"componentKey": "default"
},
{
"renderId": "root",
"routePath": "/sub-at-root",
"componentKey": "default"
}
]
}
Root节点下面的路由渲染:
// render.tsx
function RouterBase() {
const renderItems = getRegister().filterRenderItems('root');
return (
<Router>
<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}
/>
</Route>
))}
<Route path="*">
{ RedirectToDefaultRoute() }
</Route>
)
</Switch>
</Router>
);
}
export default function render(element: Element) : void {
const store = getStore();
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<RouterBase />
</Provider>
</React.StrictMode>,
element,
);
}
渲染root节点:
// index.ts
render(document.getElementById('root'));
2.2 延时渲染微前端APP组件
在上面的Root节点渲染中,我们可以看得它使用AsyncApp
组件去渲染各个微前端APP。延时渲染的核心就在AsyncApp
组件,且这是与React强相关的实现。
关注点:
- 只要底层确保
register.loadApp(app.id)
可以同时调用多次,就不会出问题。 - 务必等到APP加载成功之后,才能到注册中心取出要渲染的组件:
app.components[componentKey]
. - 每个
AsyncApp
组件实例,只需要动态加载一次,useEffect依赖once
不变变量即可。
// AsyncApp.tsx
interface AsyncAppProps extends MetadataRender {
appId: string;
disableRedirect?: boolean,
redirectOnFail?: string;
[key: string]: any;
}
enum LoadedState {
Init,
OK,
Failed,
}
function AsyncApp(props : AsyncAppProps) : React.ReactElement {
const {
appId, routePath, componentKey, disableRedirect, redirectOnFail,
} = props;
const register = getRegister();
const app = register.getApp(appId);
const isAppLoaded = app && register.isAppLoaded(app.id);
const [once] = useState(1);
const [result, setResult] = useState(
isAppLoaded ? {
loaded: LoadedState.OK,
// Can't use React.FC in useState() for React.createElement()
component: app && app.components && app.components[componentKey],
} : {
loaded: LoadedState.Init,
component: null,
},
);
useEffect(() => {
let isMounted = true;
if (app && result.loaded === LoadedState.Init) {
register.loadApp(app.id).then(() => {
if (isMounted) {
setResult({
loaded: LoadedState.OK,
component: app.components && app.components[componentKey],
});
}
}).catch(() => {
if (isMounted) {
setResult({
loaded: LoadedState.Failed,
component: null,
});
}
});
}
return () => { isMounted = false; };
}, [once]);
if (result.loaded === LoadedState.Failed && !disableRedirect) {
return redirectOnFail ? <Redirect to={redirectOnFail} /> : RedirectToDefaultRoute(routePath);
}
return (result.loaded === LoadedState.OK && result.component)
? React.createElement(result.component as any, props) : <></>;
}
export default AsyncApp;
3 动态加载APP入口文件
分为两种情况:
- 对无依赖其它APP的APP,只需加载其自身的入口CSS/JS文件。
- 对有依赖其它APP的APP,还需等待被依赖的所有APP加载完成之后,才能加载其自身的入口文件。为了提升用户体验,对当前APP使用
preload
技术。
为了让loadApp()
可以同时调用多次,我们把promiseLoading
存起来,一个APP被多次调用loadApp()都共用一个Promise。
// register.ts
class AppRegister {
loadApp(id: string) : Promise<boolean> {
const app = this.getApp(id);
if (!app) {
return Promise.reject(new Error(`No app for id: ${id}`));
}
if (app.dependencies.length === 0) {
return this.loadAppIgnoreDependencies(id);
}
// Can't use topo-sort to run Promise.all, which may finish loading the current css/js first
const depPromises = app.dependencies.map((depId) => this.loadApp(depId));
this.preloadAppEntries(id);
return Promise.all(depPromises).then(
() => this.loadAppIgnoreDependencies(id),
(e) => Promise.reject(e),
);
}
preloadAppEntries(id: string) : boolean {
const app = this.getApp(id);
if (!app) {
return false;
}
preloadMultiStyles(app.entries.filter((x) => x.toLowerCase().endsWith('.css')));
preloadMultiScripts(app.entries.filter((x) => x.toLowerCase().endsWith('.js')));
return true;
}
loadAppIgnoreDependencies(id: string) : Promise<boolean> {
const app = this.getApp(id);
if (!app) {
return Promise.reject(new Error(`No app for id: ${id}`));
}
if (app.promiseLoading) {
return app.promiseLoading;
}
app.promiseLoading = Promise.all([
loadMultiStyles(app.entries.filter((x) => x.toLowerCase().endsWith('.css'))),
loadMultiScripts(app.entries.filter((x) => x.toLowerCase().endsWith('.js'))),
]).then(
() => {
app.loadState = AppLoadState.Loaded;
return Promise.resolve(true);
},
(e) => {
app.loadState = AppLoadState.Init;
app.promiseLoading = null;
return Promise.reject(e);
},
);
return app.promiseLoading;
}
}
4 全局Redux Store
当前框架主要是样例使用,把全家桶都包含进来了。实际项目中,不会同时使用redux-saga和redux-observable。为了减小生产环境js文件的大小,可以选择redux-saga。如果不在乎那一点文件大小,可以选择RxJS + Redux-observable,就可以玩响应式编程,数据流清晰。
// store/index.ts
import {
createStore, applyMiddleware, compose,
} from 'redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import { createEpicMiddleware } from 'redux-observable';
const composeEnhancers = process.env.NODE_ENV !== 'production'
&& typeof window === 'object'
&& (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
// Specify extension’s options like name, actionsBlacklist, actionsCreators, serialize...
}) : compose;
const sagaMiddleware = createSagaMiddleware();
const epicMiddleware = createEpicMiddleware();
const enhancer = composeEnhancers(
applyMiddleware(thunk, sagaMiddleware, epicMiddleware),
// other store enhancers if any
);
const store = {
...createStore((state) => state, enhancer),
runSaga: sagaMiddleware.run,
runEpic: epicMiddleware.run,
};
// eslint-disable-next-line
export function getStore() {
return store;
}
5 公共库符号导出
需要特别注意“ES2015模块”概念:‘rxjs’与’rxjs/operators’是不同的模块,微前端APP构建时把它们区分开的、没有包含关系。所以,我们需要分别导出它们。最终,在生产版本时,export中的一堆变量都挂到全局变量rmfFramework下面,不会产生一堆全局变量。
// vendorExports.ts
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactRouterDOM from 'react-router-dom';
import * as Redux from 'redux';
import * as ReactRedux from 'react-redux';
import * as RxJS from 'rxjs';
// import * as RxJSAjax from 'rxjs/ajax';
// import * as RxJSFetch from 'rxjs/fetch';
import * as RxJSOperators from 'rxjs/operators';
// import * as RxJSTesting from 'rxjs/testing';
// import * as RxJSWebSocket from 'rxjs/webSocket';
// 'redux-observable' only use 'rxjs' and 'rxjs/operators'
import * as ReduxObservable from 'redux-observable';
import * as ReduxSaga from 'redux-saga';
import * as ReduxSagaEffects from 'redux-saga/effects';
export {
React,
ReactDOM,
ReactRouterDOM,
Redux,
ReactRedux,
RxJS,
// RxJSAjax,
// RxJSFetch,
RxJSOperators,
// RxJSTesting,
// RxJSWebSocket,
ReduxObservable,
ReduxSaga,
ReduxSagaEffects,
};
6 其它框架支持懒加载子路由的情况
- Angular Router,支持懒加载子路由。Route.loadChildren()可以懒加载子模块,子模块里面动态注册子路由。
- Vue Router,
有Hooks函数。可以在router.beforeEach()钩子函数中懒加载子模块,成功之后再调用next()确认路由跳转。在registerApp()时取到全局router,调用router.addRoutes()添加子路由。2020.09.07 需参考 为微前端Hack Vue Router。