知识准备
乾坤是什么?前端微应用有哪些优势?
qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
微前端架构具备以下几个核心价值:
- 技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权 - 独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新 - 增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略 - 独立运行时
每个微应用之间状态隔离,运行时状态不共享
具体可以参考:乾坤官网
项目背景
最近在跟总包那边通过乾坤做前端项目集成:主应用采用的是Vue2,需要集成子应用Vue3 。目前由于Vue3 刚出不久,乾坤官网和各大网站上也没有详细介绍的教程,硬着头皮只能自己上了。踩坑的辛酸只有自己知道啊,白天在公司加班到很晚,晚上回家继续搜索,有时候晚上做梦满脑子里都是代码,不过还好经过接近两周的时间终于调通了,通了的那一刻真实无比激动,成就感满满,晚上吃饭给自己加个鸡腿O(∩_∩)O~,废话不多少了,言归正传。
主应用
主应用采用Vue2由总包做,要求子应用的配置:
入口 entry: http://ip:端口/touristflow-app/
触发规则activeRule: /touristflow-default/
后台接口:http://ip:端口/touristflow
子应用
子应用由我来做采用Vue3
- 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 要和主应用定义的保持一致。
- 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.导出乾坤会用到的几个生命周期函数。
- 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 赋予主应用的根路径。
- 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 包:
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的拦截
(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 下,不论你怎么修改都不起作用
最后通过在主应用下修改样式解决。
总结
配置步骤:
- 子应用的二级目录部署及访问要成功 http://ip:port/touristflow-app
- 主应用的的entry: http://ip:port/touristflow-app/ 最后的“/” 不用忘记写。
- 主应用的activeRule “/touristflow-default” 要跟子应用里的路由 base 、路由菜单前缀对应。
- Vue3 的treeshaking 会导致main.js 里的乾坤钩子函数被treeshaking 掉,需要禁用掉treeshaking 功能,在package.json 里添加 “sideEffects":true
- 后台接口配置成功
- 前后台联调成功。
- 后台接口能请求成功,但是不渲染,request.js里response 未配置。
- 主应用启用沙箱隔离后,子应用里日期选择框样式不加载,需要从主应用里单独配置。