Skip to content

mfe guide.md

Kylin edited this page Oct 28, 2019 · 1 revision

gm1 前端改造方案

现在的方案(iframe)

缺点:

  • iframe嵌入的显示区大小不容易控制,存在一定局限性
  • 交互体验问题(蒙层区域、loading不居中)
  • 主子工程通信问题
  • 跳转时资源重复加载
  • ...

同时iframe方案也存在一些优点

优点:

  • 工程独立上线、独立部署
  • 数据、样式相互隔离
  • 实现难度较低
  • 内部错误不会影响到外部
  • ...

我们的新方案必须保证在解决iframe缺点的前提下,尽量保持iframe的优点。

为什么是微前端

如果只是单纯解决iframe带来到问题,方案有很多:

  • 方案一:将每个工程当成一个组件 打包成npm package,在主工程引入工程组件。 这种方案每次子工程上线后需要重新打包整个gm1,打包耗时巨大,并且由于工程作为组件引入,必须保证所有工程公用同一套技术栈,后期技术栈升级成本巨高。
  • 方案二:将框架工程当成一个组件 打包成npm package,在每个子工程中引入框架组件。 这种方案每个子工程都包含了一个框架工程,框架改造的时候需要发所有的工程,跳转的时候在不同子工程的页面间跳转,存在资源浪费,框架组件的状态保存是个问题,切换时会闪,交互体验不好。
  • 方案三:微前端类单页应用。 将子工程打包成js模块,主工程负责注册模块,当模块首次激活时动态获取js脚本并执行,子工程相互隔离,可以各自使用不同技术栈,独立上线、独立部署。
iframe 方案一 方案二 方案三
实现难度
独立上线 × 不完全独立
技术栈独立 × 不完全独立
样式隔离 × 不完全隔离 通过添加scope隔离
交互体验
内部状态隔离 × 不完全隔离
跳转资源不重新加载 ×
主子工程通信难度
菜单切换时框架状态保存 ×

可以看到方案三基本上能满足我们的需求。

其他目标

  • 不能对现有的前端开发方式带来太大变化,至少要有平滑过渡的机制。
  • 能够一个一个工程渐进式上线。
  • 每个为前端工程都要求可以独立运行(因为我们还有window.open方式打开的详情页面)。

路由模型

下面是gm1路由格式

sdf.600jit.com/${project-prefix}/#/${path}

当路与下面相似时,打开的时带菜单框架的页面,实际情况下current会进行base64加密

sdf.600jit.com/gm-front/#/MainPage?current=${current}

如订单列表:

sdf.600jit.com/gm-front/#/MainPage?current=/gm-business-front/#/BusinessCenter

如何渲染子工程

  • 子工程打包成一个可以被import的js模块(umd)。
  • 子工程export一个渲染方法,它接受一个参数:一个已存在的dom节点作为根组件挂载点,调用reactDOM.render将react根节点挂载到该dom节点上去。
  • 主工程监听url变化,并根据url前缀来判断当前应该调用哪个子模块的渲染方法。

实际编码过程中我们会在主工程实现一个应用注册机制,并在子工程export它自己的生命周期回调。当子模块处于激活状态时(url匹配),首次匹配会加载对应模块的config文件,取出该模块的css、js入口文件路径,加载对应模块css、入口js,如gm-business-front/index.js,然后调用mount方法,mount方法内部会调用reactDOM.render()。

@dzg/entry-webpack-plugin插件会在webpack编译完成后生成一个文件,记录本次生成的入口文件路径。

// gm-business-front/project.config.js
export default {
  "css":["css/vendors.01b344e0e869f726066a.css"],
  "main":["js/runtime~index.605e77737a7063d3efb7.js","js/2.95e191c86cf404a3646c.js","js/0.46f819f87f37f8c8594d.js"],
  "name":"gm-business-front"
}
// gm-front register.js
function register() {
  projectConfig.forEach(module => {
    singleSpa.registerApplication(
      module.name,
      () => {
        // 此方法在首次激活时调用
        return new Promise((resolve, reject) => {
          Loading.start();
          // 先加载config
          SystemJS.import(getConfigPath(module)).then(function(source) {
            let config = source.default;
            // 加载css 
            if (config.css) {
              let styles = Array.isArray(config.css) ? config.css : [config.css];
              styles.forEach(css => {
                SystemJS.import(getAssetsPath(css, module)).then(function(module) {
                  const styleSheet = module.default; // A CSSStyleSheet object
                  document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
                });
              });
            }
            // 加载js
            if (config.main) {
              let scripts = Array.isArray(config.main) ? config.main : [config.main];
              let entry = scripts.pop();
              Promise.all(scripts.map(js => SystemJS.import(getAssetsPath(js, module)))).then(function() {
                SystemJS.import(getAssetsPath(entry, module)).then(function(source) {
                  // resolve(source)
                  resolve(source.getLifeCycles(getRootDom));
                  Loading.destroy();
                });
              });
            }
          });
        });
      },
      hashPrefix(module.prefix),
      {
        customProps: {
          frameRoutePrefix: '/MainPage',
        },
      }
    );
  });

  singleSpa.start();
}
// gm-business-front index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';

import Root from './root';

export const getLifeCycles = function(getRootDom) {
  const reactLifeCycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent: spa => {
      return <Root {...spa.customProps}/>;
    },
    domElementGetter: () => {
      // 上层 react render 可能导致本次挂载的dom对象!==上次挂载的dom对象(即使看上去两者似乎一致),因此每次需要实时获取 
      return getRootDom();
    },
  });

  return {
    bootstrap: [reactLifeCycles.bootstrap],
    mount: [reactLifeCycles.mount],
    unmount: [reactLifeCycles.unmount],
    unload: [reactLifeCycles.unload]
  }
}

export const render = function(dom) {
  ReactDOM.render(<Root />, dom);
};

子工程路由注册

由于子工程需要同时满足被框架工程内嵌、window.open() 独立打开两种模式,所以需要对路由表进行改造。

  • 删除以前的动态生成方式(不改也可,考虑到可维护性还是推荐改)
  • 导出一份path-component对应的路由对象数组
  • 循环路由数组,完成路由注册
  • 新增一个动态匹配组件<route path="/MainPage"/>
// routes.js
const appRoutes = [
  {
    path: '/customerFeeRateManage',
    title: '费用模板管理',
    component: CustomerFeeRateManage,
  },
  {
    path: '/feeItemManage',
    title: '费用项目管理',
    component: FeeItemManage,
  },
  {
    path: '/floatRate',
    title: '浮动汇率',
    component: FloatRate,
  },
  {
    path: '/bankAccountManage',
    title: '银行账户管理',
    component: BankAccountManage,
  },
  {
    path: '/BaseInfo/Enterprise/InvoiceTypes',
    title: '发票类别',
    component: InvoiceTypes,
  }
}
// root.jsx

const getMainPage = props => {
  const { location } = props;
  let params = new URLSearchParams(location.search);

  let current = params.get('current');

  let route = appRoutes.find(o => o.path === pathname);

  const Component = route.component;
  return <Component {...props} />;
};

export default class Root extends Component {
  render() {
    return (
      <Provider store={store}>
        <Router history={hashHistory}>
          <Route component={app}>
            <Route path="/MainPage" key="/MainPage" component={getMainPage} />
            {appRoutes.map(route => (
              <Route path={route.path} key={route.path} component={route.component} />
            ))}
          </Route>
        </Router>
      </Provider>
    );
  }
}

css 作用域控制

@dzg/postcss-plugin-add-scope 插件会在webpack编译过程中给每个匹配到到样式规则前加一个scope

[gm-business-front] .dzg-business-center {
    display: -webkit-box;
    display: -ms-flexbox;
    display: flex;
    -webkit-box-orient: vertical;
    -webkit-box-direction: normal;
    -ms-flex-direction: column;
    flex-direction: column;
    height: 100%;
    padding: 8px;
}

@dzg/babel-plugin-add-scope插件会在webpack编译过程中找到代码中的react根结点挂载点,并给这个dom加一个自定义属性(模块名)

reducer 隔离

此方案天然隔离

index.html

module.exports = {
  ...prodWebpackConfig,
  entry: {
    index: [utils.resolve('src/index.jsx')]
  },
  output: {
    path: config.build.assetsRoot,
    filename: 'js/[name].[hash].js',
    chunkFilename: 'js/[id].[chunkhash].js',
    library: 'gm-business-front',
    libraryTarget: 'umd',
    publicPath: `/gm-business-front/`
  },
};

output.libraryTarget 改为了umd, 需要被import才会执行,所以也对每个子工程的index.html进行了改造

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="renderer" content="webkit">
    <META HTTP-EQUIV="Pragma" CONTENT="no-cache">
    <META HTTP-EQUIV="Cache-Control" CONTENT="no-cache">
    <META HTTP-EQUIV="Expires" CONTENT="0">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <script src="./static/common/ga.js"></script>

    <% htmlWebpackPlugin.files.css.forEach(function(css){ %>
    <link rel="stylesheet" href="<%= css %>" rel="stylesheet">
    <% }); %>

</head>

<body style="background: #FFFFFF">
    <div id="app"></div>
    <script src='./static/common/[email protected]/system.js'></script>
    <script src='./static/common/[email protected]/use-default.js'></script>
    <script>
        window.SystemJS = window.System;
        var scripts = '<%= htmlWebpackPlugin.files.js %>'.split(',');
        var entry = scripts.pop();

        Promise.all(scripts.map(js => SystemJS.import(js))).then(function () {
            SystemJS.import(entry).then(function (source) {
                source.render(document.getElementById('app'));
            })
        });
    </script>
    <!-- built files will be auto injected -->
</body>

</html>

开发与构建

  • 开发
yarn run dev:micro
  • 打包
yarn run build:micro

其他与原来保持一致

其他改造

改造后的子工程是以js bundle的形式被import到主工程并调用,所以子工程到一切全局作用域上到操作都会反馈到主工程。

  • 在html中直接到static下的样式文件->转移到src下 在Root.jsximport
  • static下到全局js,改为js模块,并替换调用的地方。
  • 部分组件会从url上获取参数,改造后window.location取到的是主工程到url,需要看情况修改(window.open方式打开的详情页不受影响)。
  • 部分组件会往window对象上挂变量,需要看情况修改(window.open方式打开的详情页不受影响)。
  • ...