Last updated on May 21, 2024 pm
Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks.—— Micro Frontends
一种可以让多个团队使用不同JavaScript框架的技术、策略和方法 —— 微前端
一、前言
丁香人才运营管理后台是一个面向运营使用的后台管理系统,方便运营在后台去配置一些运营活动从而减少开发手动写代码去配置功能的问题。管理后台的前端技术架构是一个基于vue全家桶的多页应用,重构前有33 个独立入口页面,并未做相关整合。
由于页面之间的互相跳转,应用之间切换造成了浏览器重刷,运营在流程操作上存在断点。同时产品也希望想将要原先分散在不同入口之间的应用整合到一个页面,目前还存在多种不同组合的情况。
如果每个入口都增加一个侧边栏组件,开发成本就是N * repeat。而基于原先的多页应用,使用微前端的架构可以平滑过渡,同时也不会产生侧边栏代码一式多份的问题。借着这个机会我在微前端做出了一些探索。
二、当前的痛点
- 客户 or 产品需求(定制化)
- 由于产品之间相互跳转,应用之间切换会造成浏览器重刷,流程体验上会存在断点,这也是运营和产品上的痛点 ✅
- 每个功能模块比较独立、有特色。如果说将来需要拆分组成一个大单页的话,拆分+组合工作量大,后期tab菜单再整合的时候还需要继续拆分+组合。 ✅
- 开发&运维成本
- 每次开发一个新模块都需要向后端提供一个新入口文件,后端告知前端路由地址,新模块部署成本。 ✅
- (未来)
多人合作开发同一种产品,但现有发布的耦合性降低了交付效率 ✅
- 多页应用(multi-page application)的问题
- 使用多页应用的方式去搭建后台产品,随着应用的复杂度的提升,代码的构建变得越来越慢 ✅
- 公共资源无法复用,页面刷新时,静态资源需要反复加载,降低了交互体验 ✅
- 多个页面状态共享变得很困难 ✅
相对比SPA:
SPA 则天生具备体验上的优势,应用直接无刷新切换,能极大的保证多产品之间流程操作串联时的流程性。缺点则在于各应用技术栈之间是强耦合的。
所以有没有一种办法既可以保证拥有单页应用的体验,又可以兼容多个技术战的兼容呢?
答案是微前端。
三、微前端是什么?
微前端是一种新架构风格,最早由 ThoughtWorks 于2016年提出,将后端微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。 其中众多独立交付的前端应用组合成一个大型整体。
在 ThoughtWorks 正式发布的最新一期(2019年4月)技术雷达中,微前端已经进入到采纳(adopt)阶段,而且它强烈推荐我们在合适的项目中应该使用这个方案。
四、微前端的价值
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用( Frontend Monolith )后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
- 技术选型灵活:主框架不限制接入应用的技术栈,可以使用市面上所有的前端技术栈子应用具备完全自主权
- 独立开发、独立部署:子应用仓库独立,每个前端应用都可独立开发,部署完成后主框架自动完成同步更新
- 独立运行:每个子应用之间状态隔离,运行时状态不共享,只需要遵循统一的接口规范或者框架,相互之间是不存在依赖关系的。
- 容错:单个应用模块挂了不会影响其他模块
- 可扩展性:单每一个服务可以独立横向扩展满足业务伸缩性
五、透视微前端架构
微前端有多种实现方式,这次主要讲现在社区里用的比较多的一种实现方式——使用模块加载器聚合多个单页应用这种方式。
这种实现方式下,核心的模块加载器在路由规则匹配的条件下负责注册、装载和卸载子应用。
相比较于单页应用是应用分发路由,微前端的实现方式则是路由分发应用。
六、微前端两种集成方式
讲完架构上的特点,我们聊一聊微前端在开发到构建流程上的一个变化。
1.构建时集成
在开发时,应用都是以单一、微小应用的形式存在,而在运行时,则通过构建系统合并这些应用,组合成一个新的应用。
这种开发模式有以下特点:
- 代码由统一仓库管理,只有一个git项目托管
- 具体不同应用开发通过git 功能分支去约束
- 使用构建系统实现依赖的打包和分离
- 比较适合项目初期代码量不是很大,模块少,编译时间短的情况下
优点:
✅ 依赖版本很容易管理,也能享受构建工具在依赖和code split上带来的好处
✅ 一次最多只需要构建一个项目
缺点:
⚠️在开发模块时改动portal的顶层路由等会产生冲突
⚠️代码构建时间跟着模块增加而增加
2.运行时集成
子应用自己打包,并将各个代码自己上传到服务器上,由在运行时,我们只需要加载相应的业务模块即可。对应的,在更新代码的时候,我们只需要更新对应的模块即可。
3.两者对比
七、技术方案
对于我来说,这些东西都是可选择的,比如独立部署和运行是适合项目扩展到多团队,大代码库的方案,那现在还是可以使用内置构建集成的方式,享受一下webpack tree-shaking 和 code Split 的便利,只不过说在代码层面要做好子应用的概念,这样在抽离成为可以独立部署的模块时的再次改造成本不会很大。
目前我选择了
- 子应用使用统一的技术栈vue
- 大仓库模式(构建时集成)
- vuex 作为状态管理(原先是redux)
- singleSPA.js 作为模块加载器
微前端有许多实现方式,今天我们只讲基于 singleSPA 框架做的「构建时集成」的整合方案。
singleSPA 是一个模块加载器,在portal 页面的管理其他框架的生命周期,加载或者卸载他们。
1.建立门户(portal)应用
“Portal项目”提供注册的接口,“子项目”进行注册,最终聚合成一个类单页应用。在整套机制中,比较核心的部分是路由注册机制,“子项目”的路由应该由自己控制,而整个系统的导航是“Portal项目”提供的。
同时,我们还要
- 呈现公用的页面元素,比如侧边栏、头部栏目
- 路由功能,告诉每个微应用何时启动
- 封装共全局调用的底层方法和服务
- 解决鉴权问题
代码目录的重新规划
以类似单页应用的结构规划portal应用
安装依赖
项目中引入 singleSPA 和其提供的vue实例注册插件
1
| npm i single-spa single-spa-vue -d
|
启动文件 app.js
1 2 3 4 5 6 7 8 9 10
| import { registerApplication, start } from 'single-spa';
registerApplication( "applicationName", ()=>import("view/app1/main.js"), location.pathname.indexOf("/app1/") === 0 );
start();
|
模板文件 index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>丁香人才管理后台 - jobmd_admin</title> </head> <body> <div id="app">
</body> </html>
|
2.包装现有vue应用
vue模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import Vue from 'vue'; import App from './App.vue'; import router from './router'; import store from 'store'; import singleSpaVue from 'single-spa-vue';
const vueLifecycles = singleSpaVue({ Vue, appOptions: { render: h => h(App), el: '#container' router, }, });
export const bootstrap = vueLifecycles.bootstrap; export const mount = vueLifecycles.mount; export const unmount = vueLifecycles.unmount;
|
到这里,最简单的只有一个微应用的项目已经完成了,有木有很简单?但多个应用之间切换其实还有很多东西要做,路由的切换得自己写。
3.公共应用-侧边栏
- 侧边栏是portal常显的子应用,所以在
app.js
使用 registerApplication
注册的时候,第三参数需要始终为 true
这样即使路由变化,侧边栏页永远不会消失。
1 2 3 4 5 6 7 8 9 10
| registerApplication("sideBar", ()=>import("view/app1/sideBar.app.js"), true );
registerApplication("otherApplication", ()=>import("view/app1/otherApplication.app.js"), () => location.pathname.indexOf("/otherApplication/") === 0 );
|
- 侧边栏有多种组合方式,所以需要使用配置项来维护。
运营C端
任务中心
小程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| export default { homeRedirect: '#/miniProgramMHR/userManage',
menuList: [ { key: 'home', menuName: '管理后台首页', path: '/admin/guessoneguess.do' }, { key: 'miniProgramMHR', menuName: 'MHR小程序管理', children: [ { menuName: '用户管理', key: 'userManage', path: '#/miniProgramMHR/userManage', component: () => import('../../view/miniProgramMHR/userManage/app.js') } ] } ] }
|
4.路由和组件注册
因为考虑到url的history模式对服务器有改造成本,所以下面都是以hash模式去做路由的管理。
应用的路由在singleSPA里注册,“应用的子项目”的路由应该由自己控制
query部分
区分不同的侧边栏,从而加载不同的侧边栏配置
hash部分
用来定义父子路由
父路由 router.mhr.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| menuList: [ { key: 'home', menuName: '管理后台首页', path: '/admin/guessoneguess.do' }, { key: 'miniProgramMHR', menuName: 'MHR小程序管理', children: [ { menuName: '用户管理', key: 'userManage', path: '#/miniProgramMHR/userManage', component: () => import('../../view/miniProgramMHR/userManage/app.js') } ] } ]
|
子路由 page1/page1.router.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import Router from 'vue-router'
export default new Router({ routes: [ { path: '/miniProgramMHR/userManage', name: 'list', component: List }, { path: '/miniProgramMHR/userManage/detail', name: 'detail', component: Detail }, { path: '/miniProgramMHR/userManage/audit', name: 'audit', component: Detail }, { path: '/miniProgramMHR/userManage/edit', name: 'edit', component: Detail } ] })
|
全局状态管理方案
侧边栏应用需要tab名称、父子之间的级联关系和对应的路由,而portal应用则需要知道当前注册哪些组件,对应的路由关系如何,这部分放在portal应用层面去做会比较好,所以他们之间的共享状态我们需要通过一个状态管理去共享,路由变化后,由portal 告诉侧边栏,展示哪些配置项,当前哪个是高亮选项。
因为使用了vue全家桶,vuex用起来就很得心应手,但如果是多种技术栈的话,redux也可以选择
需要的子应用注入store
1 2 3 4 5 6 7 8 9 10
| import store from '@/store/index'
const vueLifeCycles = singleSpaVue({ Vue, appOptions: { render: h => h(App), el: '#sideBar', store } })
|
portal 应用 app.js在路由变化时commit事件
1 2 3 4 5 6 7 8 9 10 11 12
| import store from './store/index'
window.addEventListener('single-spa:routing-event', () => { if (location.hash === '#/' || location.hash === '') { navigateToUrl(homeRedirect) return } store.commit('SET_SIDEBAR', {menu: menuList, highlightTab: getCurrentMenuName(menuList)}) })
|
6.公共服务
现在每个子应用都是独立的,会存在共享一些底层服务的情况,比如我们封装一个axios的 request.js 去拦截一些异常情况统一处理,一些公共服务比如请求loading,toast就比较适合放在portal这层
通过将实例挂载在window上实现方法上的共享
1 2 3
| export const showLoading = window.$service.showLoading export const hideLoading = window.$service.hideLoading export const Message = window.$service.Message
|
7.样式隔离
多个子应用之间开发,最不能出现的就是样式之间的相互引用和覆盖,在这一层面上,需要极力去避免,我们要防止这种情况的发生,目前还是通过vue scoped style 去避免,后期应该使用babel在打包阶段就加上命名空间。
8.公共依赖抽离
9.完整版 app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
| import { start, registerApplication, navigateToUrl } from 'single-spa' import { matchRoute, getRouteMap, getCurrentMenuName } from './router/utils' import store from './store/index' import getUrlQuery from '@/single-spa/utils/getUrlQuery' import {sideBarComponent, sideBarMenu} from './router/index'
import './utils/service' import 'iview/dist/styles/iview.css' import './style/index.less'
function initDomHook() { const frag = document.createDocumentFragment() const sideBarEl = document.createElement('DIV') const containerEl = document.createElement('DIV') const appEl = document.createElement('DIV')
sideBarEl.id = 'sideBar' appEl.id = 'container' containerEl.className = 'container'
frag.appendChild(sideBarEl) frag.appendChild(containerEl) frag.querySelector('.container').append(appEl)
document.querySelector('#app').append(frag) }
function bindRouterEvent(menuList, homeRedirect) { window.addEventListener('single-spa:routing-event', () => { if (location.hash === '#/' || location.hash === '') { navigateToUrl(homeRedirect) return } store.commit('SET_SIDEBAR', {menu: menuList, highlightTab: getCurrentMenuName(menuList)}) }) }
function registerRouter() { registerApplication(sideBarComponent.key, sideBarComponent.component, () => true)
const currentApp = getUrlQuery('app') const currentAppCfg = sideBarMenu[currentApp]
if (!currentApp) {throw new Error('未发现?app=参数,应用启动失败')} if (!currentAppCfg) {throw new Error(`未找到?app=${currentApp} 匹配的路由,应用启动失败`)}
const { menuList, homeRedirect } = currentAppCfg
Object.values(getRouteMap(menuList)).forEach(item => { item.component && registerApplication(item.key, item.component, () => matchRoute(item.path)) })
bindRouterEvent(menuList, homeRedirect)
window.store = store }
;(function init() { registerRouter() initDomHook() start() })()
|
八、总结
- 我们如何实现在一个页面里渲染多种技术栈? singleSPA
- 不同独立模块之间如何通讯? 现成的状态管理使用全局状态管理注入各个子应用
- 如何通过路由渲染到正确的模块? 通过自己定义的路由规则,通过hash路由的方式管理子模块
- 在不同子应用之间的路由该如何正确触发? 统一位置管理,防止命名冲突
- 项目代码别切割之后,通过何种方式合并到一起? 构建时集成,分包通过异步组件加载过来
- 前端微服务化后我们该如何编写我们的代码? 去router定义顶层路由,通过hash子路由管理自己路由
- 独立团队之间该如何协作? git分支功能分支
九、TODO
- 动态增减应用依赖
- vendor打包只打包公共依赖,各自依赖自己管理
- cli 使开发新模块和新项目更简单
- css 作用域的严格隔离
- contentHash
- 运行时依赖
参考: