Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks.—— Micro Frontends

一种可以让多个团队使用不同JavaScript框架的技术、策略和方法 —— 微前端

一、前言

丁香人才运营管理后台是一个面向运营使用的后台管理系统,方便运营在后台去配置一些运营活动从而减少开发手动写代码去配置功能的问题。管理后台的前端技术架构是一个基于vue全家桶的多页应用,重构前有22个独立入口页面,并未做相关整合。

由于页面之间的互相跳转,应用之间切换造成了浏览器重刷,运营在流程操作上存在断点。同时产品也希望想将要原先分散在不同入口之间的应用整合到一个页面,目前还存在多种不同组合的情况。

如果每个入口都增加一个侧边栏组件,开发成本就是N * repeat。而基于原先的多页应用,使用微前端的架构可以平滑过渡,同时也不会产生侧边栏代码一式多份的问题。借着这个机会我在微前端做出了一些探索。

二、当前的痛点

  1. 客户 or 产品需求(定制化)

    1. 由于产品之间相互跳转,应用之间切换会造成浏览器重刷,流程体验上会存在断点,这也是运营和产品上的痛点 ✅
    2. 每个功能模块比较独立、有特色。如果说将来需要拆分组成一个大单页的话,拆分+组合工作量大,后期tab菜单再整合的时候还需要继续拆分+组合。 ✅
  2. 开发&运维成本

    1. 每次开发一个新模块都需要向后端提供一个新入口文件,后端告知前端路由地址,新模块部署成本。 ✅
    2. (未来)多人合作开发同一种产品,但现有发布的耦合性降低了交付效率
  3. 多页应用(multi-page application)的问题

    1. 使用多页应用的方式去搭建后台产品,随着应用的复杂度的提升,代码的构建变得越来越慢 ✅
    2. 公共资源无法复用,页面刷新时,静态资源需要反复加载,降低了交互体验 ✅
    3. 多个页面状态共享变得很困难 ✅

相对比SPA:

SPA 则天生具备体验上的优势,应用直接无刷新切换,能极大的保证多产品之间流程操作串联时的流程性。缺点则在于各应用技术栈之间是强耦合的。
所以有没有一种办法既可以保证拥有单页应用的体验,又可以兼容多个技术战的兼容呢?
答案是微前端。

三、微前端是什么?

微前端是一种新架构风格,最早由 ThoughtWorks 于2016年提出,将后端微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。 其中众多独立交付的前端应用组合成一个大型整体。

image.png

在 ThoughtWorks 正式发布的最新一期(2019年4月)技术雷达中,微前端已经进入到采纳(adopt)阶段,而且它强烈推荐我们在合适的项目中应该使用这个方案。

image.png

四、微前端的价值

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用( Frontend Monolith )后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

  • 技术选型灵活:主框架不限制接入应用的技术栈,可以使用市面上所有的前端技术栈子应用具备完全自主权
  • 独立开发、独立部署:子应用仓库独立,每个前端应用都可独立开发,部署完成后主框架自动完成同步更新
  • 独立运行:每个子应用之间状态隔离,运行时状态不共享,只需要遵循统一的接口规范或者框架,相互之间是不存在依赖关系的。
  • 容错:单个应用模块挂了不会影响其他模块
  • 可扩展性:单每一个服务可以独立横向扩展满足业务伸缩性

五、透视微前端架构

微前端有多种实现方式,这次主要讲现在社区里用的比较多的一种实现方式——使用模块加载器聚合多个单页应用这种方式。

image.png

这种实现方式下,核心的模块加载器在路由规则匹配的条件下负责注册装载卸载子应用。
相比较于单页应用是应用分发路由,微前端的实现方式则是路由分发应用。

六、微前端两种集成方式

讲完架构上的特点,我们聊一聊微前端在开发到构建流程上的一个变化。

1.构建时集成

在开发时,应用都是以单一、微小应用的形式存在,而在运行时,则通过构建系统合并这些应用,组合成一个新的应用。

image.png

这种开发模式有以下特点:

  1. 代码由统一仓库管理,只有一个git项目托管
  2. 具体不同应用开发通过git 功能分支去约束
  3. 使用构建系统实现依赖的打包和分离
  4. 比较适合项目初期代码量不是很大,模块少,编译时间短的情况下

