Vue+微前端(QianKun)落地实施和最后部署上线总结

43,531 阅读16分钟

微前端(QianKun)落地实施和最后部署上线总结

历时不到两个月,来到新公司后,接到新需求:“要把ERP系统拆分出来,里面有包括PMS、OMS、WNS等等”模块。

当时的第一个想法就是微前端,那么接下来就是方案落地并实施改造。

graph TD
查阅资料 --> 第一次实施方案不通过 --> 第二次实施方案通过 --> 落地改造

image.png

背景

改造前的项目技术栈是 Vue全家桶(vue2.6.10+element2.12.0+webpack4.40.2+vue-cli4.5.7),用到了动态菜单、菜单权限等,路由使用history模式,所以本篇介绍的都是关于Vue接入QianKun

微前端概念

  • 类型<iframe></iframe>一样,只不过微前端是用fetch去请求js并渲染在指定的DOM容器。
  • 跟技术栈无关,任何前端技术栈都可以接入。
  • 多个应用结合在一起,可以一起运行,又可以单独运行。
  • 一个复杂庞大的项目拆成多个微应用,单独开发、单独部署、单独测试,互不影响。
  • 原理是通过在主应用引入每个子应用的入口文件(main.js),进行解析,并指定渲染的容器(DOM),其次每个子应用设置打包的文件为UMD,然后在main.js暴露(export)生命周期方法(bootstrapmountunmount),然后再其mount进行渲染,也就是new Vue(...),并在unmount执行destory

什么时候需要用到微前端

  • 类似于ERP系统的。
  • 庞大的系统需要拆分给不同团队去做时。
  • 系统里面有很多个模块,模块里面又很多个子模块时。

Qiankun 用到的API介绍

  • registerMicroApps(apps, lifeCycles?) 自动挡加载模块,一次性写好配置,直接传入,然后调用start()qiankun会自动监听url变化调用对应的应用暴漏的生命周期函数。
  • start(opts?) 配合registerMicroApps使用,当调用registerMicroApps后,运行启动。
  • loadMicroApp(app, configuration?) 手动加载模块,需要自己监听Url并手动加载模块。
  • addGlobalUncaughtErrorHandler(handler)/removeGlobalUncaughtErrorHandler(handler) 添加/移除监听应用加载错误。
  • initGlobalState(state) 初始化全局共享状态,类似于vuex,返回三个个方法,分别是setGlobalState(state)onGlobalStateChange((newState, oldState) => {})
    • setGlobalState(state) 设置全局状态
    • onGlobalStateChange((newState, oldState) => {}) 监听全局状态变化

app参数说明:

参数说明类型是否唯一默认值
name应用名称stringY
entry应用访问地址,这里用环境变量区分stringY
container应用渲染节点string
activeRule应用触发的URL前缀,最后一个/的后面的内容建议跟name相同,因为好判断出属于哪个应用的路由stringY
loader应用加载loading(loading) => {}
props传递给子应用的参数string | number | array | array
// apps 应用信息
// name 应用名称(唯一)
// entry 应用访问地址(唯一)
// container 应用渲染节点
// activeRule 应用触发的URL前缀(唯一)
// props 传递给子应用的参数
[
    {
        name: 'pms',
        entry: 'http://localhost:7083/',
        container: '#subView',
        activeRule: '/module/pms',
        loader: (loading) => console.log(loading),
        props: {
            routerBase: '/module/pms', // 子应用的路由前缀(router的base)
            routerList: [...], // 子应用的路由列表
            ...
        }
    },
    ...
]

落地实施开始

项目结构

image.png

| -- erp
     | -- .git
     | -- common // 公共模板
     | -- main // 主应用
          | -- package.json
     | -- pms // pms应用
          | -- package.json
     | -- oms // oms应用
          | -- package.json
     | -- tns // tns应用
          | -- package.json
     | -- wns // wns应用
          | -- package.json
     | -- package.json

路由设计

首先,项目是有一个登录页的,但是登录页不加载子应用,只有通过登录成功后,跳到第一个页面,才进行加载子应用的。

先统一术语:登录页启动页

image.png

这里区分一起运行和独立运行,先讲讲一起运行

一起运行

image.png

一起运行是指在主应用(main)登录,登录成功后跳转到对应的子页面。

/login -> 登录页

/module/ -> 登录成功后默认到启动页,全局路由守卫在这里判断,判断跳到这个路由,根据获取路由表数据,再跳入到路由表的第一个路由;如果路由表没数据,则代表这个用户没有菜单,那就也没权限,直接跳到回登录页,并提示就OK,不过还是看你公司产品怎么定。

主应用登录成功后,把路由存到全局状态里,除了主应用addRoute添加路由外,有两种思路处理子应用动态菜单

  1. 在路由守卫获取所有菜单后,然后通过判断前缀,把相应的子应用路由通过apps配置的props传递进去。
  2. 每个子应用第一次运行时,在全局路由守卫判断是一起运行的,直接获取全局状态里的路由表,循环判断是否属于当前子应用的路由,再addRoute进去。

