我终于把微前端(qiankun)落地生产项目了

31,353

src=http___ucc.alicdn.com_pic_developer-ecology_5eb6611c7626482994def8abaa6314db.png&refer=http___ucc.alicdn_副本.jpg

微前端介绍

什么是微前端?
微前端是一种多个团队通过独立发布功能的方式,来共同构建现代化 web 应用的技术手段及方法策略。

不同于单纯的前端框架/工具,微前端是一套架构体系,这个概念最早在2016年底由 ThoughtWorks 提出。 微前端是一种类似于后端微服务的架构,它将微服务的理念应用于浏览器端,将 Web 应用从整个的「单体应用」转变为多个小型前端应用的「聚合体」。

各个前端应用「原子化」,可以独立运行、开发、部署,从而满足业务的快速变化,以及分布式、多团队并行开发的需求。

微前端的特点

  • 技术栈无关  主框架不限制接入应用的技术栈,子应用可自主选择技术栈
  • 独立开发/部署  各个团队之间仓库独立,单独部署,互不依赖
  • 增量升级  当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
  • 独立运行时  微应用之间运行时互不依赖,有独立的状态管理
  • 提升效率  应用越庞大,越难以维护,协作效率越低下。微应用可以很好拆分,提升效率

解决问题的理念有了,那要通过怎样的技术去实现呢?

  1. iframe 最早也是最熟悉的解决方案就是通过iframe,因为它可以独立运行另一个项目,这种方案的优势:
  • 非常简单,无需任何改造
  • 完美隔离,JS、CSS 都是独立的运行环境
  • 不限制使用,页面上可以放多个 iframe 来组合业务

当然也是逃不过事务的两面性,有优点就有缺点:

  • 无法保持路由状态,刷新后路由状态就丢失(这点也不是完全不能解决,可以讲路由作为参数拼接在链接后,刷新时去参数进行页面跳转)
  • 完全的隔离导致与子应用的交互变得极其困难
  • iframe 中的弹窗无法突破其本身
  • 整个应用全量资源加载,加载太慢

既然有这么明显的问题,那就会有新的方案被创造出来

  1. 基于 single-spa 路由劫持方案

single-spa 通过劫持路由的方式来做子应用之间的切换,但接入方式需要融合自身的路由,有一定的局限性。

qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台。它对 single-spa 做了一层封装。主要解决了 single-spa 的一些痛点和不足。通过 import-html-entry 包解析 HTML 获取资源路径,然后对资源进行解析、加载。

通过对执行环境的修改,它实现了 JS 沙箱样式隔离 等特性。
接下来我就好好的讲讲qiankun是怎么落地到生产项目的,中间遇到过哪些坑。

qiankun

qiankun官网 qiankun.umijs.org/zh/guide

按照官方文档快速搞起来
主应用

  1. 安装
$ yarn add qiankun # 或者 npm i qiankun -S
  1. 在主应用注册微应用 main.js

放在main.js的尾部

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'vue app',
    entry: '//localhost:7100',
    container: '#container-vue',
    activeRule: '/micro-vue',
    // 传递给子应用的参数
    props: {
      routerBase: '/micro-vue',
    }
  },
  {
    name: 'micro-clouds',
    entry: '//localhost:7000',
    activeRule: '/micro-clouds',
    container: '#subapp2', // 子应用挂载的div
    // 传递给子应用的参数
    props: {
      routerBase: '/micro-clouds',
    }
  }
]);
// 启动微应用
start();

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

如果微应用不是直接跟路由关联的时候,你也可以选择手动加载微应用的方式:

import { loadMicroApp } from 'qiankun';

loadMicroApp({
  name: 'micro-clouds',
  entry: '//localhost:7000',
  activeRule: '/micro-clouds',
  container: '#subapp2', // 子应用挂载的div
  // 传递给子应用的参数
  props: {
    routerBase: '/micro-clouds',
  }
});

微应用
main.js

import './public-path';
import { createApp } from 'vue'
import App from '@/App.vue'
import { createRouter, createWebHistory } from 'vue-router';
import routes from '@/router'
import store from '@/store'

let instance = null
async function render(props = {}) {
  const { container, routerBase } = props;
  // 实例化vue
  instance = createApp(App);
  // 路由挂载
  const router = createRouter({
    history: createWebHistory(`${routerBase}`),
    routes
  });
  instance.use(router);
  instance.use(store);

  instance.mount(container ? container.querySelector("#app") : "#app");
}