优点:
✅ 依赖版本很容易管理,也能享受构建工具在依赖和code split上带来的好处
✅ 一次最多只需要构建一个项目

缺点:
⚠️在开发模块时改动portal的顶层路由等会产生冲突
⚠️代码构建时间跟着模块增加而增加

2.运行时集成

子应用自己打包,并将各个代码自己上传到服务器上,由在运行时,我们只需要加载相应的业务模块即可。对应的,在更新代码的时候,我们只需要更新对应的模块即可。

image.png

3.两者对比

image.png

七、技术方案

对于我来说,这些东西都是可选择的,比如独立部署和运行是适合项目扩展到多团队,大代码库的方案,那现在还是可以使用内置构建集成的方式,享受一下webpack tree-shaking 和 code Split 的便利,只不过说在代码层面要做好子应用的概念,这样在抽离成为可以独立部署的模块时的再次改造成本不会很大。

目前我选择了

  • 子应用使用统一的技术栈vue
  • 大仓库模式(构建时集成)
  • vuex 作为状态管理(原先是redux)
  • singleSPA.js 作为模块加载器

微前端有许多实现方式,今天我们只讲基于 singleSPA 框架做的「构建时集成」的整合方案。

singleSPA 是一个模块加载器,在portal 页面的管理其他框架的生命周期,加载或者卸载他们。

1.建立门户(portal)应用


image.png

“Portal项目”提供注册的接口,“子项目”进行注册,最终聚合成一个类单页应用。在整套机制中,比较核心的部分是路由注册机制,“子项目”的路由应该由自己控制,而整个系统的导航是“Portal项目”提供的。

同时,我们还要

  • 呈现公用的页面元素,比如侧边栏、头部栏目
  • 路由功能,告诉每个微应用何时启动
  • 封装共全局调用的底层方法和服务
  • 解决鉴权问题

代码目录的重新规划

以类似单页应用的结构规划portal应用

image.png

安装依赖

项目中引入 singleSPA 和其提供的vue实例注册插件

  1. npm i single-spa single-spa-vue -d

启动文件 app.js

  1. import { registerApplication, start } from 'single-spa';
  2. registerApplication(
  3. "applicationName", // 应用唯一名字
  4. ()=>import("view/app1/main.js"), //以异步组件的方式引进来
  5. location.pathname.indexOf("/app1/") === 0 // 应用在路由匹配时展示,
  6. );
  7. //启动应用
  8. start();

模板文件 index.html

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1.0">
  6. <title>丁香人才管理后台 - jobmd_admin</title>
  7. </head>
  8. <body>
  9. <div id="app">
  10. <!-- 此部分动态插入,减少对后端依赖
  11. <div id="sideBar"></div>
  12. <div class="container">
  13. <div id="container"></div>
  14. </div>
  15. </div>
  16. -->
  17. <!-- webpack script files will be auto injected -->
  18. </body>
  19. </html>

2.包装现有vue应用

vue模块

  1. //page1/page1.app.js
  2. import Vue from 'vue';
  3. import App from './App.vue';
  4. import router from './router';
  5. import store from 'store';
  6. import singleSpaVue from 'single-spa-vue';
  7. const vueLifecycles = singleSpaVue({
  8. Vue,
  9. appOptions: {
  10. render: h => h(App),
  11. el: '#container'
  12. router,
  13. },
  14. });
  15. export const bootstrap = vueLifecycles.bootstrap;
  16. export const mount = vueLifecycles.mount;
  17. export const unmount = vueLifecycles.unmount;

到这里,最简单的只有一个微应用的项目已经完成了,有木有很简单?但多个应用之间切换其实还有很多东西要做,路由的切换得自己写。

3.公共应用-侧边栏

image.png

  1. 侧边栏是portal常显的子应用,所以在 app.js 使用 registerApplication 注册的时候,第三参数需要始终为 true 这样即使路由变化,侧边栏页永远不会消失。
  1. // side模块
  2. registerApplication("sideBar", ()=>import("view/app1/sideBar.app.js"),
  3. true // 始终展示则返回true
  4. );
  5. // 其他模块
  6. registerApplication("otherApplication", ()=>import("view/app1/otherApplication.app.js"),
  7. () => location.pathname.indexOf("/otherApplication/") === 0
  8. );
  1. 侧边栏有多种组合方式,所以需要使用配置项来维护。
