qiankun 微前端实践及常见问题

我心飞翔 分类:javascript

关注公众号: 微信搜索 前端工具人 ; 收获更多的干货

一、介绍:

qiankun 项目实际搭建, 及各种微应用流行框架技术 (vue2 、vue3、react 、 umi2 、umi3)的配置;

初衷:自己当时摸索qiankun构建项目时,问题百出, 特别是umi2 umi3,百度了几天才把热门框架都集合完毕;

目的:总结出的模板项目, 便于自己后期重构项目技术选型及项目快速搭建;也为其他有需要的朋友提供示例及参考;

项目源码:已上传到 github https://github.com/laijinxian/qiankun-template 如有对你有帮助,麻烦 star 下

末尾的常见问题多数为目前开发中遇到的疑难点, 特地整理出来;有其他问题欢迎留言交流

实际项目源码就没贴出来了,都是依据这个模板构建的;

后面看下好不好把实际项目源码抽离出来,上传到github; 目前子项目使用的是 vite2.0 + vue3 + ts 以及 react + Umi3 + dva + ts

二、什么是微前端 qiankun 篇

推荐阅读 qiankun文档

其实我个人更喜欢叫成 前端微服务架构, 感觉逼格更高点...

2.1 官方介绍:

  • 微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略 (有点高深...)

2.2 我的观点:

  • 与技术栈无关、独立开发、独立部署、增量升级、独立运行;

  • 拆分、细化、解耦你的巨无霸项目; 提升开发及打包部署效率;

  • 不在局限于一个项目只能使用一种技术,一个项目可以使用 N 种技术,扩展自己技术知识面;

  • 对于比如 大型 erp、OA 之类的系统, 微前端可以让你更加的得心应手的开发;

  • 对于想重构公司辣眼睛的项目尤为合适;这也算我入手微前端的主要原因之一,下面会讲到;

  • 顺应时代潮流, 作为主流技术现在非常多的公司招聘面试基本都会问微前端,细化程度不一样;

三、 为什么用qiankun, 为什么选择qiankun

3.1 为什么用qiakun

自打进入公司,看到了现有的项目,我总结了几点

  • 项目全都使用 Vue, 一直开发下去你会发现 React 忘得快差不多了;技术的局限性;
  • 现有项目代码又臭又长,毫无规范;eslint、css预编译啥都没有;
  • 2-3层 for 循环,var之类的,粘贴复制无用代码不删除到处可见;公共代码提取、接口统一处理、工具类编写不存在的;
  • 一个项目同时出现 vue、jQuery两个大框架;运行项目、热编译、你可以先上趟厕所;
  • 每期功能迭代,先要花大半天时间去熟悉这个代码、还真不敢乱改,有毒、谁改谁后悔的那种
  • 想重构,奈何刚接手的时候项目已经很大了,并且不怎么熟悉业务,且不断的加功能; 一时重构基本不可能;千万级别的用户量出问题了这锅背不动, 时间也不允许

后面需求排期不是很紧凑,正直qiankun微前端很火,就想着使用qiankun微前端方案重构;

思路如下:

  • 目标 把一个项目按照菜单划分,一个大菜单分为一个子服务(子项目)
  • 刚开始原有项目全部划分为一个子服务,新加功能菜单划分为另一个子服务;这样既保证原有项目不变,新项目完全使用新的框架及开发风格规范;
  • 时间充裕下情况下,慢慢把其他功能按照菜单划分成子服务,慢慢的最小粒度去重构项目

3.2 目前微前端方案有:

  • iframe
  • single-spa
  • qiankun 基于 single-spa 方案实现, 更强大更易上手

推荐阅读 掘金大佬文章, 文章有详细介绍及常见问题

四、 构建步骤

项目结构:

├── main-service    // 主应用
└── sub-service     // 微应用
    └── sub-react   // react 子应用
    └── sub-umi2    // umi2 子应用
    └── sub-umi3    // umi3 子应用
    └── sub-vue2    // vue2 子应用
    └── sub-vue3    // vue3 子应用
 

推荐阅读:

  • 详细结构代码已上传github 请前往 github 查看

  • qiankun官方文档

  • 参考掘金文章

4.1 项目结构组成

主应用

vue2.x + vuec-li3 主要业务功能就是登录注册及菜单;官方推荐主应用尽可能的简单,不要涉及其他的业务功能

微应用

  • vue2.x + vue-cli3
  • vue3.x + vue-cli4 + typescript
  • react16
  • react16 + umi2 + dva
  • react16 + umi3 + dva

4.2 主应用配置

qiankun 只需要在主应用中引入,微应用不需要

