背景:
公司研发的项目主要是 GIS地图为基础的管理系统,由主体项目(管理业务模块)+ GIS地图应用,由于 GIS地图应用模块 会在多个地方使用,所以单独构建一个项目,分别在不同的业务场景中引用、交互,也在app中通过webview通信使用,之前一直使用iframe的通信方式,iframe基本也可以满足业务需求,应用分割、独立运行、分别部署,iframe方案已经满足并且不断优化已经满足需求,但是作为程序猿还是想尝试一下新技术,或许实战它可能不是最佳方案,那也只有试了才知道,那就研究一下吧。
站在巨人的肩膀,看一下天空,用了阿里的乾坤qiankun框架,尝试一下踩坑,说干就干。
首先贴一下qiankun官网,官网介绍也很详细,也在网上借鉴了很多大神的操作,下面记录一下自己学习、码代码的步骤,就当记个笔记了
一、iframe方案的实现
// 主应用 引入子应用
// 通过 iframe
<iframe webkitallowfullscreen="true" mozallowfullscreen="true" allowfullscreen="true" id="iframe" :src="url" width="100%" height="100%" scrolling="No" frameborder="0"></iframe>
// 主应用 接口 iframe 子应用页面数据
window.onmessage = params => {
console.log("子应用传来的数据",params )
}
// 主应用 向iframe 子应用发送数据
let ele = document.getElementById("iframe").contentWindow;
ele.postMessage(params, "*");
// 子应用接收主应用传来的数据
window.addEventListener("message", async params => {
console.log('接收主应用消息', params)
});
//子应用给主应用发送数据
let obj = {xxx:xxx}
window.parent.postMessage(obj, "*");
// 子应用移除监听
window.removeEventListener("message",()=>{});
二、微前端qiankun实现方案
什么是微前端(取阿里qiankun的介绍)
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端架构具备以下几个核心价值:
-
技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权 -
独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新 -
增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
-
独立运行时
每个微应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
将原本把所有功能集中于一个项目中的方式转变为把功能按业务划分成一个主项目和多个子项目,每个子项目负责自身功能,同时具备和其它子项目和主项目进行通信的能力,达到更细化更易于管理的目的。
1、微前端框架搭建--qiankun
主应用不限技术栈,只需要提供一个容器 DOM,然后注册微应用并
start
即可,微应用分为有webpack
构建和无webpack
构建项目,有webpack
的微应用
可以看到官网介绍微应用需要使用到webpack,所以在构建应用时,我们使用vue在选择构建方式时候,主应用可以使用webpack 或vite,子应用必须使用webpack
构建,所以实验学习使用的
主应用:vue3 使用cli的构建方式;子应用:vue2 使用cli方式
构建的应用大概长这样:
为了方便,贴上学习实践的demo:
主项目vue3:vue-qiankun-master: 微前端qiankun,vue3.x项目,微前端主应用
子项目vue2:vue-qiankun-child: 微前端qiankun,vue2.x项目,微前端子应用
上面demo只需要下载下来运行就可以查看效果了
2、开始构建项目
主、子项目都具备 登录、侧边菜单、主内容;页面如下
2.1、构建主应用
vue3项目构建过程可以看我的其他文章,此处省略
构建完主应用项目后,在主应用安装qiankun
yarn add qiankun # 或者 npm i qiankun -S
使用qiankun的registerMicroApps注册微应用
在主应用项目中src创建micros,在micros创建index.js、apps.js
应用切换时用到nprogress,先安装nprogress
npm install --save nprogress
# 或者
yarn add nprogress
index.js
// 子应用切换加载进度条
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import Store from "../store"
// 注册微应用主应用
import { registerMicroApps, start, addGlobalUncaughtErrorHandler, initGlobalState } from 'qiankun';
import apps from "./apps";
// 微应用通信 定义全局状态,并返回通信方法
const state = {}
const actions = initGlobalState(state);
actions.setGlobalState({
globalToken: ''
})
registerMicroApps(apps, {
beforeLoad: (app) => {
// 加载微应用前,加载进度条
NProgress.start();
console.log("before load", app.name);
if (Store.state.token) {
// 微应用加载检查登录 已登录 子应用直接传参登录
actions.setGlobalState({ globalToken: Store.state.token })
}
return Promise.resolve();
},
afterMount: (app) => {
// 加载微应用前,进度条加载完成
NProgress.done();
console.log("after mount", app.name);
return Promise.resolve();
},
});
addGlobalUncaughtErrorHandler((event) => {
console.error(event);
const { message: msg } = event
if (msg && msg.includes("died in status LOADING_SOURCE_CODE")) {
console.error("微应用加载失败,请检查应用是否可运行");
}
});
export default start;
export {
actions
}
这里用到qiankun框架的几个api,简单介绍一下
registerMicroApps(apps, lifeCycles?):
- apps -
Array<RegistrableApp>
- 必选,微应用的一些注册信息 - lifeCycles -
LifeCycles
- 可选,全局的微应用生命周期钩子
微应用注册,包含两个参数,第一个参数是微应用的一些注册信息,第二个参数是全局的微应用生命周期钩子。
start(opts?)
:
- opts -
Options
可选
我们用来启动qiankun的方法,包含一个参数
addGlobalUncaughtErrorHandler(handler)
:
- handler -
(...args: any[]) => void
- 必选
全局的未捕获异常处理器。
qiankun API具体使用方式请查看官网api
apps.js
const apps = [
/**
* name: 微应用名称 - 具有唯一性
* entry: 微应用入口 - 通过该地址加载微应用
* container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
* activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
*/
{
name: "vue-micro-app",
entry: "//localhost:8082",
container: "#micro-container",
activeRule: "#/vue2-micro-app",
props: {
router: router
}
},
];
export default apps;
apps导出的参数是registerMicroApps的第一个参数apps
* name: 微应用名称 - 具有唯一性
* entry: 微应用入口 - 通过该地址加载微应用
* container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
* activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
参数具体规则可以查看官网api介绍
mian.js
在main.js中引用,运行一下 start函数 开启微应用,sandbox: { experimentalStyleIsolation: true }开启沙箱隔离样式
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import store from './store'
import start from '@/micros'
import vueDragging from "./directives/vue-dragging.es5"
const Vue = createApp(App)
Vue.use(store)
Vue.use(router)
Vue.use(ElementPlus, { size: 'small', zIndex: 3000 })
Vue.use(vueDragging)
Vue.mount('#app')
start({ sandbox: { experimentalStyleIsolation: true } })
启动应用,我们会发现和未安装qiankun前没什么区别。
2.2、主应用中构建微应用容器和微应用菜单
主应用App.vue中添加微应用容器
<div class="app-container">
<router-view></router-view>
<!-- 新添加,微应用的容器 -->
<div id="micro-container"></div>
</div>
主应用菜单结构如下:
<div class="app-nav" v-show="appToken">
<router-link class="nav-a-btn" to="/" >主-home</router-link>
<router-link class="nav-a-btn" to="/master-about" >主-about</router-link>
<router-link class="nav-a-btn" to="/vue2-micro-app/home" >子-home</router-link>
<router-link class="nav-a-btn" to="/vue2-micro-app/about" >子-about</router-link>
</div>
我们可以看到 to中多了上面app.js
的activeRule
字段中对应的值(去掉了#号),因为#/vue2-micro-app正是触发启动微应用的条件
改造后的app.vue
<template>
<div class="app-main">
<div class="app-nav" v-show="appToken">
<template v-for="menu in menuList" :key="menu.name">
<div class="nav-a-btn" :class="{'router-active':menuActive==menu.path}"
@click="menuChangeRouter(menu)">{{menu.btnName}}</div>
</template>
</div>
<div class="app-content">
<div class="app-header-content" v-show="appToken">
<div> {{crumbsRouter}}</div>
<div>Token: {{appToken}}</div>
<el-button type="primary" round @click="loginOut">退出登录</el-button>
</div>
<div class="app-container">
<router-view></router-view>
<!-- 新添加,微应用的容器 -->
<div id="micro-container"></div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, toRefs, ref } from "@vue/reactivity";
import { computed, watch, onMounted } from "@vue/runtime-core";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "vuex";
import { actions } from "@/micros"
const Router = useRouter()
const Route = useRoute()
const Store = useStore()
let state = reactive({
menuList: [
{
name: 'home',
path: '/master-home',
btnName: '主-home'
},
{
name: 'master-about',
path: '/master-about',
btnName: '主-about'
},
{
name: 'micro-home',
path: '/vue2-micro-app/home',
btnName: '子-home'
},
{
name: 'micro-about',
path: '/vue2-micro-app/about',
btnName: '子-about'
}
],
menuActive: computed(() => Route.path),
crumbsRouter: computed(() => {
let name = ""
state.menuList.forEach(item => {
if (state.menuActive == item.path) {
name = item.btnName
}
})
return name
}),
// 从环境变量中取参数
appId: process.env.VUE_APP_MICRO_ENTRY,
appToken: computed(() => Store.state.token)
})
watch(() => Route.path, (val, oval) => {
console.log("监听路由变化", val, oval)
})
onMounted(() => {
actions.onGlobalStateChange((state) => {
// state: 变更后的状态; prevState: 变更前的状态
console.log("主应用观察者:状态改变", state);
let token = state.globalToken
Store.commit("setToken", token)
console.log("kkkkkkkkkkk")
console.log("jjjjj", Store.state.token)
})
})
let menuChangeRouter = (row) => {
state.menuActive = row.name
// 路由跳转方式
Router.push({ path: row.path })
// 跳转方法二
// window.history.pushState({}, '', '/#'+row.path)
}
let loginOut = () => {
Store.commit("loginOut")
Router.push('/login')
}
let { menuList, menuActive, crumbsRouter, appId, appToken } = toRefs(state)
</script>
<style lang="scss">
html,
body {
margin: 0;
padding: 0;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
$leftWidth: 200px;
.app-main {
display: flex;
justify-content: space-between;
width: 100%;
height: 100vh;
background: #f6f7fc;
}
.app-nav {
width: $leftWidth;
height: 100%;
display: flex;
flex-direction: column;
box-shadow: 2px 0px 10px 0px rgb(0, 47, 60, 0.2);
padding: 20px;
box-sizing: border-box;
background: #fff;
z-index: 9;
.nav-a-btn {
width: 100%;
height: 40px;
line-height: 40px;
background: #f3f4f5;
margin-bottom: 10px;
font-weight: bold;
cursor: pointer;
}
.router-active {
color: #42b983;
background: #deeefdde;
}
}
.app-content {
width: calc(100% - $leftWidth);
height: 100%;
.app-header-content {
padding: 0 20px;
width: 100%;
height: 50px;
background: #ffffff;
box-shadow: 0px 0px 8px 0px rgb(0 0 0 / 8%);
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
border-bottom: 1px solid #ccc;
}
.app-container {
width: 100%;
height: calc(100% - 50px);
overflow: auto;
padding: 20px;
box-sizing: border-box;
}
}
/* S 修改滚动条默认样式 */
::-webkit-scrollbar {
width: 8px;
background: white;
}
::-webkit-scrollbar-corner,
/* 滚动条角落 */
::-webkit-scrollbar-thumb,
::-webkit-scrollbar-track {
/*滚动条的轨道*/
border-radius: 4px;
}
::-webkit-scrollbar-corner,
::-webkit-scrollbar-track {
/* 滚动条轨道 */
background-color: rgba(180, 160, 120, 0.1);
box-shadow: inset 0 0 1px rgba(180, 160, 120, 0.5);
}
::-webkit-scrollbar-thumb {
/* 滚动条手柄 */
background-color: #00adb5;
}
/* E 修改滚动条默认样式 */
</style>
2、微应用子应用搭建
微应用需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出
bootstrap
、mount
、unmount
三个生命周期钩子,以供主应用在适当的时机调用
开始改造子应用
创建qiankun/public-path.js
// 新增:动态设置 webpack publicPath,防止资源加载出错
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
在main.js 引入 public-path.js 并改造main.js 如下:
import "./qiankun/public-path"
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
import routes from './router'
import store from './store'
import MicroActions from './qiankun/qiankun-actions'
Vue.config.productionTip = false
Vue.use(VueRouter)
// 新增:用于保存vue实例
let instance = null;
let router = null;
let microPath = ''
if (window.__POWERED_BY_QIANKUN__) {
microPath = '/vue2-micro-app'
}
/** * 新增: * 渲染函数 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行 */
function render(props) {
console.log("子应用render的参数", props)
// 新增判断,如果是独立运行不执行onGlobalStateChange
if (window.__POWERED_BY_QIANKUN__) {
if (props) {
// 注入 actions 实例
MicroActions.setActions(props);
}
// 挂载主应用传入路由实例 用于子应用跳转主应用
Vue.prototype.$microRouter = props.router
props.onGlobalStateChange((state, prevState) => {
// state: 变更后的状态; prev 变更前的状态
console.log("通信状态发生改变:", state, prevState);
store.commit('setToken', state.globalToken)
}, true);
}
// router不再是同一个实例,而是每次mount的时候都会新获取一个实例
router = new VueRouter({
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
if (to.path !== (microPath + '/login')) {
if (store.state.token) {
console.log("已经登录 token=", store.state.token)
if (window.__POWERED_BY_QIANKUN__ && !to.path.includes('vue2-micro-app')) {
next(microPath + to.path)
} else {
next()
}
} else {
console.log("子应用 - 未登录 请登录")
next(microPath + '/login')
}
} else {
next()
}
})
// 挂载应用
instance = new Vue({
router,
store,
render: (h) => h(App),
}).$mount("#micro-app");
}
/**
* 新增:
* bootstrap 只会在微应用初始化的时候调用一次,
下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log("VueMicroApp bootstraped");
}
/**
* 新增:
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
console.log("VueMicroApp mount", props);
render(props);
}
/**
* 新增:
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount() {
console.log("VueMicroApp unmount");
instance.$destroy();
instance = null;
}
// 新增:独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
// 原本启动代码
// new Vue({
// router,
// store,
// render: h => h(App)
// }).$mount('#app')
注意:$mount("#micro-app") 挂在的时候修改了根id为了区别主应用和子应用,避免出现问题,对应的入口index.html 的 id="micro-app"
接下来就是改造路由,加入判断是否微应用打开,是就走子应用路径逻辑,否就单独运行
// import Vue from 'vue'
// import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
// import store from '../store'
// Vue.use(VueRouter)
// 判断环境是否是微应用打开
let microPath = ''
if (window.__POWERED_BY_QIANKUN__) {
microPath = '/vue2-micro-app'
}
const routes = [
{
path: microPath + '/',
redirect: microPath + '/home'
},
{
name: 'Home',
path: microPath + '/home',
component: Home
},
{
name: 'About',
path: microPath + '/about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
},
{
name: 'login',
path: microPath + '/login',
component: () => import(/* webpackChunkName: "about" */ '../views/login.vue')
}
]
// const router = new VueRouter({
// routes
// })
// // 路由守卫 移动到main.js中
// router.beforeEach((to, from, next) => {
// if (to.path !== (microPath + '/login')) {
// if (store.state.token) {
// console.log("已经登录 token=",store.state.token)
// if (window.__POWERED_BY_QIANKUN__ && !to.path.includes('vue2-micro-app')) {
// next(microPath + to.path)
// } else {
// next()
// }
// } else {
// console.log("子应用 - 未登录 请登录")
// next(microPath + '/login')
// }
// } else {
// next()
// }
// })
export default routes
路由主要的改动就是每个path都添加了一个microPath变量,检测是否微应用打开
相应的路由守卫也要添加microPath变量,另外微应用的login跳转的时候也要加上microPath判断(在main.js中查看)
设置webpack,在vue.config.js设置打开配置
const path = require("path");
module.exports = {
devServer: {
// 监听端口
port: 8066,
// 关闭主机检查,使微应用可以被 fetch
disableHostCheck: true,
// 配置跨域请求头,解决开发环境的跨域问题
headers: {
"Access-Control-Allow-Origin": "*",
},
},
configureWebpack: {
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
output: {
// 微应用的包名,这里与主应用中注册的微应用名称一致
library: "vue-micro-app",
// 将你的 library 暴露为所有的模块定义下都可运行的方式
libraryTarget: "umd",
// 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
jsonpFunction: `webpackJsonp_vue-micro-app`,
},
},
};
最后启动主应用和子应用项目到浏览器上可以看到,如下页面:
好了此时微应用已经正常启动,上面页面已经做了,主应用登录子应用可以同步登录状态,主应用与子应用通信
下一章继续探索微前端的主应用如何与子应用通信,如何使用initGlobalState进行通信,敬请期待