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

8,512 阅读11分钟

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

今年可谓说是跟微前端一直有缘,换了一下家公司,领导又要搞整合,要把几个业务系统整合在一起,而且要统一样式风格,定制组件库,我内心都是,那就搞吧,刚好补充上一次微前端的方案以及修正一些错误。

为了满足领导要求,一开始就只是整个统一登录页面,然后跳转应用模块页面,点击某个模块,新开标签页进入某个系统,好了这就是整合 😝,到时间段的演示,被批不行。

领导反馈到开发层面,我脸都绿了,要求在同个页面打开,不要切换页面,我当时内心:🤣😊😐😤😭😭😭

前面是题外话,下面才是正题。

背景

此前有三个已经成型的项目,是本次接入微前端的主要核心应用,都是Vue2.x+Element架构,其中有两个是基于Vue-cli3.x,一个是基于Vue-cli2.x,考虑到接入QianKun的统一性配置,减少复杂度和损耗时间,索性把所有项目都改为Vue-cli3.x起步。

由于时间有限,本次组件库是基于Element二次封装的,基础架构比较简陋(其实是没搭建过组件库经验)。

成型效果

微前端.gif

微前端主要原理

微前端主要是对一些庞大的项目进行解耦或者几个项目想组成一个项目运行的,但不限于技术栈(React、Vue、Angular、JQuery)等。

iframe的做法是引入一个html页面,优点的就是方便快捷,缺点不利于资源管理,容易造成性能泄露、BUG 难以排查以及一些安全问题。

<iframe src="http://10.10.66.32:8003/home.html" />

微前端的做法是监听 URL 变化,动态引入相对应的资源和销毁相应的资源,达到性能最大利用率。在基于ES module的三大框架中,需要把打包后的资源设置为UMD格式,才能直接在ES module直接引入。大概是如下面这种形式

<!-- 当前url http://10.10.10.66/module/micro_A ↓↓↓↓ 需要引入的资源 -->
<script src="http://10.10.10.66/module/micro_A.js"></script>

<!-- 当前url http://10.10.10.66/module/micro_A/home ↓↓↓↓ 需要引入的资源 -->
<script src="http://10.10.10.66/module/micro_A.js"></script>
<script src="http://10.10.10.66/module/micro_A/home.js"></script>

<!-- 当前url http://10.10.10.66/module/micro_B ↓↓↓↓ 需要引入的资源 -->
<script src="http://10.10.10.66/module/micro_B.js"></script>

<!-- 当前url http://10.10.10.66/module/micro_B/home ↓↓↓↓ 需要引入的资源 -->
<script src="http://10.10.10.66/module/micro_B/home.js"></script>

微前端请求分解结构图.png

以上两种方案都是基于引入其他资源实现的,自然会存在跨域问题的,可以通过nginx代理或者设置cors

本次设计架构

承接上一篇文章:Vue+微前端(QianKun)落地实施和最后部署上线总结

微前端总体设计.png

  1. 基于 QianKunVue2.6 实现的底层架构。
  2. 配套公共包+组件库(Element二次封装)以及私库 npm,基于 Vue-cli4.xVuese
  3. 独立仓库和独立部署架构设计。
  4. 主子应用都采用hisotry路由模式。
  5. 兼容两种运行模式,分别是独立运行一起运行
  • 独立运行:顾名思义,采用微前端之后,想要单独值运行一个子应用,正常是需要启动一个主应用和需要运行的子应用,但是这样子太繁琐,不利于开发。因此才有独立运行的需求,可以通过QianKun独有的全局变量window.__POWERED_BY_QIANKUN__判断运行模式,只要在部分公共组件里做判断即可,包括路由区分,下面会讲。

    运行模式的路由区分.png

  • 一起运行:一个主应用和至少一个子应用一起运行,初期搭建完vue+Qiankun整体框架,需要这样子不断去调试,等到稳定后才会拆分出来;线上运行也算是。