这里的启动页的组件指向Layout,动态加载路由会装入到Layout的子路由,保证第一次进来启动微应用,跳转路由时,则不会触发。

既然/module/是启动页了,那么拼接子页面的?举以下几个例子,

/module/pms/A // pms应用 A页面
/module/pms/B // pms应用 B页面
/module/oms/A // oms应用 A页面

看到这里小伙伴可能会有疑问,子应用的路由前缀,都基本一样,是不是每次都要写?其实只要在子应用的路由base属性设置前缀,比如pms应用,则设置base: '/module/pms'

new Router({
    base: '/module/pms',
    routes,
    mode: 'history'
})

独立运行

独立运行是指子应用独立运行,运行后登录页、Layout基础模块包括菜单、注销,还能正常开发和使用。

这个时候就需要把登录页、LayoutApp三个模块迁移到common模块,通过引入的方式;然后根据window.__POWERED_BY_QIANKUN__判断当前运行环境是否独立运行做相对应的逻辑处理。

  • window.__POWERED_BY_QIANKUN__ true, 一起运行
  • window.__POWERED_BY_QIANKUN__ false, 独立运行
// pms应用 独立运行
/module/pms/login -> 登录页
/module/pms/ -> Layout
/module/pms/A -> A页面
/module/pms/B -> B页面

代码改造

准备材料:

  1. 应用名,这里假如叫pms
  2. 端口号,避免跟已有应用冲突,比如 7083
  3. 固定前缀,这里跟你的路由设计有关系,我取/module/

公共包配置

公共包主要是为了集成一些公共模块,比如axioselement uidayjs、样式、storeutils,子应用直接引入即可。

如果公共包有安装对应的插件,则不用在子应用再次安装,直接引入即可。这里举例element-ui

cd common
npm i element-ui -S
// pms 子应用 main.js
import { Message } from 'common/node_modules/element-ui'
Message('提示内容')
| -- common
     | -- src
          | -- api
          | -- components // 公共组件
          | -- pageg
               | -- layout
               | -- App.vue
          | -- plugins // element、dayjs、v-viewer
          | -- sdk
               | -- fetch.js // axios封装
          | -- store
               | -- commonRegister.js // 动态vuex模块,与onGlobalStateChange结合使用
          | -- styles
          | -- utils
          | -- index.js
     | -- package.json
  1. cd 进入 common
    • 并在执行 npm init -y,会生成package.json文件。
    • 修改入口文件路径,main属性为src/index.js"main": "src/index.js"
  2. 修改main.js文件内容,具体是什么,看你项目情况而定。
import store from './store'
import plugins from './plugins'
import sdk from './sdk'
import * as utils from './utils'
import globalComponents from './components/global'
import components from './components'
import * as decorator from './utils/decorator'

export { store, plugins, sdk, utils, decorator, globalComponents, components }
  1. commonRegister.js全局状态

commonRegister.js参考微前端qiankun从搭建到部署的实践中的主应用的状态封装。

// commonRegister.js

/**
 *
 * @param {vuex实例} store
 * @param {qiankun下发的props} props
 * @param {vue-router实例} router
 * @param {Function} resetRouter - 重置路由方法
 */
