本项目的初衷在于利用微前端逐渐升级公司的老项目

项目架构使用的vue+qiankun实践落地。

qiankun是一个开放式微前端架构,支持当前三大前端框架甚至jq等其他项目无缝接入。

项目为公司定制测试项目,如有需求不同或者做的不好的地方,望大佬指点。

项目演示地址:http://bestseller.soulferry.xyz

微前端-qiankun

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台,在经过一批线上应用的充分检验及打磨后,我们将其微前端内核抽取出来并开源,希望能同时帮助社区有类似需求的系统更方便的构建自己的微前端系统,同时也希望通过社区的帮助将 qiankun 打磨的更加成熟完善。

目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。

至于为什么使用微前端,请看这两个文章可能是你见过最完善的微前端解决方案
qiankun中文文档

乾坤文档讲解

  1. 微前端主应用与子应用如何构建
  2. 主应用与子应用通信(静态,无法监测到值变化)
  3. 主、子,各应用间动态通信(动态,各应用间实时监听,同步数据)
  4. 主应用资源下发至子应用
  5. 微前端鉴权方案 [x] 一:异步注册(主应用异步获取子应用注册表并将子应用对应的路由下发至子应用)
    [x] 二:异步路由(使用应用间通信,通知子应用路由数据,子应用在内部addRoutes异步插入路由)
  6. 各应用间路由基础管理 (公共资源处理 )

因为功能API较少,所以官方文档也就是寥寥的几十行,大部分开发者可能会没有思路。

项目需求以及开发思路

  • 保持老项目的权限管理
  • 不影响老项目的使用

只有简单的两项,也是最重要的两个问题,原本的鉴权方式就是在用户登录的时候,通过用户的账号获取后端分配的菜单,再由菜单异步加载对应的路由,然后通过vue-router的addRoutes添加到路由中。

因为qiankun提供的鉴权方案有说明应用可以异步加载,应用之间也是可以通讯的,所以验证的token可以存在本地存储内,所以说开发要做的就是,对前端实现就是在获取权限的时候,将菜单按照应用分开,例如:主应用按照module=main进行分配,filter之后异步加载路由,其他的应用模块,传给创建自用的方法,开始异步创建子应用。

PS:由于加载子应用需要子应用的入口地址,所以后端需要将子应用的域名配置上去,测试域名使用//localhost:端口号,正式域名:http://xxxxx,同时分配应用的模块名称,如module:A

一下先看看主应用的相关实现代码以及配置

主应用

为了将主应用的根dom id与子应用的根dom id区分开,将public下的index.html的id改为root-container,然后更改主应用的挂载id

<div id="root-container"></div>

main.js

import Vue from 'vue'
import App from './App'
import router from "./router";
import store from "./store";
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
import './permission/index'
import '@/common/sass/index.sass'

Vue.use(ElementUI, { size: "mini" });

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App),
}).$mount('#root-container')

因为所用的路由以及应用都是异步加载,所以整体的main函数没有变化

主应用鉴权-permission

import router from '@/router'
import npg from 'nprogress' // 路由加载进度条
import 'nprogress/nprogress.css' //这个样式必须引入
import store from '@/store' // 状态管理器
import cookie from '@/common/utils/cookie' // 本地cookie存储
import Conf from '@/config' // 配置文件
import local from '@/common/utils/local' // 强缓存封装
import { filterRouter } from '@/common/utils/filterRouter' // 整理异步加载路由的方法
import initSubApp from '@/permission/initSubApp' // 注册子应用的方法

/**
 * 由于菜单是由系统配置的,router部分只做token和menu的检查
 **/
router.beforeEach((to, from, next) => {
    npg.start()
    // next()
    const token = cookie.get(Conf.baseKey['token']) || ''
    if (token) {
        if (to.path === '/login') {
            next({ path: '/index', replace: true })
            return
        }
        const menu = local.get(Conf.baseKey['menu']) || []
        if (!menu || !menu.length) {
            cookie.remove(Conf.baseKey['token'])
            next('/login')
        } else {
            if (!store.getters.menu || !store.getters.menu.length) {
                store.dispatch('setMenu', menu)
            }
            // 有cookie 但是没有state说明刷新了
            if (!store.getters.hasSigned) {
                // 获取主应用的菜单
                const _m = menu.filter(el => el.module === 'main')
                // 异步加载菜单然后挂载路由
                const list = filterRouter(_m[0].children)
                router.addRoutes(list)

                // 传入所有的菜单由子应用的处理方法内部处理
                initSubApp(menu)

                store.dispatch('setSigned', true).then(() => {
                    if (!to.path.includes(Conf.mainApp)) {
                        npg.done()
                    }
                    next()
                })
                
            } else {
                next()
            }
        }
    } else {
        if (to.path === '/login') {
            next({replace: true})
        } else {
            if (to.path === '/404') {
                next()
            } else {
                next({ path: '/login', replace: true })
            }
        }
    }
})

