微前端(singleSpa + React )试玩

javascript/jquery

浏览数:26

2020-5-28

AD:资源代下载服务

前言

我们团队正在做一个XX系统,技术栈是React,目前该系统日渐庞大,开发及维护成本加大,且每次必须把整个项目一起打包,费时费力。经考虑后决定将其拆分成多个项目,由它们组合成一个完整系统,微前端架构是非常好的选择。

微前端差不多有以下几个好处:

  1. 单项目维护:比如将商品模块单拉出来形成一个项目,它可以由一个小组单独维护,实现良好解耦
  2. 复杂度降低:不需要在整个集成式的庞大系统内开发,避免巨大的代码量,开发时编译速度快,提高开发效率
  3. 容错性:单独项目发生错误不会影响整个系统
  4. 技术栈灵活:vue、react、angular 等包括其他前端技术栈都可以使用,会 vue 的不需要再学 react

对我们来说最大的好处是单项目维护

展示

UI示例图

我们将整个微前端分为两个部分:

  1. 主项目(Main):红色框部分,作为整个项目的父级,负责展示菜单模块、头部模块
  2. 子项目(Sub-apps):蓝色框部分,子项目的作用是具体的业务展示

动图展示

注意看地址栏变化,其中包含 /app1/xxx/app2/xxx,乍一看这是一个项目中两个页面的切换,实际上是来自两个独立的项目,app1 和 app2 来自不同的 git 仓库。

微前端架构图

整个流程大概为:用户访问 index.html, 此时运行模块加载器Js,加载器会根据整个系统的配置文件(project.config) 去注册各个项目,系统会先加载主项目(Main),然后会根据路由前缀动态加载对应的子项目

我们这个架构也参考了网上很多好的文章,其中核心文章可参考 https://alili.tech/archive/11…

关于 project.config

大概如下

[
 {
    isBase: false,
    name: 'app1',
    version: '1.0.0',
    //通过该路由前缀匹配加载当前入口文件
    hashPrefix: '/app1',
    //入口文件
    entry: 'http://www.xxxx.com/app1/dist/singleSpaEntry.js',
    //顶级Store
    store: 'http://www.xxxx.com/main/dist/store.js'
  }
  ......
]

技术细节

single-spa

我们找了些实现微前端的仓库,对比后决定使用single-spa

我们技术栈是 react,在子项目入口中需要使用 single-spa-react 来构建,关键代码如下:

import singleSpaReact from 'single-spa-react';

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: Root,
  domElementGetter
});

export function bootstrap(props) {
  return reactLifecycles.bootstrap(props);
}

export function mount(props) {
  return reactLifecycles.mount(props);
}

export function unmount(props) {
  return reactLifecycles.unmount(props);
}

如果你使用 vue,可以使用 single-spa-vue

然后在系统入口文件中,把所有的项目注册进来:

import * as singleSpa from 'single-spa';

singleSpa.registerApplication(
    'app1',
    () => SystemJS.import('app1-entry.js'),
    () => location.hash.startsWith(`#/app1`),
    props
  );

具体可参考 single-spa 官网 https://single-spa.js.org 这里有很多例子

Webpack 与 SystemJs

我们使用的 lerna 统一管理所有项目的依赖包,所有依赖包的版本统一,这样非常方便维护。

使用 webpack 的 dll 功能,将所有项目的公用依赖包抽离,比如 react、react-dom、react-router、mobx等

为了方便项目动态加载,我们也参考网上大佬的想法,使用了systemjs,只不过我们使用的是 0.20.19 版本,配合 systemjs ,在 Webpack 中需要改一下 libraryTarget:

output: {
    publicPath: 'http://www.xxxxx.com/',
    filename: '[name].js',
    chunkFilename: '[name].[chunkhash:8].js',
    path: path.resolve(__dirname, 'release'),
    libraryTarget: 'amd', //注意 这里使用 amd 的规范
    library: 'app1'
  },

我们没有使用 umd 规范,也没有使用 systemjs 里的 Import Maps
功能,而是直接通过 project.config 来动态加载模块入口。

app之间通信

关于这个也看了一些大佬的方案,大概就是所有的项目里有个 store,在注册入口时将所有 store 放进队列,需要更新 store 里的状态时,调用 dispatch 将所有 store 同步。

我的做法和传统单页应用一样,一个系统应该只有一个顶级 Store,由于顶级 Store 里存的一般是整个系统的公用状态 比如菜单、用户信息等,我把它放在 Main项目里,但打包时这个Store是单独抽离的:

entry: {
    singleSpaEntry: './src/singleSpaEntry.js',
    store: './src/store' //单独一个入口
  },

在注册时,将这个 Store 传入每个项目中:

//顶级Store
const mainStore = await SystemJS.import(storeURL);

singleSpa.registerApplication(
    'app1',
    () => SystemJS.import('http://www.x.com/app1/entry.js'),
    hashPrefix('/app1'),
    { mainStore }
);
singleSpa.registerApplication(
    'app2',
    () => SystemJS.import('http://www.x.com/app2/entry.js'),
    hashPrefix('/app1'),
    { mainStore }
);

这样就可以达到只管理这一个 Store 就可以,非常方便。
注意:我使用的是 Mobx 作为状态管理

前端部署

我们部署的方式非常简单,我自己写了一个 webpack 插件用于把打包后的 dist 传到 OSS 然后将项目信息传给服务端,服务端根据我传入的项目信息组织成 project.config,然后用户在访问 index.html 时会获取 project.config,此时 single-spa 根据配置注册所有项目,然后根据路由来拉取对应的项目入口文件js文件。

把子项目的挂载 DOM 放在 Main 项目里

我们的需求是 Main 作为整个项目的 Layout,其中子项目的挂载 Dom 也在 Main项目里,这就必须等到 Main 项目完全渲染完成后,才能挂载子项目。我参考了网上有些微前端的实现,把 domElementGetter 方法借鉴了过来:

function domElementGetter() {
  let el = document.getElementById('sub-module-wrap');
  if (!el) {
    el = document.createElement('div');
    el.id = 'sub-module-wrap';
  }
  let timer = null;
  timer = setInterval(() => {
    if (document.querySelector('#content-wrap')) {
      document.querySelector('#content-wrap').appendChild(el);
      clearInterval(timer);
    }
  }, 100);

  return el;
}

demo

demo地址:https://github.com/Vibing/mic…

结束语

这是我们第一次玩微前端,可能有很多地方不完美,还望各位大佬多多包涵

作者:alwaysVe