大家好,我是小黑。

公司使用技术栈是vue,最近遇到了一个需求,要把原有后台管理系统的功能模块搬迁到新的后台管理系统上面去。原本这没有多复杂的事,直接复制粘贴改改就可以,但是有这么几个坑点,我瞬间陷入了沉思:

  1. 新的后台使用的是vue3,原有的后台使用的是vue2
  2. 新的后台有自己的一套登录角色权限管理方案,旧的后台也有
  3. 由于vue3和vue2区别还是比较大的,vue3相当于整个vue重写了,虽说做了向下兼容,但是直接复制粘贴过去不太现实(主要是我试过复制了一个模块过去devtools的红色惨不忍睹)

怎么办,把vue2写好的模块重新用vue3写一次(令人窒息)?正准备含泪敲键盘的时候,我想到了以前看过的微前端的相关文章,不如试试这个玩意吧,然后,微前端正式踩坑。

什么是微前端?

按照网上的说法和小黑的理解,微前端就是​​应用分割​​,​​独立运行​​,​​独立部署​​,将原本把所有功能集中于一个项目中的方式转变为把功能按业务划分成一个主项目和多个子项目,每个子项目负责自身功能,同时具备和其它子项目和主项目进行通信的能力,达到更细化更易于管理的目的。总的来说微前端就是

一个完整应用划分成一个主应用和一个或多个微应用,应用间相互独立,可相互通信。

如何实现微前端?

符合上面条件,最容易想到的就是​​iframe​​,下面贴上两段最简单的​​iframe​​及其通讯代码

// parent.html
<div>我是parent</div>
<button id="parentBtn">parent btn</button>
<iframe src="./child.html" id="frame"></iframe>

<script>
function parentFunc(msg) {
console.log("parent 的方法:", msg)
}

var btn = document.querySelector("#parentBtn")
btn.addEventListener('click', function() {
console.log("我是parent的button")
console.log("我调用了:")
document.getElementById('frame').contentWindow.childFunc('parent');
})
</script>

// child.html
<div>我是child</div>
<button id="childBtn">child btn</button>
<script>
function childFunc(msg) {
console.log("child 的方法:", msg)
}

var btn = document.querySelector("#childBtn")
btn.addEventListener('click', function() {
console.log("我是child的button")
console.log("我调用了:")
parent.window.parentFunc('child');
})
</script>

以上两段代码放到本地服务器中就是这样的

微前端框架 qiankun 项目实战(一)--本地开发篇_css微前端框架 qiankun 项目实战(一)--本地开发篇_生命周期_02

然后点击两个按钮,就可以互相通信传参了

微前端框架 qiankun 项目实战(一)--本地开发篇_bootstrap_03

以上的两个html必须放到有域名的环境中运行,否则会报错。

当然了,这次的项目迁移我不是直接用iframe改造的,而是站在巨人的肩膀上,我用了一个叫qiankun的微前端框架改造,因为公司的代码我不能贴上来,下面我会建一个vue3项目和一个vue2项目来大概还原一下我是如何改造公司项目的,还有我遇到的坑是怎么填的。

微前端框架qiankun

首先,用vue官方的脚手架建立一个vue3的基本后台界面和一个vue2的基本后台界面,注意这里因为vue3打包使用了vite的原因,所以qiankun框架不能使用vue3作为微应用,这里我们主应用是vue3,微应用是vue2,这跟我改造的也是一致的,两个项目大概结构是一样的,如下:

微前端框架 qiankun 项目实战(一)--本地开发篇_html_04为了方便大家,贴上我建好的模板仓库地址

vue3模板:​​https://gitee.com/jimpp/vue3-main-app​​(主应用,主应用必须安装qiankun)

vue2模板:​​https://gitee.com/jimpp/vue2-micro-app​​(微应用)

上面master分支都是未改造前能独立运行的项目,dev分支是最终改造后的项目,当然自己从头到尾建立也是可以的,但是要保证两个仓库都具备router,store,登录拦截的功能

两个模板都具备这样的界面

1.登录界面

微前端框架 qiankun 项目实战(一)--本地开发篇_css_05

咳咳,简陋了点,为了显示请不要打我哈哈。

2.左侧菜单和router-view界面


微前端框架 qiankun 项目实战(一)--本地开发篇_css_06好了,下面开始基于qiankun框架改造两个项目

主应用启动qiankun

这里我使用了qiankun官网的​​registerMicroApps​​注册微应用

