基于 vue+qiankun 的前端微服务权限系统 demo

tao
发布于2021-08-23 | 更新于2021-09-08

演示地址
源码地址

什么是前端微服务

微服务是一种用于构建应用的架构方案。微服务架构有别于更为传统的单体式方案,可将应用拆分成多个核心功能,每个功能都被称为一项服务,可以单独构建和部署,这意味着各项服务在工作(包括出现故障)时不会相互影响。

微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。

2020-04-01_15-04-43.png

前端微服务的适用性

当你的单体应用已经或预期中会成为巨石应用,当你有不同技术栈的应用需要做聚合时。但是如果团队规模小,产品功能划分不清晰时为了使用而使用,那么很有可能会拖慢产品开发效率,失去很多快速出击的机会。

qiankun简介

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

概念介绍

主应用:子应用的统一接入平台;
子应用:组成主应用的单个应用;
主应用和子应用都有技术栈无关、独立开发、独立部署、独立运行的特性。

demo实现

在阅读此部分之前,建议将源码克隆下来进行对照阅读。(如果觉得源码值得参考,请帮忙点一下星星!)

在主应用中注册子应用

相关配置

我将子应用的相关配置文件都放在了platform-webbase-web/public/下面,并在之后需要的时候使用axios发送请求获取,以此来模拟子应用的配置是从服务端获取的场景。

子应用注册配置

// platform-webbase-web/public/sysconfig/microapp-config.json

[
  {
    "name": "app-microapp-web", 
    "entry": "/child/app/microapp/web/", 
    "container": "#micro-app", 
    "activeRule": "/app/microapp/web"
  }
]

注意:
子应用配置的entry字段不能与activeRule完全一致,防止刷新页面之后,页面跳出主应用直接访问子应用。

子应用描述及权限路由信息配置
这个配置存在的原因是为了让主应用根据子应用的唯一标识(如app-microapp-web)获得子应用的描述及权限路由信息。

// platform-webbase-web/public/sysconfig/all-app-info.json

{
  "app-microapp-web": {
    "appName": "微应用",
    "routes": [
      {
        "path": "/app/microapp/web/test1",
        "name": "Test1",
        "component": "/test1",
        "hidden": false,
        "meta": {
          "title": "测试页面1",
          "icon": ""
        }
      },
      {
        "path": "/app/microapp/web/test2",
        "name": "Test2",
        "component": "/test2",
        "hidden": false,
        "meta": {
          "title": "测试页面2",
          "icon": ""
        }
      }
    ]
  }
}

开发服务器配置
demo 使用Vue CLI搭建,测试环境中主应用的开发服务器应该配置当路径请求为/child/app/microapp/web代理到子应用所在服务的地址。子应用则应该添加允许跨域访问的响应头部。

// platform-webbase-web/vue.config.js

// 主应用开发服务器配置 
{
  devServer: {
    port: 5065,
    proxy: {
      '/child/app/microapp/web': {
        target: 'http://localhost:5066'
      }
    }
  }
}

// app-microapp-web/vue.config.js

// 子应用开发服务器配置
devServer: {
  port: 5066,
  disableHostCheck: true,
  headers: {
    'Access-Control-Allow-Origin': '*',
  }
}
子应用容器创建
// platform-webbase-web/src/router/index.js

// 主应用路由配置 
const routes = [
  {
    path: '/',
    redirect: '/platform/webbase/web',
    component: Layout,
    children: [
      {
        path: '/platform/webbase/web',
        name: 'Home',
        component: Home
      },
      {
        path: '/app/*',
        name: 'Portal',
        component: Portal
      }
    ],
  },
]

所有以app开头的路由全部都匹配到Portal组件,接下来在Portal组件中创建子应用容器,这样的话,所有子应用的挂载都会走主应用vue-router的路由拦截方法,防止子应用未经过主应用的路由拦截提前渲染到页面中。

<!-- platform-webbase-web/src/views/portal.vue -->

<template>
  <div id="micro-app"></div>
