微前端实践-实现React(umi框架)的子系统集成

3,114 阅读6分钟

问题引入

最近在公司遇到了一个需求,别的团队的同事想将他们用 React 编写的工程作为子系统集成到我们已有的系统中,React 工程是基于 umi 框架编写的,我们的主系统是基于 jquery 框架实现的。其实他们本来是已经实现了 React 作为子系统集成到我们的主系统中的,但是他们是借助于 iframe 实现页面嵌入的,后来因为用户体验不佳、存在安全性问题等因素而不得不放弃这种方式的集成了。

分析了一下他们的需求,其实就是一个微前端的需求,即将业务拆分成多个子系统,每个子系统可以独立开发,开发完毕后会作为一个个子模块被集成到主系统中。

思考及实现

关于微前端的解决方案有很多,比方说使用 iframe 隔离运行时、Single-SPA 等,但是因为安全性和时间性的要求,能否提供一种快速便捷的方案解决子系统的集成问题呢?

考虑到子系统是基于 umi 编写的,就想到了能否借助于 umi 提供的强大的插件机制,通过编写 umi 插件来扩展项目的编译时和运行时的能力?

我们知道,umi dev 的时候,会生成 src/pages/.umi 临时目录,里面包含 umi.jsrouter.js 等临时文件,其中的 .umi.js 文件就是编译之后生成的入口文件。看下这个文件:

...
let clientRender = async () => {
  window.g_isBrowser = true;
  let props = {};
  // Both support SSR and CSR
  if (window.g_useSSR) {
    // 如果开启服务端渲染则客户端组件初始化 props 使用服务端注入的数据
    props = window.g_initialData;
  } else {
    const pathname = location.pathname;
    const activeRoute = findRoute(require('@@/router').routes, pathname);
    // 在客户端渲染前,执行 getInitialProps 方法
    // 拿到初始数据
    if (
      activeRoute &&
      activeRoute.component &&
      activeRoute.component.getInitialProps
    ) {
      const initialProps = plugins.apply('modifyInitialProps', {
        initialValue: {},
      });
      props = activeRoute.component.getInitialProps
        ? await activeRoute.component.getInitialProps({
            route: activeRoute,
            isServer: false,
            location,
            ...initialProps,
          })
        : {};
    }
  }
  const rootContainer = plugins.apply('rootContainer', {
    initialValue: React.createElement(require('./router').default, props),
  });
  ReactDOM[window.g_useSSR ? 'hydrate' : 'render'](
    rootContainer,
    document.getElementById('root'),
  );
};
const render = plugins.compose(
  'render',
  { initialValue: clientRender },
);

...
// client render
if (__IS_BROWSER) {
  Promise.all(moduleBeforeRendererPromises)
    .then(() => {
      render();
    })
    .catch(err => {
      window.console && window.console.error(err);
    });
}
...

可以看到,编译结束后,会去调用render方法,最终通过:

ReactDOM[window.g_useSSR ? 'hydrate' : 'render'](
    rootContainer,
    document.getElementById('root'),
);

将虚拟Dom渲染到指定的id容器上。受此启发,那么我们能不能将此render方法挂载到window对象上呢,在主系统中通过调用此方法,将子系统的虚拟Dom渲染到主系统中指定的Dom容器中呢?这样,只要在主系统中引入编译后的子系统的js和css资源文件,就可以直接通过window上挂载的指定方法来实现子系统集成到主系统中。

于是,现在问题就转化为了通过umi的插件,来修改render方法,将render方法提供出来,供主系统调用。

Ok,既然有思路了,就赶紧查看了下 umi 的插件开发文档。

umi 的所有插件接口都是通过初始化插件时候的 api 来提供的。分为如下几类:

  • 环境变量,插件中可以使用的一些环境变量
  • 系统级变量,一些插件系统暴露出来的变量或者常量
  • 工具类 API,常用的一些工具类方法
  • 系统级 API,一些插件系统暴露的核心方法
  • 事件类 API,一些插件系统提供的关键的事件点
  • 应用类 API,用于实现插件功能需求的 API,有直接调用和函数回调两种方法

系统级 API 中提供了一个 modifyEntryRender 方法,可以实现对entryRender方法的修改。

通过create-umi命令,生成一个umi插件的模版,然后就可以开发插件了。

src/index.js

import { writeFileSync } from 'fs-extra';

import { join } from 'path';

const writeFile = (text, outputPath) => {
  writeFileSync(outputPath, text, { encoding: 'utf8' })
}

const generateManifestCode = (manifest) => {
  return `
      (function (window, factory) {
          if (typeof exports === 'object') {
          
              module.exports = factory();
          } else if (typeof define === 'function' && define.amd) { // eslint-disable-line
          
              define(factory); // eslint-disable-line
          } else {
          
              window.assetManifest = factory(); // eslint-disable-line
          }
      })(this, function () {
          return [${manifest.map(item => `'${item}'`).join(',')}]
      });
      `
}

