
基于 micro-app 的微前端实践
micro-app 是京东零售推出的一款微前端框架,它基于类 WebComponent 进行渲染,从组件化的思维实现微前端,旨在降低上手难度、提升工作效率。它是目前接入微前端成本最低的框架,并且提供了 JS 沙箱、样式隔离、元素隔离、预加载、虚拟路由系统、插件系统、数据通信等一系列完善的功能。
micro-app 与技术栈无关,对前端框架没有限制,任何框架都可以作为基座应用嵌入任何类型的子应用。
本文基于 micro-app@0.8.8,主应用及子应用均为 vue2
# Why Not Iframe
为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
# 开始改造
# 主应用(基座)
安装依赖。
yarn add @micro-zoe/micro-app
1在入口文件引入。
// main.js import microApp from '@micro-zoe/micro-app' microApp.start()
1
2
3新建页面,在页面中嵌入微前端应用。
<!-- my-page.vue --> <template> <div style="height:100%"> <!-- 注入子应用 其中baseroute为路由注册的路径 --> <micro-app :name="app.name" :url="app.url" baseroute="/subapp/gps" :data="microAppData" @error="handleError" @datachange="handleDataChange" ></micro-app> </div> </template> <script> import { handleSubRouteChange } from '@/utils/micro' export default { name: 'subapp-gps', data() { return { // 获取定义的app参数 app: { name: 'subapp', url: 'https://www.subapp.com/sub' }, // 需要下发给子应用的数据 microAppData: { } } }, methods: { handleError() { console.log('gpsapp 加载出错了') }, handleDataChange(e) { const data = e.detail.data // 处理子应用路由切换 if (data.path) { handleSubRouteChange('/subapp/gps' + data.path) } } } } </script>
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注册路由,如果是动态路由,需将微前端页面注册到固定路由中。
{ // 👇 非严格匹配,/subapp/gps/* 都指向 gps 页面 path: '/subapp/gps/*', name: 'subapp-gps', component: () => import('@/views/subapps/gps.vue') }
1
2
3
4
5
6
# 子应用
设置跨域支持
// vue.config.js devServer: { // 必须设置 allowedHosts: 'all', headers: { 'Access-Control-Allow-Origin': '*', } }
1
2
3
4
5
6
7
8设置基础路由
(如果基座是history路由,子应用是hash路由,这一步可以省略)
// main.js import VueRouter from 'vue-router' import routes from './router' const router = new VueRouter({ mode: 'history', // 👇 __MICRO_APP_BASE_ROUTE__ 为micro-app传入的基础路由 base: window.__MICRO_APP_BASE_ROUTE__ || process.env.BASE_URL, routes, })
1
2
3
4
5
6
7
8
9
10设置 publicPath
这一步借助了webpack的功能,避免子应用的静态资源使用相对地址时加载失败的情况,详情参考webpack文档 publicPath (opens new window)
如果子应用不是webpack构建的,这一步可以省略。
步骤1: 在子应用src目录下创建名称为
public-path.js
的文件,并添加如下内容// __MICRO_APP_ENVIRONMENT__和__MICRO_APP_PUBLIC_PATH__是由micro-app注入的全局变量 if (window.__MICRO_APP_ENVIRONMENT__) { // eslint-disable-next-line __webpack_public_path__ = window.__MICRO_APP_PUBLIC_PATH__ }
1
2
3
4
5步骤2: 在子应用入口文件的
最顶部
引入public-path.js
// main.js import './public-path'
1
2监听卸载
// main.js const app = new Vue(...) // 监听卸载操作 window.addEventListener('unmount', function () { app.$destroy() })
1
2
3
4
5
6
7
# 问题及解决
路由的跳转
在不使用
keep-alive
的情况下,主应用通过router.push
可以直接切换子应用的路由,但是会造成子应用的频繁卸载和渲染。考虑到以上问题以及页面对
keep-alive
的需求,使用数据通信来控制子应用路由的跳转。主应用:
/** * 当子应用还未渲染,通过基座控制路由跳转,子应用在初始化时会自己根据url渲染对应的页面 * 当子应用已经渲染,则直接控制子应用进行内部跳转 * @param {*} path 跳转路径 */ export const handleSubPath = path => { const params = path.split('/') const appNameTemp = params[2] const appName = `sub-${appNameTemp}` const replaceStr = `/subapp/${appNameTemp}` let childPath = path.replace(replaceStr, '') !childPath && (childPath = '/') // 防止地址为空 if (!getActiveApps().includes(appName)) { router.push(path) } else { // 主应用通过下发data数据控制子应用跳转 microApp.setData(appName, { path: childPath }) } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19子应用:
// 监听基座下发的数据变化 window.microApp.addDataListener((data) => { // 当基座下发path时进行跳转 if (data.path && data.path !== router.currentRoute.fullPath) { router.push(data.path) window.microApp.dispatch({ name: window.__MICRO_APP_NAME__, path: data.path }) } })
1
2
3
4
5
6
7
8基座和子应用同时使用高德地图,子应用初次渲染正常,卸载 app 后再次进入无法渲染地图。
基于该问题,我提交了一个 issue (opens new window) ,然后解决了。作者说后续版本会修复,不过我还没有测试。
// 监听卸载操作 window.addEventListener('unmount', function () { // 👇 暂时在卸载子应用时,手动删除子应用的 AMap 对象 delete window.rawWindow.AMap app.$destroy() })
1
2
3
4
5
6