likes
comments
collection
share

乾坤主应用Vue2 集成子应用Vue3艰苦踩坑历程

作者站长头像
站长
· 阅读数 30

知识准备

乾坤是什么?前端微应用有哪些优势? qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

微前端架构具备以下几个核心价值:

  • 技术栈无关 主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级 在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  • 独立运行时 每个微应用之间状态隔离,运行时状态不共享 具体可以参考:乾坤官网

项目背景

最近在跟总包那边通过乾坤做前端项目集成:主应用采用的是Vue2,需要集成子应用Vue3 。目前由于Vue3 刚出不久,乾坤官网和各大网站上也没有详细介绍的教程,硬着头皮只能自己上了。踩坑的辛酸只有自己知道啊,白天在公司加班到很晚,晚上回家继续搜索,有时候晚上做梦满脑子里都是代码,不过还好经过接近两周的时间终于调通了,通了的那一刻真实无比激动,成就感满满,晚上吃饭给自己加个鸡腿O(∩_∩)O~,废话不多少了,言归正传。

主应用

主应用采用Vue2由总包做,要求子应用的配置: 入口 entry: http://ip:端口/touristflow-app/ 触发规则activeRule: /touristflow-default/ 后台接口:http://ip:端口/touristflow

子应用

子应用由我来做采用Vue3

  1. vue.config.js
'use strict'
const path = require('path');
function resolve(dir) {
  return path.join(__dirname, dir)
}
const packageName = 'touristflow-default';
// const packageName = require('./package.json').name;
const port = 9002;
const prod = process.env.NODE_ENV === 'production';

const publicPath = prod ? '/touristflow-app/':'/';
module.exports = {
  publicPath:publicPath,
  outputDir: 'dist',
  assetsDir: 'static',
  productionSourceMap: false,
  filenameHashing: true,
  lintOnSave: false,
  runtimeCompiler: true,
  devServer: {
    port: port,
    hot: true,
    disableHostCheck: true,
    overlay: {
      warnings: false,
      errors: true
    },
    //以上的ip和端口是我们本机的;下面为需要跨域的
    proxy: {//配置跨域
      '/touristflow': {
        target: 'http://localhost:30080/touristflow',//这里后台的地址模拟的;应该填写你们真实的后台接口
        ws: true,
        changOrigin: true,//允许跨域
        pathRewrite: {
          '^/touristflow': ''//请求的时候使用这个api就可以
        }
      }
    },
    headers: {
      'Access-Control-Allow-Origin': '*' // 重要
    }

  },
  css:{
    loaderOptions:{
      sass:{
        prependData:`@import "public/common.scss";`,
      }
    }
  },
  // 自定义webpack配置
  configureWebpack: {
    resolve: {
      alias: {
        '@': resolve('src')
      }
    },
    output: {
      // 把子应用打包成 umd 库格式
      library: `${packageName}`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${packageName}`
    },
  }
}

备注:const publicPath = prod ? '/touristflow-app/':'/' 这句主要实现子应用的二级目录部署。子应用的访问路径为:http://ip:port/touristflow-app

nginx 的配置为:

server {
  #客流后台管理
        listen  30081;#默认端口是80,如果端口没被占用可以不用修改
        server_name  localhost;
        #charset koi8-r;
        #access_log  logs/host.access.log  main;
        #vue或者React项目的打包后的dist
        location /touristflow-app {
        alias /opt/server/touristflow-app;
            #需要指向下面的@router
            try_files $uri $uri/ /touristflow-app/index.html;
            index  index.html index.htm;
        }

         location /touristflow {
        proxy_pass http://ip:port/touristflow/;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host    $http_host;
        proxy_set_header X-Real-IP $remote_addr;

    }
  }
output: {
      // 把子应用打包成 umd 库格式
      library: `${packageName}`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${packageName}`
    }

packageName 要和主应用定义的保持一致。

  1. main.js
import './public-path';// 加载对乾坤public-path 的配置
import {createApp} from 'vue';
import App from './App.vue';
import store from './store';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import '../public/common.scss';
import "@/vendor/Blob.js";
import "@/vendor/Export2Excel.js";
import router from "./router";
import VueCookies from 'vue-cookies';

const isQiankun = window.__POWERED_BY_QIANKUN__;
//用于保存vue实例
let instance = null
console.log("是否isQiankun:" + isQiankun);
console.log(" CommonJS模块化:" + (typeof exports === 'object' && typeof module === 'object') || (typeof define === 'function' && define.amd) || typeof exports === 'object')

function render(props = {}) {
    const {container} = props
    instance = createApp(App);
    instance.provide('$cookies',VueCookies);
    instance.use(store)
        .use(router)
        .use(ElementPlus)
        .mount(container ? container.querySelector('#app') : '#app')
}

export async function bootstrap() {
    console.log('[客流] vue app bootstraped');
}

export async function mount(props) {
    console.log('[客流] props from main framework', props);
    storeTest(props);
    render(props);
}

export async function unmount() {
    console.log('[客流] unmount')
    instance.unmount();
    instance._container.innerHTML = "";
}

/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
    console.log('update props', props);
}


// 独立运行时直接挂载应用
if (!isQiankun) {
    render()
}

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,
        },
    });
}