</template>

<script>
import startQiankun from '@/micro'

export default {
  name: 'Portal',
  mounted(){
    if(!window.qiankunStarted){
      window.qiankunStarted = true
      // 自定义请求方法,在所有访问子应用的请求中加入了自定义头,这样可以实现子应用只能被拥有自定义头的请求所访问,其他请求则拒绝访问
      const request = url => { return fetch(url, { headers: { 'X-Web-Base': 'platform-webbase-web' } }) }
      startQiankun({ fetch: request })
    }
  }
}
</script>
主应用路由拦截
// platform-webbase-web/src/permission.js

import router from './router'
import store from './store'

// 根据路由路径切换当前激活的子应用方法
function switchAppRoutes(path){
  // 根据路由路径得到形式如app-microapp-web的appKey
  let appKey = path.split('/').splice(1, 3).join('-') || ''

  if(store.getters.appKey !== appKey){
    let appInfo = store.getters.allAppInfo[appKey]
    store.dispatch('app/setAppInfo', { appKey, ...appInfo })
  }
}

router.beforeEach(async (to, from, next) => {
  const hasAllAppInfo = store.getters.hasAllAppInfo
  try {
    // 判断是否已经获取所有子应用的描述及路由信息,否则需要首先进行获取
    if (hasAllAppInfo) {
      // 判断路径是否存在,否则跳转到 404 页面
      if (store.getters.allAppRoutePath.some(item => to.path.includes(item))) {
        next()
        // 根据路由路径切换当前激活的子应用
        switchAppRoutes(to.path)
      } else {
        next({ name: 'Page404' })
      }
    } else {
      await store.dispatch('permission/getAllAppInfo')
      next({ ...to, replace: true })
    }
  } catch(error) {
    console.log(error || 'Has Error')
    next({ name: 'Home' })
  }
})

注意:
1.与路由配置path*匹配 404 页面的方法不同,这里使用了根据路径是否存在来跳转 404 页面。另外也可以在子应用中通过通信的方法告知主应用路径不存在跳转到 404 页面;
2.子应用与主应用必须有规范的命名方法,比如项目组名-项目名-模块名的命名方法,这样才能根据应用名称判断当前激活的子应用。

注册子应用

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

// platform-webbase-web/src/micro/index.js

import { Message } from 'element-ui'
import { 
  registerMicroApps, 
  start, 
  addGlobalUncaughtErrorHandler 
} from 'qiankun'
import { getMicroappConfig } from '@/api/permission'
import store from '@/store'

// 传递给子应用获取自身路由信息的方法
function getRoutes(appKey){
  if (!appKey) return []
  const { allAppInfo } = store.getters
  // 子应用根据自身的唯一标识获取路由信息
  return allAppInfo[appKey] && allAppInfo[appKey].routes
}

(async function(){
  let microapps = []

  // 获取子应用注册配置并添加props,props 可以包括获取路由信息、用户信息的方法等
  await getMicroappConfig().then(res => {
    microapps = res.data

    // 子应用添加获取路由信息的方法
    microapps.map(item => {
      item.props = { getRoutes }
    })
  })

  // 注册子应用,这里可以在钩子函数中做一些操作
  registerMicroApps(microapps, {
    beforeLoad: () => {
      return Promise.resolve()
    },
    afterMount: () => {
      return Promise.resolve()
    }
  })

  // 添加全局错误捕获弹出提示内容
  addGlobalUncaughtErrorHandler((event) => {
    const { message: msg } = event
    if(msg && msg.includes("died in status LOADING_SOURCE_CODE")){
      Message.error("请检查应用是否正常运行")
    }
  })
})()

// 导出启动乾坤的方法
export default start

子应用的改造

导出相应的生命周期钩子

子应用需要在自己的入口导出bootstrapmountunmount三个生命周期钩子,以供主应用在适当的时机调用。

// app-microapp-web/src/main.js