yarn add qiankun # 或者 npm i qiankun -S
 

4.3 主应用 src 下 注册微应用

主应用 src 下新建 qiankun/index.js

import {
  registerMicroApps,
  runAfterFirstMounted,
  setDefaultMountApp,
  start
} from "qiankun";
import store from '../store/index'
import { instance } from "../main";
import 'nprogress/nprogress.css'

/**
 * Step1 初始化应用(可选)
 */

function loader(loading) {
  if (instance && instance.$children) {
    // instance.$children[0] 是App.vue,此时直接改动App.vue的isLoading
    instance.$children[0].isLoading = loading;
  }
}

/**
 * Step2 注册子应用
 */

const microApps = [
  {
    name: 'sub-vue2',
    developer: 'vue2.x',
    entry: '//localhost:7788',
    activeRule: '/sub-vue2',
  },
  {
    name: 'sub-vue3',
    developer: 'vue3.x',
    entry: '//localhost:7799',
    activeRule: '/sub-vue3'
  },
  {
    name: 'sub-react',
    developer: 'react16',
    entry: '//localhost:7755',
    activeRule: '/sub-react'
  },
  {
    name: 'sub-umi2',
    developer: 'umi2.x',
    entry: '//localhost:7766',
    activeRule: '/sub-umi2'
  },
  {
    name: 'sub-umi3',
    developer: 'umi3.x',
    entry: '//localhost:7733',
    activeRule: '/sub-umi3'
  }
]

const apps = microApps.map(item => {
  return {
    ...item,
    loader, // 给子应用配置加上loader方法
    container: '#subapp-container', // 子应用挂载的div
    props: {
      developer: item.developer, // 下发基础路由
      routerBase: item.activeRule, // 下发基础路由
      getGlobalState: store.getGlobalState // 下发getGlobalState方法
    }
  }
})

registerMicroApps(apps, {
  beforeLoad: app => {
    console.log('before load app.name====>>>>>', app.name)
  },
  beforeMount: [
    app => {
      console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name)
    }
  ],
  afterMount: [
    app => {
      console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name)
    }
  ],
  afterUnmount: [
    app => {
      console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name)
    }
  ]
})

/**
 * Step3 设置默认进入的子应用
 */
setDefaultMountApp('/sub-vue2')

/**
 * Step4 启动应用
 */
start();

runAfterFirstMounted(() => {
  console.log("[MainApp] first app mounted");
});

export default apps

 

4.4 微应用导出生命周期钩子

各种框架配置推荐阅读 官方文档

下面以 vue3.xreact umi3 为例; 其他微服务配置请前往 [github](https://github.com/laijinxian/qiankun-template) 源码查看

子应用的名称最好与父应用在 qiankun/index.js 中配置的名称一致(这样可以直接使用package.json中的name作为output

vue3.x 微应用

首先 vue create sub-vue3 创建项目

修改 main.js 导出生命周期函数
// @ts-nocheck
import "./public-path";
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import App from "./App.vue";
import routes from "./router";
import store from "./store";

let router = null;
let instance = null;

function render(props = {}) {
  const { container } = props;
  router = createRouter({
    history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? "/sub-vue3" : "/"),
    routes
  });

  instance = createApp(App);
  instance.use(router);
  instance.use(store);
  instance.mount(container ? container.querySelector("#app") : "#app");
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log("%c ", "color: green;", "vue3.0 app bootstraped");
}

function storeTest(props) {
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) =>
        console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
      true
    );
  props.setGlobalState &&
    props.setGlobalState({
      ignore: props.name,
      user: {
        name: props.name
      }
    });
}

export async function mount(props) {
  storeTest(props);
  render(props);
  instance.config.globalProperties.$onGlobalStateChange =
    props.onGlobalStateChange;
  instance.config.globalProperties.$setGlobalState = props.setGlobalState;
}

export async function unmount() {
  instance.unmount();
  instance._container.innerHTML = "";
  instance = null;
  router = null;
}

 
新建 vue.config.js
const path = require('path');
const { name } = require('./package');

function resolve(dir) {
  return path.join(__dirname, dir);
}

module.exports = {
  outputDir: 'dist',
  assetsDir: 'static',
  filenameHashing: true,
  devServer: {
    hot: true,
    disableHostCheck: true,
    port: '7799',
    overlay: {
      warnings: false,
      errors: true,
    },
    clientLogLevel: "warning",
    disableHostCheck: true,
    compress: true,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
    historyApiFallback: true,
    overlay: { warnings: false, errors: true }
  },
  // 自定义webpack配置
  configureWebpack: {
    resolve: {
      alias: {
        '@': resolve('src'),
      },
    },
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

 
src 新建 public-path.js 并引入
/* eslint-disable @typescript-eslint/camelcase */
if ((window as any).__POWERED_BY_QIANKUN__) {
  /* eslint-disable @typescript-eslint/camelcase */
  __webpack_public_path__ = (window as any).__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
 

react umi3 微应用

创建项目

推荐阅读: umi 官方文档

$ mkdir myapp && cd myapp
$ yarn create umi
 
安装
$ npm install --save-dev @umijs/plugin-qiankun
$ yarn add @umijs/plugin-qiankun
 
修改 src/app.js 导出生命周期函数
import './public-path'

export const dva = {
  config: {
    onError(err) {
      err.preventDefault();
      console.error(err.message);
    },
  },
};

export const qiankun = {
  // 应用加载之前
  async bootstrap(props) {
    console.log('app1 bootstrap', props);
  },
  // 应用 render 之前触发
  async mount(props) {
    console.log('app1 mount', props);
    storeTest(props);
  },
  // 应用卸载之后触发
  async unmount(props) {
    console.log('app1 unmount', props);
  },
};

function storeTest(props) {
  props.onGlobalStateChange &&
    props.onGlobalStateChange(
      (value, prev) => console.log(`[onGlobalStateChange - ${props.name}]:`, value, prev),
      true,
    );
  props.setGlobalState &&
    props.setGlobalState({
      ignore: props.name,
      user: {
        name: props.name,
      },
    });
}
 
修改 .umirc.js 文件 引入 @umijs/plugin-qiankun 插件
// ref: https://umijs.org/config/
export default {
  mountElementId: 'sub-umi3',
  base: `sub-umi3`, // 子应用的 base,默认为 package.json 中的 name 字段
  treeShaking: true,
  routes: [
    { exact: false, path: '/', component: '../layouts/index',
      routes: [
        { exact: false, path: '/', component: '../pages/index' },
        { component: './404.js' }
      ],
    }
  ],
  plugins: [
    ['@umijs/plugin-qiankun', {
      keepOriginalRoutes: true
    }],
    // ref: https://umijs.org/plugin/umi-plugin-react.html
    ['umi-plugin-react', {
      antd: true,
      dva: true,
      dynamicImport: { webpackChunkName: true },
      title: 'react',
      dll: false,
      
      routes: {
        exclude: [
          /models\//,
          /services\//,
          /model\.(t|j)sx?$/,
          /service\.(t|j)sx?$/,
          /components\//,
        ],
      },
    }],
  ],
}

 
src 新建 public-path.js 并引入
/* eslint-disable @typescript-eslint/camelcase */
if ((window as any).__POWERED_BY_QIANKUN__) {
  /* eslint-disable @typescript-eslint/camelcase */
  __webpack_public_path__ = (window as any).__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
 

以上就是微前端的基本配置 demo, 源码 [github](https://github.com/laijinxian/qiankun-template) 查看

接下来真正的项目重构实操及进阶

五、 项目重构实践、进阶中常见问题

5.1 qiankun 常见报错

推荐阅读: 官方文档总结 qiankun.umijs.org/zh/faq

5.2 状态管理, 主应用和微应用之间的通信

qiankun 通过 initGlobalState: 定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法;

onGlobalStateChange: 在当前应用监听全局状态,有变更触发 callback;

setGlobalState: 按一级属性设置全局状态,微应用中只能修改已存在的一级属性; 换句话说只能修改主用于预先定义的属性,后面添加的属性无效

官方列子 发布-订阅的设计模式
主应用

import { initGlobalState, MicroAppStateActions } from 'qiankun';

// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);

actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
 

微应用

// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {

  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });

  props.setGlobalState(state);
}
 

5.3 各应用之间的独立仓库以及聚合管理

实际开发中项目存储在公司仓库中,以 gitLab 为例, 当子应用一多,全部放在一个仓库下面, 这时候就显得很臃肿了,也很庞大,大大的增加了维护成本,和开发效率;

我们可以通过 sh 脚本, 初始只需要克隆主仓库代码, 然后通过 sh 脚本去一键拉取所有子应用;

主仓库新建 script/clone-all.sh 文件 内容如下

# 子服务 gitLab 地址
SUB_SERVICE_GIT=('http://gitlab.qinlinkeji.com/xxxxxx/qiankun-sub-service-vue.git' 'http://gitlab.qinlinkeji.com/xxxxxx/qiankun-sub-service-react.git')
SUB_SERVICE_NAME=('qiankun-sub-service-vue' 'qiankun-sub-service-react')

# 子服务
if [ ! -d "sub-service" ]; then
  echo '创建sub-service目录...'
  mkdir sub-service
fi
echo '进入sub-service目录...'
cd sub-service


# 遍历克隆微服务
for i in ${!SUB_SERVICE_NAME[@]}
do
  if [ ! -d ${SUB_SERVICE_NAME[$i]} ]; then
    echo '克隆微服务项目'${SUB_SERVICE_NAME[$i]}
    git clone ${SUB_SERVICE_GIT[$i]}
  fi
done

echo '脚本结束...'
# 克隆完成
 

代码拉取完成后, 紧接着就是下载各个项目的依赖及运行

应用根目录安装 npm i npm-run-all -D
package.json 文件 scripts 命令如下

"scripts": {
    "clone:all": "bash ./scripts/clone-all.sh",
    "install": "npm-run-all --serial install:*",
    "install:main": "cd main-service && cnpm i",
    "install:sub-vue2": "cd  sub-service/sub-vue2 && yarn install",
    "install:sub-vue3": "cd  sub-service/sub-vue3 && yarn install",
    "install:sub-react": "cd sub-service/sub-react && cnpm i",
    "install:sub-umi2": "cd sub-service/sub-umi2 && yarn install",
    "install:sub-umi3": "cd sub-service/sub-umi3 && yarn install",
    "start": "npm-run-all --parallel start:*",
    "start:sub-react": "cd sub-service/sub-react && npm start",
    "start:sub-vue2": "cd sub-service/sub-vue2 && npm start",
    "start:sub-vue3": "cd sub-service/sub-vue3 && yarn start",
    "start:sub-umi2": "cd sub-service/sub-umi2 && yarn start",
    "start:sub-umi3": "cd sub-service/sub-umi3 && yarn start",
    "start:main": "cd main-service && yarn start",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
 

步骤: 第一步 clone 主应用, 然后依次执行 yarn clone:all --> yarn install --> yarn start 即可运行整个项目

5.4 子应用之间的独立开发

需求: 每次项目的迭代,有可能只涉及其中某个应用功能,

期望: 只需要单独打开这个子应用修改即可;并不是整个庞大项目一起启用

目标: 应用解耦的同时也能高效撸代码

问题: 整个项目中都需要一个登录态(登录凭证 token), 上面说到登录token是在主应用中维护的, 不启动主应用,子应用怎么拿到登录态token呢;

解析: 其实登录的主要作用都是获取到用户信息及 token 后, 保存在浏览器缓存中,比如 localStorage、sessionStorage、cookie、IndexedDB , 需要的地方获取即可;

方法: 子应用中通过 qiankun 提供的 window.__POWERED_BY_QIANKUN__ 属性, 很直接的知道目前是否运行在 qiankun 的主应用的上下文中;全局维护一个变量,控制是否展示 iframe的登录页

if (!window.__POWERED_BY_QIANKUN__) {
  // 不在主应用的上下文中
}
 

当不在qiankun主应用上下文环境中时, 通过 iframe 形式, 直接引入登录页面, 完成登录把用户数据及token存入缓存中即可;

要注意的是:

  • 浏览器默认不支持iframe文件的 script 脚本执行; 需要设置 sandbox="allow-scripts allow-same-origin" 两个属性即可
  • 下面代码是通过本地的 html 文件 (登录页);在vue-cli3中我们需要吧 html 静态、文件放在 public/static下面
  • 当然当你项目发布到服务器之后, 把上面步骤删了,直接在iframe里引用登录页面即可; iframeurl 指向你的线上登录页; 这样下来子应用只需要加个 iframe 一行代码,即可完成子应用的登录态获取

实例: vue 子应用 某页面

<template>
  <!-- <iframe src="https://juejin.cn/" width="400" height="300" sandbox="allow-scripts allow-same-origin"></iframe> -->
  <iframe ref="iframe" name="iframe" width="400" height="300" sandbox="allow-scripts allow-same-origin"></iframe>
</template>
<script>
export default {
  data () {
    return {
      html: require("static/login.html")
    }
  },
  mounted() {
    this.$refs.iframe.srcdoc = this.html
    console.log(localStorage.getItem('userInfo'))
  }
}
</script>

 

5.5 如何提取出公共的依赖库

官方说法: 并不推荐这种做法, 因为微服务主要目标是解耦大型应用, 并且当你升级某个项目的公共依赖之后,意味着其他子应用也升级了, 很难保证不出问题;但你确实想那么做,那么也有方法:

方法1: 官方推荐你可以在微应用中将公共依赖配置成 external,然后在主应用中导入这些公共依赖;
推荐阅读: 掘金文章

方法2: 我的做法是 通过 webpackDllPlugin 动态链接库, 生成静态 json 在子应用中引
入; DllPluginwebpack内置的插件,不需要额外安装; 这里就不贴代码了, 代码有点多, DllPlugin教程很多, 百度到处是, 跟着配置下webpack.dll.config.js就行;

DllPlugin 也是项目优化的一个手段, 自己配置一遍印象更深

5.6 如何提取出公共方法

在这我个人也不怎么推荐, 因为子应用是不同框架 vue\react\umi-react 并不能保证方法能同时作用于这几个框架项目, 不能的话那何来公共方法一说;

当然你的子应用全是同一个框架那上面的话当我没说。。。

有需求就有方法: 推荐参考 掘金文章 更详细:

  • npm指向本地file地址:npm install file:../common。直接在根目录新建一个common目录,然后npm直接依赖文件路径。
  • npm指向私有git仓库: npm install git+ssh://xxx-common.git
  • 发布到npm私服

demo 中我用的是第一种方法,当然不嫌麻烦可以选用第三种发布到npm私服,嫌私服难搭,可以用后台的manven私服,把你的公共代码给后台让让后台发布; manven 私服按我的理解后台标配;

第一种方法 指向本地file地址事例; vue 子应用 main.js 引入你的本地公共代码并注册

import globalRegister from '../../../main-service/src/store/global-register'
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  storeTest(props);
  render(props);
  globalRegister(store, props)
}
 

5.7 微应用之间如何跳转

  • 主应用和微应用都是 hash 模式,主应用根据 hash 来判断微应用,则不用考虑这个问题。
  • 主应用根据 path 来判断微应用

history 模式的微应用之间的跳转,或者微应用跳主应用页面,直接使用微应用的路由实例是不行的,原因是微应用的路由实例跳转都基于路由的 base。有两种办法可以跳转:

  1. history.pushState():mdn用法介绍
  2. 将主应用的路由实例通过 props 传给微应用,微应用这个路由实例跳转。
// 用法 第二、第三参数分别是子应用名称及激活路由
history.pushState(null, 'sub-react', '/sub-react');
 

5.8 微应用文件更新之后,访问的还是旧版文

项目上线后由于是独立仓库独立开发独立部署, 微应用文件更新之后,访问的还是旧版文;

服务器需要给微应用的 index.html 配置一个响应头:Cache-Control no-cache,意思就是每次请求都检查是否更新。

Nginx 为例:

location = /index.html {
  add_header Cache-Control no-cache;
}
 

5.9 应用加载的资源会 404

原因是 webpack 加载资源时未使用正确的 publicPath

可以通过以下两个方式解决这个问题:

a. 使用 webpack 运行时 publicPath 配置
qiankun 将会在微应用 bootstrap 之前注入一个运行时的 publicPath 变量,你需要做的是在微应用的 entry js 的顶部添加如下代码:

__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
 

关于运行时 publicPath 的技术细节,可以参考 webpack 文档。

runtime publicPath 主要解决的是微应用动态载入的 脚本、样式、图片 等地址不正确的问题。

b. 使用 webpack 静态 publicPath 配置
你需要将你的 webpack publicPath 配置设置成一个绝对地址的 url,比如在开发环境可能是:

{
  output: {
    publicPath: `//localhost:${port}`,
  }
}
 

5.10 如何部署

推荐阅读 官方文档 更详细

主应用和微应用都是独立开发和部署,即它们都属于不同的仓库和服务

场景:主应用和微应用部署到同一个服务器(同一个IP和端口)
如果服务器数量有限,或不能跨域等原因需要把主应用和微应用部署到一起。

通常的做法是主应用部署在一级目录,微应用部署在二/三级目录。

微应用想部署在非根目录,在微应用打包之前需要做两件事:

  1. 必须配置 webpack 构建时的 publicPath 为目录名称,更多信息请看 webpack 官方说明 和 vue-cli3 的官方说明

  2. history 路由的微应用需要设置 base ,值为目录名称,用于独立访问时使用。

部署之后注意三点:

  1. activeRule 不能和微应用的真实访问路径一样,否则在主应用页面刷新会直接变成微应用页面。
  2. 微应用的真实访问路径就是微应用的 entryentry 可以为相对路径。
  3. 微应用的 entry 路径最后面的 / 不可省略,否则 publicPath 会设置错误,例如子项的访问路径是 http://localhost:8080/app1,那么 entry 就是 http://localhost:8080/app1/

以上问题大多数可前端官方文档常见问题查看, 只不过不是很详细, 有的需要自己去百度完善

六、参考链接

掘金: juejin.cn/post/687546…

回复

我来回复
  • 暂无回复内容