在主应用的src文件夹下新建一个micros文件夹,在micros文件夹新建​​index.js​​,​​app.js​

微前端框架 qiankun 项目实战(一)--本地开发篇_生命周期_07

// index.js
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import {
registerMicroApps,
addGlobalUncaughtErrorHandler,
start,
} from "qiankun";// 微应用注册信息
import apps from "./app";

registerMicroApps(apps, {
beforeLoad: (app) => {
// 加载微应用前,加载进度条
NProgress.start();
console.log("before load", app.name);
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;

首先​​yarn add nprogress​​安装nprogress这个库,是为了到时候在加载微应用的时候有进度条显示,这里用到了官方的几个api

  1. ​registerMicroApps​​:包含两个参数,第一个参数是微应用的一些注册信息,第二个参数是全局的微应用生命周期钩子。
  2. ​addGlobalUncaughtErrorHandler​​:全局的未捕获异常处理器,微应用发生报错的时候亦可以用这个api捕捉。
  3. ​start​​:我们用来启动qiankun的方法,包含一个参数,具体的参数用途不再详述。

以上详细的api请点击​​这里​​:

// app.js

const apps = [
{
name: "vue-micro-app",
entry: "//localhost:8081",
container: "#micro-container",
activeRule: "#/vue2-micro-app",
},
];
export default apps;

app.js导出的是上面​​registerMicroApps​​的第一个参数,是一个对象数组,其中数组每个字段的作用如下:

  1. ​name​​:微应用的名称,后面改造微应用的时候一定要与这个name对应
  2. ​entry​​:微应用运行的域名加端口,我用的是本地8081端口
  3. ​container​​:启动微应用需要一个dom容器,里面就是这个dom容器的id,用class应该也是可以的
  4. ​activeRule​​:触发启动微应用的规则,当检测到url中含有activeRule的值时,将启动微应用

添加完上述两个js后,我们回到main.js,目前的main.js应该是这样的

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '@/assets/main.css'

createApp(App).use(store).use(router).mount('#app')

改造也非常简单,把上面micros中的​​index.js​​引入,然后运行一下​​start函数​​就大功告成了

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '@/assets/main.css'
import start from '@/micros'


createApp(App).use(store).use(router).mount('#app')

start()

刷新一下浏览器,发现主应用和改造前并无差异!

主应用添加微应用容器和微应用菜单

目前主应用app的菜单代码结构如下

<div class="nav" v-if="token">    
<div class="menu">
<router-link to="/">Parent Home</router-link>
</div>
<div class="menu">
<router-link to="/about">Parent About</router-link>
</div>
</div>

现在我们添加两个菜单,分别对应子应用的​​home​​和​​about​

<div class="nav" v-if="token">    
<div class="menu">
<router-link to="/">Parent Home</router-link>
</div>
<div class="menu">
<router-link to="/about">Parent About</router-link>
</div>
<!--- 新添加 --->
<div class="menu">
<router-link to="/vue2-micro-app">Child Home</router-link>
</div>
<div class="menu">
<router-link to="/vue2-micro-app/about">Child About</router-link>
</div>
</div>

<div class="container">
<div class="header" v-if="token">Child Header</div>
<div class="router-view">
<router-view />
<!-- 新添加,微应用的容器 -->
<div id="micro-container"></div>
</div>
</div>

相信你也发现了,to中多了上面​​app.js​​的​​activeRule​​字段中对应的值(去掉了#号),因为#/vue2-micro-app正是触发启动微应用的条件

这是刷新我们的微应用,然后点击一下​​Child Home​​菜单,你会发现有两个报错

第一个是跨域报错,因为我们主应用运行在8080端口,微应用是8081端口,后面用nginx做一下代理就好

第二个报错就是源自于我们的微应用还未改造,所以还等什么,赶紧改造微应用

微应用改造

官网写了,微应用入口必须导出 ​​bootstrap​​、​​mount​​、​​unmount​​ 三个生命周期钩子,以供主应用在适当的时机调用

这是微应用改造前的​​main.js​

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '@/assets/main.css'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

下面我们来改造一下​​main.js​

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import '@/assets/main.css'
Vue.config.productionTip = false

// 新增:用于保存vue实例
let instance = null;

// 新增:动态设置 webpack publicPath,防止资源加载出错
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

/** * 新增: * 渲染函数 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行 */
function render() {
// 挂载应用
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')

请注意,render方法中我把$mount后的参数改为了​​#micro-app​​,这是为了区分主应用和微应用中​​index.html​​的根id,所以微应用中的public文件夹的​​index.html​​也要改为​​micro-app​

然后还要对webpack配置进行改造,微应用根目录添加​​vue.config.js​​文件

const path = require("path");

module.exports = {
devServer: {
// 监听端口
port: 8081,
// 关闭主机检查,使微应用可以被 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`,
},
},
};

然后还要改造一下我们的路由

if (window.__POWERED_BY_QIANKUN__) {  
microPath = '/vue2-micro-app'
}

const routes = [
{
path: microPath + '/login',
name: 'login',
component: Login
},
{
path: microPath + '/',
redirect: microPath + '/home'
},
{
path: microPath + '/home',
name: 'Home',
component: Home
},
{
path: microPath + '/about',
name: 'About',
component: () => import( /* webpackChunkName: "about" */ '../views/About.vue')
}
]

router.beforeEach((to, from, next) => {
if (to.path !== (microPath + '/login')) {
if (store.state.token) {
next()
} else {
next(microPath + '/login')
}
}
else {
next()
}
})

路由主要的改动就是每个​​path​​都添加了一个​​microPath​​变量,用于检测是否由微前端改动,相应的路由守卫也要添加​​microPath​​变量,另外微应用的​​login​​跳转的时候也要加上​​microPath​​判断

最后重启一下我们的微应用,再去我们的主应用点击一下​​Child Home​​菜单,如无意外你就会得到和我下面截图一样的界面微前端框架 qiankun 项目实战(一)--本地开发篇_生命周期_08没错,你已经成功了!vue2的项目已经成功嵌入到vue3中去了

但是,细心的你也发现了,我已经登录了一次了,为什么又要登录一次呀,所以,接下来我们要利用通信去解决掉这个问题。

主应用和微应用通信

应用间的通信,我们要利用​​qiankun​​框架的​​initGlobalState​​和​​MicroAppStateActions​​ api,相关的api介绍如下:

​setGlobalState​​:设置 ​​globalState​​ - 设置新的值时,内部将执行​​浅检查​​,如果检查到​​globalState​​发生改变则触发通知,通知到所有的​​观察者​​函数。

​onGlobalStateChange​​:注册​​观察者​​函数 - 响应​​globalState​​变化,在​​globalState​​发生改变时触发该​​观察者​​函数。

​offGlobalStateChange​​:取消​​观察者​​函数 - 该实例不再响应​​globalState​​变化。

所以我们再次改造一下两个项目,首先是主应用的micros/index.js

import {  
registerMicroApps,
addGlobalUncaughtErrorHandler,
start,
initGlobalState // 新增
} from "qiankun";

const state = {}
const actions = initGlobalState(state);

export { actions }

以上新增了并导出了​​actions​​,然后去到​​login.vue​

import { actions } from "@/micros"; //新增

const login = () => {
if (username.value && password.value) {
store.commit("setToken", "123456");
// 新增
actions.setGlobalState({globalToken: "123456"});
router.push({path: "/"});
}
};

引入​​actions​​并新增了​​actions.setGlobalState​​方法

然后是子应用的​​main.js​

function render(props) {  
console.log("子应用render的参数", props)
// 新增
props.onGlobalStateChange((state, prevState) => {
// state: 变更后的状态; prev 变更前的状态
console.log("通信状态发生改变:", state, prevState);
// 这里监听到globalToken变化再更新store
store.commit('setToken', '123456') }, true);
// 挂载应用
instance = new Vue({
router,
store,
render: (h) => h(App),
}).$mount("#micro-app");}

在​​render​​方法中我们加上onGlobalStateChange,并且第二位参数置为true,这样微应用一启动的时候,我们马上就可以看到刚刚设置的​​globalToken​​:123456

好了已经改造完毕,我们刷新重新登录主应用然后点击微应用的菜单,可以看到微应用不需要再登录了,如下图:

微前端框架 qiankun 项目实战(一)--本地开发篇_css_09好像还是有点问题喔,微应用的菜单怎么展示出来了???

别怕,最后一步,留给亲爱的你去解决吧,思路就是在微应用中利用​​window.__POWERED_BY_QIANKUN__​​去判断是否通过qiankun启动的,是的话我们写个变量使用v-if将微应用的菜单和头部隐藏,不就完事了?微前端框架 qiankun 项目实战(一)--本地开发篇_生命周期_10微前端框架 qiankun 项目实战(一)--本地开发篇_生命周期_11