// eslint-disable-next-line no-underscore-dangle
if (!window.__POWERED_BY_QIANKUN__) {
  render({ container: "", routerBase: "/micro-clouds" });
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  await render(props);
}

export async function unmount() {
  instance.unmount();
}

public-path.js

(function () {
  if (window.__POWERED_BY_QIANKUN__) {
    // eslint-disable-next-line
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
    // __webpack_public_path__ = `${process.env.BASE_URL}/`
  }
})()

到这里我们就把主应用和微应用融合起来了,涉及的代码量也就百八十行,至于跳转可以自己在主应用加几个按钮通过history.pushState(null, item.activeRule, item.activeRule)

image.png

这个是我当时模仿的demo: github.com/fengxianqi/…

其实跑通以上demo还是挺简单的,我一开始也是看着qiankun官网,在网上找了个demo比划比划,很简单嘛!如果我这边文章只分享到这里那完全没有必要去分享了,往后看才是实际生产项目的搭法。

qiankun + vue3 搭建生产项目

通过上面的demo例子抛转引玉,应该对微前端是怎么跑起来的有了大致的认识。我当时也以为搞定了上面的demo就可以直接用在生产项目了。没料到实际用起来和demo完全是两回事,下面请听我一一解析这当中遇到的坑。

1. 路由

由于demo项目没有用vue-router,直接是通过history.pushState跳转,一切看来都是那么的没有,没有告警没有报红。但在实际的vue项目中,路由的改变,vue-router是会自动去匹配路由的,当匹配到子路由在当前router中没有配置时就会报警报红,如果直接跳转又没法运用到vue-router中的能力,怎么融合路由才能达到要求呢?

答案是通过一个component组件来承载,它可以使qiankun的代码与基座项目极大的降低耦合,代码可维护性增强,最重要的是可以统一将微应用路由归并到这个组件路由来,相当于接入微前端就像增加了一个普通组件这么简单。

主应用配置

src/router/index.js

{
    path: '/:micro(micro-vue|micro-clouds):endPath(.*)',
    name: 'MicroApp',
    meta: { title: '微前端应用' },
    component: () => import(/* webpackChunkName: "qiankun" */'@/views/qiankun/MicroApp.vue')
 },

通过正则匹配路由,只要是微应用的路由都可以匹配进来,当然需要事先定义好微应用的路由前缀,比如micro-xxx,增加微应用就在这里(micro-vue|micro-clouds)加一个匹配前缀。

新建MicroApp.vue组件
src/views/qiankun/MicroApp.vue

<template>
  <div class="layout-container micro-container" v-loading="$store.state.app.isLoadingMicro"
    element-loading-text="Loading...">
    <div id="subapp1"></div>
    <div id="subapp2"></div>
  </div>
</template>

<script>
import {
  onMounted, watch, reactive, onUnmounted
} from 'vue'
import { loadMicroApp, addGlobalUncaughtErrorHandler } from 'qiankun'
import { useRoute } from 'vue-router'
import { microApps, registerApps } from '@/views/qiankun/micro-app'

export default {
  name: 'MicroApp',
  setup() {
    const microList = reactive({})
    const route = useRoute()
    const state = reactive({
      elementLink: null
    });
    const activationHandleChange = async (path) => {
      const activeRules = microApps.map((app) => app.activeRule)
      const isMicro = activeRules.some((rule) => path.startsWith(rule))
      if (!isMicro) return
      const microItem = microApps.find((app) => path.startsWith(app.activeRule.toString()))
      if (!microItem) return
      // 如果已经加载过一次,则无需再次加载
      const current = microList[microItem.activeRule.toString()]
      if (current) return

      // 缓存当前子应用
      const micro = loadMicroApp({ ...microItem })
      microList[microItem.activeRule.toString()] = micro
      try {
        await micro.mountPromise
      } catch (e) {
        console.error('=======', e)
      }
    }
    // qiankun全局异常捕获
    addGlobalUncaughtErrorHandler((event) => console.log(event))
    // 监测路由变化
    watch(() => route.path, async (newValue) => {
      activationHandleChange(newValue)
    })
    
    onMounted(async () => {
      if (window.qiankunStarted) return
      window.qiankunStarted = true
      registerApps()
      activationHandleChange(route.path)
    })
    onUnmounted(() => {
      window.qiankunStarted = false
      // 离开微应用路由时会卸载开启的微应用
      Object.values(microList).forEach((mic) => {
        mic.unmount()
      })
    })
    return {
    }
  }
}
</script>
<style lang="scss" scoped>
.micro-container{
  background: var(--system-container-main-background);
}
</style>

