微前端在网易七鱼的实践

213 阅读13分钟

一、前言

网易七鱼是一款客户服务与智能营销的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框架,从创建基座应用,到分别选取新老技术栈两个有代表性的应用进行了微前端改造,并总结了改造过程中遇到的问题。

本次实践达到了以下目的:

  1. 积累微前端实践经验,为将来从巨石应用拆分及微前端改造做准备
  2. 使管理端不同技术栈的二方应用接入不再需要使用iframe,优化了产品体验
  3. 收敛管理端外层框架,使新应用的接入不再需要理会顶部导航和一级菜单
  4. 提供前端增量升级能力,后续可以更好地复用历史代码、实施渐进式重构

微前端不是一个框架,而是一套架构体系,基座应用的创建和子应用的改造是它的基础设施,此外还应该包括中心化的微应用治理平台,通过平台对应用进行配置管理、权限管理、应用关联关系和状态查询等。有了上述能力后,可以通过它们统一管控所有的微应用,为SaaS产品提供自由组合的能力,使技术为业务带来更大的价值,下一篇文章将会介绍如何从0到1构建微前端管理平台。