备注: 1.导入public-path.js 2.配置render 函数。 3.导出乾坤会用到的几个生命周期函数。

  1. public-path.js 跟main.js 在同一目录
/* eslint-disable*/
if (window.__POWERED_BY_QIANKUN__) {
  /* eslint-disable*/
  console.log("public-path.js 开始加载");
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  console.log("__webpack_public_path__:"+__webpack_public_path__);
  console.log("public-path.js 加载完成");
}

备注:这里配置主要的作用是,当通过乾坤调用时动态的给webpack的public_path 赋予主应用的根路径。

  1. Router>>index.js 路由配置
import {createRouter, createWebHashHistory} from 'vue-router';
import store from '@/store/index.js';
import Home from "../views/Home.vue";

let microPath = "";
const isQiankun = window.__POWERED_BY_QIANKUN__;
console.log("路由 index.js [isQiankun]:" + isQiankun);
if (isQiankun) {
    microPath = "/touristflow-default";
}

const routes = [
    {
        path: '/',
        redirect: '/passengerFlowStatistics'
    },
    {
        path: "/",
        name: "Home",
        component: Home,
        children: [
            {
                path: "/passengerFlowStatistics",
                name: "PassengerFlowStatistics",
                meta: {
                    title: '客流统计报表'
                },
                component: () => import( /* webpackChunkName: "PassengerFlowStatistics" */ "../views/PassengerFlowManagement/PassengerFlowStatistics.vue")
            }, {
                path: "/systemConfiguration",
                name: "SystemConfiguration",
                meta: {
                    title: '系统配置'
                },
                component: () => import( /* webpackChunkName: "SystemConfiguration" */ "../views/SystemManagement/SystemConfiguration.vue")
            }, {
                path: "/exhibitionPassengerFlow",
                name: "ExhibitionPassengerFlow",
                meta: {
                    title: '展项客流统计'
                },
                component: () => import( /* webpackChunkName: "ExhibitionPassengerFlow" */ "../views/PassengerFlowManagement/ExhibitionPassengerFlow.vue")
            }, {
                path: "/passengerFlowThreshold",
                name: "PassengerFlowThreshold",
                meta: {
                    title: '客流阀值配置'
                },
                component: () => import( /* webpackChunkName: "PassengerFlowThreshold" */ "../views/SystemManagement/PassengerFlowThreshold.vue")
            }, {
                path: "/systemLog",
                name: "SystemLog",
                meta: {
                    title: '系统日志'
                },
                component: () => import( /* webpackChunkName: "PassengerFlowThreshold" */ "../views/SystemManagement/SystemLog.vue")
            }, {
                path: "/deviceManagement",
                name: "DeviceManagement",
                meta: {
                    title: '设备管理'
                },
                component: () => import( /* webpackChunkName: "PassengerFlowThreshold" */ "../views/SystemManagement/DeviceManagement.vue")
            }
        ]
    },
    {
        path: "/login",
        name: "Login",
        meta: {
            title: '登录'
        },
        component: () => import( /* webpackChunkName: "login" */ "../views/Login.vue")
    }
];
const routes_qiankun = [
    {
        path: microPath + '/',
        redirect: microPath + '/passengerFlowStatistics'
    },
    {
        path: microPath + "/passengerFlowStatistics",
        name: "PassengerFlowStatistics",
        meta: {
            title: '客流统计报表'
        },
        component: () => import(  "../views/PassengerFlowManagement/PassengerFlowStatistics.vue")
    }, {
        path: microPath + "/systemConfiguration",
        name: "SystemConfiguration",
        meta: {
            title: '系统配置'
        },
        component: () => import( /* webpackChunkName: "SystemConfiguration" */ "../views/SystemManagement/SystemConfiguration.vue")
    }, {
        path: microPath + "/exhibitionPassengerFlow",
        name: "ExhibitionPassengerFlow",
        meta: {
            title: '展项客流统计'
        },
        component: () => import( /* webpackChunkName: "ExhibitionPassengerFlow" */ "../views/PassengerFlowManagement/ExhibitionPassengerFlow.vue")
    }, {
        path: microPath + "/passengerFlowThreshold",
        name: "PassengerFlowThreshold",
        meta: {
            title: '客流阀值配置'
        },
        component: () => import( /* webpackChunkName: "PassengerFlowThreshold" */ "../views/SystemManagement/PassengerFlowThreshold.vue")
    }, {
        path: microPath + "/systemLog",
        name: "SystemLog",
        meta: {
            title: '系统日志'
        },
        component: () => import( /* webpackChunkName: "PassengerFlowThreshold" */ "../views/SystemManagement/SystemLog.vue")
    }, {
        path: microPath + "/deviceManagement",
        name: "DeviceManagement",
        meta: {
            title: '设备管理'
        },
        component: () => import( /* webpackChunkName: "PassengerFlowThreshold" */ "../views/SystemManagement/DeviceManagement.vue")
    },
    {
        path: "/login",
        name: "Login",
        meta: {
            title: '登录'
        },
        component: () => import( /* webpackChunkName: "login" */ "../views/Login.vue")
    }
];

