Bifrost(英 [‘bi:frɔst])原意彩虹桥,北欧神话中是连通天地的一条通道。而在漫威电影《雷神》中,Bifrost是神域——阿斯加德(Asgard)的出入口,神域的人通过它自由穿梭于“九界”(指九个平行的宇宙)之间。借用“彩虹桥”的寓意,我们希望Bifrost可以成为前端不同SPA(Single Page Application)系统之间的桥梁,使得不同的单页应用可以用这种方式实现功能的自由聚合/拆分。

项目背景

立项之初,闪购赋能企管平台(以下简称“企管平台”)仅仅是面向单个商家的CRM管理系统,采用常规的Vue单页应用方式来实现。随着项目的推进,它的定位逐渐发生了变化,从一个单一业务的载体逐渐变成了面向多种场景的商家管理平台。另一方面,由于系统由多个前端团队共同开发维护,越来越多的问题随之浮现:

  • 异地协作时,信息同步不及时引起的代码冲突以及修改公共组件引入的Bug。
  • 不同的商家针对同一个页面存在定制化的需求。
  • 已经实现的一些功能需要集成到企管平台中来。

因此,我们希望构建一个更高维度的解耦方案,使我们能够在开发阶段把互不干涉的模块拆成一个个类似后端微服务架构那样的子系统,各自迭代,在运行时集成为一个能够覆盖上述各种使用场景的完整系统。

方案选型

首先,我们整理了核心诉求,按优先级排序如下:

  • 希望异地开发时不同的模块能够独立开发、独立部署。
  • 对已在线上运行的项目,希望能够低成本地接入企管平台,而不需要对开发、部署流程做大规模的改动。
  • 各个子系统独立运行,互不影响,但允许我们在开发阶段与其他子系统进行联调。
  • 保持单页应用的体验。
  • 由于现有项目都是基于Vue技术栈开发,因此,我们的框架并不需要做到技术栈无关,只要满足Vue的项目即可。

基于以上这些诉求,我们调研了目前市面上常用的微前端方案,最常见的方案有:

  • 基于Nginx的路由分发。
  • 使用Iframe将页面嵌入。

除此之外,还有美团集团内部的微前端实践——美团HR系统(用微前端方式搭建类单页应用)和业界比较知名的微前端框架——SingleSPA。

这些方案的优劣整理如下:

从用户体验角度出发,Nginx和Iframe首先被否决;HR系统的方案需要对现有的项目进行改造,把不同团队目前开发的项目整合到同一个单页应用中,在项目快速迭代的过程中,成本过高,所以也被否掉。SingleSPA看起来完美,但它没有照顾到实际生产环境中的开发、部署的差异性,并不是Product-Ready。综合多种因素考虑,我们最终决定采用自研的方式来实现微前端化Bifrost。

核心架构

Bifrost框架在设计的时候参考了SingleSPA的思路,将系统了分为主系统和子系统。

主系统是用来控制子系统的调度中心,职责包括:

  • 维护子系统的注册表。
  • 管理各个子系统的生命周期。
  • 传递路由信息。
  • 加载子项目的入口资源。
  • 为子系统的实例提供挂载点。

子系统只负责业务逻辑的实现。如果进一步细分的话,子系统可以分为业务子系统、实现公共菜单子系统、导航布局子系统,其中布局子系统会先于业务子系统加载。

Bifrost采用路由消息分发的方式来控制子系统的加载和跳转。主系统维护了一条路由消息总线,当路由发生变化时,子系统会将路由事件推送给路由总线,然后由路由总线决定加载/跳转的目标子系统。如果路由不需要切换子系统,则交由当前子系统进行处理。

如果子系统发生切换,主系统会在DOM中添加对应子系统的挂载节点,并异步加载系统的静态资源。由于子系统都是完整的Vue实例,当子系统的代码加载并执行之后,子系统就会自动在其对应的挂载节点上渲染相应的内容。

整个系统的生命周期如下图所示:

具体实现

基于Bifrost实现的项目架构如下图所示:

这里,我们主要关注主系统、业务子系统和布局子系统的实现。

主系统

主系统的逻辑比较简单,主要是实例化Bifrost中定义的Platform对象,并注册各个子系统。子系统的注册信息包括:

  • AppName:子系统名,与系统的路由前缀保持对应,同时也会作为子系统在DOM中挂载节点的ID。
  • Domain:非必填,如果出现多个路由前缀都对应同一个子系统,可以通过Domain进行映射。
  • ConfigPath:对应子系统配置文件的URL。

一个简单的主系统实现如下:

import { Platform } from '@sfe/bifrost'

