一、前言
网易七鱼是一款客户服务与智能营销的SaaS产品。在七鱼业务中,有在线系统、呼叫系统、机器人、工单系统、数据大屏等业务线,它们分布在两个业务端,管理端和客服端。这两个端的功能框架类似,都是由外层框架(顶部导航、一级菜单)及中间的内容区组成。
二、业务现状
随着业务体量的增大与功能的增多,主系统作为一个巨石应用复杂度越来越高,所有的业务线耦合在一起,在系统构建、业务分离、开发维护方面带来了新的挑战。
为解决以上问题,我们最初选用了 「MPA + iframe」 的技术方案。先按业务维度从巨型单体应用中拆分出多个子应用,并用 React 技术栈对它们进行了重构,通过 iframe 的方式隔离新老技术栈。这些子应用基于 URL 解耦,每个子应用可以独立开发、运行和部署。
「MPA + iframe」 的技术方案既有优点也有缺点。
用 MPA 方案可以允许子应用使用不同技术栈,父子应用之间天然隔离,但是浏览器页面跳转时不能保持单页应用的流畅体验,父子应用通信困难。
用 iframe 可以方便地隔离新老技术栈,但是也带来了一些问题:
问题 | 举例 | 较好的解决方案 |
---|---|---|
父子框架URL不同步、浏览器前进后退按钮异常 | -- | 定义父子框架路由映射,利用postMessage和history API 解决 |
父子框架 UI 不同步 | 遮罩层只能遮盖iframe所在的区域、iframe内的弹框无法相对外层页面居中 | 无 |
子框架的全局上下文与父框架完全隔离,导致父子框架通信困难、同步数据冗余 | -- | 无 |
加载慢,体验较差 | -- | 无 |
项目最开始时采用的开发框架是NEJ(Nice Easy Javascript),它的依赖管理系统、控件系统等特性为早期的项目开发做出了很大的贡献,现在它完成了自己的历史使命,项目开始向 React 技术栈过渡。
下图展示了应用框架现状:
可以看到,整个系统中使用了NEJ和React两套技术栈。React 外层框架内部嵌入的是React应用,这些应用分别引用了各自的外层框架,并通过React业务组件库复用。NEJ 外层框架内部的情况则比较复杂,部分场景嵌入的是NEJ应用,还有部分场景是通过iframe嵌入的React应用,这些React应用中的部分页面中也有通过iframe再次嵌入NEJ应用的场景。
因为NEJ老技术栈的组件支持匮乏,而且历史遗留代码较多,导致它们的开发和维护成本都很高。
目前前端工程正处于技术栈统一的过渡期,需要维护两套外层框架,后续将逐渐由 NEJ 转向 React。对于新增的应用,则直接采用React技术栈。
随着新应用的增多,外层框架被引用的次数越来越多,每次更新都需要发布多个应用,使用新技术栈的外层框架的维护成本为越来越高。
微前端是目前比较火的话题,它是微服务在前端领域的扩展。它将前端整体拆分为多个更小、更易管理的片段,可以解决工程复杂度高、多技术栈共存、开发维护困难等问题。微前端的两大特性,微应用技术栈无关,每个微应用可以独立开发、运行和部署,可以很好的匹配现有的业务场景。
因此我们将目光转到了对现有应用进行微前端改造上。
三、微前端改造
改造的好处
将现有的应用进行微前端改造可以带来以下好处:
- 积累实践经验,为将来从巨石应用拆分及微前端改造做准备
- 去除接入二方应用时使用的iframe,优化产品体验
- 收敛外层框架,提升研发效率,降低维护成本
- 提供前端增量升级能力,后续可以更好地复用历史代码、实施渐进式重构
社区内的微前端解决方案有许多种,包括:
single-spa 只解决了应用之间的加载方案,没有考虑其他的周边问题。
qiankun 基于 single-spa,提供了更加开箱即用的API,具备 JS 沙箱、样式隔离、子应用并行等能力。
icestark 约束了框架应用必须基于 React,不利于后续的技术栈优化。
Magix 适合做单页应用的项目,不支持多个实例,不满足业务需求。
Luigi 是一个基于 iframe 的微前端框架,仍有前文提到的 iframe 带来的产品体验问题。
Ara Framework 是一个基于 Airbnb's Hypernova 的,由服务端渲染延伸出的微前端框架,接入时对原应用的侵入较多。
WidgetJS 是一个轻量级的微前端方案,文档不够友好。
综合考虑业务场景、上手难度、文档友好性、代码入侵性、可维护性等方面,最终选择的微前端解决方案是qiankun。接下来就是基于qiankun的微前端改造了。
业务分析与改造效果
七鱼的微前端改造,从技术层面涉及到React、NEJ两类技术栈,从业务层面涉及到管理端、客服端。
因为最终目的是所有前端工程统一到React技术栈,而管理端部分应用的外层框架已经用React重构过,所以先从管理端下手。
首先分别从新、老技术栈应用中选取一个应用进行改造,积累相关经验。应用选择的标准是无复杂的业务逻辑、流量少,以降低改造风险。新技术栈应用选的是首页应用,老技术栈应用选的是数据大屏应用。
来看一下七鱼微前端改造后的主页:
这里说明两个概念,基座应用(也称为主应用、框架应用等)和子应用(也称为微应用)。
- 基座应用负责整体布局、子应用的配置和调度。一般包含各个子应用公有的部分,比如外层框架
- 子应用负责自身业务逻辑的渲染
可以看到,上图用红框标出了主页的两个组成部分,外层框架(顶部导航、一级菜单)和中间内容区。
外层框架就是由基座应用控制的,通过监听URL进行路由分发、子应用调度等。内容区由一个或多个子应用控制,上图中的内容区就是由一个首页子应用控制的。
大致的改造步骤
1、创建管理端基座工程basic-admin
基座应用只包含各个子应用共有的部分
2、创建首页子工程micro-index、大屏子工程micro-bigscreen,以及相应的应用和集群
3、在项目的入口文件里,暴露相应的生命周期钩子,供qiankun识别
4、修改打包配置,使物料以umd的方式输出,以webpack为例:
const webpackConfig = {
//...
output: {
//...
library: `${packageName}-[name]`, // 此处的packageName为子应用名,如micro-bigscreen
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
}
};
5、新增微应用对应的内部路由,改造网关
- 内部路由用于注册子应用,正常情况下用户无法直接访问到。
- 改造后的网关需要将所有匹配到基座URL前缀的请求,都定向到基座应用。
6、兼容七鱼PC客户端(低版本Chrome浏览器内核)
- qiankun加载资源时依赖的fetch API的兼容性问题
- 因为height继承等导致的样式问题
7、在基座应用中调用qiankun的API,将子应用注册到基座应用,如:
registerMicroApps(
[
{
name: 'micro-index',
entry: '//' + location.hostname + '/_MicroIndex',
container: '#subapp-container',
activeRule: '/madmin/home',
},
{
name: 'micro-bigscreen',
entry: '//' + location.hostname + '/_MicroBigscreen/index',
container: '#subapp-container',
activeRule: '/madmin/dashboard',
}
]
);
四、微前端架构下的业务变化
服务网关的变化
微前端改造后,所有管理端相关子应用的URL前缀为「/madmin/」,如主页的URL为「/madmin/home/」。服务网关需要将所有以「/madmin/」开头的路由定向到管理端基座应用。
结合网关的微前端架构图如下:
子应用的开发模式
子应用有独立的仓库,部署完之后,将应用的发布产物注册到基座应用里,这些产物可以是子应用的访问地址,也可以是资源配置对象(scripts + styles + html)。
需要注意的是,在子应用与基座应用开发联调时,子应用读取的是基座应用的同步数据,Mock的同步数据需要在基座应用中配置。同理,子应用用到的接口代理也需要在基座应用中配置全。
基座应用的整体流程
基座应用启动后会监听URL变化,当用户访问系统时,根据当前访问的URL和注册的路由信息,能够匹配到当前需要加载的子应用信息,然后去加载子应用的资源并渲染子应用。
当用户点击触发跳转时,如果路由变化触发的是一个内部URL跳转,会直接根据应用内部的路由逻辑渲染页面。如果路由变化触发的是跨应用的跳转,则重新回到上面的路由匹配的流程中。
下图是微前端改造后的应用框架:
按照上述的子应用改造过程,可以逐步完成管理端的微前端改造。接下来就是对客服端的微前端改造了。
虽然客服端与管理端的框架结构类似,但是它们的URL是解耦的,而且它们一级菜单和顶部导航的业务功能差别较大,共用同一个基座应用会导致应用复杂度过高,最好是另外创建一个客服端专用的基座应用,两个基座应用通过业务组件库复用组件。
未来整体的应用框架如下:
有了微前端的助力,整个系统可以更加平滑地进行技术栈升级,最终实现前端技术栈的统一,更高效地赋能业务发展。
五、遇到的问题及解决方案
1、子应用接入基座应用后,babel-polyfill报错
babel-polyfill不支持引用多次(基座应用和子应用分别引用了一次),直接去除babel-polyfill会导致无法单独运行子应用,可以改用idempodent-babel-polyfill。
2、基座应用访问子应用资源报404错误
资源路径有问题,需要配置运行时的public path。
if (window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
} else {
__webpack_public_path__ = window.location.protocol + "//" + window.location.host + "/";
}
3、报错提示找不到子应用容器
将sandbox设置为strictStyleIsolation,会启用严格的样式隔离,原理是把子应用内容渲染到基座容器的shadow dom中,导致无法直接获取基座应用的dom元素。
取消strictStyleIsolation,只设置jsSandBox为true就不会有问题。
样式隔离的最佳实践是采用约定式隔离:
用CSS命名空间、CSS Module、css-in-js等工程化手段,避免写全局样式。
4、本地联调时基座应用访问子应用资源时报跨域错误
开发环境使用browserSync进行浏览器同步,qiankun框架通过浏览器的fetch API获取子应用的资源,会存在跨域问题,所以需要设置cors
为true。
browserSync({
//...
cors: true
});
5、子应用引入qiankun生命周期后,无法独立运行
添加条件判断,非qiankun环境下,走之前的运行环境。
修改entry.js
的render条件:
if (!window.__POWERED_BY_QIANKUN__) {
ReactDOM.render(
<Root store={store} history={history} routes={routes}/>, document.getElementById('react-content')
);
}
6、本地联调时子应用因为有热加载导致报错
使用ScriptExtHtmlWebpackPlugin插件修改webpack配置,为每个页面的入口js加entry属性。
tplPlugins.push(
new ScriptExtHtmlWebpackPlugin({
custom: {
test: /(?<!vendors.*)entry.js$/,
attribute: 'entry'
}
}
));
7、本地联调时子应用调用Mock接口或同步数据报错
在子应用与基座应用开发联调时,子应用读取的是基座应用的全局配置。本地环境基座应用可能接入很多子应用,其他子应用用到的接口代理要配全,否则调不到接口。同理,Mock的同步数据也要在基座应用配置全。
8、低版本浏览器加载资源时cookie丢失
qiankun框架通过浏览器的fetch API获取子应用的资源。Chrome 内核71及之前的版本,即使网址与调用脚本同源,fetch API也不会自动发送cookie。
需要在基座应用中启动应用时,对fetch进行显式的参数配置:
qiankun.start({
//...
fetch: (url, init) => {
return window.fetch(url, {
...init,
credentials: 'same-origin' // 在当前域名内自动发送 cookie
});
}
});
9、非React环境引入qiankun生命周期的方式
定义一个与子应用名称一致的全局变量,生命周期钩子函数必须返回promise,如果不支持promise需要引入promise-polyfill。入口文件可以这样写:
(function(win) {
// 此处的'micro-bigscreen'与注册到基座应用的子应用名称一致
win['micro-bigscreen'] = {
bootstrap: function() {
// 必须返回promise,否则子应用无法正常启动
return Promise.resolve();
},
mount: function() {
return Promise.resolve();
},
unmount: function() {
return Promise.resolve();
}
};
})(window);
10、PC客户端子应用变量访问报错:Uncaught TypeError: 'get' on proxy
PC客户端注入了window.cefQuery与window.cefQueryCancel变量,它们的属性描述符中writable与configurable都为false,经过JS沙箱Proxy后直接访问它们会报错:
Uncaught TypeError: 'get' on proxy。
因为只有子应用用到了沙箱,此报错只会影响子应用,基座应用不受影响。
解决方法是,分别从window.cefQuery与window.cefQueryCancel复制出新的变量window.cefQuery2与window.cefQueryCancel2,修改它们的属性描述符writable与configurable为true。
然后将微前端子应用中引用window.cefQuery与window.cefQueryCancel的地方分别修改为window.cefQuery2与window.cefQueryCancel2。
基座应用中的相关代码:
const polyfillPcPlatform = () => {
if (window.cefQuery) {
Object.defineProperty(window, 'cefQuery2', {
value: window.cefQuery,
writable: true,
configurable: true
});
}
if (window.cefQueryCancel) {
Object.defineProperty(window, 'cefQueryCancel2', {
value: window.cefQueryCancel,
writable: true,
configurable: true
});
}
};
//注册子应用
registerMicroApps(
[
//...
],
{
beforeLoad: [
app => {
// 兼容PC客户端
polyfillPcPlatform();
}
],
//...
}
);
六、总结
本文从网易七鱼的实际业务需求出发,介绍了如何在SaaS系统中进行微前端的实践,包括业务现状分析、技术选型、落地步骤、兼容新老技术栈等。实践基于qiankun框架,从创建基座应用,到分别选取新老技术栈两个有代表性的应用进行了微前端改造,并总结了改造过程中遇到的问题。
本次实践达到了以下目的:
- 积累微前端实践经验,为将来从巨石应用拆分及微前端改造做准备
- 使管理端不同技术栈的二方应用接入不再需要使用iframe,优化了产品体验
- 收敛管理端外层框架,使新应用的接入不再需要理会顶部导航和一级菜单
- 提供前端增量升级能力,后续可以更好地复用历史代码、实施渐进式重构
微前端不是一个框架,而是一套架构体系,基座应用的创建和子应用的改造是它的基础设施,此外还应该包括中心化的微应用治理平台,通过平台对应用进行配置管理、权限管理、应用关联关系和状态查询等。有了上述能力后,可以通过它们统一管控所有的微应用,为SaaS产品提供自由组合的能力,使技术为业务带来更大的价值,下一篇文章将会介绍如何从0到1构建微前端管理平台。