const router = createRouter({
    history: createWebHashHistory(isQiankun ? '/touristflow-default' : '/'),
    routes: isQiankun ? routes_qiankun : routes
})
if (!isQiankun) {
    router.beforeEach((to, from, next) => {
        // 跳转到非登录页面的其他页面时需先判断是否登录
        let IS_LOGIN = store.getters.getToken;
        if (to.path !== microPath + '/login') {
            // 未登录则跳转到登录页面
            if (IS_LOGIN) {
                next()
            } else {
                next(microPath + '/login')
            } // path就是配置路由文件里的路由path(属性值一定要相同)
        } else {
            // 已登录则跳转到首页
            if (IS_LOGIN) next(microPath + '/passengerFlowStatistics')
            else next()
        }
    })
}


export default router;

注意:(1)子应用的路由要跟主应用的路由方式保持一直,主应用用的hash,子应用也要用hash方式。

const router = createRouter({
    history: createWebHashHistory(isQiankun ? '/touristflow-default' : '/'),
    routes: isQiankun ? routes_qiankun : routes
})

创建路由时,base要取/touristflow-default,跟主应用里配置的触发规则保持一致。 (2)乾坤路由菜单前都要加上 /touristflow-default 5. request.js

import axios from 'axios';
import {ElMessageBox} from 'element-plus'
import store from '@/store/index.js';
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

const microPath = "/touristflow-default";
const isQiankun = window.__POWERED_BY_QIANKUN__;
console.log("requst isQiankun:" + isQiankun);
// 创建一个axios实例
const service = axios.create({
    headers: {
        'content-type': 'application/json;charset=UTF-8',
    },
    // baseURL: process.env.VUE_APP_BASE_API,
    baseURL: process.env.NODE_ENV === 'production' ? window.configObj.httpUrl : window.configObj.httpUrl,
    changeOrigin: true, //是否跨域
    timeout: 60000
})

if (!isQiankun) {
    console.log("request 走 非乾坤")
    // 添加请求拦截器
    service.interceptors.request.use(config => {
        config.headers['X-Token'] = store.getters.getToken;
        // config.headers['X-Token'] = 'd30d134a-3627-4941-a06b-86bec6119da5';
        return config;
    }, error => {
        // 请求错误时做些事
        return Promise.reject(error);
    });
// 添加响应拦截器
    service.interceptors.response.use(response => {
        NProgress.start();
        if (response.data.code == 1000) {
            ElMessageBox.confirm(
                '登录状态已过期,您可以继续留在该页面,或者重新登录',
                '系统提示',
                {
                    confirmButtonText: '重新登录',
                    cancelButtonText: '取消',
                    type: 'warning',
                }
            ).then(() => {
                store.commit("setToken", '');
                location.href = '/#' + microPath + '/login';
            }).catch(() => {

            })
        }
        if (response.status == 200) {
            const res = response.data;
            // 如果返回的状态不是200 就主动报错
            NProgress.done()
            return res;
        } else {
            NProgress.done()
            return Promise.reject("服务器请求错误")
        }
    }, error => {
        return Promise.reject(error); // 返回接口返回的错误信息
    })
} else {
    console.log("request 走 乾坤")
    // 添加请求拦截器
    service.interceptors.request.use(config => {
        if (store.getters.getToken){
            config.headers['X-CSRF-TOKEN'] = store.getters.getToken;
        }
        return config;
    }, error => {
        // 请求错误时做些事
        return Promise.reject(error);
    });
    // 添加响应拦截器
    service.interceptors.response.use(response => {
        if (response.status == 200) {
            const res = response.data;
            return res;
        } else {
            return Promise.reject("服务器请求错误")
        }
    }, error => {
        return Promise.reject(error); // 返回接口返回的错误信息
    })

}


