基于qiankun微前端,分享如何将vue2过渡到vue3的实战篇(附github源码)

2,032 阅读7分钟

前言

  • 本章主要分享微前端源码,对理论仅简单描述,如还未了解什么是微前端,或理解微前端基本组成等,可移步笔者上篇:juejin.cn/post/702545…

  • 该实战案例,适合中台管理系统,想将vue2升级到vue3的小伙伴们。

1)项目概况

本项目案例,主要使用微前端qiankun框架,打通vue2.6 + vue3.0 + vue3.2(vite)。包含子父通信,

应用名称应用级别使用框架端口
mainvue2.68080
crmvue3.28081
salevue3.08082

2)应用基本搭建

  • vue2: vue create main

      {
        "name": "main",
        "version": "0.1.0",
        "private": true,
        "scripts": {
          "serve": "vue-cli-service serve",
          "build": "vue-cli-service build",
          "lint": "vue-cli-service lint"
        },
        "dependencies": {
          "ant-design-vue": "^1.7.8",
          "core-js": "^3.6.5",
          "qiankun": "^2.5.1",
          "register-service-worker": "^1.7.2",
          "vue": "^2.6.11",
          "vue-router": "^3.2.0",
          "js-cookie": "^2.2.1",
          "vuex": "^3.4.0"
        },
        "devDependencies": {
          "@vue/cli-plugin-babel": "~4.5.0",
          "@vue/cli-plugin-eslint": "~4.5.0",
          "@vue/cli-plugin-router": "~4.5.0",
          "@vue/cli-plugin-vuex": "~4.5.0",
          "@vue/cli-service": "~4.5.0",
          "babel-eslint": "^10.1.0",
          "eslint": "^6.7.2",
          "eslint-plugin-vue": "^6.2.2",
          "vue-template-compiler": "^2.6.11"
        },
        "eslintConfig": {
          "root": true,
          "env": {
            "node": true
          },
          "extends": [
            "plugin:vue/essential",
            "eslint:recommended"
          ],
          "parserOptions": {
            "parser": "babel-eslint"
          },
          "rules": {}
        },
        "browserslist": [
          "> 1%",
          "last 2 versions",
          "not dead"
        ]
      }
    

package.json

  • vue3: create-vite-app crm

package.json

{
  "name": "crm",
  "version": "0.0.0",
  "scripts": {
    "serve": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "path": "^0.12.7",
    "sass": "^1.43.2",
    "vite-plugin-style-import": "^1.4.0",
    "vue": "^3.2.16",
    "vue-router": "^4.0.12",
    "vuex": "^4.0.0-0",
    "vite-plugin-qiankun": "1.0.10",
    "vuex-persistedstate": "^4.1.0"
  },
  "devDependencies": {
    "@types/js-cookie": "^3.0.0",
    "@types/node": "^16.11.1",
    "@vitejs/plugin-vue": "^1.9.3",
    "ant-design-vue": "^2.2.8",
    "typescript": "^4.4.3",
    "vite": "^2.6.4-beta",
    "vue-tsc": "^0.3.0"
  }
}

image.png

3)重置子应用模式

主应用与子应用分别注册后,即可完成数据通信。此时,修改子应用的启动方式:

import { setupAntd } from "@/plugins/antd"
import { routes } from "@/router"
import { setupStore } from "@/store"
import { qiankunWindow, renderWithQiankun } from "vite-plugin-qiankun/dist/helper"
import { createApp } from "vue"
import { createRouter, createWebHistory } from "vue-router"
import registerMainStore from '../../main/src/globalStore/register'
import App from "./App.vue"
import store from "./store"


let instance: any = null
const history: any = null
function render(props: any = {}) {
  const { container } = props
  instance = createApp(App)
  setupAntd(instance) // 引入antd
  setupStore(instance) // 引入store
  const history = createWebHistory(qiankunWindow.__POWERED_BY_QIANKUN__ ? "/crm" : "/")
  const router = createRouter({
    history,
    routes
  })

  instance.use(router)
  instance.mount(container ? container.querySelector("#app") : document.getElementById("app"))
  if (qiankunWindow.__POWERED_BY_QIANKUN__) {
    console.log("crm正在作为子应用运行")
  }
}