router.afterEach( () => {
    npg.done()
})

主应用layout

<template lang='pug'>
    #root-wrapper
        c-header
        #root-content(:class='collage ? "hideSidebar":""')
            c-menu.sidebar-container
            section(class='app-main', id='app-main-wrapper')
                router-view
</template>

因为子应用的渲染是类似服务端或者iframe哪种方式将子应用的页面整个打入,挂载dom #app-main-wrapper 下的,而router-view也是将路由组件挂载到父级dom下,所以,如果当前路由是子组件,那么router-view就是空,如果是主应用路由那个子应用的domcontent就是空,所以相互不影响

子应用的注册方法-initSubApp

应用之间的通信方法:

  • qiankun提供的 initGlobalState
  • rxjs全局事件总线

rxjs:

import { Subject } from ‘rxjs’
export default new Subject()

rxjs教程

initGlobalState:

// 在主应用注册官方通信方案
const actions = initGlobalState(msg.state);
// 注册消息监听函数
actions.onGlobalStateChange((state, prev) => console.log(`主应用应用监听到来自${state.from}发来消息:`, state, prev));
actions.setGlobalState(msg.state);
actions.offGlobalStateChange();

这里为了方便对比,两个都上

import pager from '@/common/utils/broadcast' // 引入rxjs
import store from "@/store";
import childEmit from '@/common/utils/childEmit'
import { routerActive } from '@/common/utils/routerActive'
import * as parentUtils from '@/common/utils/index'
import Conf from '@/config'
// 导入乾坤函数
import {
    registerMicroApps, // 注册子应用方法
    setDefaultMountApp, // 设默认启用的子应用
    runAfterFirstMounted, // 有个子应用加载完毕回调
    start, // 启动qiankun
    addGlobalUncaughtErrorHandler, // 添加全局未捕获异常处理器
    initGlobalState, // 官方应用间通信
} from 'qiankun'
// 导入应用间通信介质:呼机
let msg = {
    data: store&&store.getters || {}, // 从主应用仓库读出的数据
    components: {}, // 从主应用读出的组件库
    utils: parentUtils, // 从主应用读出的工具类库
    emitFnc: childEmit, // 从主应用下发emit函数来收集子应用反馈
    pager, // 从主应用下发应用间通信呼机
    state: {
        message: "主应用的props",
        time: +new Date(),
        from: ""
    }
};

// 在主应用注册呼机
pager.subscribe(v => {
    console.log(`父级应用监听到子应用${v.from}发来消息:`, v)
    store.dispatch('setToken', v.token)
});
// 在主应用注册官方通信方案
const actions = initGlobalState(msg.state);
// 注册消息监听函数
actions.onGlobalStateChange((state, prev) => console.log(`主应用应用监听到来自${state.from}发来消息:`, state, prev));
actions.setGlobalState(msg.state);
actions.offGlobalStateChange();

注册子组件方法

const isPro = process.env.NODE_ENV === 'production'

const initSubApp = (menus = []) => {
    if (!menus || !Array.isArray(menus) || !menus.length) {
        throw new Error('接口没有菜单导入,无法创建子应用,请联系后端')
    }

    let defaultApp = null
    let subApps = []
    // 获取所有的非主应用的菜单
    const data = (menus.filter(el => el.module !== Conf.mainApp))
    if (!data.length) return
    for (let k = 0; k < data.length; k++) {
        const el = data[k];
        if (el.defaultRegister) {
            defaultApp = el.path
        }
        subApps.push({
            name: el.module,
            entry: isPro ? el.proEntry : el.devEntry, // 通过运行环境配置子应用入口
            container: '#app-main-wrapper', // 子应用的挂载dom
            activeRule: routerActive(el.path),  // 子应用的激活规则
            props: { ...msg, ROUTES: el.children, routerBase: el.path, actions }
        })   
    }
    console.log(subApps, 'subAppList')
    // return
    registerMicroApps(subApps, {
        beforeLoad: [
            app => {
                console.log("before load", app);
            }
        ],
        beforeMount: [
            app => {
                console.log("before mount", app);
            }
        ],
        afterUnmount: [
            app => {
                console.log("after unload", app);
            }
        ]
    })
    // 设置默认子应用,因为主应用有一个默认页,所以就禁止渲染默认子应用了,需要的可以打开,或者说你的主应用就是一个架子或者壳,菜单全都是按模块分的子应用一定要开启默认子应用,不过如果是主应用只做架子的话,有另外一种方式更合适,这里等会再说
    if (!defaultApp) defaultApp = subApps[0].path
    // setDefaultMountApp(defaultApp);
    // 第一个子应用加载完毕回调
    runAfterFirstMounted((app) => {
        console.log(app)
    });
    // 启动微服务
    start();
    // 设置全局未捕获一场处理器
    addGlobalUncaughtErrorHandler(event => console.log(event));
}