function registerCommonModule(store, props = {}, router, resetRouter) {
  if (!store || !store.hasModule) {
    return
  }

  // 获取初始化的state
  // eslint-disable-next-line no-mixed-operators
  const initState = (props.getGlobalState && props.getGlobalState()) || {
    menu: null, // 菜单
    user: {}, // 用户
    auth: {}, // token权限
    app: 'main' // 启用应用名,默认main(主应用),区分各个应用下,如果运行的是pms,则是pms,用于判断路由
  }

  // 将父应用的数据存储到子应用中,命名空间固定为common
  if (!store.hasModule('common')) {
    const commonModule = {
      namespaced: true,
      state: initState,
      actions: {
        // 子应用改变state并通知父应用
        setGlobalState({ commit }, payload = {}) {
          commit('setGlobalState', payload)
          commit('emitGlobalState', payload)
        },
        // 初始化,只用于mount时同步父应用的数据
        initGlobalState({ commit }, payload = {}) {
          commit('setGlobalState', payload)
        },
        // 登录
        async login({ commit, dispatch }, params) {
          // ...
          dispatch('setGlobalState')
        },
        // 刷新token
        async refreshToken({ commit, dispatch }) {
          // ...
          dispatch('setGlobalState')
        },
        // 获取用户信息
        async getUserInfo({ commit, dispatch }) {
          // ...
          dispatch('setGlobalState')
        },
        // 登出
        logOut({ commit, dispatch }) {
          to(api.logout())
          commit('setUser')
          commit('setMenu')
          commit('setAuth')
          dispatch('setGlobalState')
          if (router) {
            router && router.replace && router.replace({ name: 'Login' })
          } else {
            window.history.replaceState(null, '', '/login')
          }
          resetRouter && resetRouter() // 重置路由
        },
        // 获取菜单
        async getMenu({ commit, dispatch, state }) {
          // ...
          dispatch('setGlobalState')
        },
        setApp({ commit, dispatch }, appName) {
          commit('setApp', appName)
          dispatch('setGlobalState')
        }
      },
      mutations: {
        setGlobalState(state, payload) {
          // eslint-disable-next-line
          state = Object.assign(state, payload)
        },
        // 通知父应用
        emitGlobalState(state) {
          if (props.setGlobalState) {
            props.setGlobalState(state)
          }
        },
        setAuth(state, data) {
          state.auth = data || {}
          if (data) {
            setToken(data)
          } else {
            removeToken()
          }
        },
        setUser(state, data) {
          state.user = data || {}
        },
        setMenu(state, data) {
          state.menu = data || null
        },
        setApp(state, appName) {
          state.app = appName
        }
      },
      getters: {
          // ...
      }
    store.registerModule('common', commonModule)
  } else {
    // 每次mount时,都同步一次父应用数据
    store.dispatch('common/initGlobalState', initState)
  }
}

子应用配置

  1. 修改package.json
    • name属性为应用名
    • dependencies属性添加一个"common": "../common",为了引入公共包。
    • 修改vue.config.jspublicPath属性固定前缀+应用名/module/pms
    • 设置header允许跨域请求。
    • 引入package.json,设置publicPath固定前缀+应用名configureWebpack.output设置打包后的格式为UMD方便Qiankun引入和设置公共包common参与编译。
      // vue.config.js
      const { name } = require('./package.json')
      module.exports = {
          publicPath: `/module/${name}`, // /module/pms
          devServer: {
              // 端口号配置在环境变量中
              port: process.env.VUE_APP_PORTheaders: {
                'Access-Control-Allow-Origin': '*',
                'Cache-Control': 'no-cache',
                Pragma: 'no-cache',
                Expires: 0
            }
          },
          ...
          configureWebpack: {
              output: {
                  // 把子应用打包成 umd 库格式
                  library: `${name}-[name]`,
                  libraryTarget: 'umd',
                  jsonpFunction: `webpackJsonp_${name}`
              }
          },
          // 设置common要参与编译打包(ES6 -> ES5)
          transpileDependencies: ['common']
      }
      
  2. 设置唯一端口,在.env里面设置端口号,这里端口号没有说必须要这里设置,你也在其他地方设置,看你项目设计而定,但是端口号必须唯一,不跟已有应用发生冲突
// .env
VUE_APP_PORT=7083
  1. src下新建一个public-path.js文件
;(function () {
  if (window.__POWERED_BY_QIANKUN__) {
    if (process.env.NODE_ENV === 'development') {
      // eslint-disable-next-line
      __webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}${process.env.BASE_URL}`
      return
    }
    // eslint-disable-next-line
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
    // __webpack_public_path__ = `${process.env.BASE_URL}/`
  }
})()

  1. 改造main.js文件
// main.js
import './public-path'
import Vue from 'vue'
import Router from 'vue-router'
import store from './store'
import common from 'common'
import App from 'common/src/pages/App'

// Vue.use(common.plugins.base, isNotQiankun) // 安装common的Plugins插件
// Vue.use(common.globalComponents) // 全局组件
Vue.use(Router)

const { name: packName } = require('../package.json')
require('@styles/index.scss')

const _import = require('@router/_import_' + process.env.NODE_ENV)
// true:一起运行,false:独立运行
const isNotQiankun = !window.__POWERED_BY_QIANKUN__


Vue.config.productionTip = false
let instance = null

/**
 * 子项目默认初始化
 * @param {Object} props - 主应用传递的参数
 */
function render(props) {
    const { container, routerBase, routerList, name } = props || {}
    // 初始化路由
    const router = new Router({
        base: isNotQiankun ? process.env.BASE_URL : routerBase,
        routes: routerList || [],
        mode: 'history'
    })

    instance = new Vue({
        name,
        router,
        store,
        provide: {
          name: packName,
          isNotQiankun
        },
        render: (h) => h(App) // 公用APP.vue
    }).$mount(container ? container.querySelector('#app') : '#app')
}

// 如果独立运行时,则会执行这里
if (isNotQiankun) {
  // 独立运行时,应该干点什么事

  render()
}

/**
 * qiankun 框架子应用的三个生命周期
 * bootstrap 初始化
 * mount 渲染时
 * unmount 卸载
 */

export async function bootstrap(props) {
    // Vue.prototype.$mainBus = props.bus
}

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

export async function unmount() {
    instance.$destroy()
    instance.$el.innerHTML = ''
    instance = null
}
  1. 设置全局路由守卫
// router/config.js
import NProgress from 'common/node_modules/nprogress' // Progress 进度条
import store from '@store'
import { utils } from 'common'
import Layout from 'common/src/pages/layout' // 引入cmmom的layout
const _import = require('@router/_import_' + process.env.NODE_ENV)
const { name } = require('../../package.json')

const isNotQiankun = !window.__POWERED_BY_QIANKUN__

// 路由白名单
const whitelist = ['/login', '/404', '/401', '/']

export default {
    install(router) {
        router.beforeEach(async (to, from, next) => {
            // 这里采用了主应用props传入子应用的方式
            // 一起运行时,路由拦截交给主应去做,子应用不做任何操作,避免冲突
            if (!isNotQiankun) return next()

            // 当独立运行时,执行开启进度条和获取菜单
            NProgress.start()

            // 设置启动应用,也可以在main.js直接设置,感觉这里设置会好一点(神秘加成)
            store.dispatch('common/setApp', name)

            // 进入路由的时白名单时,则直接next
            if (whitelist.includes(to.path)) return next()

            // 没有权限(token),重定向到登录页
            if (!store.getters['common/token']) return next({ path: '/login', replace: true })
            
            // 有菜单时,判断是否启动页(/layout/),是的话,重定向到路由表的第一个
            if (store.getters['common/menu']) { 
                const match = utils.findFirstRoute(store.getters['common/menu'])
                if (!(to.path === '/layout/' && match)) return next()
                const { base } = router.options
                return next({ path: match.path.replace(base, '') })
            } else {
                // 没有路由时,则获取
                const [err, routes] = await utils.to(store.dispatch('common/getMenu'))
                if (err) return next('/login')
                const routerList = utils.filterRouter(routes ? [routes] : [], _import, Layout, 0)
                const { children } = routerList[0]
                children.forEach((e) => {
                    router.addRoute({
                    ...e,
                    path: e.path.startsWith('/') ? e.path : `/${e.path}`
                })
                })
                next({ ...to, replace: true })
                return next()
            }
        })

        router.afterEach(() => {
            isNotQiankun && NProgress.done() // 结束Progress
        })
    }
}

主应用配置

  1. 在src创建micro目录,在里面创建三个文件,apps.jsstore.jsindex.js
// micro/apps.js
import store from './store'
import Vue from 'vue'
import vuexStore from '@store'
import { OPEN_LOADING, CLOSE_LOADING } from '@store/types'
import { utils } from 'common'

// 全局路由前缀
export const MODULE_NAME = 'module'

/**
 * 根据应用名称获取菜单,比如pms
 * @param {string} name - 应用名
 * @returns {array} 应用路列表
 */
function getRoute(name) {
  const routerList = vuexStore.getters['common/menu'] || []
  const childPath = `/${MODULE_NAME}/${name}`
  const match = routerList.find((e) => e.path === childPath)
  if (!match) return []
  return Array.isArray(match.children) ? match.children : []
}

// 是否生产环境
const isProduction = process.env.NODE_ENV === 'production'

/**
 * name: 子应用名称 唯一
 * entry: 子应用路径 唯一
 * container: 子应用渲染容器 固定
 * activeRule: 子应用触发路径 唯一
 * props: 传递给子应用的数据
 */
const apps = [
  {
    name: 'pms',
    entry: 'http://localhost:7083/',
    container: '#subView'
  },
  {
    name: 'oms',
    entry: 'http://localhost:8823/',
    container: '#subView'
  }
]

// {
//   name: 'childTemplate',
//   entry: 'http://localhost:8082/module/childTemplate/',
//   container: '#subView',
//   activeRule: '/module/childTemplate',
//   props: {
//     routerBase: '/module/childTemplate',
//     getGlobalState: store.getGlobalState,
//     components: [MainComponent],
//     utils: {
//       mainFn
//     }
//   }
// }
export default (routerList) =>
  apps.map((e) => ({
    ...e,
    entry: `${isProduction ? '/' : e.entry}${MODULE_NAME}/${e.name}/?t=${utils.rndNum(6)}`,
    activeRule: `/${MODULE_NAME}/${e.name}`,
    // container: `${e.container}-${e.name}`, // KeepAlive
    loader: (loading) => {
      if (loading) {
        vuexStore.commit(`load/${OPEN_LOADING}`)
      } else {
        vuexStore.commit(`load/${CLOSE_LOADING}`)
      }
    },
    props: {
      routerBase: `/${MODULE_NAME}/${e.name}`, // 子应用路由的base
      getGlobalState: store.getGlobalState, // 提供子应用获取公共数据
      routerList: getRoute(e.name, routerList), // 提供给子应用的路由列表
      bus: Vue.prototype.$bus // 主应用Bus通讯
    }
  }))

// micro/store.js
import { initGlobalState } from 'qiankun'
import Vue from 'vue'

// 父应用的初始state
// Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。
export const initialState = Vue.observable({
    menu: null,
    user: {},
    auth: {},
    tags: [],
    app: 'main'
})

const actions = initGlobalState(initialState)

actions.onGlobalStateChange((newState, prev) => {
    // console.log('父应用改变数据', newState, prev)
    for (const key in newState) {
    initialState[key] = newState[key]
    }
})

// 自定义一个get获取state的方法下发到子应用
actions.getGlobalState = (key) => {
    // 有key,表示取globalState下的某个子级对象
    // 无key,表示取全部
    return key ? initialState[key] : initialState
}

export default actions
// micro/index.js
import {
  registerMicroApps,
  // setDefaultMountApp,
  start,
  addGlobalUncaughtErrorHandler
} from 'qiankun'
import apps from './apps'
import { Message } from 'common/node_modules/element-ui'
import NProgress from 'common/node_modules/nprogress'
import router from '@router'
import { utils } from 'common'
export default function (routerList) {
  registerMicroApps(apps(routerList), {
    beforeLoad: (app) => {
      // console.log('--------beforeLoad', app)
      NProgress.start()
    },
    beforeMount: (app) => {
      // console.log('--------beforeMount', app)
      // console.log('[LifeCycle] before beforeMount %c%s', 'color: green;', app.name)
    },
    afterMount: (app) => {
      NProgress.done()
      // console.log('-------afterMount', app)
      // console.log('[LifeCycle] before afterMount %c%s', 'color: green;', app.name)
    },
    beforeUnmount: (app) => {
      // console.log('-------beforeUnmount', app)
      // console.log('[LifeCycle] before beforeUnmount %c%s', 'color: green;', app.name)
    },
    afterUnmount: (app) => {
      // console.log('-------afterUnmount', app)
      // console.log('[LifeCycle] after afterUnmount %c%s', 'color: green;', app.name)
    }
  })

  // 监听错误
  addGlobalUncaughtErrorHandler(
    utils.debounce((event) => {
      const { error } = event
      if (error && ~error.message?.indexOf('LOADING_SOURCE_CODE')) {
        Message.error(`${error.appOrParcelName}应用加载失败`)
        router.push({ name: 'Child404' })
      }
    }, 200)
  )

  // 默认加载应用
  // setDefaultMountApp('/module/childTemplate/')

  start()
}

使用的时候,引入micro即可。

<template>
    <!-- #subView 就是刚才app里的container -->
    <div
        id="subView"
        v-loading="loading"
        element-loading-text="正在加载子应用中..." />
</template>

<script>
import micro from '@/micro'
import { GET_LOADING } from '@store/types'
export defalt {
    computed: {
        loading() {
            return this.$store.getters[`load/${GET_LOADING}`]
        }
    },
    mounted() {
        // 启动加载微应用
        micro()
    }
}
</script>

FAQ&注意点

  1. 加载子应用时,必须先主应用写好容器节点,对用的字段是appcontainer,而且必须等到容器节点加载完成才去运行微运用,也就是放到mounted生命周期里运行。

  2. appnameentryactiveRule必须唯一。

  3. appentry建议通过环境变量进行判断赋值,因为部署的时候,可以有三个模式:

    1. 多个应用对应多个端口,那就要微应用的请求允许跨域,因为主应用是通过fetch去获取子应用的静态资源的,然后通过正则去解析出来子应用的静态资源信息,然后fetch下来,所以必须要求这些静态资源支持跨域。
    2. 多个应用一个端口,通过正则表达式动态匹配子应用路径,这个时候就要求应用名应用触发的URL前缀的/最后一个字符一样,就是appnameactiveRule字段。
    const isProduction = process.env.NODE_ENV === 'production'
    const apps = [
        {
            name: 'pms'
            entry: isProduction ? '/' : 'http://localhost:7083/',
            activeRule: '/module/pms'
            ...
        },
        ...
    ]
    
  4. 全局状态通讯,有几种方法

    1. vue.observable+initGlobalState(state)+getGlobalState()+ setGlobalState()+onGlobalStateChange(handle)方法结合。通过observable初始化数据,让数据变为可响应的,再传入initGlobalState返回一个对象,把这个对象通过appprops传递给子应用调用,当state发生变化时,onGlobalStateChange就会响应变化,并作出改变,类似watch
    import { initGlobalState } from 'qiankun'
    import Vue from 'vue'
    
    // 父应用的初始state
    // Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。
    export const initialState = Vue.observable({
        name: 'xxx'
    })
    
    const actions = initGlobalState(initialState)
    
    actions.onGlobalStateChange((newState, prev) => {
      // console.log('父应用改变数据', newState, prev)
      for (const key in newState) {
        initialState[key] = newState[key]
      }
    })
    
    // 定义一个获取state的方法下发到子应用
    actions.getGlobalState = (key) => {
      return key ? initialState[key] : initialState
    }
    
    // 子应用使用时,类似 setData
    // const state = actions.getGlobalState() // 获取
    // state.name = '4'
    // actions.setGlobalState(state) // 设置
    
    export default actions
    
    1. 在1的例子上再升级,加上vuex+registerModule动态模块,可以扩展把用户模块(登录、获取token、获取菜单、获取应用、注销)放到里,让每个应用不用重新写一次用户模块,查看例子commonRegister.js配置
  5. 路由拦截设计,当一起运行时,则交给主应用处理;当独立运行时,则由运行的子应用处理,判断是一起运行还是独立运行可以通过window.__POWERED_BY_QIANKUN__的值判断。

  6. 路由表判断归属,提供一种思路,可以通过设置应用名匹配URL前缀最后一个/后的内容相同,然后判断前缀是否相同。

    {
        name: 'pms' // pms跟下面的pms一样就好了
        activeRule: '/module/pms'
    }
    
  7. 多个应用设置同一个名称的挂载节点(#app),导致渲染错误。可以通过父应用传过来的props中的container节点,通过这个container再寻找下面的#app

    // main.js
    function render(props) {
        const { container, routerBase, routerList, name } = props || {}
        new Vue({
            ...
        }).$mount(container ? container.querySelector('#app') : '#app')
    }
    
  8. commonRegister.jsinitState初始内容必须跟主应用src/micro/store.jsinitialState一样,否则会导致一起运行与单独运行的全局状态对不上,无法保持一致。

  9. vue-devtools没显示出子应用的节点,无法调试。这里其实是因为子应用没有父节点来继承它导致的,所以手动设置一下即可。

// main.js
const isNotQiankun = !window.__POWERED_BY_QIANKUN__

/**
 * 子项目默认初始化
 * @param {Object} props - 主应用传递的参数
 */
function render(props) {
    ...省略
    // 解决vue-devtools在qiankun中无法使用的问题
    if (!isNotQiankun && process.env.NODE_ENV === 'development') {
        // vue-devtools  加入此处代码即可
        const instanceDiv = document.createElement('div')
        instanceDiv.__vue__ = instance
        document.body.appendChild(instanceDiv)
    }
}

动画4.gif

  1. 快速生成子应用,可以预先建好一个模板子应用childTemplate,然后用node.js脚本生成,其中只要修改应用名、端口号,不过剩下一些路由、script脚本要手动加。

code.png

KeepAlive改造

image.png

面包削切换,管理页面缓存。

这里提供一种已经实践并部署上线的方案,使用loadMicroApp手动加载子应用实现,不使用registerMicroApps,防止成为地中海。

微前端的KeepAlive跟平时的有点不同,因为是多个微应用结合在一起的项目了,里面有多个Vue实例,所以各个微应用都要写<KeepAlive></KeepAlive>标签,然后在commonRegister.js,添加tags: []初始数据,在新增/切换/删除面包削的时候要往里面pushsplice

由于一起运行后,从pms应用切换到oms应用后,pms应用如果是使用多级路由,并且还是Layout组件里面包裹<KeepAlive></KeepAlive>做缓存的话,这个时候只剩下最后最外层的App组件节点,刚才Layout组件的缓存也会消失。

因为这个时候路由地址是oms应用的,故pms应用跟当前路由找不到匹配的组件,所以无法匹配二级路由,导致Layout组件消失,进而导致缓存也消失了。

切换前后的路由变化:
切换前:module/pms/A
切换后:module/oms/B

切换前后的组件变化:
切换前:App - Layout(KeepAlive)
切换后:App

路由变化,导致组件匹配不到就很明显了。

独立运行则使用Layout组件模式,在这里面使用<KeepAlive></KeepAlive>

先看改造完效果

动画5.gif

所以有了如下改造思路:

设计思路

  1. 所有微应用都引用同一个App组件和同一个Layout组件,故可以把AppLayout放到公共包(common)里。
  2. appcontainer设置唯一,并在主应用上循环渲染出来,给到子应用渲染。
    • 一起运行:主应用用Layout装载所有子应用,并且把所有子应用路由转为一级路由,然后给到主应用Layout路由的children;子应用App组件启用KeepAliveLayout组件只给主应用使用。
    // 主应用路由
    const mainRoutes = [
        {
            path: '/module',
            component: Layout,
            children: []
        }
    ]
    
    const childRoutesFlag = [...] // 已经把所有子应用路由转为一级路由
    mainRoutes.[0].children.push(...childRoutesFlag)
    
    • 独立运行:启动应用App组件不启用KeepAlive,采用Layout组件,当作容器,并在里面启用KeepAlive

公共包/src/pages/App组件

<template>
  <div id="app" class="WH">
    <template v-if="!isQiankun">
      <RouterView class="WH app__container" />
    </template>
    <template v-else>
      <Transition name="slide-left" mode="out-in" appear>
        <KeepAlive :include="tags">
          <RouterView class="WH app__container" />
        </KeepAlive>
      </Transition>
    </template>
  </div>
</template>

<script>
// App.vue
export default {
  name: 'APP',
  computed: {
    isQiankun() {
      return window.__POWERED_BY_QIANKUN__
    },
    tags() {
      if (!this.isQiankun) return []
      const tags = this.$store.getters['common/tags']
      const { base } = this.$router.options
      return tags
        .filter((e) => e.path.startsWith(base) && (e.meta || {}).keepAlive === 1)
        .map((e) => {
          const pathSplit = e.path.replace(base, '').split('/').pop() || ''
          return pathSplit
            .replace(/-(\w)/g, ($0, $1) => $1.toUpperCase())
            .replace(/^([a-z]{1})/, ($0) => $0.toUpperCase())
        })
    }
  }
}
</script>

公共包/src/pages/Layout组件

<template>
  <div class="layout WH">
    <!-- <LayoutSide class="layout__left" :isCollapse="isCollapse" /> -->
    <div class="layout__right">
      <!-- <LayoutHeader v-model="isCollapse" /> -->
      <template v-if="route.meta.isNotChild || isNotQiankun">
        <ElScrollbar :vertical="false" class="scroll-container">
          <div class="layout__main__container">
            <Transition name="slide-left" mode="out-in" appear>
              <KeepAlive :include="tags">
                <RouterView :key="key" class="WH layout__main__view" />
              </KeepAlive>
            </Transition>
          </div>
        </ElScrollbar>
      </template>
      <Component
        :is="container"
        v-show="container && !isNotQiankun"
        class="layout__container WH"
      ></Component>
    </div>
  </div>
</template>

<script>
// Layout
export default {
  name: 'Layout',
  props: {
    // 渲染子应用的组件,只有在主应用使用时才传入
    // main/router/index.js
    // import ChildContainer from '@components/ChildContainer'
    // {
    //   path: '/module',
    //   component: Layout,
    //   props: {
    //     container: ChildContainer,
    //     isNotQiankun: false
    //   },
    //   children: []
    // }
    container: {
      type: Object,
      default: null
    },
    isNotQiankun: {
      type: Boolean,
      default: true
    }
  },
  inject: {
    isNotQiankun: {
      default: false
    }
  },
  computed: {
    route() {
      return this.$route
    },
    key() {
      return this.$route.fullPath
    },
    tags() {
      const tags = this.$store.getters['common/tags']
      const { base } = this.$router.options
      return tags
        .filter(
          (e) => (e.path.startsWith(base) || this.isNotQiankun) && (e.meta || {}).keepAlive === 1
        )
        .map((e) => {
          const pathSplit = e.path.replace(base, '').split('/').pop() || ''
          return pathSplit
            .replace(/-(\w)/g, ($0, $1) => $1.toUpperCase())
            .replace(/^([a-z]{1})/, ($0) => $0.toUpperCase())
        })
    }
  }
}
</script>

主应用/src/components/ChildContainer组件,渲染子应用的

<template>
  <div
    v-loading="loading"
    :element-loading-text="`正在加载${childName}子应用中...`"
    class="childContainer WH"
  >
    <ElScrollbar ref="scrollContainer" :vertical="false" class="scroll-container">
      <template>
        <div
          v-for="(item, index) in childList"
          v-show="activation.startsWith(item.activeRule)"
          :id="item.container.replace('#', '')"
          :key="index"
          class="sub-content-wrap WH"
        />
      </template>
    </ElScrollbar>
  </div>
</template>

<script>
// 子容器
import apps from '@micro/apps'
import { GET_LOADING, OPEN_LOADING, CLOSE_LOADING } from '@store/types'
import { loadMicroApp } from 'qiankun'
export default {
  name: 'ChildContainer',
  data() {
    return {
      microList: new Map()
    }
  },
  computed: {
    loading() {
      return this.$store.getters[`load/${GET_LOADING}`]
    },
    childList() {
      return apps()
    },
    activation() {
      return this.$route.path || ''
    },
    childName({ activation, childList }) {
      return childList.find((item) => activation.startsWith(item.activeRule))?.name || ''
    }
  },
  watch: {
    activation: {
      immediate: true,
      handler: 'activationHandleChange'
    }
  },
  methods: {
    //  监听路由变化,新增/修改/删除 缓存
    async activationHandleChange(path, oldPath) {
      this.$store.commit(`load/${OPEN_LOADING}`)
      await this.$nextTick()
      const { childList, microList } = this
      const conf = childList.find((item) => path.startsWith(item.activeRule))
      if (!conf) return this.$store.commit(`load/${CLOSE_LOADING}`)
      
      // 如果已经加载过一次,则无需再次加载
      const current = microList.get(conf.activeRule)
      if (current) return this.$store.commit(`load/${CLOSE_LOADING}`)

      // 缓存当前子应用
      const micro = loadMicroApp({ ...conf, router: this.$router })
      microList.set(conf.activeRule, micro)
      micro.mountPromise.finally(() => {
        this.$store.commit(`load/${CLOSE_LOADING}`)
      })
    }
  }
}
</script>

Nginx部署

Nginx部署方案有三种,如果没有特殊需求,个人推荐第三种

  1. 多个应用多个端口,主应用配置多个子应用路径转发到相对应的子应用端口上。
    • 优点:子应用能单独访问
    • 缺点:每有新增子应用时,则每次都要新增一个子应用端口和转发
    http{
        # main
        server {
            listen       80;
            location / {
                try_files $uri $uri/ /index.html;
                root   /usr/share/nginx/main;
                index  index.html index.htm;
            }
            location /module/pms {
               try_files $uri $uri/ /index.html;
               proxy_pass http://127.0.0.1:8081;
            }
            location /module/oms {
                try_files $uri $uri/ /index.html;
                proxy_pass http://127.0.0.1:8082;
             }
        }
        
        # pms
        server {
            listen       8081;
            location / {
                try_files $uri $uri/ /index.html;
                root   /usr/share/nginx/pms;
                index  index.html index.htm;
            }
        }
        
        # oms
        server {
            listen       8082;
            location / {
                try_files $uri $uri/ /index.html;
                root   /usr/share/nginx/oms;
                index  index.html index.htm;
            }
        }
    }
    
  2. 多个应用一个端口,子应用需要一个二级目录装着,子应用只配置一个location即可,但是目录名必须跟主应用的Layout路由的path属性一样,并且应用名必须跟是部署的目录一致,比如有主应用(main),子应用有pms、oms,那么该目录结构如下:
    | -- main
         | -- index.html
    | -- module
         | -- pms
              | -- index.html
         | -- oms
              | -- index.html
    
    • 优点:一个端口即可,location只需两个,一个主应用,一个子应用
    • 缺点:子应用都得在一个指定的目录下,打包后完需要用sh命令,改变dist目录名和位置,增加复杂度;对于部分运维部署软件,可能无法回滚;无法单独访问子应用
    server {
        listen       80;
        location / { # 主应用
            root   /data/web/qiankun/main;
            index index.html;
            try_files $uri $uri/ /index.html;
        }
        
        # ^~ 匹配任何以/module/开头的任何查询并且停止搜索。任何正则表达式将不会被测试。
        # module 必须与 主应用的Layout路由的path 一直
        location ^~ /module/ { # 所有子应用
            alias /data/web/qiankun/module;
            try_files $uri $uri/ /index.html;
        }
    }
    
    
    1. 多个应用一个端口,通过正则表达式匹配后缀名,用alias或者rewrite重写请求,要求应用名必须跟是部署的目录一致,可以设置vue.config.jsoutputDir属性,改变dist目录名。
    先分析请求规则
    请求 -> /module/ // 主应用的启动页
    请求 -> /module/pms/A // pms应用 A页面
    请求 -> /module/pms/B // pms应用 B页面
    请求 -> /module/oms/C // oms应用 C页面
    请求 -> /module/oms/D // oms应用 D页面
    
    根据如上规则,可以知道第一个/后面的module是固定的,第二个/后面就是应用名,第三个/后面是具体的路由地址。所以根据如上规则可以用正则表达式匹配,并改写请求。
    • 优点:一个端口,location只需两个,一个主应用,一个子应用;子应用的location用正则表达式动态匹配,并用rewrite动态重写url;在服务器打包完后的路径就是最终路径,不用改写目录。
    • 缺点:Nginx的location正则匹配性能消耗性能大一点?
    server {
        listen       80;
        location / { # 主应用
            root   /data/web/qiankun/main;
            index index.html;
            try_files $uri $uri/ /index.html;
        }
        
        location ^~ /module/(.*) { # 所有子应用
            try_files $uri $uri/ /index.html;
            if ($1 != "") { # 有值时,则跳到对应的子应用
                alias /data/web/qiankun/$1
                # rewrite "/module/(.*)" "/data/web/qiankun/$1" last;
            } else { # 没有值时,则跳到主应用
                alias /data/web/qiankun/main
                #rewrite "/module/(.*)" "/data/web/qiankun/main" last;
            }
        }
    }
    

参考链接

看完这篇的人可以看看升级版

Vue+微前端(QianKun)落地实施和最后部署上线总结(二)普通版

最后,招贤纳士

base: 广州-海珠区-磨碟沙地铁

广州爆米科技正在在招人啦~

职位薪资内容
中高级前端工程师15-35kvue全家桶+nuxt+flutter+element-ui+vant,主要是供应链和商城
中高级JAVA工程师15-35k
中高级PHP工程师15-35k
中高级测试工程师15-30k
电商产品经理20-40k
供应链产品经理20-40k
测试总监30-50

简历发到我邮箱,我内推。iikiiklk@qq.com,邮件标题:简历-姓名-前端 附上你的简历