new Platform({
  layoutFrame: {
    render () {
      // render layout
    }
  },
  appRegister: [
    { appName: 'app1', configPath: '/path/to/app1/config.js' }
  ]
}).start()

业务子系统

在设计方案时,我们始终保持一个理念,就是保证对业务代码的零侵入,因此业务系统改造的工作量很小。代码层面,只需要把原本子系统的初始化流程放到AppContainer对象的Mounted回调函数里即可:

import { AppContainer } from '@sfe/bifrost'
import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)
const router = new VueRouter({})
new AppContainer({
  appName: 'app1',
  router,
  mounted () {
    return new Vue({
      router,
      components: { App },
      template: '<App/>'
    }).$mount()
  }
}).start()

另外,还需要修改子系统的构建流程,构建完成之后生成一个包含子系统入口资源信息的配置文件。一个典型的配置文件如下:

((callback) => callback({      
  scripts: [
    '/js/chunk-vendors.dee65310.js','/js/home.b822227c.js'
  ],
  styles: [
    '/css/chunk-vendors.e7f4dbac.css','/css/home.285dac42.css'
  ]
}))(configLoadedCb.crm)

此处我们实现了@sfe/bifrost-config-plugin插件,在Webpack构建脚本中引入该插件就可以自动生成项目对应的配置文件。配置文件是一个立即执行函数,主系统可以通过JSONP的方式读取配置文件中的内容。

在实际生产环境中,我们可以将子系统发布到任意CDN,只要能够保证配置文件的URL始终不变,那么无需依赖任何服务,主系统就可以感知到子系统的发布。

布局子系统

布局子系统是用来实现菜单和导航栏的Vue工程,本质上和一般的业务子系统没有区别。只需要注意,布局子系统使用的是LayoutContainer而非AppContainer进行包装。

import { LayoutContainer } from '@sfe/bifrost'
import Vue from 'vue'

import App from './app'

new LayoutContainer({
  appName: 'layout',
  router,
  onInit ({ appSlot, callback }) {
    Vue.config.productionTip = false
    const app = new Vue({
      el: '#app',
      router,
      store,
      render: (h) => (
        <App appSlot={appSlot} />
      )
    })
    callback()
  }
}).start()

布局子系统作为主系统的一部分,既可以放在主系统中去实现,也可以像其他子系统一样通过异步的方式去加载。在我们的项目中,结合了上面两种方式(布局子系统既可以为作为常规的Vue项目构建,也可以发布成NPM包),每次发布时,会同时发布布局的静态资源和NPM包。主系统通过NPM包的方式引入布局子系统,将它打包到项目中,避免线上运行时,额外加载布局子系统的资源,减小项目体积,加快渲染速度。

本地开发时,我们则会通过Bifrost定义的MockPlatform异步加载布局子系统的静态资源,保证线上/线下运行效果的一致性,方便本地联调。

工程实践

代码层面的改动虽然不多,但要在实际的生产环境中落地,还需要解决一系列老生常谈的问题,包括:

  • 本地开发时,如何保证与线上实际运行效果的一致性?
  • 如何实现全局状态管理和子系统之间的通信?
  • 如何对公共依赖和公共模块进行管理?
  • 发布部署流程需要怎样调整?

根据闪购业务实践,我们总结了一套适用于Bifrost的解决方案。

本地联调

采用微前端的方式意味着子系统的完全隔离,这给我们的开发带来了一系列困扰:

  • 本地开发时,无法看到当前开发的功能在主系统中实际运行的效果。
  • 子系统之间有时会存在跳转关系,在开发阶段难以验证这种跳转逻辑的正确性。

为了解决这些问题,Bifrost定义了MockPlatform。MockPlatform的思路很简单:既然主系统可以动态加载线上的子系统,那么我们只需要在开发时,模拟主系统的运行方式,去加载其他子系统的线上资源,之后就可以像调用后端API一样同各个子系统进行联调了。这也就解释了为什么布局子系统在输出NPM包的同时还维护了一份静态资源。

MockPlatform的API同Platform对象的API是一致的,开发时,我们只需要按照主系统的方式引用布局或业务子系统的配置文件URL即可:

// ...others...

new AppContainer({
  // ...others...
  runDevPlatform: process.env.NODE_ENV === 'development', // 只在开发环境下启动mock platform
  devPlatformConfig: {
    layoutFrame: {
      mode: 'remote',
      configPath: 'path/to/layout/config.js'
    },
    appRegister: [{
      appName: 'app2',
      mode: 'remote',
      configPath: 'path/to/app2/config.js'
    }]
  },
  // ...others...
}).start()