src/views/qiankun/micro-app.js

import { registerMicroApps, start } from 'qiankun'
import store from '@/store'
import utils from '@/assets/js/utils';

export const microApps = [
  {
    name: 'micro-clouds',
    entry: process.env.VUE_APP_CLOUDS,
    activeRule: '/micro-clouds',
    container: '#subapp2', // 子应用挂载的div
    props: {
      routerBase: '/micro-clouds',
      mainStore: store,
      user: utils.getStorage('user')
    }
  }
]

export const registerApps = () => {
  registerMicroApps(microApps, {
    beforeLoad: (app) => {
      store.commit('app/loadingMicro', true)
      console.log('before load app.name====>>>>>', app.name)
    },
    beforeMount: [
      (app) => {
        console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name)
      }
    ],
    afterMount: [
      (app) => {
        store.commit('app/loadingMicro', false)
        console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name)
      }
    ],
    afterUnmount: [
      (app) => {
        console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name)
      }
    ]
  })

  start({
    sandbox: {
      // 默认开启预加载
      prefetch: 'all',
      // qiankun提供的样式隔离方法(严格模式)
      strictStyleIsolation: true,
      experimentalStyleIsolation: true
    }
  })
}

微应用配置
mian.js

import './public-path';
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import NProgress from '@/assets/js/nprogress';
import * as ELIcons from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import draggable from 'vuedraggable'
import 'element-plus/dist/index.css'
import 'normalize.css' // css初始化
import '@/assets/style/common.scss' // 公共css
import '@/assets/style/iconfont.css'
import App from '@/App.vue'
import { createRouter, createWebHistory } from 'vue-router';
import routes from '@/router'
import store from '@/store'

let instance = null
async function render(props = {}) {
  const { container, routerBase } = props;
  
  instance = createApp(App);
  const router = createRouter({
    history: createWebHistory(`${routerBase}`),
    routes
  });
  instance.use(router);
  instance.use(ElementPlus, {
    locale: zhCn
  });
  instance.use(store);
  instance.component('draggable', draggable)
  /* eslint-disable */
  for (const [key, component] of Object.entries(ELIcons)) {
    instance.component(key, component);
  }

  instance.mount(container ? container.querySelector("#app") : "#app");

  // eslint-disable-next-line no-underscore-dangle
  if (window.__POWERED_BY_QIANKUN__) {
    router.afterEach((to) => {
      const matched = to.matched.map((item) => {
        return {
          ...item,
          path: `${routerBase}${item.path}`,
          redirect: `${routerBase}${item.path}`,
        };
      })
      props.mainStore.dispatch("app/getMicroBreadcrumb", [
        ...matched
      ]);
    });
  }

  router.beforeEach(async (to, from, next) => {
    NProgress.start();
    if (store.getters["user/isLogin"]) {
      next();
    } else if (store.state.app.whiteList.includes(to.path)) {
      // 白名单页面
      next();
    } else {
      next("/login"); // 全部重定向到登录页
    }
  });

  router.afterEach((to) => {
    const keepAliveComponentsName = store.getters["keepAlive/keepAliveComponentsName"] || [];
    const { name } = to.matched[to.matched.length - 1].components.default;
    if (to.meta && to.meta.cache && name && !keepAliveComponentsName.includes(name)) {
      store.commit("keepAlive/addKeepAliveComponentsName", name);
    }
    NProgress.done();
  });
}

// eslint-disable-next-line no-underscore-dangle
if (!window.__POWERED_BY_QIANKUN__) {
  render({ container: "", routerBase: "/micro-clouds" });
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}

export async function mount(props) {
  // 标记当前启动形式为微服务启动
  store.commit("app/microChange", true);
  await render(props);
}

export async function unmount() {
  instance.unmount();
}

public-path.js

(function () {
  if (window.__POWERED_BY_QIANKUN__) {
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  }
}());

vue.config.js

const path = require('path')

