前言
前端微服务能解决什么痛点,为什么需要前端微服务?
1.后台系统繁多。一个域名对应一个前端服务,或者对应一个前端系统。
2.系统变大后较难维护。随着需求增加,需管理的代码量增加,发展到后期较难维护。
3.上线风险。系统全量代码上线,风险较大。
4.统一技术栈的局限性。
方案思路设计
初步设计

目前的主要实现方式: 1.iframe嵌套方式; 2.资源点加载方式; 3.路由注册方式;
实现前端微服务,我们需要做到什么?
1.将前端代码拆分,随着业务需求增加,将代码拆分成多个项目;
2.实现一个前端服务作为上层入口。即一个域名,可包括所有系统;
3.统一权限的控制,根据权限区分当前角色可查看那些系统页面;
4.统一的登陆权限,SSO。
5.路由控制方案,根据路由分发到不同的前端项目;
请求分发
可以根据ng的配置,来实现请求的分发。

ng配置示例:
转发规则上限制数据请求格式必须是系统名+Api做前缀这样保障了各个系统之间的请求可以完全隔离。
server {
listen 80;
server_name xxx.xx.com;
location /project/api/ {
set $upstream_name "server.project";
proxy_pass http://$upstream_name;
}
...
location / {
set $upstream_name "web.portal";
proxy_pass http://$upstream_name;
}
}
资源点加载方式:

这种方式的设计核心于路由注册方式大致相同
- 主框架的定位则仅仅是:导航路由 + 资源加载框架。
路由系统及 Future State
Future State 如果在一个前端微服务框架中,进行如下操作:

此时浏览器的地址可能是 bigdata.qingsongchou.com/subApp/123/… 想象一下,此时我们手动刷新一下浏览器,会发生什么情况?
由于我们的子应用都是 lazy load 的,当浏览器重新刷新时:
- 主框架资源会被重新加载
- 同时异步load子应用的静态资源
- 主应用的路由系统已经激活
- 子应用的资源可能还没有完全加载完毕
从而导致路由注册表里发现没有能匹配子应用 /subApp/123/detail 的规则,这时候就会导致跳 NotFound 页或者直接路由报错。
这个问题在所有 lazy load 方式加载子应用的方案中都会碰到,早些年前 angularjs 社区把这个问题统一称之为 Future State。
为什么要说Future State
这是在前端微服务的方案设计中不得不关注的一个问题。为了解决这中问题,我们需要设计一套路由加载方案:
- 主框架配置子应用的路由为
subApp: { url: '/subApp/**', entry: './subApp.js' } - 当浏览器的地址为 /subApp/abc 时,先加载 entry 资源,待 entry 资源加载完毕,确保子应用的路由系统注册进主框架之后,再去由子应用的路由系统接管 url change 事件。
- 同时在子应用路由切出时,主框架需要触发相应的 destroy 事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用,如 React 场景下
destroy = () => ReactDOM.unmountAtNode(container)。
如上方案将url地址和静态资源相关联起来,建立起路由系统;
组合
因为需要做到各个子系统单独打包部署,各系统之间与主应用不存在过多的绑定联系。
我们需要使用运行时加载子应用这种方案。
js入口 or HTML入口
确定以什么形式引入子系统资源。
-
js入口的方式,是子应用将资源打成一个 entry script,但这个方案的限制也颇多:
- 子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。
- 资源的并行加载等特性也无法利用上
-
HTML入口方式将更为灵活,主框架可以通过 fetch html的方式获取子应用的静态资源,同时将 HTML document作为子节点塞到主框架的容器中:
- 极大的减少主应用的接入成本
- 子应用的开发方式及打包方式基本上也不需要调整
- 解决子应用之间样式隔离的问题
对比如上两种方案,显然HTML入口引入的方式更为合适;
那么HTML入口方案下,主框架注册子应用的方式则变成:
framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})
通过整合页可以减少一次请求html:
framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})
路由注册方式
路由的注册是整个设计方案的核心,通过路由映射来区分不同的系统,这样才能使得“请求”得到分发。
“子项目”的路由应该由自己控制,而整个系统的导航是“主项目”提供的。
路由注册是通过子项目向主项目实施路由注册的方式:
let Router = <Router
fetchMenu = {fetchMenuHandle}
routes = {routes}
history = {history}
>
ReactDOM.render(Router,document.querySelector("#app"));
<Router>
<Route path="/" component={App}>
<Route path="/namespace/xx" component={About} />
<Route path="inbox" component={Inbox}>
<Route path="messages/:id" component={Message} />
</Route>
</Route>
</Router>
具体注册使用了全局的window.app.routes,“主项目”从window.app.routes获取路由,“子项目”把自己需要注册的路由添加到window.app.routes中,子项目的注册如下:
let app = window.app = window.app || {};
app.routes = (app.routes || []).concat([
{
code:'attendance-record',
path: '/attendance-record',
component: wrapper(() => async(require('./nodes/attendance-record'), 'caiwu')),
}]);
在主系统中访问页面的时候,子系统自行注册了路由。
js作用域控制
- 子项目
路由注册成功后,问题就来到了资源加载上,路由对应着资源(js,css,img...),加载对应的资源后,作用域问题就需要得到解决。
同样通过window.app来控制项目作用域;
let app = window.app || {};
app = {
routes:[...],
init:function(namespace,reducers){...}
};
window.app主要功能: routes 用于存放全局的路由,子项目路由添加到window.app.routes,用于完成路由的注册 init 注册入口,为子项目添加上namesapce标识,注册上子项目管理数据流的reducers
子项目完整的注册:
import reducers from './redux/caiwu-reducer';
let app = window.app = window.app || {};
app.routes = (app.routes || []).concat([
{
code:'attendance-record',
path: '/attendance-record',
component: wrapper(() => async(require('./nodes/attendance-record'), 'caiwu')),
// ... 其他路由
}]);
function wrapper(loadComponent) {
let React = null;
let Component = null;
let Wrapped = props => (
<div className="namespace-caiwu">
<Component {...props} />
</div>
);
return async () => {
await window.app.init('namespace-caiwu',reducers);
React = require('react');
Component = await loadComponent();
return Wrapped;
};
}
其中做了这几件事情:
- 把路由添加到window.app中。
- 业务第一次功能被调用的时候执行window.app.init(namespace,reducers),注册项目作用域和数据流的reducers。
- 对业务功能的挂载节点包装一个根节点:Component挂载在className为namespace-kaoqin的div下面。
这样就完成了“子项目”的注册,“子项目”的对外输出是一个入口文件和一系列的资源文件,这些文件由webpack构建生成。
- 主项目
主项目的init方法:
let inited = false;
let ModalContainer = null;
app.init = async function (namespace,reducers) {
if (!inited) {
inited = true;
// 加载common文件。
ModalContainer = document.createElement('div');
document.body.appendChild(ModalContainer);
}
ModalContainer.setAttribute('class', `${namespace}`);
mountReducers(namepace,reducers)
};
创建一个div容器。
并挂reducer在redux上。
css作用域控制
//webpack打包部分,在postcss插件中 添加namespace的控制
config.postcss.push(postcss.plugin('namespace', () => css =>
css.walkRules(rule => {
if (rule.parent && rule.parent.type === 'atrule' && rule.parent.name !== 'media') return;
rule.selectors = rule.selectors.map(s => `.namespace-kaoqin ${s === 'body' ? '' : s}`);
})
));
使用webpack插件,给css样式表加上对应的命名空间;
至此,我们完成了系统的拆分和整合。下面总结一下:
1.做到前端微服务的方式很多,核心载于建立主应用和子应用之间的联系;
2.加载资源的方式,亦是核心之一。
3.因为是多个子应用在同一个主应用中运行,所以作用域隔离和央视隔离是需要解决的问题。