运营C端 任务中心 MHR小程序 简历定制
image.png image.png image.png image.png
  1. export default {
  2. // 根路由重定向
  3. homeRedirect: '#/miniProgramMHR/userManage',
  4. /**
  5. * 以下配置menu
  6. * 注意:key值不能相同,不然这里注册的高亮和展开二级菜单必定显示不正常
  7. * */
  8. menuList: [
  9. {
  10. key: 'home',
  11. menuName: '管理后台首页',
  12. path: '/admin/guessoneguess.do' // 外链不以#开头
  13. },
  14. {
  15. key: 'miniProgramMHR',
  16. menuName: 'MHR小程序管理',
  17. children: [
  18. {
  19. menuName: '用户管理',
  20. key: 'userManage',
  21. path: '#/miniProgramMHR/userManage',
  22. component: () => import('../../view/miniProgramMHR/userManage/app.js')
  23. }
  24. ]
  25. }
  26. ]
  27. }

4.路由和组件注册

因为考虑到url的history模式对服务器有改造成本,所以下面都是以hash模式去做路由的管理。
应用的路由在singleSPA里注册,“应用的子项目”的路由应该由自己控制

image.png

query部分

区分不同的侧边栏,从而加载不同的侧边栏配置

image.png

hash部分

用来定义父子路由

父路由 router.mhr.js

  1. menuList: [
  2. {
  3. key: 'home',
  4. menuName: '管理后台首页',
  5. path: '/admin/guessoneguess.do' // 外链不以#开头
  6. },
  7. {
  8. key: 'miniProgramMHR',
  9. menuName: 'MHR小程序管理',
  10. children: [
  11. {
  12. menuName: '用户管理',
  13. key: 'userManage',
  14. path: '#/miniProgramMHR/userManage',
  15. component: () => import('../../view/miniProgramMHR/userManage/app.js')
  16. }
  17. ]
  18. }
  19. ]

子路由 page1/page1.router.js

  1. import Router from 'vue-router'
  2. export default new Router({
  3. routes: [
  4. {
  5. path: '/miniProgramMHR/userManage',
  6. name: 'list',
  7. component: List
  8. },
  9. {
  10. path: '/miniProgramMHR/userManage/detail',
  11. name: 'detail',
  12. component: Detail
  13. },
  14. {
  15. path: '/miniProgramMHR/userManage/audit',
  16. name: 'audit',
  17. component: Detail
  18. },
  19. {
  20. path: '/miniProgramMHR/userManage/edit',
  21. name: 'edit',
  22. component: Detail
  23. }
  24. ]
  25. })

全局状态管理方案

侧边栏应用需要tab名称、父子之间的级联关系和对应的路由,而portal应用则需要知道当前注册哪些组件,对应的路由关系如何,这部分放在portal应用层面去做会比较好,所以他们之间的共享状态我们需要通过一个状态管理去共享,路由变化后,由portal 告诉侧边栏,展示哪些配置项,当前哪个是高亮选项。

image.png

因为使用了vue全家桶,vuex用起来就很得心应手,但如果是多种技术栈的话,redux也可以选择

需要的子应用注入store

  1. import store from '@/store/index'
  2. const vueLifeCycles = singleSpaVue({
  3. Vue,
  4. appOptions: {
  5. render: h => h(App),
  6. el: '#sideBar',
  7. store
  8. }
  9. })

portal 应用 app.js在路由变化时commit事件

  1. import store from './store/index'
  2. // 设置高亮模块
  3. window.addEventListener('single-spa:routing-event', () => {
  4. // 根路由的话跳转
  5. if (location.hash === '#/' || location.hash === '') {
  6. navigateToUrl(homeRedirect)
  7. return
  8. }
  9. store.commit('SET_SIDEBAR', {menu: menuList, highlightTab: getCurrentMenuName(menuList)})
  10. })

6.公共服务

现在每个子应用都是独立的,会存在共享一些底层服务的情况,比如我们封装一个axios的 request.js 去拦截一些异常情况统一处理,一些公共服务比如请求loading,toast就比较适合放在portal这层

通过将实例挂载在window上实现方法上的共享

  1. export const showLoading = window.$service.showLoading
  2. export const hideLoading = window.$service.hideLoading
  3. export const Message = window.$service.Message

