写在开头
微前端,现在也算是一个老生常谈的问题了。微前端的概念来源于微服务的架构理念,其核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用,它于2016年提出,主要为了解决 iframe
的各种潜在问题。✅
在当今的前端开发领域,随着业务复杂度的不断攀升以及团队规模的持续扩大,单体式前端应用架构逐渐暴露出诸多弊端。微前端架构应运而生,它旨在将一个大型的前端应用拆分成多个可独立开发、部署、运行的小型应用,这些小型应用能够协同工作,共同构建出功能丰富、体验流畅的完整系统。
当前主流的微前端方案如下:
- iframe:Web中的原生方案,也是微前端的起源,接入很简单,但是最大缺点就是通信很麻烦。
- single-spa:较早兴起的微前端框架,是后续很多微前端框架的奠基石。
- qiankun:基于
single-spa
,由阿里巴巴出品,国内比较主流的微前端方案,社区也很活跃,值得入手使用。 - micro-app:基于
Webcomponent
的微前端方案,由京东出品,不久前刚刚发布了v1.0
版本。不久前,小编刚好在掘金上看了官方发布的文章介绍,可以看看,传送门。 - EMP:基于
webpack 5 module federation
(模块联邦) 的微前端方案,由欢聚时代出品。 - 无界:腾讯推出的一款微前端解决方式。它是一种基于
Web Components + iframe
的全新微前端方案,继承iframe
的优点,补足iframe
的缺点,让iframe
焕发新生。
本次要分享的是关于微前端的 qiankun
方案相关的内容,请诸君按需食用哈。
先贴个官方文档,内容不多,自行浏览一遍哈:传送门。🚀🚀🚀
主应用
主应用在 qiankun
架构中扮演着 "容器" 与 "协调者" 的角色,负责整合、调度各个子应用资源,把控整体页面布局与路由逻辑。
这里小编以 Vue3
作为主应用框架的项目为例,先来初始化项目:
js
npm create vite@latest
小编选择的是 Vue3+TS
形式。
安装路由:
js
npm i vue-router -S
新建 router/index.ts
文件:
js
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
// 必须是history模式
history: createWebHistory(),
routes: [],
})
export default router;
在
qiankun
微前端框架中,主应用的路由模式选择会影响到子应用的加载和路由整合。虽然没有严格规定主应用不能使用createHashHistory
模式,但在一些场景下,createWebHistory
模式更适合与qiankun
配合使用。你也不想你的应用路径是这样子吧,/#/main/#/yd-vue2,看着就挺奇怪。🤡
路由与布局你可以根据自己的实际需求场景再进行扩展,这里就不多阐述啦。
主应用还必须安装 qiankun
:
js
npm i qiankun -S
进行子应用的注册,main.ts
文件:
js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
// 引入qiankun
import { registerMicroApps, start } from 'qiankun';
// 为了样式好看一点,使用element-plus,你可以自行选择
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
// 进行子应用的注册
registerMicroApps([
{
name: 'yd-vue2', // 取自子应用 package.json 文件中的name属性,必填
entry: '//localhost:3001', // 子应用端口
container: '#yd-container', // 子应用挂载的dom
activeRule: '/yd-vue2', // 子应用的路由入口
props: {} // 传给子应用的数据,可以传递一些主应用初始化全局性的数据,比如语言、全局配置,其他数据的传递后续咱们使用发布订阅的形式来完成
},
{
name: 'yd-react',
entry: '//localhost:3002',
container: '#yd-container',
activeRule: '/yd-react',
props: {}
},
{
name: 'yd-vue3',
entry: '//localhost:3003',
container: '#yd-container',
activeRule: '/yd-vue3',
props: {}
}
]);
start({
sandbox: {
//样式隔离
experimentalStyleIsolation: true
}
});
const app = createApp(App);
app.use(ElementPlus);
app.use(router);
app.mount('#app');
(如果你的子应用服务一开始还没有启动,可以先把子应用的注册逻辑注释了,等子应用一个一个启动再放开,否则,可能会有报错。🙈)
关于
start(opts?)
的opts
参数情况可以自行看看官方文档描述,没几个配置项,自行过一遍就行啦。😋
App.vue
文件:
js
<template>
<el-menu mode="horizontal">
<el-menu-item index="1"><router-link to="/yd-vue2">子应用(vue2)</router-link></el-menu-item>
<el-menu-item index="2"><router-link to="/yd-react">子应用(react)</router-link></el-menu-item>
<el-menu-item index="3"><router-link to="/yd-vue3">子应用(vue3)</router-link></el-menu-item>
</el-menu>
<h1>以下为子应用内容:</h1>
<div id="yd-container"></div>
</template>
这样子,主应用基本就完成了,接下来就需要将子应用给接入进来。
子应用
子应用的接入其实官网都有详细的介绍,可以自行先瞅瞅:传送门。
小编接下来会逐步介绍接入 Vue2
、React18
与 Vue3
的具体步骤,你可以做一个参考。😁
Vue2
初始化项目:
js
vue create your-project-name
修改 vue.config.js
:
js
const { defineConfig } = require('@vue/cli-service');
const { name } = require('./package.json');
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
port: 3001,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd',
// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
chunkLoadingGlobal: `webpackJsonp_${name}`,
},
},
})
上述,修改项目配置文件本质就做三件事情:
- 端口:子应用的端口与主应用注册的端口保持一致。
- 跨域:子应用的代理服务允许跨域,否则,当主应用加载子应用的静态资源时会产生跨域的情况。
- 命名空间:给每个子应用的代码块加载逻辑创建了一个独立的命名空间。
在 Webpack 5 中,为了更好地管理代码块(chunk)的加载,尤其是在微前端环境下避免多个子应用之间代码块加载的冲突,引入了
chunkLoadingGlobal
配置来替换之前的jsonpFunction
。
启动项目:
js
npm run serve
新建 src/public-path.js
文件:
js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
然后,再来修改 main.js
文件,qiankun
要求子应用在其入口文件中正确导出特定的生命周期函数,其实就是要导出这三个函数:bootstrap/mount/unmount
。
js
import './public-path.js';
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
let instance = null;
function render(props = {}) {
console.log('子应用(yd-vue2)', props);
const { container } = props;
instance = new Vue({
render: h => h(App),
}).$mount(container ? container.querySelector('#app') : '#app')
}
// 非qiankun环境下,也能独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
render(props);
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}
三个生命周期函数的作用可以看看小编写的注释,也可以瞧瞧文档的描述。而其他代码的调整主要是为了让这个项目在非 qiankun
的环境下也能独立运行。
还有,最开头引入 ./public-path.js
文件,进行路径的重写,具体解释如下:
-
window.__POWERED_BY_QIANKUN__
是 qiankun 框架在子应用运行环境中设置的一个标识变量。这个变量用于判断子应用是否是在 qiankun 微前端环境下运行。 -
__webpack_public_path__
是 Webpack 用于指定公共资源加载路径的变量。当子应用在 qiankun 环境下运行时,可以通过设置:__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
子应用的资源加载路径会被动态地修改为 qiankun 框架指定的路径(
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
是 qiankun 注入的公共路径变量)。这样可以确保子应用的资源(如 JavaScript 文件、CSS 文件等)能够正确地被主应用加载,避免因为资源加载路径错误而导致子应用无法正常运行。
修改后,子应用就算接入到主应用中了,如何来验证呢❓
你可以在主应用中去访问子应用注册的路由入口:
React18
初始化项目:
js
create-react-app my-app --template typescript
(Vue 的小伙伴可能需要全局先装一下 React 的脚手架,npm install -g create-react-app
)
安装 react-app-rewired 依赖,它的作用是对 Webpack 的配置进行重写:
js
npm install react-app-rewired -D
重写 Webpack 的配置也可以使用 @rescripts/cli 依赖。
但是,由于小编本次项目使用的是
React18
版本,它的react-scripts
依赖版本到了5.x.x
,而@rescripts/cli
要求其版本只能是 "2~4" (react-scripts@"2 - 4" from @rescripts/cli@0.0.16
),所以,小编才采用了react-app-rewired
依赖。(其实你也可以使用
--force
强制忽略进行安装,但是还是不推荐。💀npm install @rescripts/cli --force
)它们俩的配置文件命名不同:
@rescripts/cli
:.rescriptsrc.js
react-app-rewired
:config-overrides.js
但它们两者都用于在基于
create-react-app
构建的项目中自定义 Webpack 配置,只是实现方式和侧重点略有不同。
根目录下新建 config-overrides.js
文件:
js
const { name } = require('./package.json');
module.exports = {
webpack: (config) => {
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
// webpack 5 需要把 jsonpFunction 替换成 chunkLoadingGlobal
config.output.chunkLoadingGlobal = `webpackJsonp_${name}`;
config.output.globalObject = 'window';
return config;
},
devServer: (_) => {
const config = _;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
config.historyApiFallback = true;
config.hot = true;
config.watchContentBase = false;
config.liveReload = false;
return config;
},
};
修改 package.json
文件,先仅修改 start
命令玩玩就行:
js
{
"scripts": {
// "start": "react-scripts start",
"start": "react-app-rewired start",
}
}
修改项目端口号,根目录下新建 .env
文件:
js
PORT=3002
(使用 create-react-app
构建的 React 项目中,支持直接通过 .env
文件来配置项目环境变量,如端口号等等)
启动项目:
js
npm start
新建 src/public-path.js
文件:
js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
修改项目入口文件(index.tsx
),一样导出三个生命周期函数:
js
import './public-path.js'
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
let root: ReactDOM.Root | null = null;
function render(props: any) {
console.log('子应用(yd-react)', props);
const { container } = props;
root = ReactDOM.createRoot(
container ? container.querySelector('#root') : document.querySelector('#root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
export async function bootstrap() {}
export async function mount(props: any) {
render(props);
}
export async function unmount() {
root!.unmount();
}
reportWebVitals();
这就没啥可说的了,一样的操作,导出生命周期函数,并且将其改造成非 qiankun 下也能独立运行的形式。
如果你出现了主应用查看子应用时,静态资源(图片、字体)有报错或者跨域行为,如:
你可以检查配置文件是否正确配置了跨域,或者是子应用的入口文件最开头是否引入了
public-path.js
文件。正确的静态资源文件引入时,应当是带有子应用的前缀,如下图。👇👇👇
最后,记得去主应用中访问子应用注册的路由入口进行验证。
Vue3
初始化项目:
js
npm create vite@latest
安装 vite-plugin-qiankun 依赖,它能帮助咱们快速接入 qiankun 环境:
js
npm install vite-plugin-qiankun -D
修改项目配置文件(vite.config.ts
):
js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import qiankun from "vite-plugin-qiankun";
import { name } from './package.json';
export default defineConfig({
plugins: [
vue(),
// 接入qiankun环境
qiankun(name, {
useDevMode: true,
}),
],
server: {
port: 3003,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
});
启动项目:
js
npm run dev
修改入口文件(main.ts
):
js
import { createApp } from 'vue'
import App from './App.vue'
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper';
let app: any = undefined;
const render = (props: any) => {
console.log('子应用(yd-vue3)', props);
const { container } = props;
app = createApp(App)
app.mount(container ? container.querySelector('#app') : '#app')
}
const initQianKun = () => {
renderWithQiankun({
bootstrap() {},
mount(props) {
render(props);
},
unmount() {
app.unmount()
},
update() {}
})
}
qiankunWindow.__POWERED_BY_QIANKUN__ ? initQianKun() : render({});
Vue3 在插件的配合下,接入 qiankun 环境简直就是手到擒来啦~👻
应用之间的通信
微前端的架构很好的解决了传统单体前端应用面临的各种问题,不过,由于将整个应用拆分成多个小型、相对独立的子应用。然而,拆分后的各个子应用并非孤立存在,它们之间往往需要进行数据交互与通信,以保证业务流程的连贯性与整体性。
但是呢,在绝大多数场景下,咱们着重考量的往往是主应用与子应用间的通信事宜。要知道,子应用通常是以路由作为关键区分基准的,基于此特性,子应用彼此间直接通信的情况较为少见。即便确实存在这类需求,大概率也会借助主应用来搭建一座临时 "沟通桥梁",以此实现信息交互。
既然如此,接下来摆在我们面前、亟待深入探讨的关键问题便是:主应用与子应用究竟该如何高效、精准地进行通信呢❓
qiankun
的架构中提供了 Action 的通信方式,但它比较适合业务划分清晰,项目情况比较简单的微前端应用。在复杂一点的业务系统中可能就不是很好用了,而且好像听说官方要弃用了❓反正这次咱们就不使用了,也不讲解,感兴趣的小伙伴可以自行上官网了解哈。😋
这次咱们通过 "发布/订阅" 的设计模式来自行编写一个通讯模块,它基于 window
对象。🌐
在主应用中,新建 qiankun.js
文件,直接贴上代码瞧瞧:
js
if (window.__POWERED_BY_QIANKUN__) {
// 仅在子应用下执行
window['__webpack_public_path__'] = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
window._QIANKUN_YD = window._QIANKUN_YD || {
// 通信
event: (() => {
class Emitter {
constructor() {
this.events = {};
this.watchs = [];
}
add(eventName, callback, count) {
if (!eventName || typeof callback !== "function") return;
if (!this.events[eventName]) {
this.events[eventName] = [];
this.events[eventName].push({ callback, count });
} else {
const hasExist = this.events[eventName].some(
(item) => item.callback === callback && item.count === count
);
!hasExist && this.events[eventName].push({ callback, count });
}
}
emit(...args) {
const [eventName, ...restArgs] = args;
const callbacks = this.events[eventName] || [];
if (eventName && this.watchs.length > 0) {
this.watchs.forEach((item) => {
item.apply(this, [eventName, ...restArgs]);
});
}
if (eventName && callbacks.length > 0) {
callbacks.forEach(({ callback, count }) => {
callback.apply(this, [eventName, ...restArgs]);
count && this.off(eventName, callback);
});
}
}
on(eventName, callback) {
this.add(eventName, callback, 0);
}
once(eventName, callback) {
this.add(eventName, callback, 1);
}
off(eventName, callback) {
const callbacks = this.events[eventName] || [];
if (callbacks.legnth <= 0) return;
if (!callback) this.events[eventName] = [];
callbacks.forEach((item, index) => {
if (item.callback === callback) {
callbacks.splice(index, 1);
}
});
}
watch(callback) {
if (typeof fn !== 'function') return;
this.watchs.push(callback);
}
}
return new Emitter();
})(),
// 数据共享(具备持久化能力)
store: (() => {})(),
};
大概五十行代码,虽然没有写注释,但是,俺相信你应该能看得懂哈😁,很标准的一个发布/订阅的设计模式。
然后,在主应用的入口文件( main.ts
)中引入:
js
import './qiankun.js';
import { createApp } from 'vue'
// ...
咱们再来假设一个场景,如让子应用主动与父应用通信 ,具体场景可以是子应用去打开主应用的全局 loading
交互。
咱们先来主应用中订阅 loading
事件,主应用 App.vue
文件:
js
<script lang="ts" setup>
import { onMounted } from 'vue';
import { ElLoading } from 'element-plus';
onMounted(() => {
// 订阅loading事件
window._QIANKUN_YD.event.on('loading', () => {
const loadingInstance = ElLoading.service({ fullscreen: true });
setTimeout(() => {
loadingInstance.close()
}, 3000);
})
})
</script>
然后,子应用中(以 yd-vue2
为例),同样新建 qiankun.js
文件,文件内容是一样的。
在子应用的入口文件( main.js
)中引入:
js
import './qiankun.js';
// import './public-path.js'; // public-path.js文件的内容也整合在qiankun.js文件中了
import Vue from 'vue'
// ...
子应用 App.vue
文件发布 loading
事件去触发主应用的订阅:
js
<template>
<div id="app">
<button @click="openLoading">打开全局的loading</button>
</div>
</template>
<script>
export default {
methods: {
openLoading() {
// 发布loading事件
window._QIANKUN_YD.event.emit('loading')
}
}
}
</script>
效果:
这样子就完成了子应用主动与主应用之间的通信了,同样,如果是主应用要主动与子应用通信,反过来操作一遍就行啦。😝
发布订阅模式在微前端 qiankun 架构下,能成功解耦各应用间直接依赖,以松散灵活结构实现多元技术栈应用高效通信,契合复杂业务系统持续迭代、扩展需求,助力前端项目高效协同、稳健运行。无论是简单数据传递,还是复杂业务联动,都能依托该通信方案达成无缝交互,发挥微前端架构最大效能。当然,这些都是后话了,反正,灵活、方便、不受限技术栈就是咱们的最终追求。😁
_QIANKUN_YD.event
相关 API 说明:
语法 | 说明 |
---|---|
window._QIANKUN_YD.event.on(eventName, cb) |
订阅 |
window._QIANKUN_YD.event.one(eventName, cb) |
订阅,仅执行一次 |
window._QIANKUN_YD.event.emit(eventName, arg1, arg2, ...) |
发布 |
window._QIANKUN_YD.event.off(eventName, cb) |
解除订阅 |
window._QIANKUN_YD.event.watch(eventName, cb) |
监听所有的订阅事件 |
数据共享(持久化)
不过,你不会以为这就完了吧?🙊
现在咱们再来考虑一个场景,例如:用户在主应用中进行登录,紧接着跳转去访问子应用。按常理来讲,此时子应用可不该让用户再进行登录了,而应当无缝衔接,直接沿用主应用的登录状态才对。说白了,这背后的核心操作就是要把主应用登录成功后获取到的 "身份令牌"------ 也就是 Token,巧妙地传递给子应用,好让子应用能揣着它大摇大摆地去访问接口,畅通无阻地获取所需数据。
这个场景在微前端架构中属于非常常见的情况了,但是,面对这个问题场景,咱们来琢磨琢磨前面讲过的发布/订阅的设计模式通信能满足这种场景吗❓
好像不太行是吧?🙈🙈🙈
这个时候就需要来考虑新的形式了,有没有注意到小编在 qiankun.js
文件中还留一个后手❗store
就是用来解决这类场景的,并且它也具备持久化的能力。
直接贴代码来瞧瞧:
js
// ...
window._QIANKUN_YD = window._QIANKUN_YD || {
event: (() => {
// ...
})(),
store: (() => {
class Storage {
constructor() {
// 持久化
this.storage = generatorStorage(window.localStorage);
// this.storage = generatorStorage(window.sessionStorage);
// 全局数据,生命周期同window
this.global = {};
}
set(key, value) {
return this.storage.set(key, value);
}
get(key) {
return this.storage.get(key);
}
remove(key) {
this.storage.remove(key);
}
clear() {
this.storage.clear();
}
setGlobalData(key, value) {
this.global[key] = value;
}
getGlobalData(key) {
return this.global[key];
}
}
return new Storage();
})(),
};
function generatorStorage(storage) {
const prefix = (key) => `__qiankun_yd_${key}`;
return {
set(key, value) {
const valueFormat = (value) => {
if (
["[object Array]", "[object Object]"].includes(
Object.prototype.toString.call(value)
)
) {
return JSON.stringify(value);
} else {
return value;
}
};
storage.setItem(prefix(key), valueFormat(value));
},
get(key) {
try {
return JSON.parse(storage.getItem(prefix(key)));
} catch {
return storage.getItem(prefix(key));
}
},
remove(key) {
storage.removeItem(prefix(key));
},
clear() {
storage.clear();
},
};
}
在主应用中模拟登陆情况,App.vue
文件:
js
<template>
<el-menu
mode="horizontal"
>
<el-menu-item index="1"><router-link to="/yd-vue2">子应用(vue2)</router-link></el-menu-item>
<el-menu-item index="2"><router-link to="/yd-react">子应用(react)</router-link></el-menu-item>
<el-menu-item index="3"><router-link to="/yd-vue3">子应用(vue3)</router-link></el-menu-item>
</el-menu>
<h1>主应用内容:</h1>
<button @click="login">登录</button>
<h1>以下为子应用内容:</h1>
<div id="yd-container"></div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue'
import { ElLoading } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
function login() {
const loadingInstance = ElLoading.service({ fullscreen: true });
setTimeout(() => {
loadingInstance.close();
// 登录成功,储存token
window._QIANKUN_YD.store.set('token', '我是一个token');
// 跳转去访问子应用
router.push('/yd-vue2');
}, 1000);
}
</script>
在子应用的入口文件中获取 Token 用于后续的请求,main.js
文件:
js
import './qiankun';
import Vue from 'vue'
import App from './App.vue'
// 获取主应用的token
const token = window._QIANKUN_YD.store.get('token');
console.log('[yd-vue2] 这是从主应用中获取的token:', token);
// ...
在子应用入口文件中能正确获取到主应用的 Token 基本就算大功告成🥳。当然,在日常的开发里,多数情形下咱们并不会在 main.js
文件里使用到 Token,你可以在你自个封装的 axios
、fetch
或是其他类似的请求处理模块的拦截器中,同样去获取主应用的 Token,并将其携带至实际的请求中去。
这应该不难理解哈,原理挺简单的,我们借助了 window.localStorage
API 来实现数据共享,也侧面到达一个主应用与子应用通信的效果。并且 window.localStorage
API 具备持久化能力,在项目实践中,如用户偏好设置、历史记录等需要长期稳固存储数据的场景,咱们也可以使用这个 _QIANKUN_YD.store
来完成。
_QIANKUN_YD.store
相关 API 说明:
语法 | 说明 |
---|---|
window._QIANKUN_YD.store.set(key, value) |
储存数据,具备持久化 |
window._QIANKUN_YD.store.get(key) |
获取数据 |
window._QIANKUN_YD.store.remove(key) |
清除单项数据 |
window._QIANKUN_YD.store.clear() |
清除全部数据 |
window._QIANKUN_YD.store.setGlobalData(key, value) |
储存全局数据,生命周期同window |
window._QIANKUN_YD.store.getGlobalData(key, value) |
获取全局数据 |
至此,本篇文章就写完啦,撒花撒花。