/**
 * bootstrap 只会在子应用初始化的时候调用一次,下次子应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap(){
  // console.log('[vue] vue app bootstraped')
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props){
  // console.log('[vue] props from main framework', props)
  render(props)
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载子应用的应用实例
 */
export async function unmount(){
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
  router = null
}
渲染方法
// app-microapp-web/src/main.js

// 判断当前环境不存在qiankun变量时,不向 render 方法传递参数
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

通常在主应用调用mount方法时触发子应用的render渲染方法。

// app-microapp-web/src/main.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'
import routes from './router'
import actions from './shared/actions'

let instance = null
let router = null

const _import = file => require('@/views' + file).default
// 注册路由将字符串表示的 component 转换为 component 对象
function generateAppRoutes(routes){
  const appRoutes = JSON.parse(JSON.stringify(routes))
  appRoutes.forEach(item => {
    item.component = _import(item.component)
  })
  return appRoutes
}

function render(props) {
  let container

  if (props) {
    // 获取子应用容器
    container = props.container
    // 在 Actions 类上设置获取路由信息等方法
    actions.setActions(props)
  }

  // 默认 vue-router 的 baseUrl 和 routes
  let appBase = '/child/app/microapp/web'
  let appRoutes = routes

  if (window.__POWERED_BY_QIANKUN__) {
    // 挂载到主应用当中时 vue-router 的 base-url 与 routes
    appBase = ''
    appRoutes = generateAppRoutes(actions.getRoutes('app-microapp-web'))
  }

  router = new VueRouter({
    base: appBase,
    mode: 'history',
    routes: appRoutes,
  })

  instance = new Vue({
    router,
    render: h => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app')
}

注意:
子应用路由动态配置的原因是为了解决某个子应用路径权限不再被用户拥有,用户访问该页面时也跳转到了 404 页面,但是该页面的 created 或者 mounted 钩子函数仍然被触发。

打包配置

除了代码中暴露出相应的生命周期钩子之外,为了让主应用能正确识别子应用暴露出来的一些信息,子应用的打包工具需要增加如下配置。

// app-microapp-web/vue.config.js

const { name } = require('./package')
const publicPath = '/child/app/microapp/web'

module.exports = {
  publicPath,
  configureWebpack: {
    output: {
      library: `${name}`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  }
}

在 CSS 中引入的图片或者字体可能会出现访问不到的问题,原因是qiankun将外链样式改成了内联样式,但是字体文件和背景图片的加载路径是相对路径。以下是一种解决方案:

// app-microapp-web/vue.config.js

module.exports = {
  chainWebpack(config){
    config.module.rule('fonts')
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 4096, // 小于 4kb 将会被打包成 base64
        fallback: {
          loader: 'file-loader',
          options: {
            name: 'fonts/[name].[hash:8].[ext]',
            publicPath
          }
        }
      })
      .end()
    config.module.rule('images')
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 4096, // 小于 4kb 将会被打包成 base64
        fallback: {
          loader: 'file-loader',
          options: {
            name: 'img/[name].[hash:8].[ext]',
            publicPath
          }
        }
      })
  }
}

主子应用通信

主应用初始化全局状态
// platform-webbase-web/src/shared/actions.js

import { initGlobalState } from "qiankun"

const initialState = {}
const actions = initGlobalState(initialState)

export default actions
// platform-webbase-web/src/main.js

import Vue from 'vue'
import actions from '@/shared/actions'

// 将 action 注册为全局变量使其可以在每个 Vue 的实例中可用
Vue.prototype.$actions = actions
// platform-webbase-web/src/layout/components/Navbar.vue

// 主应用顶部导航信箱功能实现
export default {
  name: 'Navbar',
  data() {
    return {
      msgList: [],
    }
  },
  created() {
    this.$actions.setGlobalState({ newMsg: '' })
    this.$actions.onGlobalStateChange(state => {
      if(state.newMsg){
        this.msgList.push(state.newMsg)
      }
    })
  }
}
子应用传递消息给主应用
// platform-webbase-web/src/shared/actions.js

