基于 micro-app 的微前端实践

15/11/2022 frontend 1.5k words in 5 minutes -

micro-app 是京东零售推出的一款微前端框架,它基于类 WebComponent 进行渲染,从组件化的思维实现微前端,旨在降低上手难度、提升工作效率。它是目前接入微前端成本最低的框架,并且提供了 JS 沙箱、样式隔离、元素隔离、预加载、虚拟路由系统、插件系统、数据通信等一系列完善的功能。

micro-app 与技术栈无关,对前端框架没有限制,任何框架都可以作为基座应用嵌入任何类型的子应用。

本文基于 micro-app@0.8.8,主应用及子应用均为 vue2

# Why Not Iframe

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

# 开始改造

# 主应用(基座)

  1. 安装依赖。

    yarn add @micro-zoe/micro-app
    
    1
  2. 在入口文件引入。

    // main.js
    import microApp from '@micro-zoe/micro-app'
    microApp.start()
    
    1
    2
    3
  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
  4. 注册路由,如果是动态路由,需将微前端页面注册到固定路由中。

     {
         // 👇 非严格匹配,/subapp/gps/* 都指向 gps 页面
         path: '/subapp/gps/*',
         name: 'subapp-gps',
         component: () => import('@/views/subapps/gps.vue')
     }
    
    1
    2
    3
    4
    5
    6

# 子应用

  1. 设置跨域支持

    // vue.config.js
    devServer: {
      // 必须设置
      allowedHosts: 'all',
      headers: {
        'Access-Control-Allow-Origin': '*',
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
  2. 设置基础路由(如果基座是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
  3. 设置 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
  4. 监听卸载

    // main.js
    const app = new Vue(...)
    
    // 监听卸载操作
    window.addEventListener('unmount', function () {
      app.$destroy()
    })
    
    1
    2
    3
    4
    5
    6
    7

# 问题及解决

  1. 路由的跳转

    在不使用 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
  2. 基座和子应用同时使用高德地图,子应用初次渲染正常,卸载 app 后再次进入无法渲染地图。

    基于该问题,我提交了一个 issue (opens new window) ,然后解决了。作者说后续版本会修复,不过我还没有测试。

    // 监听卸载操作
    window.addEventListener('unmount', function () {
      // 👇 暂时在卸载子应用时,手动删除子应用的 AMap 对象
      delete window.rawWindow.AMap
      app.$destroy()
    })
    
    1
    2
    3
    4
    5
    6

# 部署

官网部署文档 (opens new window)

# micro-app # frontend