-
Notifications
You must be signed in to change notification settings - Fork 2
mfe guide.md
- 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>
);
}
}
@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加一个自定义属性(模块名)
此方案天然隔离
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.jsx
中import
-
static
下到全局js,改为js模块,并替换调用的地方。 - 部分组件会从url上获取参数,改造后window.location取到的是主工程到url,需要看情况修改(window.open方式打开的详情页不受影响)。
- 部分组件会往window对象上挂变量,需要看情况修改(window.open方式打开的详情页不受影响)。
- ...