借助MockPlatform,我们项目在开发阶段的感受和开发普通的单页应用没有任何差异,如果某个我们依赖的子系统更新了功能,只需要让对应的RD发布一下,就可以在本地看到它的最新效果。

全局状态

除了本地联调,全局通信也是微前端项目中绕不开的一个话题。由于我们所有项目采用的都是Vue技术栈,所以会选择基于Vuex来实现全局通信。Bifrost的主系统会维护一个全局的Vuex Store,用于保存全局状态。

当子系统希望监听全局状态时,子系统并不是直接订阅全局Store(Vue的依赖收集机制也决定了子系统无法响应全局Store的变化),而是借助Bifrost提供的syncGlobalStore函数来订阅全局Store。调用该函数后,任何全局状态的变化都会被同步到本地Store的Global命名空间下。之后,就可以像普通的单页应用那样,调用Vuex的mapState方法实现和全局状态的双向绑定。

import { AppContainer, syncGlobalStore } from '@sfe/bifrost'
import Vue from 'vue'
import Vuex from 'vuex'
// ...others...
Vue.use(Vuex)
const store = new Vuex.Store({})
new AppContainer({
  mounted () {
    // 同步全局store状态
    syncGlobalStore(store)
    
    // ...others...
    return new Vue({
      store,
      router,
      components: { App },
      template: '<App/>'
    }).$mount()
  }
}).start()

如果子系统自身的状态需要共享,Bifrost还会提供installGlobalModule函数。该函数会将当前子系统需要共享的状态挂载到全局Store下,其他子系统可以通过前面提到的方式来同步这些状态。虽然Bifrost提供了子系统通信的能力,但在实际拆分子系统时,应该尽量避免这种情况发生。如果两个子系统之间需要频繁通信,那就应该考虑把他们划分到同一个子系统。

公共依赖

由于各个子系统都需要集成到企管平台,为了保证体验的一致性,大家都是基于同样的组件库进行开发。几乎所有项目都会依赖lodash、Moment等基础库,因此如果不对公共依赖进行管理,项目会加载大量冗余代码。

针对这个问题,我们采用的是Webpack External方式来解决。构建时,各个子系统会将公共依赖排除,主系统会打包一份包含所有这些公共依赖的DLL文件。子系统在运行时,直接从全局引用对应的依赖。如果子系统希望使用某些库的特定版本,也可以选择不排除这些依赖项。这在子系统希望升级某些依赖库的时候显得极为有用:通过子系统的局部升级,可以限制依赖库升级的影响范围,避免造成全局影响。

DLL文件会包含大部分公共依赖,但有一个例外——我们不会将Vue打到DLL文件中。因为在实际开发中,很多库都喜欢向Vue的原型链上挂载方法和属性。如果不同团队开发时挂载的内容恰好用到同一个字段,就会带来不可预知的影响。

模块复用

除了底层的依赖,我们还需要考虑对公共的业务模块和工具函数进行复用。在企管平台,我们为公共业务组件库和公共函数库创建独立的Git工程,然后将所有的子系统和公共模块通过Git Submodule的方式引入到主系统的工程中。主系统采用Lerna的方式组织代码,各个子系统在开发时,可以通过软链直接引用到本地公共模块的代码,实现公共模块的复用。当公共模块发生更新,直接调用Lerna Publish就可以同时更新所有子系统package.json中依赖版本。

发布及部署流程

前面提到,主系统采用的是JSONP方式加载子系统的配置文件,整个发布过程都只需要发布静态资源,因此,Talos(美团内部自研的持续集成平台)提供的前端静态资源发布的能力就可以满足我们的需求。每次发布时,只需要构建有更新的项目,并将打包后的静态资源上传到CDN即可。

版本控制

采用微前端架构还有一个额外的好处:在Nginx和实际的业务层之间,多了一层主系统,我们可以像客户端一样,动态决定需要加载的子系统版本。基于此,我们实现了子系统的版本控制和定向灰度功能。发布时,我们通过参数确定本次发布是否是灰度版本。在发布成功后,会记录本次发布的灰度信息、版本和配置文件URL等信息。

主系统每次启动时,首先会调用接口确定当前用户所处的链路(全量/灰度),再根据链路信息加载相应的子系统。我们记录了每次发布的资源URL,所以也支持子系统的版本切换。只需要在版本服务中修改各条链路上需要激活的子系统版本,就可以轻松实现子系统版本切换。

埋点及错误上报