function storeMonitor(props: any) {
  if (props.onGlobalStateChange) {
    props.onGlobalStateChange((value: any, prev: any) => {
      console.log(`[子应用crm接受数据成功]:`, value)
      store.dispatch("syncMainProject", value)
    }, true)
  }
}

renderWithQiankun({
  bootstrap() {
    console.log("crm,vue3启动成功")
  },
  mount(props) {
    store.dispatch("initMainProject", props)
    storeMonitor(props)
    render(props)
    registerMainStore(store, props)
  },
  unmount(props) {
    console.log("crm已卸载")
    instance.unmount()
    instance._container.innerHTML = ""
    history.destroy() // 不卸载  router 会导致其他应用路由失败
    instance = null
  }
})

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  console.log(`主应用crm启动`)
  render()
}

4)应用路由配置

qiankun使用微前端有两个思路:

  • 1)registerMicroApps
  • 2)loadMicroApp

笔者的观点,registerMicroApps比较适合商城页面的衔接,而中台管理系统,因为有需要共享的菜单栏,头部等,loadMicroApp更适合。

image.png

先查看最简单的挂载:

const app = loadMicroApp({
    name: 'crm',
    entry: 'http://localhost:8081', // 对应的路由地址
    container: '#crm_Container',  // 挂载的id
    activeRule: '/crm',, // 转发的地址
    props: {
        // ...附带参数
   }
});
start();

此时,还需要考虑,主应用与子应用的区别,不同应用的切换(采用方案为同事挂载多个,不在当前子项目将display:none隐藏)等。可直接看调试后代码:

export default {
  data() {
    return {
      loadedApp: {},
      microApps: [
        {
          name: 'crm',
          entry: 'http://localhost:8081',
          container: '#crm_Container',
          activeRule: '/crm',
        },
        {
          name: 'sale',
          entry: 'http://localhost:8082',
          container: '#appChild2',
          activeRule: '/sale',
        },
      ],
    };
  },
  computed: {
    ...mapGetters(['getToken']),
  },
  methods: {
    isQianKun( routePath = this.$route.path ){
      const microApp = this.microApps.find(item => routePath.includes(item.activeRule));
      return microApp;
    },
    goQiankun( routePath = this.$route.path ) {
      const loadedApp = this.loadedApp;
      const microApp = this.microApps.find(item => routePath.includes(item.activeRule));
      // 如果是子应用
      if (microApp) {
        // 将主应用的路由转化为子路由URL
        const childRoutePath = routePath.replace(microApp.activeRule, '');
        // 如果没有加载当前子应用
        if (!loadedApp[microApp.name]) {
          // 开始加载
          const app = loadMicroApp({
            ...microApp,
            props: {
              token: this.getToken,
              getGlobalState: actions.getGlobalState // 下发getGlobalState方法
            }
          }); // 加载子应用
          // 开始完成
          app.loadPromise.then(() => {});
          loadedApp[microApp.name] = {
            // 将当前子应用存入loadedApp缓存
            app,
            subRoutes: [childRoutePath],
          };
        } else {
          // 如果已加载子应用,将子应用的路由记录到数组中
          const subRoutes = loadedApp[microApp.name].subRoutes;
          if (!subRoutes.includes(childRoutePath)) {
            subRoutes.push(childRoutePath);
          }
        }
        // 通知子应用增加 keep-alive 的 include
        actions.setGlobalState(loadedApp);
      }
      this.loadedApp = loadedApp;
      start();
    },
  },
};

这样,即可控制不同的路由,进入到不同的应用。再由不同的应用显示对应的页面。

参考链接:qiankun.umijs.org/zh/api#regi…

5)启动公用配置

同时存在多个项目的情况下,每次运行将要逐个npm , npm run serve等。如果此时,外边能直接启动所有的项目的话就方便许多。那么,安排。

新建package.json:

  {
    "name": "qiankun",
    "version": "0.0.1",
    "description": "来自稀土掘金,我叫逐步前行",
    "main": "index.js",
    "devDependencies": {
      "npm-run-all": "^4.1.5"
    },
    "scripts": {
      "install": "npm-run-all --serial install:*",
      "install:main": "cd main && npm install",
      "install:crm": "cd platform && npm install",
      "install:sale": "cd platform && npm install",
      "serve": "npm-run-all --parallel serve:*",
      "serve:main": "cd main && npm run serve",
      "serve:crm": "cd crm && npm run serve",
      "serve:sale": "cd sale && npm run serve"
    },
    "keywords": [
      "main",
      "platform"
    ],
    "author": "逐步前行",
    "license": "MIT",
    "__npminstall_done": false
  }