子应用的相关配置

这里暂且只说vue项目,不过其他的也都是配置webpack,一通百通嘛

vue.config.js

const path = require('path')
const { name } = require('./package.json')  // 获取应用名称
function resolve(dir) {
    return path.join(__dirname, dir)
}
const port = 9529
const isProduction = process.env.NODE_ENV === 'production'
module.exports = {
    publicPath: isProduction ? '/' : `//localhost:${port}`, // 配置根目录
    lintOnSave: isProduction, // eslint
    devServer: { // 这一步比较重要,本地环境的话,需要配置请求头,不然的话会跨域,端口好的话一定要与后端的配置一致
        hot: true,
        disableHostCheck: true,
        port,
        headers: {
            'Access-Control-Allow-Origin': '*'
        }
    },
    configureWebpack: {
        // 这个vue脚手架本来就有配置,我就是习惯了
        resolve: {
            alias: {
                '@': resolve('src'),
            },
        },
        output: { //为了方便区分,不配置也无所谓,因为部署的时候也是将项目部署在不同的服务器或者不同的文件,然后分配一个域名
            library: `${name}-[name]`,
            libraryTarget: 'umd',
            jsonpFunction: `webpackJson_${name}`
        }
    }
}

main.js的配置如下

import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'
import routes from '@/router'
import store from '@/store'
import '@/plugins'  //我这里主要挂在一些组件,看个人需求,可配置自定义的组件,或者本地的JS引入等
import { routeMatch } from '@/permission' // 动态路由配置

Vue.config.productionTip = false;
// 判断是否为qiankun环境下
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

let router = null; //声明路由
let instance = null; // 声明子应用实例
const __qiankun__ = window.__POWERED_BY_QIANKUN__; // 声明环境

// 子应用的注册
export async function bootstrap({ components, utils, emitFnc, pager, actions }) {
  // 注册主应用下发的组件
  Vue.use(components);
  // 把工具函数挂载在vue $mainUtils对象
  Vue.prototype.$mainUtils = utils;
  // 把mainEmit函数一一挂载
  Object.keys(emitFnc).forEach(i => {
    Vue.prototype[i] = emitFnc[i]
  });
  // 在子应用注册呼机
  pager.subscribe(v => {
    console.log(`监听到子应用${v.from}发来消息:`, v)
    // store.dispatch('app/setToken', v.token)
  })
  Vue.prototype.$pager = pager;
  // 在子应用注册官方通信
  actions.onGlobalStateChange((state, prev) => console.log(`子应用block监听到来自${state.from}发来消息:`, state, prev)); 
  Vue.prototype.$actions = actions;
}

// 子应用的挂载
export async function mount({ data = {}, ROUTES, routerBase, state } = {}) {
// 如果是qiankun则动态分配路由
  const initRouter = __qiankun__ && routeMatch(ROUTES, routerBase)
  router = new VueRouter({
    base: __qiankun__ ? routerBase : "/",
    mode: "history",
    routes: __qiankun__ ? routes.concat(initRouter) : routes, // 如不是qiankun环境则使用本地路由或者说本地的鉴权方式
  });
  instance = new Vue({
    router,
    store,
    render: h => h(App, {
      props: { ...data, ...state } , // 导入主应用下发的参数以及数据
    })
  }).$mount("#app");
}

// 子应用的注销
export async function unmount() {
  instance.$destroy();
  instance = null;
  router = null;
}

// 单独开发环境
__qiankun__ || mount();

子应用上报数据

dom:

<template>
  <div id="app">
    <p class="message">这是父应用传过来的message:{{message}}</p>
    <div class="parent-child-communication">
      <h3>父子应用通信:</h3>
      <div style="margin-bottom: 20px;">
        <span>rxjs通信方案:{{ myMsg }}</span>
        <el-button
          class="right"
          type="primary"
          size="medium"
          @click="callParentChange('rxjs')"
          >通知父应用变天了</el-button
        >
      </div>
      <div>
        <span>官方通信方案:{{ myMessage }}</span>
        <el-button
          class="right"
          type="primary"
          size="medium"
          @click="callParentChange('default')"
          >通知父应用收到</el-button
        >
      </div>
    </div>
    <div class="listbtns">
      <div class="item">
        <span class="tit">点我跳转当前应用的其他页面</span>
        <el-button type='primary' @click="navigate('default')">立即跳转</el-button>
      </div>
      <div class="item">
        <span class="tit">点我跳转其他应用:</span>
        <el-button type='primary' @click="navigate('parent')">立即跳转</el-button>
      </div>
    </div>
    <router-view></router-view>
  </div>
</template>

js:

<script>

export default {
  name: 'App',
  props: {
    msg: String,
    message: String
  },
  data: () => {
    return {
      myMsg: "",
      myMessage: ""
    }
  },
  created () {
    this.myMsg = this.msg
    this.myMessage = this.message
  },
  methods: {
    navigate(type = 'default') {
      if (type === 'default') {
        const routers = ['/', '/a', '/b', '/c']
        const _cp = this.$route.path
        const _i = Math.floor(Math.random()*3)
        const ot = (routers.filter(el => el !== _cp))
        if (ot[_i] === _cp) return
        this.$router.push({
          path: ot[_i]
        })
      } else {
        this.$mainUtils.routerGo('/main/a');
      }
    },
    callParentChange(type) {
      if (type === "default") {
        this.myMessage = "子应用connext-block收到";
        this.$actions.setGlobalState({
          message: this.myMessage,
          from: "connext-block"
        });
        return;
      }
      this.myMsg = "子应用已通过rxjs收到通知";
      this.$pager.next({
        from: "connext-block",
        token: "子应用已通过rxjs收到通知"
      });
    }
  }
}
</script>

应用间的路由跳转以及当前应用内的路由跳转

如果是当前应用内部的跳转,使用vue-router的方法

 this.$router.push({path: xxx})

如果是应用间的跳转

function routerGo(href = '/', title = null, stateObj = {}) {
    window.history.pushState(stateObj, title, href);
}

export {
    routerGo // 跨应用路由跳转
}

调用方式以主应用的menu为例:

template(v-for="child in el.children" v-if="!child.hidden")
                    el-menu-item(:index="child.path", :key="child.name", @click="goToUrl(el.module, el.path, child.path)")
                        span(v-if="child.meta&&child.meta.icon" :icon-class="child.meta.icon")
                        span(v-if="child.meta&&child.name" slot="title") {{child.name}}

script

goToUrl: _.debounce((title, herf, shref) => {
//判断是否为主应用的菜单
                if (title.includes(Conf.mainApp)) {
                    ME.$router.push({
                        path: shref
                    })
                    return
                }
                routerGo(herf + shref, title)
            }, 300)

项目部署

因为路由使用的history模式而且还要考虑主应用的访问跨域问题,所以一定要在子应用的nginx conf下配置

如下代码:

xxxxx

location / 
    {
       #这部分主要是解决history模式刷新404
        root 项目的根目录;
	    try_files $uri $uri/ /index.html;
        index index.php index.html index.htm default.php default.htm default.html;
        #这部分是为了解决跨域,可以设置origin 为 * ,但是不建议这么做
        add_header 'Access-Control-Allow-Origin' 'http://xxxx';
        add_header 'Access-Control-Allow-Credentials' true;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        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';
        if ($request_method = 'OPTIONS')
        {
        	return 204;
        }
    }
xxxxxx

常规项目部署不多说了都一样的

结语

以上为整个微前端demo的开发重点,以及部署过程,整体的思想就是异步加载,环境判断

再次强调,如果有哪里做的不好的地方希望大佬们指点,另外下边是GitHub地址:

https://github.com/SymbolMK/micro-frontends-app

有什么不清楚地可以看看代码,觉得可以的话点一下star,谢谢

Donate

如果你觉得看的得劲了,帮你解决了问题,请喝杯奶茶呗,或者推荐一个好的就业机会也是可以的。谢谢了

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注