7.样式隔离

多个子应用之间开发,最不能出现的就是样式之间的相互引用和覆盖,在这一层面上,需要极力去避免,我们要防止这种情况的发生,目前还是通过vue scoped style 去避免,后期应该使用babel在打包阶段就加上命名空间。

image.png

8.公共依赖抽离

image.png

9.完整版 app.js

  1. import { start, registerApplication, navigateToUrl } from 'single-spa'
  2. import { matchRoute, getRouteMap, getCurrentMenuName } from './router/utils'
  3. import store from './store/index'
  4. import getUrlQuery from '@/single-spa/utils/getUrlQuery'
  5. import {sideBarComponent, sideBarMenu} from './router/index'
  6. import './utils/service' // 全局提示组件
  7. import 'iview/dist/styles/iview.css'
  8. import './style/index.less'
  9. /**
  10. * 动态插入dom挂载节点,减少对后端依赖
  11. * <div id="app">
  12. * <div id="sideBar"></div>
  13. * <div class="container">
  14. * <div id="container"></div>
  15. * </div>
  16. * </div>
  17. * */
  18. function initDomHook() {
  19. const frag = document.createDocumentFragment()
  20. const sideBarEl = document.createElement('DIV')
  21. const containerEl = document.createElement('DIV')
  22. const appEl = document.createElement('DIV')
  23. sideBarEl.id = 'sideBar'
  24. appEl.id = 'container'
  25. containerEl.className = 'container'
  26. frag.appendChild(sideBarEl)
  27. frag.appendChild(containerEl)
  28. frag.querySelector('.container').append(appEl)
  29. document.querySelector('#app').append(frag)
  30. }
  31. // 绑定路由事件
  32. function bindRouterEvent(menuList, homeRedirect) {
  33. // 设置高亮模块
  34. window.addEventListener('single-spa:routing-event', () => {
  35. // 根路由的话跳转
  36. if (location.hash === '#/' || location.hash === '') {
  37. navigateToUrl(homeRedirect)
  38. return
  39. }
  40. store.commit('SET_SIDEBAR', {menu: menuList, highlightTab: getCurrentMenuName(menuList)})
  41. })
  42. }
  43. // 注册路由和侧边栏
  44. function registerRouter() {
  45. // 根据当前路由注册app
  46. registerApplication(sideBarComponent.key, sideBarComponent.component, () => true)
  47. // 注册当前的menu菜单
  48. const currentApp = getUrlQuery('app') // exp: ?app="2c"
  49. const currentAppCfg = sideBarMenu[currentApp]
  50. if (!currentApp) {throw new Error('未发现?app=参数,应用启动失败')}
  51. if (!currentAppCfg) {throw new Error(`未找到?app=${currentApp} 匹配的路由,应用启动失败`)}
  52. const { menuList, homeRedirect } = currentAppCfg
  53. Object.values(getRouteMap(menuList)).forEach(item => {
  54. item.component && registerApplication(item.key, item.component, () => matchRoute(item.path))
  55. })
  56. bindRouterEvent(menuList, homeRedirect)
  57. window.store = store
  58. }
  59. ;(function init() {
  60. registerRouter()
  61. initDomHook()
  62. start()
  63. })()

八、总结


  1. 我们如何实现在一个页面里渲染多种技术栈? singleSPA
  2. 不同独立模块之间如何通讯? 现成的状态管理使用全局状态管理注入各个子应用
  3. 如何通过路由渲染到正确的模块? 通过自己定义的路由规则,通过hash路由的方式管理子模块
  4. 在不同子应用之间的路由该如何正确触发? 统一位置管理,防止命名冲突
  5. 项目代码别切割之后,通过何种方式合并到一起? 构建时集成,分包通过异步组件加载过来
  6. 前端微服务化后我们该如何编写我们的代码? 去router定义顶层路由,通过hash子路由管理自己路由
  7. 独立团队之间该如何协作? git分支功能分支

九、TODO

  • 权限树
  • 动态增减应用依赖
  • vendor打包只打包公共依赖,各自依赖自己管理
  • cli 使开发新模块和新项目更简单
  • css 作用域的严格隔离
  • contentHash
  • 运行时依赖

参考: