一、前言
网易七鱼是提供围绕客户服务与智能营销的 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 进行路由分发、子应用调度等。内容区由一个或多个子应用控制,上图中的内容区就是由一个首页子应用控制的。
大致的改造步骤
- 创建管理端基座工程 basic-admin;
- 基座应用只包含各个子应用共有的部分;
- 创建首页子工程 micro-index、大屏子工程 micro-bigscreen,以及相应的应用和集群;
- 在项目的入口文件里,暴露相应的生命周期钩子,供 qiankun 识别;
- 修改打包配置,使物料以 umd 的方式输出,以 webpack 为例:
const webpackConfig = {
//...
output: {
//...
library: `${packageName}-[name]`, // 此处的packageName为子应用名,如micro-bigscreen
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
}
};
- 新增微应用对应的内部路由,改造网关:
- 内部路由用于注册子应用,正常情况下用户无法直接访问到;
- 改造后的网关需要将所有匹配到基座 URL 前缀的请求,都定向到基座应用;
- 兼容七鱼 PC 客户端(低版本 Chrome 浏览器内核):
- qiankun 加载资源时依赖的 fetch API 的兼容性问题;
- 因为 height 继承等导致的样式问题;
- 在基座应用中调用 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();
}
],
//...
}
);
六、总结
本次微前端实践基于 qiankun 框架,创建了管理端基座应用,将管理端首页和数据大屏应用进行了微前端改造,改造涉及 React 和 NEJ 两套技术栈,达到了以下目的:
- 积累了微前端实践经验,为将来从巨石应用拆分及微前端改造做准备;
- 使管理端不同技术栈的二方应用接入不再需要使用 iframe,优化了产品体验;
- 收敛了管理端外层框架,使新应用的接入不再需要理会顶部导航和一级菜单;
- 提供了前端增量升级能力,后续可以更好地复用历史代码、实施渐进式重构;
微前端不是一个框架,而是一套架构体系,基座应用的创建和子应用的改造是它的基础设施,除了基础设施外还有配置中心和观察工具。配置中心包括参数配置、版本管理、发布策略等。观察工具有一定的运维职能,包括应用状态的可见、可控性等。
有了上述能力后,可以通过它们统一管控所有的微应用,为 SaaS 产品提供自由组合的能力,使技术为业务带来更大的价值。
更多技术干货,欢迎关注【网易智企技术+】