module.exports = {
  publicPath: process.env.NODE_ENV === 'development' ? '/' : '/micro-clouds',
  configureWebpack: {
    output: {
      library: 'micro-clouds-[name]',
      libraryTarget: 'umd',
      jsonpFunction: 'webpackJsonp_[name]'
    },
  },
  devServer: {
    port: 4003,
    disableHostCheck: true,
    headers: {
      'Access-Control-Allow-Origin': '*' // 主应用获取子应用时跨域响应头
    },
    proxy: {
      '/api': {
        target: process.env.VUE_APP_TEST,
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
}

到这为止,一个完整的微前端架构就搭建好了,下面来介绍各个细节的交互是怎么处理的。

2. 父子应用耦合以及单独使用

简单讲就是为了满足更多更灵活的使用要求,咱们设计要能满足主应用与微应用耦合在一起使用,也可以子应用单独使用,不仅满足了用户的需求,还可以方便开发人员调试。(联调子应用时子应用一定是能独立启动的,并且有自己的登录、菜单功能,说白了就是一个完整的系统)

为了区分微前端运行还是单独运行,我们需要在子应用中插入微前端标识。

# 单独启动子应用时会通过这里来启动项目

if (!window.__POWERED_BY_QIANKUN__) {
  render({ container: "", routerBase: "/micro-clouds" });
}
# 这是子应用中微前端的钩子函数,如果是通过微前端启动,就通过这里来启动项目

export async function mount(props) {
  // 标记当前启动形式为微服务启动
  store.commit("app/microChange", true);
  // props是主应用传过来的参数
  await render(props);
}

区分好了之后,就需要将子应用中的菜单、头部状态栏隐藏,只留下内容区,连内容区周围的间隙都要去掉,因为主应用内容区也有间隙,避免样式冲突。

截屏2022-06-29 下午12.19.13.png

3. 父子应用通信

原则上应该尽可能得降低父子应用之间的耦合,以免子应用单独使用时无法正常运行,也会增加维护成本。但是像登录信息的传递是有必要的,父应用登录了将登录信息传到子应用,避免子应用检测到无登录状态跳转到登录页面。

# 主应用
export const microApps = [
  {
    name: 'micro-clouds',
    entry: process.env.VUE_APP_CLOUDS,
    activeRule: '/micro-clouds',
    container: '#subapp2', // 子应用挂载的div
    props: {
      routerBase: '/micro-clouds',
      // 父应用的store传递给子应用
      mainStore: store,
      // 父应用传递给子应用的登录信息
      user: utils.getStorage('user')
    }
  }
]
# 子应用接受父应用传过来的登录信息并写入storage中
export async function mount(props) {
  // 父级传过来的登录信息写进子系统缓存中
  utils.setStorage("user", props.user);
  // 标记当前启动形式为微服务启动
  store.commit("app/microChange", true);
  await render(props);
}

4. 面包屑怎么融合父子应用中的路由

现在的后台应用基本都有面包屑要么就是有一个tabs标签栏,在非微前端应用中,是一个非常简单的问题,直接to.matched就能查找出当前的路由嵌套关系,面包屑遍历出来就OK。但是在微前端中怎么知道子应用中的路由嵌套关系,那么就涉及父子应用的通信,当子应用监听到路由变化时上报子应用的to.match给父应用。由于这里涉及到主子应用的to.match切换,当打开的是主应用的页面就用主应用的to.match,打开的是子应用的页面就用子应用的to.match,所以我这里统一用vuex来处理

# 主应用vuex
const actions = {
    // 处理父应用的to.match
    getBreadcrumb({ commit }, matched) {
        const levelList = matched.filter((item) => item.meta && item.meta.title && item.meta.breadcrumb !== false);
        commit('setBreadcrumb', JSON.parse(JSON.stringify(levelList)));
    },
    // 处理微应用的to.match
    getMicroBreadcrumb({ commit }, matchedList) {
        const microLevel = matchedList.filter((ele) => ele.meta && ele.meta.title && ele.meta.breadcrumb !== false);
        /*
         * 为了解决这个告警采用 JSON.parse(JSON.stringify(microLevel))   vue3.0需要注意的地方
         * runtime-core.esm-bundler.js:198 [Vue warn]: Avoid app logic that relies on enumerating keys on a component instance.
         * The keys will be empty in production mode to avoid performance overhead.
         */
        commit('setMicroBreadcrumb', JSON.parse(JSON.stringify(microLevel)));
  }
}
# 主应用面包屑组件中
const getBreadcrumb = async () => {
  // 判断是微应用就去微应用上报的to.match
  if (route.path.startsWith('/micro')) {
    // 这里需要做一个vuex异步处理,否则取不到子应用上报的to.match
    store.subscribe((mutation, state) => {
      if (mutation.type === 'app/setMicroBreadcrumb') {
        levelList.value = store.state.app.microBreadcrumbs
      }
    })
  } else {
    await store.dispatch('app/getBreadcrumb', route.matched)
    levelList.value = store.state.app.breadcrumbs
  }
}
# 子应用上报to.match给父应用
# 子应用main.js

# 好好回想一下,在父应用中传递给子应用的props中就有一个`mainStore`,这个就是父应用的store,可以直接在子应用中使用`props.mainStore.dispatch("app/getMicroBreadcrumb", [ ...matched ]);`

function render(props = {}) {
    ...
    if (window.__POWERED_BY_QIANKUN__) {
        router.afterEach((to) => {
          // 你可能发现这里为啥要改造`path、redirect`,路径前面拼接了一个`routerBase`
          // 是因为在父应用中,如果子应用路由不带子应用的前缀标识路由是没法跳转进子应用页面的!!!
          const matched = to.matched.map((item) => {
            return {
              ...item,
              path: `${routerBase}${item.path}`,
              redirect: `${routerBase}${item.path}`,
            };
          })
          props.mainStore.dispatch("app/getMicroBreadcrumb", [
            ...matched
          ]);
        });
    }
    ...
}

到这里基本上配置完成了,后面是一些遇到的样式bug,一定要实践,光看可能效果不大

5. 父子应用中样式冲突的问题

截屏2022-06-29 下午2.45.44.png

当在父应用打开子应用时出现样式问题,正常在vue + element-plus的项目中,是这么引组件的样式

截屏2022-06-29 下午2.52.36.png 通过审查元素发现这个弹窗的dom节点的class样式少了一些属性,也就是光有class少了样式值,所以导致样式问题。目测是import引入样式的没法作用到子应用节点,改成在public/index.html通过link的方式引用

截屏2022-06-29 下午2.58.35.png

成功解决样式问题,需要注意的是当遇到样式问题时可以从 引入方式、append-to-body属性(element-plus组件)着手排查。

配置完整个微前端架构,看似百八十行代码,其实遇到的问题还是蛮多的,跟撸个demo完全是两回事,网上可查的资料又少,官方文档只是介绍了那几个api的使用,干完这票确实提升不少,值得去尝试,我认为这是构架师必备技能。

6. qiankun加载子应用的协议会转成http的问题

前面由于都是在本地以及测试环境验证过,因为都是http协议所以没有暴露出这个问题。当到沙盒以及生产环境时,子应用页面加载不出来,我也没有改动代码,测试和生产是同一份代码,一直报这个错误

截屏2022-07-19 下午6.18.03.png

也就是主应用是 https 协议,子应用是http协议,导致了这个问题。现在问题清楚了,又有另外一个疑问,我的主应用指向子应用的路由是 https 啊,为什么被改成了 http,这个问题我不太清楚是不是qiankun这家伙干的,官方也没说。

解决办法:

public/index.html 头部加上这个

<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

如果是统一加上这个,测试环境和本地环境又不行了,得区分开发,怎么办?

我们都知道,打包后生成的index.html是由 html-webpack-plugin 插件生成的,沿着这条线索找,插件文档果然有meta这个配置项github.com/jantimon/ht…

那么在vue-cli生成的项目中没有单独使用html-webpack-plugin,而是在 vue.config.js里的pages入口这里配置,具体代码:

pages: {
    index: {
      entry: 'src/main.js',
      template: 'public/index.html',
      filename: 'index.html',
      title: '采贝中台',
      meta: process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
      ? {}
      : {
        'Content-Security-Policy': { 'http-equiv': 'Content-Security-Policy', content: 'upgrade-insecure-requests' }
      }
    }
},

完美解决!

项目部署

部署方式很多种,我这里只介绍我在项目中的实践。

image.png

主应用:main.xxx.com/home
子应用A: clouds.xxx.com/projectA/ho…
子应用B: clouds.xxx.com/projectB/ho…
子应用C: clouds.xxx.com/projectC/ho…

现在的部署都流行CICD,docker + k8s,所以我采用的是每个项目都单独部署一个docker,最后通过 域名 + 项目前缀进入项目,nginx配置如下:

# 主应用
location / {
    root      /usr/share/nginx/html;
    index     index.html;  # 入口文件
    try_files $uri $uri/ /index.html;
}
# 子应用
location /micro-projectA {
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

    alias     /usr/share/nginx/html;
    try_files $uri $uri/ /micro-projectA/index.html;
}

location / {
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

    root /usr/share/nginx/html;
    try_files $uri $uri/ /index.html;
}

具体阿里云云效流水线搭建、部署、域名、负载均衡等相关的操作可以翻开我其他文章来看

记一次gitlab仓库迁移阿里云云效以及相关的流水线改造全过程
来屡屡前端包部署以及nginx配置

源码

gitee.com/spring_zou/…