软件技术学习笔记

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

React微前端实战教程(框架篇)

大系统拆分成微前端之后,为了提升用户体验,都是按需延时加载相关的CSS/JS。微前端框架react-micro-frontend-framework的职责:

  1. 提供微前端APP注册接口。有了注册接口才能让框架反向找到微前端APP的React组件。
  2. 作为整个前端的底座,负责启动React框架、渲染root节点,提供按需渲染某个微前端APP的接口——React中以组件的方式。
  3. 找到相关微前端的资源位置,按照依赖次序加载CSS/JS。
  4. 提供多个微前端之间的通信机制——全局Redux Store。
  5. 提供前端公共库服务——符号导出。

选择React做微前端框架的其中一个原因是:React Router支持动态路由,都是在渲染每层路由组件时判断加载什么内容。而别的路由框架就不是这个样子,有的是静态配置路由,如果它支持Middleware或者Hook机制,还能玩微前端,否则就得重新开发一个路由框架。所以,统一技术栈选择React + React Router,让微前端更加容易落地。

为了方便继续阅读,我们先对齐概念:在前端浏览器中跑的微前端,除了框架(framework),其它的我都称之为微前端APP或APP或SubApp。元信息、polyfill与framework是用户开始浏览时就被后端(比如我们的server-go)直接塞到返回的html文档中,它们不需要动态加载。

1 APP注册接口

注册接口分为两个:

  1. registerFromMetadata(), 在框架启动时,根据后端提供的Metadata进行注册。调用见:getRegister().registerFromMetadata(Util.getMetadataApps());。该部分主要定义每个微前端APP的资源位置、微前端APP之间的依赖关系(一般是依赖第三方库,我们把第三方库也定义为微前端)。
  2. 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 渲染底座

渲染底座分为两部分:

  1. 渲染root节点。微前端框架只提供一个白板,具体在什么路由显示什么APP,由APP的元信息定义。用户的默认路由也是在后端配置定义。
  2. 延时渲染微前端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强相关的实现。

关注点

  1. 只要底层确保register.loadApp(app.id)可以同时调用多次,就不会出问题。
  2. 务必等到APP加载成功之后,才能到注册中心取出要渲染的组件:app.components[componentKey].
  3. 每个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入口文件

分为两种情况:

  1. 无依赖其它APP的APP,只需加载其自身的入口CSS/JS文件。
  2. 有依赖其它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