export default service

后台配置

(1)本地引入三个jar 包: 乾坤主应用Vue2 集成子应用Vue3艰苦踩坑历程 pom.xml

<!--引入浪潮本地jar包-->
        <dependency>
            <groupId>com.inspur.msy</groupId>
            <artifactId>green-channel-core</artifactId>
            <version>1.3.6</version>
            <scope>system</scope>
            <systemPath>${pom.basedir}/lib/green-channel-core-1.3.6.jar</systemPath>
        </dependency>
        <dependency>
            <groupId>com.inspur.msy</groupId>
            <artifactId>green-channel-rcv</artifactId>
            <version>1.3.6</version>
            <scope>system</scope>
            <systemPath>${pom.basedir}/lib/green-channel-rcv-1.3.6.jar</systemPath>
        </dependency>
        <dependency>
            <groupId>bcprov</groupId>
            <artifactId>bcprovjdk15to18</artifactId>
            <version>1.69</version>
            <scope>system</scope>
            <systemPath>${pom.basedir}/lib/bcprov-jdk15to18-1.69.jar</systemPath>
        </dependency>

(2) 添加 MyGreenChannelAuthorizeSpringFilter

package com.dechnic.psas.shiro;

import com.inspur.msy.component.greenchannel.web.filter.GreenChannelAuthorizeSpringFilter;
import com.inspur.msy.component.greenchannel.web.utils.AuthTokenCheckUtils;
import lombok.Data;
import lombok.extern.java.Log;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @description: 继承 过滤器重写过滤doFilterInternal业务方法
 * @author: maty
 * @time: 2022/2/16 10:14
 */
@Log
public class MyGreenChannelAuthorizeSpringFilter extends GreenChannelAuthorizeSpringFilter {

    public MyGreenChannelAuthorizeSpringFilter() {
        super();
    }