export default function (api, options) {
  const { integrateName, fileList = [] } = options;
  const { paths } = api;
  const { absOutputPath } = paths;

  api.addEntryCode(`
    window['${integrateName}'] = {};
    window['${integrateName}'].render = function(selector) {
      if (__IS_BROWSER) {
        Promise.all(moduleBeforeRendererPromises)
          .then(() => {
            render().then(result => { result(selector)});
          })
          .catch(err => {
            window.console && window.console.error(err);
          });
      }
    }
`);

  api.modifyEntryRender(() => {
    return `
      window.g_isBrowser = true;
      let props = {};
      // Both support SSR and CSR
      if (window.g_useSSR) {
        // 如果开启服务端渲染则客户端组件初始化 props 使用服务端注入的数据
        props = window.g_initialData;
      } else {
        const pathname = location.pathname;
        const activeRoute = findRoute(require('@@/router').routes, pathname);
        // 在客户端渲染前,执行 getInitialProps 方法
        // 拿到初始数据
        if (
          activeRoute &&
          activeRoute.component &&
          activeRoute.component.getInitialProps
        ) {
          const initialProps = plugins.apply('modifyInitialProps', {
            initialValue: {},
          });
          props = activeRoute.component.getInitialProps
            ? await activeRoute.component.getInitialProps({
              route: activeRoute,
              isServer: false,
              location,
              ...initialProps,
            })
            : {};
        }
      }

      const rootContainer = plugins.apply('rootContainer', {
        initialValue: React.createElement(require('./router').default, props),
      });

      return function(selector){
        ReactDOM.render(
          rootContainer,
          document.getElementById(selector),
        );
      }
    `
  });

  api.onBuildSuccess(() => {
    let outputFileList = [...fileList];
    const manifestText = generateManifestCode(outputFileList);
    writeFile(manifestText, join(absOutputPath, 'asset-manifest.js'));
  });
}

该插件做了一下几件事:

  1. addEntryCode 方法里面将render方法挂载到了 window 对象的integrateName对象上,integrateName是由插件的参数传入的,需要和主系统约定好。
  2. modifyEntryRender 方法重写了 clientRender 方法, 最后返回一个 function
    return function(selector) {
        ReactDOM.render(rootContainer, document.getElementById(selector));
    };
    
    这么写的目的有两个,一个是防止原来的 render 方法去调用 clientRender 的时候直接将虚拟 Dom 渲染了出来;第二个是目的是返回一个函数,方便集成的时候调用传参。
  3. 最后在 onBuildSuccess 方法里面会根据插件的 fileList 参数将编译之后的资源文件传入,在dist目录下生成一个 asset-manifest.js 文件,这样在主系统中可以直接通过加载 asset-manifest.js 文件就可以加载到所有静态资源了。

在umi子工程的 .umirc.js中配置好插件,并安装 umi-integrate-plugin 包:

plugins: [
    // ref: https://umijs.org/plugin/umi-plugin-react.html
    ['umi-plugin-react', {
      antd: true,
      dva: true,
      dynamicImport: { webpackChunkName: true },
      title: 'umi-app',
      dll: true,
      locale: {
        enable: true,
        default: 'en-US',
      },
      routes: {
        exclude: [
          /models\//,
          /services\//,
          /model\.(t|j)sx?$/,
          /service\.(t|j)sx?$/,
          /components\//,
        ],
      },
    }],
    ['umi-integrate-plugin', {
      integrateName: 'gcc',
      fileList: [
        '/umi.js',
        '/umi.css',
      ]
    }]
  ],

同时需要在 .umirc.js 文件中将路由切换为 memory 路由:

export default {
  history: 'memory',
}

开启缓存路由的目的是为了防止子工程集成进主工程之后,子工程路由的切换会影响主工程的路由。

接着执行$ npm run build命令,cddist 目录下通过 http-server 启动一个静态服务,打开浏览器访问静态服务地址,在控制台输入 window.gcc.render('root') 就可以看到子工程被渲染出来了。

主工程中我们可以借助于 loaderjs 来加载 asset-manifest.js 文件,获取到子工程的 js 和 css 文件。

const loadjs = require('loadjs');

const cdnUrl = 'http://localhost:8080';

loadjs(`${cdnUrl}/asset-manifest.js`, () => {
  const assetManifest = (window as any).assetManifest;
  const jsReg = /\.(.js)$/;
  const cssReg = /\.(.css)$/;

  const jsFileList = [];
  const cssFileList:any = [];

  assetManifest.forEach(item => {
    if (jsReg.test(item)) {
      jsFileList.push(`${cdnUrl}${item}`);
    }

    if (cssReg.test(item)) {
      cssFileList.push(`${cdnUrl}${item}`);
    }
  });

  loadjs([...jsFileList, ...cssFileList], {
    success: () => {
      (window as any).gcc.render('root');
    },
    async: false,
  });
});

存在的问题

当然这种集成方式还是会存在很多不足的地方,比方说:

集成多个 umi 工程的时候,每个工程都需要打包一次,多个工程有很多第三方的包其实是相同的,但是每个工程都需要将这些包打包引入,造成很多冗余。

其次,如果多个子 umi 工程都使用来dva,集成之后 dvastore 是共享的,容易造成多个子工程的 store 数据互相污染,这就需要在开发的时候进行约定好,确保 namespace 不能重复。

源码大家可以参考这里