function emptyAction() {
  console.warn('current execute action is empty!')
  return false
}

class Actions {
  actions = {
    onGlobalStateChange: emptyAction,
    setGlobalState: emptyAction,
    getRoutes: emptyAction
  }

  setActions(actions) {
    this.actions = actions
  }

  // 监听全局状态变化的方法
  onGlobalStateChange(...args) {
    return this.actions.onGlobalStateChange(...args)
  }

   // 修改全局状态的方法
  setGlobalState(...args) {
    return this.actions.setGlobalState(...args)
  }

  getRoutes(...args) {
    return this.actions.getRoutes ? this.actions.getRoutes(...args) : emptyAction()
  }
}

const actions = new Actions()

export default actions
<!-- app-microapp-web/src/views/test1/index.vue -->

<!-- 子应用向主应用的信箱中添加消息功能实现 -->
<template>
  <div class="app-container">
    <p>应用间通信</p>
    <el-form :inline="true">
      <el-form-item>
        <el-input v-model="msg" style="width:360px"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="sendMsg">发送消息</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script>
import actions from "@/shared/actions"

export default {
  name: 'Test2',
  data() {
    return {
      msg: '测试消息'
    }
  },
  methods: {
    sendMsg() {
      actions.setGlobalState({ newMsg: this.msg })
    }
  }
}
</script>

其他问题

路由跳转问题

主应用和子应用都是hash模式,主应用根据hash来判断子应用,则不用考虑这个问题。而主应用根据path来判断子应用时,可使用history.pushState方法,或者将主应用路由实例传递给子应用,子应用用这个路由实例跳转。

公共依赖问题

主应用和子应用在打包工具中配置externals提取公共依赖

module.exports = {
  configureWebpack: {
    externals: {
      'vue': 'Vue',
      'vue-router': 'VueRouter',
      'vuex': 'Vuex',
      'element-ui': 'ELEMENT'
    }
  },
}

index.html中使用外链的方式引入依赖

<!DOCTYPE html>
<html lang="en">
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <script src="<%= BASE_URL %>static/js/vue@2.6.11.js"></script>
    <script ignore src="<%= BASE_URL %>static/js/vue-router@3.2.0.js"></script>
    <script ignore src="<%= BASE_URL %>static/js/vuex@3.6.0.js"></script>
    <script src="<%= BASE_URL %>static/js/element-ui@2.14.1.js"></script>
    <!-- built files will be auto injected -->
  </body>
</html>

qiankun将子项目的外链script标签,内容请求到之后,会记录到一个全局变量中,下次再次使用,他会先从这个全局变量中取。这样就会实现内容的复用,只要保证两个链接的 URL 一致即可。

如果子项目需要复用主应用的依赖,只需要给子应用index.html中公共依赖的scriptlink标签加上ignore属性(这是自定义的属性,非标准属性)。有了这个属性qiankun便不会再去加载这个JS/CSS,而子应用独立运行,这些JS/CSS仍能被加载,如此,便实现了子应用复用主应用的依赖。需要注意的是,主应用中的公共依赖需要暴露出来(挂载到 window 上),且需要和子应用版本保持一致。

注意:
demo 中子应用的VueElement UI没有在script标签上添加ignore属性,因为在开发过程中开发人员有时会在Vue原型上添加一些全局方法或者变量,添加ignore属性之后应用修改Vue原型上的全局方法或者变量可能会导致命名冲突的问题。

CSS隔离

demo 通过手动的方式确保主应用与子应用之间的样式隔离。比如给主应用的所有样式添加一个前缀。

在最新的 qiankun 版本中,也可以尝试通过配置 { sandbox : { experimentalStyleIsolation: true } } 的方式开启运行时的 scoped css 功能,从而解决应用间的样式隔离问题。 但是启用之后可能会对弹窗等一些插入到 body 节点之下的元素样式造成影响。


参考文章:
1.微前端在小米 CRM 系统的实践
2.全面解析微前端框架qiankun及其系列文章