    /**
     *  解析 浪潮Token  直接放开
     * @param request
     * @param response
     * @param filterChain
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("MyGreenChannel doFilter 开始");
        AuthTokenCheckUtils.parseAndVerify(request);
        log.info("MyGreenChannel doFilter 结束");
        filterChain.doFilter(request, response);
    }
}

(3)登陆过滤器里去掉对“/touristflow” 的请求url的拦截 乾坤主应用Vue2 集成子应用Vue3艰苦踩坑历程 (4)白名单里添加对正式服务器域名的配置ip

踩坑碰到的问题

问题一: Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry

参考官网地址1 踩坑主要点:通过参考官网和从各大网站搜索资料,不停的反复的尝试,就跟爱迪生发明电灯一样,失败了一次又一次,每一次满怀信心部署上去,想去见证奇迹时都失败了,可是我并没有放弃,当有一天老大问题调的怎么样时,我的回答是还没有调好,我不甘心这样放弃,甚至差一点换成auth2 的方式。直到有一天我在看前端知识时,无意间看到Vue3的新特性 treeshaking,我才彻底明白原来给乾坤准备的那几个函数被treeshaking掉了。然后我又从webpack 官网里了解到 将package.json 中 添加 "sideEffects": true 就可以了 完整的package.json 文件:

{
  "name": "touristflow-default",
  "version": "0.1.0",
  "private": true,
  "sideEffects": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build:prod": "vue-cli-service build",
    "build:stage": "vue-cli-service build --mode staging"
  },
  "dependencies": {
    "axios": "^0.24.0",
    "core-js": "^3.6.5",
    "echarts": "^5.2.2",
    "element-plus": "^1.3.0-beta.1",
    "file-saver": "^2.0.5",
    "nprogress": "^0.2.0",
    "qs": "^6.10.3",
    "vue": "^3.0.0",
    "vue-cookies": "^1.7.4",
    "vue-router": "^4.0.0-0",
    "vuex": "^4.0.0-0",
    "vuex-persistedstate": "^4.1.0",
    "xlsx": "^0.17.4"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "@vue/compiler-sfc": "^3.0.0",
    "node-sass": "^4.12.0",
    "sass-loader": "^8.0.2",
    "script-loader": "^0.7.2",
    "webpack": "^4.7.0",
    "webpack-cli": "^4.9.2"
  }
}

问题二:后台接口都能调通,但是前端不渲染 前台请求后台我用的是axios 的工具, 这个问题主要是request.js 里面只定义了请求拦截,没有定义response 导致的,完整代码如下: request.js

import axios from 'axios';
import {ElMessageBox} from 'element-plus'
import store from '@/store/index.js';
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

const microPath = "/touristflow-default";
const isQiankun = window.__POWERED_BY_QIANKUN__;
console.log("requst isQiankun:" + isQiankun);
// 创建一个axios实例
const service = axios.create({
    headers: {
        'content-type': 'application/json;charset=UTF-8',
    },
    // baseURL: process.env.VUE_APP_BASE_API,
    baseURL: process.env.NODE_ENV === 'production' ? window.configObj.httpUrl : window.configObj.httpUrl,
    changeOrigin: true, //是否跨域
    timeout: 60000
})

if (!isQiankun) {
    console.log("request 走 非乾坤")
    // 添加请求拦截器
    service.interceptors.request.use(config => {
        config.headers['X-Token'] = store.getters.getToken;
        // config.headers['X-Token'] = 'd30d134a-3627-4941-a06b-86bec6119da5';
        return config;
    }, error => {
        // 请求错误时做些事
        return Promise.reject(error);
    });
// 添加响应拦截器
    service.interceptors.response.use(response => {
        NProgress.start();
        if (response.data.code == 1000) {
            ElMessageBox.confirm(
                '登录状态已过期,您可以继续留在该页面,或者重新登录',
                '系统提示',
                {
                    confirmButtonText: '重新登录',
                    cancelButtonText: '取消',
                    type: 'warning',
                }
            ).then(() => {
                store.commit("setToken", '');
                location.href = '/#' + microPath + '/login';
            }).catch(() => {

            })
        }
        if (response.status == 200) {
            const res = response.data;
            // 如果返回的状态不是200 就主动报错
            NProgress.done()
            return res;
        } else {
            NProgress.done()
            return Promise.reject("服务器请求错误")
        }
    }, error => {
        return Promise.reject(error); // 返回接口返回的错误信息
    })
} else {
    console.log("request 走 乾坤")
    // 添加请求拦截器
    service.interceptors.request.use(config => {
        if (store.getters.getToken){
            config.headers['X-CSRF-TOKEN'] = store.getters.getToken;
        }
        return config;
    }, error => {
        // 请求错误时做些事
        return Promise.reject(error);
    });
    // 添加响应拦截器
    service.interceptors.response.use(response => {
        if (response.status == 200) {
            const res = response.data;
            return res;
        } else {
            return Promise.reject("服务器请求错误")
        }
    }, error => {
        return Promise.reject(error); // 返回接口返回的错误信息
    })

}


export default service

问题三:日期选择框样式在子应用里怎么调都不起作用

主要原因是主应用虽然和子应用通过沙箱隔离,起了一部分作用,但是像有些组件 “日期选择框” 则之前挂到了主应用的body 下,不论你怎么修改都不起作用

参考乾坤主应用和子应用的样式隔离

最后通过在主应用下修改样式解决。

总结

配置步骤:

  1. 子应用的二级目录部署及访问要成功 http://ip:port/touristflow-app
  2. 主应用的的entry: http://ip:port/touristflow-app/ 最后的“/” 不用忘记写。
  3. 主应用的activeRule “/touristflow-default” 要跟子应用里的路由 base 、路由菜单前缀对应。
  4. Vue3 的treeshaking 会导致main.js 里的乾坤钩子函数被treeshaking 掉,需要禁用掉treeshaking 功能,在package.json 里添加 “sideEffects":true
  5. 后台接口配置成功
  6. 前后台联调成功。
  7. 后台接口能请求成功,但是不渲染,request.js里response 未配置。
  8. 主应用启用沙箱隔离后,子应用里日期选择框样式不加载,需要从主应用里单独配置。