6)应用样式隔离

不同应用在同一浏览器窗口同时显示,如果不处理,将互相影响。

这里快速分享几个方案:

  • 不同项目内部组件库,可以使用bem直接区分。可以保证不会有重叠。

  • 如果同一页面,同时显示ant-design-vue 1.0版本,与ant-design-vue 2.0版本,可以使用重命名组件库的思维。

我们可以把ant-design-vue 2.0的前缀修改成 ant2-, 这样就不会与原来的ant- 冲突。

export default defineConfig(
    ...,
    css: {
        preprocessorOptions: {
          less: {
            modifyVars: {
              "ant-prefix": "ant2"
            },
            javascriptEnabled: true
          }
        }
     }
}

APP.vue

 <div class="app">
    <a-config-provider :locale="locale" prefix-cls="ant2">
        <router-view />
    </a-config-provider>
  </div>

image.png

记得,把对应的ant样式文件,ant-替换为ant2-

7)应用状态共享

此时需要考虑不同项目通信的问题,我们先引入qiankun自带的initGlobalState。 直接看代码。

主应用注册实例:

import { initGlobalState } from 'qiankun';
import Vue from 'vue';
import utils from "../utils/utils";

// 父应用的初始state
const initialState = Vue.observable({
  type: "",
});

const actions = initGlobalState(initialState);

actions.onGlobalStateChange((state, prev) => {
  console.log('主应用监听变化', state, prev);
  const newState = JSON.parse( JSON.stringify(state));
  console.log('newState', newState);
  for (const key in newState) {
    initialState[key] = newState[key]
  }
});

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

export default actions;

子应用接受实例:

/**
 * @param {vuex实例} store 
 * @param {qiankun下发的props} props 
 */
 function registerMainStore(store, props = {}) {
    if (!store || !store.hasModule) {
      return
    }
    // 获取初始化的state
    const initState = props.getGlobalState && props.getGlobalState() || {        
    }

    // 将父应用的数据存储到子应用中,命名空间固定为global
    if (!store.hasModule('global')) {
      // 这里是全局的store
      const globalModule = {
        namespaced: true,
        state: initState,
        actions: {
          // 子应用改变state并通知父应用
          setGlobalState({ commit }, payload) {
            commit('setGlobalState', payload)
            commit('emitGlobalState', payload)
          },
          // 初始化,只用于mount时同步父应用的数据
          initGlobalState({ commit }, payload) {
            commit('setGlobalState', payload)
          },
        },
        mutations: {
          setGlobalState(state, payload) {
            // eslint-disable-next-line
            state = Object.assign(state, payload)
          },
          // 通知父应用
          emitGlobalState(state) {
            console.log(`通知父应用成功,参数为:`, state);
            if (props.setGlobalState) {
              props.setGlobalState(state)
            }
          },
        },
      }
      store.registerModule('global', globalModule)
    } else {
      // 每次mount时,都同步一次父应用数据
      store.dispatch('global/initGlobalState', initState)
    }
  }

  export default registerMainStore

此时,我们只需要在子应用mount生命周期,添加监听,即可实时接收到主应用的通信:

function storeMonitor(props: any) {
  if (props.onGlobalStateChange) {
    props.onGlobalStateChange((value: any, prev: any) => {
      console.log(`[子应用crm接受数据成功]:`, value)
      store.dispatch("syncMainProject", value)
    }, true)
  }
}

8)用户权限打通

用户信息与权限等,笔者的设计是由主应用统一维护。子应用需要,我们可以利用上述"应用状态共享"的方案,同步到所有子应用:

我们在主应用登录时,同步子应用:

 // 假设setToken, 为登录方法,需redirectToken同步子应用。
 setToken(state , token){
    state.token  = token;
    Cookies.set('token', token);
    setTimeout(() => {
      globalStore.setGlobalState({ type: 'redirectToken', token });
    }, 100);
  }

子应用接受消息:

function storeMonitor(props: any) {
  if (props.onGlobalStateChange) {
    props.onGlobalStateChange((value: any, prev: any) => {
      console.log(`[子应用crm接受数据成功]:`, value)
      store.dispatch("syncMainProject", value)
    }, true)
  }
}

  // 同步到store
  async syncMainProject({ commit, dispatch, getters }: ActionContext<IQianKunState, IStore>, obj: any) {
    switch (obj.type) {
      case "redirectToken":
        commit("setToken", obj?.token)
        break;
    }
  }
  

此时,子应用就可以实时同步用户状态。至于用户状态获取后,怎么显示,那属于各个应用自治的问题了。

9)tab切换

image.png

tab的切换,涉及到两个痛点,一个是缓存(下述会单独分析)。还有另外一个就是项目之间的控制:

需要每次走一遍主应用逻辑,也要同时检查是否唤起子应用。

  <template>
    <div>
      <a-tabs v-model="tabActive" type="editable-card" @change="onChange" @edit="onDel">
        <a-tab-pane
          v-for="(item, index) in tabList"
          :key="index"
          :tab="item.name"
          :closable="true"
        >
        </a-tab-pane>
      </a-tabs>
    </div>
    </template>
    <script>
    import { mapGetters } from "vuex";
    import qiankun from "../views/qiankun.js";

    export default {
      mixins: [qiankun],
      data(){
        return{
          tabActive: Number(this.$store.getters.getActiveTabs )
        }
      },
      computed: {
        tabList(){
          return this.$store.getters.getTabItems;
        },
      },
      watch:{
        '$store.getters.getActiveTabs': function(val){
           this.tabActive = val;
        }
      },
      methods: {
        onDel(targetKey, action) {
          this.$store.dispatch("delTabs", targetKey);
        },
        onChange(targetKey, action) {
          this.tabActive = targetKey;
          this.$router.push({ path: this.tabList[targetKey].path });
          this.$store.commit("setActiveTabs", targetKey); 
          this.isQianKun() && this.goQiankun(); // 走子项目路由
        },
      },
    };
 </script>

10)打通keep-alive

首先,keep-alive由各个项目自治。我们只需要维护好,哪一些改缓存,哪一些不该缓存。这些应该在主项目维护好:

主应用:

<div v-show="$route.path.startsWith('/main')">
    <keep-alive :include="getCacheTabs>
        <router-view></router-view>
    </keep-alive>
 </div>

 <div v-for="o in microApps" v-show="$route.path.startsWith(o.activeRule)" :key="o.name">
     <KeepAlive>
         <div :id="o.container.slice(1)"></div>
     </KeepAlive>
 </div>
 

所有页面跳转,均由主应用处理。如有子应用需要页面跳转,也经过主应用的形式。

   /*
    url: 跳转的路由路径
    delTab: 是否删除当前标签
    name: 调整的tab名称,不传将会获取配置中的名称
    obj.isRouterName: 是否打开router的name模式, meta路由额外参数
  */
  redirectPage(url, delTab = false, name, obj = {}) {
    const mainRoutes = vue.$router.options.routes[1].children;
    console.log(`mainRoutes`, mainRoutes);
    const path = url.indexOf("?") ? url.split("?")[0] : url; 
    const nowIndex = mainRoutes.findIndex(item => {
      return item.path === path
    })
    const isExistMain = nowIndex !== -1
    if( isExistMain ){ //主应用跳转
      const meta = obj.meta ? obj.meta :  mainRoutes[nowIndex].meta;
      const routerName = name || meta.title;
      vue.$store.dispatch("setTabs", { path: url, name: routerName, delTab })
      vue.$router.push({ path: url });
    } else { //子应用跳转
      vue.$store.dispatch("setTabs", { path: url, name, delTab })
      vue.$router.push({ path: url });
    }
}

11)公用抽离

案例demo太小,本案例暂为提供抽离。

但是想要实现也比较简单,新建common项目。如登录页面等需要复用,可以统一到common项目应用。

12)qiankun部署

直接给nginx配置,

server {

    listen 80;
    listen 443 ssl;
    server_name ****;

    include conf.d/ssl/ssl.conf;
    include conf.d/oss/oss.conf;

    location /crmMicro/ {
        proxy_pass http://**.**.**.**:8081/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /saleMicro {
        proxy_pass http://**.**.**.**:8082/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

   location / {
        proxy_pass http://**.**.**.**:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

}

结语

匆忙的文章与案例,有不理解的地方,欢迎留言!

github地址:github.com/zhuangweizh…