这里我们主要讨论Bifrost框架的埋点方案。在Bifrost项目中,可以借助主系统提供的一系列钩子函数实现针对子系统的埋点,包括:onAppLoading、onAppLoaded、onAppRouting、onError。每当子系统发生切换都会调用onAppRouting函数,因此我们可以在这里记录子系统加载的次数(PV)。onAppLoading和onAppLoaded则会在子系统初次加载时调用,通过计算Loading和Loaded成功率的比值,我们可以得到子系统加载的成功率。子系统加载失败时,会调用onError函数,帮助排查子系统加载失败的原因。

收益

今年年初,我们对企管平台进行了微前端改造,目前系统已经在线上平稳运行半年时间,支持上百个零售商品牌,上千家门店业务的运转。

采用微前端架构,给我们项目带来的好处是显而易见的:

  • 实现了异地合作开发时的完全解藕。采用微前端架构之后,两地团队在开发过程中再也没有遇到代码冲突的问题。
  • 避免了单页应用发展成“巨石”应用。目前,企管平台总共实现了上百个页面,采用微前端的方式进行划分后,每个子系统包含的页面都不超过三十个,子系统的可维护性得到大大提高。
  • 今年企管平台经历了两次大的组件库版本升级。第一次升级时,项目还是单页应用,我们在暂停业务开发的基础上,耗费了大约一周的时间对所有的页面进行回归验证、完成升级。第二次升级时,我们已经完成了项目的微前端改造,可以通过增量的方式,先升级不常用的子系统,验证通过后再升级其他子系统。这样既不用中断正常的业务开发,也保证了依赖库升级时的影响范围和风险可控。

不是“银弹”

当然,同所有的架构方案一样,微前端这种模式也存在一些折衷和妥协。在获得低耦合和灵活性的同时,也引入了额外的复杂度。在微前端项目中,我们需要考虑多个工程的规范和代码质量的统一,需要引入更多的自动化工具来管理项目的发布部署流程,还需要处理多个前端工程运行在同一个域名下引起的Cookie覆盖等问题。

因此,在采用微前端架构之前,建议大家要谨慎的评估自己的项目是否真的适合采用微前端的方式,避免盲目引入微前端导致项目难以维护,得不偿失。

我们认为,如果项目中存在以下两个场景,比较适合采用微前端架构:

  • 功能模块较多,且各个功能模块相对较为独立的中后台系统。
  • 项目存在大量历史遗留问题,希望在保留已有功能的基础上,开发新的功能模块。

其他大部分项目都可以通过调整代码结构,构建单页应用,甚至采用最传统的多页应用等方式来进行优化、调整,从来达到降低耦合的目的。微前端并不是“银弹”。

期许

从去年12月立项至今,Bifrost经历了近一年的迭代,发布了2个大版本和38个小版本。诞生之初,Bifrost仅仅是针对企管平台这个特定业务场景的微前端方案。如今,已进化为面向Vue技术栈的通用微前端框架。期间,我们围绕Bifrost,逐步完善了整个微前端技术体系的建设,实现了Bifrost主/子系统的脚手架工程和命令行工具、子系统的管理平台、灰度发布功能等一系列平台和工具,完成了Bifrost微前端生态的雏形。

当然,Bifrost依然还有很多可以提升的地方。未来,我们将会从以下几个方面进一步完善Bifrost:

  • 提供更加完善的前端微服务治理工具。
  • 实现JS和CSS沙盒。
  • 支持更多的技术栈。

结语

随着前端工程的日益复杂,我们对可扩展的前端架构的诉求也变得更加强烈。微前端作为一种前端解藕的方案,自然更加频繁地被大家所提及和应用。另一方面,虽然网上已经有了很多关于微前端的讨论,但依然缺乏真正落地到生产环境的案例。因此,我们希望通过对闪购团队近半年在微前端方案上的实践分享,帮助大家对微前端从概念到应用有一个更加清晰的认识,也期待与大家一起交流,碰撞出更多的火花。

作者简介

雨甫,美团闪购前端研发工程师。

招聘

美团闪购是美团点评旗下的零售到家业务,闪购专注于为消费者提供丰富、便捷的零售品类选择和及时配送服务,为零售商家提供线上、线下的整体解决方案,助力商家提升经营效率。

用户通过手机下单即可快速买到周边各类商家提供的丰富商品,涵盖食材生鲜、超市便利、鲜花绿植、母婴用品和健康护理等众多品类。美团闪购与美团外卖共享配送网络,平均30分钟配送上门,24小时无间断配送,打造全品类一站式零售到家平台。

美团闪购前端团队诚招高级前端开发、前端开发专家。欢迎各位大佬的加入,共同打造极致的LBS电商体验。感兴趣同学可投递简历至:tech@meituan.com(邮件标题注明:美团闪购前端团队)