Vue+微前端(QianKun(二)普通版.png

主应用和子应用关系如下:

总体路由设计.png

按运行模式来

公共包+组件库

公共包分为以下两个部分:

  1. 组件库
  2. 公共方法 - 统一管理子应用一些渲染方法

公共包设计.png

安装依赖

npm i element-ui@2.15.6 -S

公共包+组件库整体设计

组件库设计其实很简单,本次是基于vue cli4.x,最后打包上传到 npm,网上很多教程,先看看本次目录

common
├── .vscode
├── dist // src打包后的静态文件
├── docs // 组件库生成文档的静态文件
├── docsStatic // 替换vuese引入的静态文件
│   ├── docute.css
│   └── docute.js
├── generators // plop 快速生成文件模板
│   ├── packagesComponent // 组件模板
│   │   ├── component.vue.hbs
│   │   ├── index.js.hbs
│   │   └── index.js
│   └── index.js
├── lib // 组件库打包后的文件
├── packages // 组件库源码
│   ├── Chart // 基于eCharts封装组件
│   │   ├── src
│   │   │   ├── components
│   │   │   └── index.vue
│   │   └── index.js // 该组件入口文件
│   ├── ...其他组件
│   ├── utils // 工具类
│   │   ├── childRender.js // 子应用渲染方法
│   │   ├── commonStore.js // 动态vuex模块,与onGlobalStateChange结合使用,集成登录、注销、菜单、应用等共有模块
│   │   ├── fetch.js // 统一封装axios请求,集成token、白名单、HTTP异常等处理
│   │   ├── routerAuth.js // 子应用路由通用拦截,token、白名单、动态菜单等
│   │   └── index.js // 该工具类入口文件
│   ├── constant.js // 公用常量
│   ├── axios.js // 暴露axios插件
│   ├── cookie.js // 暴露js-cookie插件
│   ├── day.js // 暴露dayjs插件
│   ├── icon.js // 阿里图标库
│   └── index.js // 组件库入口文件
├── public
├── src // 项目入口,将作为编写组件库时的测试地方
│   ├── assets // 静态资源
│   │   ├── icons // 阿里图标静态文件
│   │   ├── images
│   │   └── styles
│   ├── components
│   ├── mixins
│   ├── router
│   ├── utils
│   ├── views
│   ├── api.js
│   ├── App.vue
│   ├── constant.js
│   └── main.js
├── .env // 通用环境变量
├── .env.lib // 打包组件库的环境变量
├── .eslintignore // 配置忽略eslint检验文件的地方
├── .eslintrc
├── .npmignore // 配置上传npm忽略的文件
├── gulpfile.js // gulp,配置一些自动转换的地方
├── package.json
├── vue.config.js
└── vuese.config.js // vuese配置文件

组件库配置

参考Vue-Cli3 搭建组件库

上面是单入口文件配置,本次是多入口文件,按照下面稍加修改

  • 不把src改为examples,保存原样就好了。
  • 新增组件,组件文件夹名以大写驼峰格式(后面有用),比如下面:
├── packages // 组件库源码
│   ├── Chart // 基于eCharts封装组件
│   │   ├── src
│   │   │   └── index.vue
│   │   └── index.js // 该组件入口文件
│   ├── Button // 基于eCharts封装组件
│   │   ├── src
│   │   │   └── index.vue
│   │   └── index.js // 该组件入口文件
  • 生成组件模板

    1. 安装npm i plop -D
    2. 新建generators目录,按照下面目录结构建好
├── generators // plop 快速生成文件模板
│   ├── packagesComponent // 组件模板
│   │   ├── component.vue.hbs
│   │   ├── index.js.hbs
│   │   └── index.js
│   └── index.js
  1. packages.json中的scripts添加
"gen": "plop --plopfile generators/index.js",

npm run gen示例

来看一下生成的样子

gen生成的组件示例

运行文档的效果(文档在后面讲)

image.png

  • 新增.env.env.lib环境变量配置文件
// .env
// 除了lib模式,其他都是dist
VUE_APP_DIR = dist
// .env.lib
NODE_ENV = production
VUE_APP_LIB = true // 用于识别lib模式
VUE_APP_DIR = lib
  • packages/index.js使用require.context自动引入以大写驼峰命名的组件文件夹
import scrollbar from './scrollbar' // 引入滚动条
import './icon' // 引入图标
const { version } = require('../package.json')

const req = require.context('./', true, /\.js$/)
const requireAll = (requireContext) =>
  requireContext
    .keys()
    // 这里正则判断大写驼峰的组件文件 ./BaseSelect/index.js 这种形式
    .filter((e) =>
      /^\.\/((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?\/index.js$/.test(e)
    )
    .map((e) => requireContext(e).default)

const components = requireAll(req)

const install = function (Vue) {
  // 判断是否安装
  if (install.installed) return
  install.installed = true
  // 遍历注册全局组件
  components.map(
    (component) => component.name && Vue.component(component.name, component)
  )
  Vue.use(scrollbar)
}

if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export default {
  // 导出的对象必须具有 install,才能被 Vue.use() 方法安装
  install,
  // 以下是具体的组件列表
  ...components.reduce(
    (prev, cur) => (!cur.name ? prev : { ...prev, [cur.name]: cur }),
    {}
  ),
  scrollbar,
  version, // 版本号
}
  • 修改vue.confif.js
// vue.confif.js
const path = require('path')
const fs = require('fs')

const resolve = (dir) => path.resolve(__dirname, dir)
const isProduction = ['production'].includes(process.env.NODE_ENV)

const packName = require('./package.json').name
const isLib = !!process.env.VUE_APP_LIB
const join = path.join

// 驼峰命名转为kebab case形式(xx-xx)
function toLine(str) {
  return str
    .replace(/([A-Z])/g, function (match) {
      return '-' + match.toLowerCase()
    })
    .replace(/^-/, '')
}

// 获取多入口
function getEntries(path) {
  const files = fs.readdirSync(resolve(path))
  const entries = files.reduce((ret, item) => {
    const itemPath = join(path, item)
    const isDir = fs.statSync(itemPath).isDirectory()
    if (isDir) {
      ret[toLine(item)] = resolve(join(itemPath, 'index.js'))
    } else {
      const [name] = item.split('.')
      ret[name] = resolve(`${itemPath}`)
    }
    return ret
  }, {})
  return entries
}

module.exports = {
  devServer: {
    port: 3006,
    host: '0.0.0.0',
  },

  outputDir: process.env.VUE_APP_DIR,

  chainWebpack: (config) => {
    config.plugins.delete('prefetch')
    config.plugins.delete('preload')
    if (isLib) {
      // 组件模式不存在下面这些功能,去掉即可
      config.optimization.delete('splitChunks')
      config.plugins.delete('copy')
      config.plugins.delete('html')
      config.plugins.delete('hmr')
      config.entryPoints.delete('app')
      config.optimization.minimize(false) // 压缩代码
    } else {
      // 非组件库打包
      config.optimization.minimize(isProduction) // 压缩代码
      config.optimization.splitChunks({
        chunks: 'all',
      })

      config.resolve.symlinks(true) // 修复HMR

      config.plugin('html').tap((args) => {
        // 修复 Lazy loading routes Error
        args[0].chunksSortMode = 'none'
        return args
      })
    }

    if (process.env.npm_config_report) {
      config
        .plugin('webpack-bundle-analyzer')
        .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin)
    }

    config.module
      .rule('js')
      .include.add(resolve('src'))
      .end()
      // 扩展 webpack 配置,使 packages 加入编译
      .include.add(resolve('packages'))
      .end()
      .use('babel')
      .loader('babel-loader')

    // 静态资源全转为base64
    config.module
      .rule('images')
      .use('url-loader')
      .loader('url-loader')
      .tap((options) => Object.assign(options, { limit: 200000 }))

    config.resolve.extensions
      .add('.js')
      .add('.vue')
      .add('.json')
      .add('.css')
      .add('.scss')

    // 添加别名
    config.resolve.alias
      .set('@', resolve('src'))
      .set('@assets', resolve('src/assets'))
      .set('@styles', resolve('src/assets/styles'))
      .set('@images', resolve('src/assets/images'))
      .set('@views', resolve('src/views'))
      .set('@components', resolve('src/components'))
      .set('@utils', resolve('src/utils'))
      .set('@mixins', resolve('src/mixins'))
      .set('@router', resolve('src/router'))
      .set('@packages', resolve('packages')) // 组件路径别名
  },

  configureWebpack: (config) => {
    if (isProduction) {
      // gzip
      // 其他插件
    } else {
      // 开发环境方便调试代码
      config.devtool = 'eval-source-map'
    }

    if (isLib) {
      config.entry = getEntries('packages') // 多入口文件
      config.output.filename = '[name]/index.js'
      config.output.libraryTarget = 'umd' // 设置打包后的各式为umd
      config.output.libraryExport = 'default' // 设置组件库只有export default导出时有效,方便tree shaking
      config.output.library = packName
      config.output.globalObject = 'this' // 这里配置this,是因为需要在nodejs环境中引入某些包
    }
  },

  css: {
    // 是否使用css分离插件
    extract: isLib ? { filename: 'style/[name].css' } : isProduction,
    sourceMap: false,
    loaderOptions: {
      sass: {
        // 全局scss
        prependData: '@import "~@styles/global.scss";',
      },
    },
  },
}
  • 打包设置,在packages.json中的scripts中加入
"build:lib": "vue-cli-service build --mode lib",

至此,组件库基本形成,下面分享几个开发遇到的坑点

  1. 本篇中组件库是基于element-ui二次封装的,一开始是在main.js全局引入element-ui组件,开发组件的时候直接使用element-ui组件,而且运行起来好好的,但是到了子应用引入的时候,就报错了。

    这是因为组件库是全局引入element-ui组件库的,打包的时候并不会去打包main.js中的内容。解决方便就是子应用再一次引入element-ui组件库或者在开发组件时,用到多少组件就手动引入多少个,自然不是很方便。

  2. 组件库写在src/assets/styles中的样式生效,但是子应用引用不生效。

    这个问题跟上面一样,main.js不会打包的,所以如果想要让生效,则packages/index.js引入。

  3. 子应用按需引入组件库设置

    子应用安装babel-plugin-import

npm i babel-plugin-import -D

然后修改 babel.config.js 文件

module.exports = {
  plugins: [
    [
      'import',
      {
        libraryName: 'ele-common', // 组件库名称
        style: (name) => {
          const cssName = name.split('/')[2]
          return `ele-common/lib/style/${cssName}.css`
        },
      },
    ],
  ],
}

生成组件文档

采用Vuese生成组件,具体可以去看官方配置。

nl是组件库的前缀,类似el-或者v-这些,最好是起一个,可以防止样式冲突。

  1. 安装
npm i @vuese/cli -D
  1. 新增vuese配置文件,在根目录新增veuse.config.js,写入
// vuese.config.js
const { version } = require('./package.json')
module.exports = {
  genType: 'docute',
  title: `公共库+组件库-v${version}`,
  include: ['packages/**/src/index.vue'],
  outDir: 'docs',
}
  1. packages.json中的scripts加入
// rimraf 是删除工具
// 开发运行文档
"dev:doc": "rimraf docs && npm run build:lib && vuese gen && vuese serve --open",
// 打包文档
"build:doc": "rimraf docs && npm run build:lib && vuese gen",
  1. 强化生成组件模板,把vuese的一些基本注释给加上
├── generators // plop 快速生成文件模板
│   ├── packagesComponent // 组件模板
│   │   ├── component.vue.hbs
│   │   ├── index.js.hbs
│   │   └── index.js
│   └── index.js
# generators/packagesComponent/component.vue.hbs
<template>
  <div class="nl-{{dashCase fileName}}">
    <!-- 默认slot -->
    <slot>
      <!-- {{ fileName }} -->
      {{ fileName }}
    </slot>
  </div>
</template>

<script>
// @vuese
// @group {{group}}
// {{describe}}
export default {
  name: 'Nl{{properCase fileName}}',
  props: {
    // value注释
    value: {
      type: String
    }
  },
  methods: {
    /**
     * @vuese
     * clear方法注释
     * @arg 入参(参数)说明
     */
    clear(bol) {
      // emit事件说明 submit
      // @arg 参数说明
      this.$emit('submit', true)
    }
  }
}
</script>

<style lang="scss">
.nl-{{dashCase fileName}} {
  /*  */
}
</style>
# generators/packagesComponent/index.js.hbs
// packages/{{properCase fileName}}/index.js
import component from './src/index.vue'

component.install = function (Vue) {
  Vue.component(component.name, component)
}

export default component
// generators/packagesComponent/index.js
module.exports = {
  description: '创建packages组件',
  prompts: [
    {
      type: 'input',
      name: 'fileName',
      message: '请输入组件名(大写驼峰)',
      validate(value) {
        if (!value) return '错误,组件名不能为空!'
        if (!/^((\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?$/.test(value)) {
          return '错误,组件名必须是大写驼峰!'
        }
        return true
      },
    },
    {
      type: 'list',
      name: 'group',
      message: '选择组件分组类型',
      choices: ['单组件', '业务组件', '页面'],
      default: '单组件',
    },
    {
      type: 'input',
      name: 'describe',
      message: '清输入组件描述',
      default: '组件描述',
    },
  ],
  actions: () => [
    {
      type: 'add',
      path: '../packages/{{fileName}}/src/index.vue',
      templateFile: './packagesComponent/component.vue.hbs',
    },
    {
      type: 'add',
      path: '../packages/{{fileName}}/index.js',
      templateFile: './packagesComponent/index.js.hbs',
    },
  ],
}
  1. 内网运行文档和根目录的README.md改为文档首页

    由于是公司内网搭建组件库,不能连外网,但是vuese生成的文档页面index.html中引入在线 cdn 的 css 和 js 资源。

docs/index.html

改变也很简单,在生成文档后的,用gulp替换一下即可

在根目录新建docsStatic文件夹和gulpfile.js文件,手动把引入 js 和 css 下载下来,放入docsStatic文件夹中。

同理,根目录的README.md改为文档首页只需正则匹配对对应替换内容即可。

gulpfile.js改为如下

// gulpfile.js
const { src, series, dest, task } = require('gulp')
const fs = require('fs')
const through2 = require('through2')
const { outDir } = require('./vuese.config')

/**
 * 拷贝docStatic文件到docs目录下
 * @returns
 */
function copyDocStatic() {
  return src(['./docsStatic/*', './README.md']).pipe(dest(outDir))
}

/**
 * 修改docs/index.html的引入js和css,默认是cdn(无法在内网运行)
 */
function modifyDocs() {
  const docsIndexPath = `./${outDir}/index.html`
  return src(docsIndexPath).pipe(
    through2.obj((file, _, cb) => {
      if (!file.isBuffer()) return cb()
      const fileContent = file.contents
        .toString()
        .replace(/href=".+"/, `href="./docute.css"`)
        .replace(/src=".+"/, `src="./docute.js"`)
        .replace(/JSON.parse\((.*)\.replace/, ($1, $2) => {
          // 把根目录的MD加入vuese展示中
          const data = JSON.parse(
            $2.replace(/\&\#34\;/g, '"').replace(/\'/g, '')
          )
          data.unshift({ title: '安装说明', link: '/README' })
          const menu = JSON.stringify(data)
          return `JSON.parse('${menu}'.replace`
        })
      fs.writeFile(docsIndexPath, fileContent, (err) => {
        if (err) return console.error(err)
        cb()
      })
    })
  )
}

exports.default = series(copyDocStatic, modifyDocs)

package.json中的是scripts修改一下

// rimraf 是删除工具
// 开发运行文档
- "dev:doc": "rimraf docs && npm run build:lib && vuese gen && vuese serve --open",
+ "dev:doc": "rimraf docs && npm run build:lib && vuese gen && gulp && vuese serve --open",
// 打包文档
- "build:doc": "rimraf docs && npm run build:lib && vuese gen",
+ "build:doc": "rimraf docs && npm run build:lib && vuese gen && gulp",

build-lib.gif

image.png

运行首页样子

image.png

  1. 不足或者短板
  • vuese只能生成propssloteventmethod文档,不能生成 demo 示例文档,除非配合gulp,后期考虑替换Storybook,有条件的同学可以考虑更好的。

Layout、Login、404、401 页面的组件

这几个页面组件比较特殊,因为独立运行一起运行两种模式都会用到,启动页的路由也会做一个区分,所以做一个兼容方案。

404 和 401 组件无需区分直接注入子应用即可,Login 组件和 Layout 组件需要根据运行模式判断不同的场景。

Login 组件只需在props暴露一个home的启动页配置即可,一般登录完成后就会固定跳到一个启动页中。在本篇设计中,按照运行模式的不同,独立启动是/layout,一起运行是/module

<template>...</template>

<script>
// packages/Login/src/index.vue

export default {
  props: {
    home: {
      type: String,
      default: '/layout',
    },
  },
  methods: {
    // 登录方法
    async handleLogin() {
      // 登录过程...
      // 登录成功后
      // 跳转到启动页
      this.$router.replace({ path: this.home })
    },
  },
}
</script>

Layout 组件是基本页面的组成,视图子组件集成了头部一级菜单二级菜单三级菜单面包屑渲染容器,功能包含应用选择菜单跳转菜单命中退出登录用户信息展示根据运行模式渲染子应用容器

通过props暴漏一个container:[Object, Function]入参,用来渲染子应用的容器。

layout设计.png

<template>
  ...
  <!-- 渲染子应用 一起运行 -->
  <Component :is="container" v-if="container" />
  <!-- 渲染当前应用本身的路由 单独运行 -->
  <RouterView v-else />
  ...
  <template>
    <script>
      export default {
        props: {
          // qianKun子容器,一起运行穿,单独运行不需要
          container: {
            type: [Object, Function],
            default: null,
          },
        },
      }
    </script></template
  >
</template>

贴一下大概代码

<!-- Layout.vue -->
<template>
  <div class="nl-layout" :class="{ hideAside: isCollapse }">
    <!-- 头部 -->
    <LayoutHeader />
    <div class="nl-layout__container">
      <LayoutMenu class="nl-layout__container-menu" />

      <div class="nl-layout__container-view">
        <!-- 面包屑 -->
        <NlBreadcrumb
          class="nl-layout__container-breadcrumb"
          prefix="所在位置:"
        />

        <!-- 二级菜单 -->
        <SubMenu />

        <!-- 三级菜单 -->
        <ThreeMenu />

        <!-- 渲染子应用 一起运行 -->
        <Component :is="container" v-if="container" />
        <!-- 渲染当前应用本身的路由 单独运行 -->
        <RouterView v-else />
      </div>
    </div>
  </div>
</template>

公共包

├── packages // 组件库源码
│   ├── utils // 工具类
│   │   ├── childRender.js // 子应用渲染方法
│   │   ├── commonStore.js // 动态vuex模块,与onGlobalStateChange结合使用,集成登录、注销、菜单、应用等共有模块
│   │   ├── fetch.js // 统一封装axios请求,集成token、白名单、HTTP异常等处理
│   │   ├── routerAuth.js // 子应用路由通用拦截,token、白名单、动态菜单等
│   │   └── index.js // 该工具类入口文件

公共包,顾名思义,是把各个应用都会用到的一些方法或者参数都集合起来,以便减少各个应用的代码量、简单好维护,最最最重要的是,统一管理统一出口开发人员专注写业务

除此之外,本篇设计中,把登录注销获取应用获取菜单四个接口放在公共库中,好处就是可以直接调用,坏处就是需要应用开发时配置好通用的代理转发。

专注写代码

  1. childRender.js子应用渲染,这里是把qiankun官方要求的修改子应用渲染(new Vue)方法过程封装起来,在此基础上根据运行模式按需注入默认路由(404、404、login、layout),减少子应用复杂度。

未封装前

image.png

封装状态后

image.png

具体实现过程过程

  • function render方法是针对渲染子应用的一系列繁杂的代码,集中于一个可扩展的方法中,其中包括区分运行模式下获取当前子应用的路由并注入注入动态 vuex 模块初始化 Vue解决 vue-devtool 无法使用问题
  • function publicPath主要解决的是子应用动态载入的 脚本、样式、图片 等地址不正确的问题,具体参考publicPath
// packages/defaultRoutes.js 子应用 默认路由

import { IS_QIANKUN } from '@/constant'
import Login from './Login'
import Not404 from './Not404'
import Not401 from './Not401'

const defaultRoutes = [
  {
    path: '/404',
    name: 'Not404',
    component: Not404,
  },
  {
    path: '/401',
    name: 'Not401',
    component: Not401,
  },
]

if (!IS_QIANKUN) {
  // 独立运行时才需要注入login路由
  defaultRoutes.push(
    {
      path: '/login',
      name: 'Login',
      component: Login,
    },
    { path: '/', redirect: '/login' }
  )
}

export default defaultRoutes
// packages/utils/childRender.js
import { IS_QIANKUN } from '@/constant'
import { flatRoute } from '@utils'
import commonStore from './commonStore'
import defaultRoutes from '@packages/defaultRoutes'

/**
 * 找出属于子应用的路由,并针对属于菜单类型的数据进行扁平化,最后动态引入路由
 * [{ path: '/app1', children: [{ path: 'home', type: 2 菜单类型 ... }] }] -> [{ path: '/app1/home', type: 2 }]
 * @param {Array} routers - 树结构的路由数据
 * @param {String} name - 应用的名称
 * @param {Function} _import - 应用动态引入路由方法
 * @returns
 */
function getRoute(routers, name, _import) {
  if (!routers) return []
  const match =
    routers.find((e) => {
      return e.path.replace(/\//g, '') === name.replace(/\//g, '')
    })?.children || []
  if (!match) return []
  return flatRoute(match, _import).map((e) => ({
    ...e,
    path: e.path.startsWith('/') ? e.path : `/${e.path}`,
  }))
}

/**
 * 初始化子应用渲染
 * @export
 * @param {Object} [{
 *   props = {},
 *   Vue, - Vue实例
 *   App, - App根组件
 *   storeInstance, - store实例
 *   routerInstance, - router实例
 *   packName, - 子应用名
 *   renderNode = '#app', - 渲染子应用节点
 *   env = {}, - 环境变量
 *   initRouter - 路由初始方法,用于做动态路由
 *   _import - 应用动态引入路由方法,如 (path) => () => import(`@/${path}`)
 * }={}]
 * @returns
 */
export function render({
  props = {},
  Vue,
  App,
  storeInstance,
  packName,
  renderNode = '#app',
  env = {},
  initRouter,
  _import,
} = {}) {
  // 独立运行:routerList为空[];一起运行:routerList为动态获取的路由
  const { container, routerBase, routerList, name, path } = props || {}
  const { BASE_URL, NODE_ENV } = env

  if (!initRouter) return console.error('缺少初始路由方法')

  // 获取当前子应用的路由
  const routes = getRoute(routerList, path, _import)

  // 初始化路由
  const router = initRouter(IS_QIANKUN ? routerBase : BASE_URL, [
    ...defaultRoutes,
    ...routes,
  ])

  // 注入公共store
  commonStore(storeInstance, props, router, NODE_ENV)

  const instance = new Vue({
    name: name || packName,
    router,
    store: storeInstance,
    provide: {
      packName,
      IS_QIANKUN,
    },
    render: (h) => h(App),
  }).$mount(container ? container.querySelector(renderNode) : renderNode)

  // 解决vue-devtools在qiankun中无法使用的问题
  if (IS_QIANKUN && NODE_ENV === 'development') {
    // vue-devtools  加入此处代码即可
    const instanceDiv = document.createElement('div')
    instanceDiv.__vue__ = instance
    document.body.appendChild(instanceDiv)
  }

  return instance
}

/**
 * 初始化,必须一开就使用
 * https://qiankun.umijs.org/zh/faq#a-%E4%BD%BF%E7%94%A8-webpack-%E8%BF%90%E8%A1%8C%E6%97%B6-publicpath-%E9%85%8D%E7%BD%AE
 * @export
 * @param {String} NODE_ENV - 环境变量
 * @param {Number} port - 端口
 * @param {String} baseUrl
 * @param {String} host
 */
export function publicPath(NODE_ENV, port, baseUrl, host = 'localhost') {
  if (IS_QIANKUN) {
    if (NODE_ENV === 'development') {
      // host 如果传的是本机IP,可解决局域网下开发,别人访问你本地的地址资源报错的情况
      window.__webpack_public_path__ = `//${host}:${port}${baseUrl}`
      return
    }
    window.__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
    // __webpack_public_path__ = `${process.env.BASE_URL}/`
  }
}
  1. commonStore.js动态vuex模块,用于解决主子应用通讯和公共业务集合。
  • 通过 qiankun 官方中的initGlobalStateonGlobalStateChangesetGlobalState的 API 配合vuex实现主子应用
  • 基于前者,可以提取公共业务,比如登录注销获取用户信息获取应用信息获取菜单信息等,都可以基于vuex封装,实现各个应用直接调用,无需重复封装。

除了主子应用通讯可以照抄,其他思路过程可以参考,具体实现过程如下

// packages/utils/commonStore.js
/* eslint-disable no-unused-vars */
import { getToken, setToken, removeToken } from '@utils/auth'
import { COMMON_DEFAULT_DATA, IS_QIANKUN, LOGIN_INFO_KEY } from '@/constant'
import { encode } from '@utils/encrypt'
import api from '@/api'
import { to, flatFormatMap, toObject, toType } from '@utils'
import { Message } from 'element-ui'

// 判断上次是否记住密码,且是密文状态
const isRememberAccount = (params) => {
  const cacheData = JSON.parse(localStorage.getItem(LOGIN_INFO_KEY))
  if (!cacheData) return
  if (
    params.password === cacheData?.password &&
    params.account === cacheData?.account
  )
    return true
}

/**
 * 公共模块,用于登录、注册、权限分发以及主子应用通讯
 * @param {vuex实例} store
 * @param {qiankun下发的props} props
 * @param {vue-router实例} router
 * @param {production|development} env
 */
function registerCommonModule(store, props = {}, router, env) {
  if (!store || !store.hasModule) return
  // 获取初始化的state
  const initState =
    (props?.getGlobalState && props.getGlobalState()) || COMMON_DEFAULT_DATA

  // 将父应用的数据存储到子应用中,命名空间固定为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) {
          // 登录过程...
          const { token, user } = data
          commit('setUser', Object.assign(data, user))
          commit('setToken', token)
          dispatch('setGlobalState')
          return data
        },
        // 登出
        logOut({ commit, dispatch }, isMain) {
          to([api.logOut()])
          commit('setUser')
          commit('setMenu')
          commit('setToken')
          dispatch('setGlobalState')

          if (router) {
            router.replace && router.replace({ name: 'Login' })
          } else {
            window.location.href = '/login'
          }
        },
        // 获取应用
        async getApps({ commit, dispatch, getters }) {
          if (getters.apps) return getters.apps

          // 获取应用过程...
          const data = res.data
          commit('setApps', data)
          dispatch('setGlobalState')
          return data
        },
        // 获取菜单
        async getMenu({ commit, dispatch, getters }) {
          const apps = await dispatch('getApps')
          if (!apps) Message.error('暂无应用')

          // 如果已经加载则不用再次调接口
          if (getters.menu) return getters.menu

          // 获取菜单过程...
          // 扁平化菜单...
          const data = res.data
          commit('setMenu', data)
          dispatch('setGlobalState')
          return data
        },
        // 设置当前应用
        setApp({ commit, dispatch }, appName) {
          commit('setApp', appName)
          dispatch('setGlobalState')
        },
      },
      mutations: {
        setGlobalState(state, payload) {
          state = Object.assign(state, payload)
        },
        // 通知父应用
        emitGlobalState(state) {
          if (!props.setGlobalState) return
          props.setGlobalState(state)
        },
        // 设置环境变量
        setEnv(state, env) {
          state.env = env
        },
        setToken(state, token = null) {
          token ? setToken(token) : removeToken()
          state.token = token
        },
        // 设置用户信息
        setUser(state, data = null) {
          localStorage[data ? 'setItem' : 'removeItem'](
            'userInfo',
            JSON.stringify(data)
          )
          state.user = data
        },
        // 设置应用
        setApps(state, data = null) {
          state.apps = data
        },
        // 设置菜单
        setMenu(state, data = null) {
          state.menu = data
        },
        // 当前当前应用
        setApp(state, appName) {
          state.app = appName
        },
      },
      getters: {
        userInfo: (state) => {
          const userInfo = JSON.parse(localStorage.getItem('userInfo'))
          // 持久化写法
          if (userInfo) {
            state.user = userInfo
          }
          return state.user
        },
        token: (state) => {
          const token = getToken()
          if (token) {
            state.token = token
          }
          return state.token
        },
        menu: (state) => state.menu,
        menuMap: (state, getters, rootState, rootGetters) => {
          return flatFormatMap(rootGetters['common/menu'])
        },
        app: (state) => state.app,
        apps: (state) => state.apps,
        isQianKun: () => IS_QIANKUN,
        env: (state) => state.env,
      },
    }
    store.registerModule('common', commonModule)

    // 第一次时设置环境变量
    env && store.commit('common/setEnv', env)
  } else {
    // 每次mount时,都同步一次父应用数据
    store.dispatch('common/initGlobalState', initState)
  }
}

export default registerCommonModule

@/constant是定义一些常量的地方,上面过程中会使用COMMON_DEFAULT_DATA,这个是定义默认state的值。

// src/constant.js
// 统一路由前缀
export const ROUTER_PRE = 'module'

// 公共默认数据
export const COMMON_DEFAULT_DATA = {
  menu: null,
  apps: null, // 应用数据
  token: null,
  user: null,
  env: null, // 环境变量
  app: 'main', // 启用应用,区分当前是什么应用下
}

// 如果一起运行时,则有这个变量
export const IS_QIANKUN = !!(typeof window === 'undefined' ? {} : window)
  ?.__POWERED_BY_QIANKUN__
  1. routerAuth.js根据运行模式,进行应用路由拦截,配合function render中的动态注入路由使用。其中拦截有运行模式白名单token注入动态菜单以及针对独立运行嵌套Layout组件,让子应用独立运行时跟一起运行减少差异,加快开发效率。
// packages/utils/routerAuth.js
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { to as toPromise, flatRoute, toType } from '@utils'
import { IS_QIANKUN } from '@/constant'
import Layout from '../Layout'

const nextLogin = (from, next, packName) => {
  if ((packName === 'main' || !IS_QIANKUN) && from?.name === 'Login') {
    NProgress.done()
  }
  next(from?.name === 'Login' ? false : { name: 'Login', replace: true })
}

/**
 * 路由拦截
 * @param {Object} param
 * @param {vux-router} param.router - vue-router实例
 * @param {Vuex} param.store - vuex实例
 * @param {Array} param.whitePath - 路由白名单
 * @param {String} param.packName - 包名,默认main
 * @param {(router) => ()} param._import - 引入路由的方法 (route) => () => import(route)
 */
export default function ({
  router,
  store,
  whitePath = ['/401', '/404', '/login'],
  packName = 'main',
  _import,
} = {}) {
  router.beforeEach(async (to, from, next) => {
    // 主应用或者单独运行时启动进度条
    if (packName === 'main' || !IS_QIANKUN) {
      NProgress.start()
    }

    // 如果是一起运行,则子应用不用判断路由
    if (packName !== 'main' && IS_QIANKUN) return next()

    // 白名单
    if (filterPath(to.path, whitePath)) return next()

    // token
    if (!store.getters['common/token']) return nextLogin(from, next, packName)

    // 已有菜单
    if (store.getters['common/menu']) return next()

    // 获取菜单
    const [err, res] = await toPromise(store.dispatch('common/getMenu'))
    if (err) return nextLogin(from, next, packName)

    if (packName && _import) {
      // 添加动态路由
      const flat = flatRoute(
        res.find((e) => e.path.replace(/^\//, '') === packName)?.children || [],
        _import
      )
      const routes = formatRoute(flat || [])
      router.addRoute(routes)

      return next({ ...to, replace: true })
    }

    next()
  })

  router.afterEach(() => {
    if (packName === 'main' || !IS_QIANKUN) {
      NProgress.done()
    }
  })
}

/**
 * 独立运行时,套个Layout
 * @param {Array} data
 * @returns
 */
function formatRoute(data = []) {
  const home = data.find((e) => e.meta.isHome) || data[0] || null
  return {
    path: '/layout',
    name: 'Layout',
    component: Layout,
    redirect: home ? `/layout/${home.path}` : null,
    children: data || [],
  }
}

/**
 * 过滤白名单,支持正则
 * @param {String} path
 * @param {Array} whitePath
 * @returns
 */
function filterPath(path, whitePath) {
  return (whitePath || []).some((e) => {
    if (toType(e, 'String')) return e === path
    if (toType(e, 'RegExp')) return e.test(path)
  })
}
  1. fetch.js是基于axios二次封装,解决各个应用的http请求的可扩展方法,其中包括有根据运行模式进行对应的拦截处理token 拦截重复请求拦截通用拦截HTTP 异常拦截,返回GETDELPOSTPUTPATCHPOST_FILEformData 上传、GET_EXPORT导出流文件和service通用方法调用。
// packages/utils/fetch.js
/**
 * 统一请求服务
 */
import axios from 'axios'
import { TOKEN_KEY } from '@/constant'
import { debounce, toType } from '@utils'
import { getToken, removeToken, setToken } from '@utils/auth'
const { Message } = require('element-ui')

/**
 * 处理空参数都为null
 * @param {Object} params - 参数
 */
const handlerNullParams = (config) => {
  const params = config[isTypeList(config.method)]
  for (const key in params) {
    if (Object.prototype.hasOwnProperty.call(params, key)) {
      const val = params[key]
      if (val === '') {
        params[key] = null
      }
    }
  }
}

const paramsList = ['get', 'delete', 'patch']
const dataList = ['post', 'put']
const isTypeList = (method) => {
  if (paramsList.includes(method)) {
    return 'params'
  } else if (dataList.includes(method)) {
    return 'data'
  }
}

const getRequestIdentify = (config, isResult = false) => {
  let url = config.url
  if (isResult) {
    url = config.baseURL + config.url.substring(1, config.url.length)
  }
  const params = { ...(config[isTypeList(config.method)] || {}) }
  delete params.t
  return encodeURIComponent(url + JSON.stringify(params))
}

const codeMessage = {
  400: '请求错误,参数错误',
  401: '未授权,请重新登录',
  403: '拒绝访问',
  404: '请求错误,未找到该资源',
  406: '请求方法未允许',
  408: '请求超时',
  410: '请求的资源被永久删除,且不会再得到的',
  422: '当创建一个对象时,发生一个验证错误',
  500: '系统异常',
  502: '网关错误',
  503: '服务不可用,服务器暂时过载或维护',
  504: '网关超时',
}

const geoMsg = (code, msg) => (code ? `code: ${code}, ${msg}` : msg || '')
const geoStatusMsg = (code, msg) =>
  code ? `status: ${code}, ${msg}` : msg || ''

// 内置默认不用登录的接口
const notTokenApisBuilt = ['/web/session', '/web/currentUser']

// 默认配置
const defaultParams = {
  baseURL: '/',
  validateStatus: (status) => status <= 500, // 拦截状态码大于或等于500
  headers: {
    common: { Accept: 'application/json; charset=UTF-8' },
    patch: { 'Content-Type': 'application/json; charset=UTF-8' },
    post: { 'Content-Type': 'application/json; charset=UTF-8' },
    put: { 'Content-Type': 'application/json; charset=UTF-8' },
  },
  transformRequest: (data) => JSON.stringify(data),
  timeout: 30000, // 请求超时时间
  isRepeatRequest: true, // 是否开启重复请求拦截
  isResetEmptyParams: false, // 是否开启重置空参数
  isMain: false, // 是否主应用
  request: null,
  requestError: null,
  response: null,
  responseError: null,
  requestCb: null, // 请求前参数处理
  // 不需要token的接口
  notTokenApis: [],
}

/**
 * 创建axios实例,参数如上,基本axios官方相同,除了isRepeatRequest、request、requestError、response、responseError
 * @param {Function} params.isRepeatRequest - 是否开启重复请求拦截
 * @param {Function} params.request - 请求前拦截
 * @param {Function} params.requestError - 请求前拦截异常处理
 * @param {Function} params.response - 请求后拦截
 * @param {Function} params.responseError - 请求后拦截异常处理
 * @param {Vuex|Vue} instance - Vue或者Vuex
 * @returns { GET, DEL, POST, PUT, PATCH, POST_FILE, GET_EXPORT }
 */
export default (params = defaultParams, instance) => {
  params = Object.assign(defaultParams, params || {})

  // 保证只会连续执行一次退出登录流程
  let EXIT = true

  // 创建axios实例
  const service = axios.create(params)

  const {
    isRepeatRequest,
    request,
    requestError,
    response,
    responseError,
    requestCb,
    notTokenApis,
    isResetEmptyParams,
  } = params

  const notTokenApi = [...notTokenApis, ...notTokenApisBuilt]

  const pending = {}
  const CancelToken = axios.CancelToken

  /**
   * 取消重复
   * @param {string} key - 请求唯一url
   * @param {boolean} isRequest - 是否开启
   */
  const removePending = (key, isRequest = false) => {
    if (pending[key] && isRequest) {
      pending[key]('取消重复请求')
    }
    delete pending[key]
  }

  const expiredTip = debounce(() => {
    Message.error('登录过期或者失效,请重新登录!')
  }, 1000)

  const getStore = () => {
    if (!instance) return
    if (instance?.prototype?.$store) {
      return instance.prototype.$store
    }
    return instance
  }

  /**
   * 请求前拦截
   * @param {object} config - axios实例
   */
  const defaultRequest =
    request ||
    ((config, requestData) => {
      config.cancelToken = new CancelToken((c) => {
        pending[requestData] = c
      })

      config.baseURL.indexOf('http') !== 0 &&
        (config.baseURL =
          location.origin + '/' + config.baseURL.replace(/^\//, ''))

      const store = getStore()

      // 处理为空的参数,设置为null
      isResetEmptyParams && handlerNullParams(config)

      // 统一获取/设置参数,因为get和post两种的请求方法的参数放在不同位置
      Object.defineProperty(config, 'inputParams', {
        get: () => config[isTypeList(config.method)],
        set: (params) => {
          config[isTypeList(config.method)] = params
        },
      })

      requestCb && requestCb(config)

      // 公共应用内置请求,走这里
      if (!store) {
        const token = getToken()
        if (!notTokenApi.includes(config.url.replace(config.baseURL, ''))) {
          if (token) {
            EXIT = true
            config.headers[TOKEN_KEY] = token
          } else {
            pending[requestData]('cancelToken')
            if (EXIT) {
              expiredTip()
              window.history.pushState('/login')
            }
            EXIT = false
          }
        }
        return config
      }

      // 其他应用走这里
      const token = store.getters['common/token']
      if (!notTokenApi.includes(config.url.replace(config.baseURL, ''))) {
        if (token) {
          EXIT = true
          config.headers[TOKEN_KEY] = token
        } else {
          pending[requestData]('cancelToken')
          if (EXIT) {
            expiredTip()
            store.dispatch('common/logOut', params.isMain)
          }
          EXIT = false
        }
      }

      return config
    })

  /**
   * 请求前拦截异常处理
   * @param {object} config - axios实例
   */
  const defaultRequestError = requestError || ((error) => Promise.reject(error))

  /**
   * 移除重复请求
   * @param {Object} response
   */
  const removeRequestIdentify = (response) => {
    // 把已经完成的请求从 pending 中移除
    const requestData = getRequestIdentify(response.config)
    removePending(requestData, true)

    return response
  }

  /**
   * 请求后拦截
   * @param {object} config - axios实例
   */
  const defaultResponse =
    response ||
    ((response) => {
      const store = getStore()
      const res = response.data

      if (response.config.responseType === 'blob' && toType(res) === 'Blob') {
        return res
      } else if ([1005, 1006, 1008].includes(res.code)) {
        // 1006 认证异常
        // 登录过期
        if (!EXIT) return Promise.reject(res)

        if (store) {
          store.dispatch('common/logOut', params.isMain)
        } else {
          removeToken()
          window.history.pushState('/login')
        }
        Message.error(geoMsg(res.code, res?.msg))
        EXIT = false
        return Promise.reject(res)
      } else if (![1, 200].includes(res.code)) {
        Message.error(
          geoMsg(res.code, res?.msg || res?.data?.msg || '系统异常')
        )
        return Promise.reject(res)
      }

      return res
    })

  /**
   * 请求后拦截异常处理
   * @param {object} config - axios实例
   */
  const defaultResponseError =
    responseError ||
    ((error) => {
      // cancelToken 取消请求
      if (axios.isCancel(error)) return Promise.reject(error)

      if (!error.response) {
        Message.error('当前网络不可用')
        return Promise.reject(error)
      }

      const msg =
        error.response?.data?.msg ||
        error.response?.data?.data?.msg ||
        codeMessage[error.response?.status] ||
        '系统异常'

      Message.error(geoStatusMsg(error.response?.status, msg))

      return Promise.reject(error)
    })

  // request 请求前拦截
  service.interceptors.request.use((config) => {
    const requestData = getRequestIdentify(config, true)
    isRepeatRequest && removePending(requestData, false)

    return defaultRequest(config, requestData)
  }, defaultRequestError)

  // response 请求后拦截器
  service.interceptors.response.use((response) => {
    isRepeatRequest && removeRequestIdentify(response)
    return defaultResponse(response)
  }, defaultResponseError)

  /**
   * get请求方法
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} params - 请求参数
   * * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const GET = (url, params = {}, other) => {
    params.t = new Date().getTime() // get方法加一个时间参数,解决ie下可能缓存问题.
    return service({
      url: url,
      method: 'GET',
      params,
      ...(other || {}),
    })
  }

  /**
   * delete请求方法
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} params - 请求参数
   * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const DEL = (url, params = {}, other) => {
    params.t = new Date().getTime() // get方法加一个时间参数,解决ie下可能缓存问题.
    return service({
      url: url,
      method: 'DELETE',
      params,
      ...(other || {}),
    })
  }

  /**
   * post请求方法
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} data - 请求参数
   * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const POST = (url, data = {}, other) => {
    return service({
      url,
      method: 'POST',
      data,
      ...(other || {}),
    })
  }

  /**
   * put请求方法
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} data - 请求参数
   * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const PUT = (url, data = {}, other) => {
    return service({
      url,
      method: 'PUT',
      data,
      ...(other ?? {}),
    })
  }

  /**
   * patch请求方法
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} data - 请求参数
   * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const PATCH = (url, params = {}, other) => {
    return service({
      url,
      method: 'PATCH',
      params,
      ...(other ?? {}),
    })
  }

  /**
   * post上传文件请求方法
   * !! 必须使用formData方式
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} data - 请求参数
   * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const POST_FILE = (url, data = {}, other) => {
    return service({
      url,
      method: 'POST',
      data,
      headers: {
        'Content-Type': 'multipart/form-data;charset=UTF-8',
      },
      transformRequest: (data) => data,
      ...(other ?? {}),
    })
  }

  /**
   * get导出文件
   * @export axios
   * @param {String} url - 请求地址
   * @param {Object} data - 请求参数
   * @param {Object|undefined|Null} 其他参数
   * @returns
   */
  const GET_EXPORT = (url, params = {}, other) => {
    return service({
      url,
      method: 'GET',
      params,
      responseType: 'blob',
      timeout: 1000 * 60 * 3,
      ...(other ?? {}),
    })
  }

  return {
    service,
    GET,
    DEL,
    POST,
    PUT,
    PATCH,
    POST_FILE,
    GET_EXPORT,
  }
}

在子应用中使用的时候如下

src目录下创建一个public-path.js文件,写入

// src/public-path.js
import { publicPath } from 'common/lib/utils'

const { NODE_ENV, VUE_APP_PORT, BASE_URL } = process.env
publicPath(NODE_ENV, VUE_APP_PORT, BASE_URL)

src/router.js创建一个initRouter方法,配合function render,用于给当应用初始化路由的

// src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import store from '@store'
import { name as packName } from '../../package.json'
import { routerAuth } from 'ele-common/lib/utils'

const _import = require('./_import_' + process.env.NODE_ENV)
/** 静态路由 */
export const constantRouterMap = []

let router

// 初始化路由
export function initRouter(base, routes) {
  routes = [...constantRouterMap, ...(routes || [])]
  router = new Router({
    base: base,
    routes,
    mode: 'history',
  })

  Vue.use(Router)

  // 路由白名单
  const whitePath = ['/404', '/401', '/']
  // 路由拦截
  routerAuth({ router, store, whitePath, packName, _import })
  return router
}

export default router

src/main.js中参考下面内容编写

// src/main.js
import './public-path.js' // 必须在第一行引入
import Vue from 'vue'
import { initRouter } from './router'
import store from './store'
import App from './App'

import { render } from 'ele-common/lib/utils'
import { IS_QIANKUN } from 'ele-common/lib/constant'
const { name: packName } = require('../package.json')

// 独立运行
if (!IS_QIANKUN) {
  render({
    Vue,
    App,
    storeInstance: store,
    packName,
    env: process.env,
    initRouter,
  })
}

// ↓↓↓↓↓↓ 一起运行 ↓↓↓↓↓↓
let instance
export async function bootstrap(props) {
  console.log('bootstrap', props)
}

export async function mount(props) {
  const _import = (path) => () => import(`@views/${path}`)
  instance = render({
    props,
    Vue,
    App,
    storeInstance: store,
    packName,
    env: process.env,
    initRouter,
    _import,
  })
}

export async function unmount() {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
}

自动发布

项目是基于内网的,内网又是基于Nexus管理npm的。

先了解一下npm版本自动控制

执行 npm version [updateType]

  • patch 补丁,小改动,版本最后一位数字加一
  • minor 次要修改,次版本号加一
  • major 主要修改,大改,版本首位加一
  1. 安装依赖
  • npm-cli-login: 用来自动npm
  • shlljs: 在js中执行shell
npm i npm-cli-login shlljs -D
  1. 新增publish.js,写入自动发布脚本
const { version } = require('./package.json')
const shell = require('shelljs')
const npmLogin = require('npm-cli-login')
const username = 'npm账号'
const password = 'npm密码'
const email = 'xxx'
const registry = 'Nexus上配置的npm地址'

console.log('=========正在登录npm=========')
npmLogin(username, password, email, registry)
console.log('=========登录完成=========')

console.log(`=========正在发布${version}版本至[Nexus上配置的npm地址]=========`)
shell.exec('npm publish')
console.log('=========发布完成!=========')
  1. .npmignore文件中指定发布npm忽略的文件
src/
packages/
docs/
generators/
dist/
docsStatic/
node_modules/
deploy/

# 忽略指定文件
vue.config.js
babel.config.js
package-lock.json
postcss.config.js
vuese.config.js
gulpfile.js
publish.js
.*
  1. .npmrc设置项目npm下载源指向
registry=Nexus上配置的npm地址
  1. package.json中设置包名、作者相关信息、入口文件、搜索关键字、发布命令和发布到npm的地址
  • name: 发布到npm的包名称
  • version: 版本号,每次发布都往上加1
  • description: 包描述
  • main: 项目入口文件
  • keyword: 搜索关键字
  • publishConfig: 发布到npm的地址,Nexus环境下该字段记得要配,不然发包会报错,公网npm则不要求
  "name": "ele-common",
  "version": "0.0.51",
  "description": "Vue+微前端(QianKun)落地实施和最后部署上线公共包",
  "main": "lib/index/index.js",
  "keyword": "ele-common",
  "scripts": {
    ...
    "pub": "git pull && npm version patch && npm i && rimraf node_modules/.cache && npm run build:lib && node ./publish.js && git push"
  },
  "publishConfig": {
    "registry": "Nexus上配置的npm地址"
  }
  1. 尝试发布
npm run pub

pub.gif

本地调试公共包|组件库

一般是在子应用联调新开发的组件,为了能达到方便快捷,不用每次修改就发布新包上去,造成版本过多。因此可以采用本地调试的方法。

  1. 子应用设置package.jsondependencies,新增"ele-common": "file:../ele-common"file:固定格式,后面跟着本地路径,可以../../
  2. 如果项目有开启eslint,记得关闭引入本地包的校验,否则会卡死,具体在.eslintignore文件中新增../ele-common,后面路径跟上一样。
  3. 一般步骤是,组件库修改完组件后,执行npm run build:lib,子应用这边重新启动即可,不过如果是第一次执行完 1 和 2 步骤,则需要额外执行npm i再执行启动项目。

主应用

主应用的作用.png

主应用在这个微前端设计架构中,启着一个桥梁作用,管理着所有子应用的资源的加载和卸载。本篇中使用QianKun中的loadMicroApp进行手动管理资源,方便后期扩展,比如埋点、或者KeepAlive

按代码层面来讲,主应用就是引入QianKun配置实现主子通讯和渲染子容器组件。

主要步骤

  1. 安装依赖

npm i ele-common element-ui@2.15.6 qiankun -S

element-ui版本必须跟公共库保持一致,不然可能出现会部分组件使用异常

  1. 主子通讯
  • initGlobalState: 初始化全局数据方法
  • onGlobalStateChange: 监听全局数据变化方法
  • setGlobalState: 设置全局数据方法
declare type MicroAppStateActions = {
    onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void;
    setGlobalState: (state: Record<string, any>) => boolean;
    offGlobalStateChange: () => boolean;
}

function initGlobalState(state: Record<string, any>): MicroAppStateActions

主子通讯主要是通过Qiankun中的initGlobalState来进行初始化全局数据,initGlobalState返回的onGlobalStateChange监听数据变化和setGlobalState设置全局数据方法,搭配公共库中的commonStore.js进行使用。

新建src/micro/apps.jssrc/micro/store.js

// src/micro/apps.js
import store from './store'
import Vue from 'vue'
import vuexStore from '@store'
import { SET_LOAD } from '@store/types'
import { rndNum } from 'ele-common/lib/utils'
import { ROUTER_PRE } from 'ele-common/lib/constant'

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

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

// {
//   name: 'micro_A',
//   entry: 'http://localhost:8082/module/micro_A/',
//   container: '#subView-micro_A',
//   activeRule: genActiveRule('/module/micro_A/'),
//   props: {
//     routerBase: '/module/micro_A', // 子应用路由的base
//     getGlobalState: store.getGlobalState, // 提供子应用获取公共数据,最好是统一通过store.getters['common/xx']这样获取
//     routerList: vuexStore.getters['common/menu'] || [], // 传递路由给到子应用
//     path: e.path.replace(/\//g, ''),
//     bus: Vue.prototype.$bus,
//     window // 沙盒问题
//   }
// }
export default (apps, container = '#subView') =>
  apps.map((e) => {
    // 本地启动调试,需提前手动在localStorage写入子应用本地地址
    const devUrl = localStorage.getItem(e.path)
    return {
      ...e,
      title: e.name,
      name: e.path,
      // 本地调试 entry: 'http://localhost:7003/module/micro_A'
      // 生产环境 entry: '/module/micro_A'
      entry: `${isProduction ? '/' : devUrl || e.entry || ''}${ROUTER_PRE}/${e.path}/?t=${rndNum(
        6
      )}`,
      activeRule: `/${ROUTER_PRE}/${e.path}`,
      container: `${container}-${e.path}`,
      loader: (loading) => {vuexStore.commit(SET_LOAD, !!loading), // 加载loading
      props: {
        routerBase: `/${ROUTER_PRE}/${e.path}`, // 子应用路由的base
        getGlobalState: store.getGlobalState, // 提供子应用获取公共数据,最好是统一通过store.getters['common/xx']这样获取
        routerList: vuexStore.getters['common/menu'] || [], // 传递路由给到子应用
        path: e.path.replace(/\//g, ''),
        bus: Vue.prototype.$bus,
        window // 沙盒问题
      }
    }
  })

// src/micro/store.js
import { COMMON_DEFAULT_DATA } from 'ele-common/lib/constant'
import { initGlobalState } from 'qiankun'
import Vue from 'vue'

// 父应用的初始state
// Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。
// COMMON_DEFAULT_DATA 初始化数据统一定义在公共库中
export const initialState = Vue.observable({
  ...COMMON_DEFAULT_DATA,
  app: 'main',
})

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) => {
  // 有key,表示取globalState下的某个子级对象
  // 无key,表示取全部

  return key ? initialState[key] : initialState
}

export default actions
  1. 新建一个管理渲染子应用的容器组件
<template>
  <div
    v-loading="loading"
    :element-loading-text="`正在加载${childName}子应用中...`"
    class="childContainer WH"
  >
    <!-- 循环子应用 -->
    <div
      v-for="(item, index) in childList"
      v-show="activation.startsWith(item.activeRule)"
      :id="item.container.replace('#', '')"
      :key="index"
      class="sub-content-wrap WH"
    />
  </div>
</template>

<script>
// src/components/ChildContainer.vue
// 子容器管理
import apps from '@/micro/apps'
import { GET_LOAD, SET_LOAD } from '@store/types'
import {
  loadMicroApp,
  addGlobalUncaughtErrorHandler,
  removeGlobalUncaughtErrorHandler,
} from 'qiankun'
export default {
  name: 'ChildContainer',
  computed: {
    loading() {
      return this.$store.getters[GET_LOAD]
    },
    // 拿到获取到的子应用列表
    childList() {
      const app = this.$store.getters['common/apps'] || []
      return apps(app)
    },
    activation() {
      return this.$route.path || ''
    },
    // 通过路由path匹配当前加载的子应用
    childName({ activation, childList }) {
      return (
        childList.find((item) => activation.startsWith(item.activeRule))
          ?.title || ''
      )
    },
  },
  watch: {
    activation: {
      immediate: true,
      handler: 'activationHandleChange',
    },
  },
  created() {
    this.initEvent()
  },
  beforeDestroy() {
    this?.curMicro?.unmount()
  },
  methods: {
    initEvent() {
      // 监听全局错误事件
      addGlobalUncaughtErrorHandler(this.handleLoad)

      this.$once('hook:beforeDestroy', () => {
        removeGlobalUncaughtErrorHandler(this.handleLoad)
      })
    },
    handleLoad(event) {
      console.log(event)
    },
    async activationHandleChange(path, oldPath) {
      this.$store.commit(SET_LOAD, true)
      await this.$nextTick()

      const { curMicro, childList } = this
      this.oldMicro = curMicro

      const conf = childList.find((item) => path.startsWith(item.activeRule))
      if (!conf) {
        // 当前路由不属于任何子应用时,则要销毁之前的子应用
        // 销毁旧的应用
        this.oldMicro?.unmount()
        return this.$store.commit(SET_LOAD, false)
      }

      // 当前路由的子应用跟上一次一样时,则不做任何操作
      if (curMicro?.path === conf.path)
        return this.$store.commit(SET_LOAD, false)

      // 剩下的就是不一样的条件了,则销毁旧的应用和加载新的应用
      // 销毁旧的应用
      this.oldMicro?.unmount()

      this.curMicro = loadMicroApp({ ...conf, router: this.$router })
      this.curMicro.path = conf.path

      this.curMicro.bootstrapPromise.finally(() => {
        this.$store.commit(SET_LOAD, false)
      })
    },
  },
}
</script>

<style lang="scss">
.scroll-container {
  height: 100%;
  .el-scrollbar__wrap {
    height: calc(100% + 8px);
  }
}
.sub-content-wrap {
  > div {
    width: 100%;
    height: 100%;
  }
}
</style>
  1. 主应用不需要initRouter,只需公共库中的页面组件404401LoginLayout,然后直接创建路由

有个地方要注意一下,加载子应用的路由要配置通配符

  • /module 不加载任何子应用
  • /module/micro_A 加载 micro_A 子应用
  • /module/micro_A/home 加载 micro_A 子应用下的 home 页面
  • /module/micro_B 加载 micro_B 子应用

针对上述路由情况,只需配置path: /module*即可

// src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import ChildContainer from '@components/ChildContainer'
import { Not404, Not401, Login, Layout } from 'ele-common'
import { routerAuth } from 'ele-common/lib/utils'

export const routes = [
  {
    path: '/login',
    name: 'Login',
    props: {
      home: '/module',
    },
    component: Login,
  },
  {
    path: '/404',
    name: '404',
    component: Not404,
  },
  {
    path: '/401',
    name: '401',
    component: Not401,
  },
  { path: '/', redirect: '/sso' },
  {
    path: '/module*',
    name: 'Module',
    component: Layout,
    props: {
      // 前面讲到的Layout接收一个container入参
      // 当在主应用运行时,则会渲染这个子应用管理容器
      container: ChildContainer,
    },
  },
]

const router = new Router({
  mode: 'history',
  routes,
})

Vue.use(Router)

// 路由拦截
routerAuth({ router, store, whitePath })
  1. main.js引入commonStores.js
// src/main.js
import 'whatwg-fetch'
import 'custom-event-polyfill'
import 'core-js/stable/promise'
import 'core-js/stable/symbol'
import 'core-js/stable/string/starts-with'
import 'core-js/web/url'
// 上面几个是兼容IE11
import Vue from 'vue'
import router from './router'
import store from './store'
import App from './App'

import actions from './micro/store'
import { commonStore } from 'ele-common/lib/utils'

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import 'ele-common/lib/style/index.css'

Vue.use(ElementUI)

// 初始化动态vuex
commonStore(store, actions, router, process.env.NODE_ENV)

Vue.config.productionTip = false
new Vue({
  router,
  store,
  provide: {
    packName: 'main',
  },
  render: (h) => h(App),
}).$mount('#app')

子应用

子应用按照以下配置即可

  1. 安装依赖

npm i ele-common element-ui@2.15.6 -S

element-ui版本必须跟公共库保持一致,不然可能出现会部分组件使用异常

  1. 修改package.json中的name的值为应用名称,比如micro_A
  2. 根目录修改或新增.env文件,并设置VUE_APP_PORT={端口号随意,不冲突就好}
  3. 新增src/store/index.js文件,并写入以下内容(结合实际项目),用于初始化Vuex
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)
export default new Vuex.Store({
  // ...
})
  1. 新增src/router/index.js文件,并下写入以下内容(结合实际项目),用于初始化路由
// src/router/index.js
import Router from 'vue-router'
import Vue from 'vue'
import { routerAuth } from 'ele-common/lib/utils'
import { name as packName } from '../../package.json'
import store from '@/store'

const defaultRoutes = [
  // ...写你项目的默认路由(路由白名单)
]

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

// 动态引入路由方法
export const _import = (file) => () => import(`@/${file}`)

/**
 * 初始化路由
 * @export initRouter
 * @param {String} routerBase - 路由Base
 * @param {Array} routerList - 统一通过公共包赋值路由,独立运行时,只有401、401、Login,一起运行时,则会有该应用配置的动态路由以及包括独立运行时所拥有的路由
 */
export const initRouter = (routerBase, routerList) => {
  const routes = [...defaultRoutes, ...(routerList || [])]
  router = new Router({
    mode: 'history',
    base: routerBase,
    routes,
  })

  Vue.use(router)

  // 设置路由拦截,对`token`和白名单进行拦截处理以及根据菜单动态引入组件
  routerAuth({ router, store, whitePath, packName, _import })

  // 自定义路由拦截,可以继续这么加
  // router.beforeEach(...)
  // router.afterEach(...)

  return router
}
  1. src目录下新增public-path.js文件,并写入以下内容
// src/public-path.js
import { publicPath } from 'ele-common/lib/utils'
const { NODE_ENV, VUE_APP_PORT, BASE_URL } = process.env
publicPath(NODE_ENV, VUE_APP_PORT, BASE_URL)
  1. main.js中修改
// src/main.js
// 必须在第一行引入
import './public-path'
import Vue from 'vue'
import store from '@/store'
import App from './App'
import { initRouter, _import } from '@/router'

import { render } from 'ele-common/lib/utils'
import { IS_QIANKUN } from 'ele-common/lib/constant'
const { name: packName } = require('../package.json')

Vue.config.productionTip = false

// render集成了new Vue功能

// 独立运行时
if (!IS_QIANKUN) {
  render({
    Vue,
    App,
    storeInstance: store,
    packName,
    env: process.env,
    initRouter,
  })
}

// 一起运行时
let instance

// 提供给QianKun加载当前子应用使用的,跟umd类型有关系
export async function bootstrap(props) {
  Vue.prototype.$mainBus = props.bus
  // 子应用会把自己的window作为一个沙盒,所以如果需要访问主应用window,需要通过 window.$window 进行访问
  // window.$window = props.window
}

// 提供给QianKun加载当前子应用使用的,跟umd类型有关系
export async function mount(props) {
  instance = render({
    props,
    Vue,
    App,
    storeInstance: store,
    packName,
    env: process.env,
    initRouter,
    _import,
  })
}

// 提供给QianKun加载当前子应用使用的,跟umd类型有关系
export async function unmount(props) {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
}
  1. vue.config.js文件中,加入以下配置
// vue.config.js
const { ROUTER_PRE } = require('ele-common/lib/constant')
const { name } = require('./package.json')

module.exports = {
  publicPath: `/${ROUTER_PRE}/${name}` // module 是固定前缀
  devServer: {
    ...其他配置,
    port: process.env.VUE_APP_PORT, // 设置端口
    headers: {
      'Access-Control-Allow-Origin': '*', // 本地运行时,开启cors
      // 开发防止缓存
      'Cache-Control': 'no-cache',
      Pragma: 'no-cache',
      Expires: 0
    }
  },
  ...其他配置
  configureWebpack: {
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`
    }
  }
}

常见问题

  1. 子应用如果应用有使用Vue.extend,并且添加domdocument.body中,记得在应用销毁前(unmount)的时候销毁,避免内存泄漏,并且在Vue.use使用该插件的时候,记得要写两个地方注册。

这是因为子应用是渲染在#app > #app中,前面的#app是主应用,后面#app是子应用,而且插件把dom插入document.body中后是跟前面的#app平级的,导致销毁子应用的时候是执行unmount函数中的内容只需销毁了后面的#app,而此时插件的dom还在。可以细品一下这段代码$mount(container ? container.querySelector('#app') : '#app')。还有一个更优雅的解决方案就是把dom插入到#app > #app中。

// viewer.js
import Viewer from './Viewer.vue'

export default {
  install(Vue) {
    const Constructor = Vue.extend(Viewer)
    let instance = new Constructor()

    document.body.appendChild(instance.$mount().$el)
  },
}

// main.js
import Vue from 'vue'
import Viewer from './viewer.js'

if (!IS_QIANKUN) {
  // ...other
  Vue.use(Viewer)
}

export async function mount(props) {
  Vue.use(Viewer)
}

export async function unmount() {
  // 销毁Viewer
}
  1. 使用了公共库中的路由拦截方法,怎么在此基础上添加自定义拦截?可以继续使用router.beforeEach进行拦截,不过要重新判断一次白名单。

image.png

  1. 子向主传递组件,可通过在commonStore.js新增一个组件配置

image.png

使用的时候

<template>
    <div>
        <!-- 使用传递子应用传递的组件 -->
        <Component :is="component" />
    </div>
</template>

<script>
export default {
    computed() {
        return this.$store.getters['common/getters'].component
    }
}
</script>

部署方案

部署方案分两套,各有优劣。由于我这边环境不多,只能一个端口方式,强烈有推荐双端口方案

参考部署目录

image.png

双端口部署方案

端口A部署主应用,端口B部署所有子应用且设置支持cors

  • 缺点:需要两个端口
  • 优点:解决页面刷新直接跳到子应用
# 主应用 9001
{
  listen 9001;

  location / {
    root /data/front-end/front-micro/micro_main;
    index index.html;

    try_files $uri $uri/ /index.html;

    expires -1;
    add_header Cache-Control no-cache;
  }
}

# 所有子应用 9002
{
  listen 9002;

  # cors
  add_header 'Access-Control-Allow-Origin' "$http_origin";
  add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
  add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type    ';
  add_header 'Access-Control-Allow-Credentials' 'true';
    
  # 子应用A
  location /module/micro_A {
    index index.html;
    try_files $uri $uri/ /micro_A/index.html;
    alias /data/front-end/front-micro/micro_A;

    expires -1;
    add_header Cache-Control no-cache;
  }

  # 子应用B
  location /module/micro_B {
    index index.html;
    try_files $uri $uri/ /micro_B/index.html;
    alias /data/front-end/front-micro/micro_B;

    expires -1;
    add_header Cache-Control no-cache;
  }

  # 子应用C
  location /module/micro_C {
    index index.html;
    try_files $uri $uri/ /micro_C/index.html;
    alias /data/front-end/front-micro/micro_C;

    expires -1;
    add_header Cache-Control no-cache;
  }
}

加载QianKun配置中的entry要改为http://10.10.10.66:9002/module/[应用]

{
  name: 'micro_A',
  entry: 'http://10.10.10.66:9002/module/micro_A',
  container: '#subView-micro_A',
  activeRule: '/module/micro_A/',
  props: {
    ...
  }
}

一个端口方案

所有应用都部署在一个端口上,不会存在跨域

  • 优点:只需管理一个端口
  • 缺点:页面刷新直接跳到子应用
{
  listen 9001;
    
  # 主应用
  location / {
    root /data/front-end/front-micro/micro_main;
    index index.html;

    try_files $uri $uri/ /index.html;

    expires -1;
    add_header Cache-Control no-cache;
  }

  # 子应用A
  location /module/micro_A {
    index index.html;
    try_files $uri $uri/ /micro_A/index.html;
    alias /data/front-end/front-micro/micro_A;

    expires -1;
    add_header Cache-Control no-cache;
  }

  # 子应用B
  location /module/micro_B {
    index index.html;
    try_files $uri $uri/ /micro_B/index.html;
    alias /data/front-end/front-micro/micro_B;

    expires -1;
    add_header Cache-Control no-cache;
  }

  # 子应用C
  location /module/micro_C {
    index index.html;
    try_files $uri $uri/ /micro_C/index.html;
    alias /data/front-end/front-micro/micro_C;

    expires -1;
    add_header Cache-Control no-cache;
  }
}

demo 地址

核心代码和思路都放到文章里了,如果还需要的话emmm我抽空!!!