主题
qiankun 微前端实践
什么是微前端
微前端类比于后端的微服务,它的对立面为单体应用、巨石应用,即一个站点或一个应用的代码都在一个项目里面,每次的一个小的代码更改都需要重新集成打包部署整个项目,随着时间的推移,参与项目的人员更替变多,会带来各种问题:代码里增加,打包时间增长,维护困难,不便于技术升级等 微前端的出现解决了这一些问题,微前端可以将巨石应用拆分成多个可以独立开发、独立部署、独立运行的小应用,然后将这些个小应用通过技术手段组装成一个大的应用,但是用户使用体验上是无感知的,并没有一个使用上的割裂感,开发中即使更改了某个页面逻辑, 也只用打包这一个页面所在的应用,不会对其他页面造成影响
微前端的优缺点
优点
技术栈无关
:每个应用仅需关注自身的技术栈,不需要因为技术选型不同而影响其他应用独立开发、独立部署
:每个微应用仓库独立,可以独立开发、独立部署增量升级
:可以在不影响旧系统的情况下,逐步迁移旧系统,是一种非常好的实施渐进式重构的手段和策略独立运行时
:每个微应用之间状态隔离,运行时状态不共享 缺点更多的资源
:微前端的每一个应用(基座+子应用)都需要独立集成独立部署,因此需要更多的服务资源配置更多
:每个微应用都可以独立访问,若是不仅过配置,可以直接访问子应用,因此在生产上,若不想子应用被直接访问,需要在网关或者 nginx 上做一个重定向的操作,访问了某一个微应用的域名需要重定向到基座,通过基座加载子应用
微前端实现的方案
iframe
webComponents
single-spa
qiankun
qiankun 的基本原理
qiankun
是对signal-spa
的封装,并解决了样式和js
隔离(沙箱机制)的问题, 还让微前端变得更易于上手,基座应用通过qiankun
内部的方法监听路由路径的变化,当检测到基座中注册的某个子应用路由匹配规则能匹配上当前路由时,会以entry
为入口用fetch
加载子应用html
,然后分析 html, 注释内部的link-->css
, script-->js
脚本资源,换做import-html-entry
来加载这些资源,放到沙箱中执行(css 为影子 DOM 或样式前缀,js 为快照沙箱或者 proxy 沙箱),更对细节见掘金 微前端原理分析
基座应用
vue@3.4.12
vite@5.2.0
vue-router@4.3.2
pinia@2.7.1
element-plush@2.7.3
rxjs@7.8.1
qiankun@2.10.16
> qiankun-base
1.主要功能
mock
模拟动态路由,通过接口请求路由表- 根据异步路由表生成子应用注册所需配置,在生成配置时候将每一个子应用的异步路由表以
props
的方式传递给子应用, 子应用的异步路由逻辑在子应用内部完成 - 结合异步路由以及本地路由,生成左侧系统菜单,并展示
- 承载一些系统级别页面,或者无需权限的页面,例如:404、登录、用户详情等
- 系统整体架构的页面布局
- 封装一些系统级别通用的公共方法,在注册子应用的时候暴露给子应用
- 暴露组件库给子应用(若有需要的话),避免子应用再次安装
- 暴露通用组件给子应用
- 通过
rxjs
来进行应用之间通信,暴露rxjs
实例给子应用 pinia
可用于存储一些基座应用自身业务数据,以及整个系统通用数据(登录信息、权限信息、菜单信息等)
2.代码细节
- 基座应用入口文件
src/main.js
js
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import router from "./router/index.js";
import "element-plus/dist/index.css";
import "./style.css";
import App from "./App.vue";
const pinia = createPinia();
import "./permission.js"; // 权限入口文件
const app = createApp(App);
app.use(ElementPlus);
app.use(router);
app.use(pinia);
app.mount("#app");
- 权限控制入口文件
src/permission.js
js
import { userStore } from "@/store/index";
import router from "@/router/index";
import { registerMicroApps } from "qiankun";
import pager from "@/utils/rxjs";
pager.subscribe((v) => {
// 在主应用注册呼机监听器,这里可以监听到其他应用的广播
console.log(`[qiankun-base] 监听到子应用${v.from}发来消息:`, v);
// store.xxxx('app/setToken', v.token) // 这里处理主应用监听到改变后的逻辑
});
router.beforeEach(async (to, from, next) => {
const userstore = userStore();
/* TODO:这里逻辑为跳转路由时候检查是否登录,需要自己实现, 这里是伪代码*/
if (!userstore.isLoggedIn) {
await userstore.registerUser();
await userstore.registryMenu();
// 拿到菜单, 注册应用
const apps = getMicorApps(userstore.menuList, userstore);
registerMicroApps(apps);
}
/* 解决子应用内操作路由切换后, 再回来基座应用切换路由会导致history.state.back 错乱, 导致undefined */
if (
window.history.state === null ||
window.history.state.back !== from.path
) {
history.replaceState(
{
back: from.path,
current: to.path,
forward: null,
position: NaN,
replaced: false,
scroll: null
},
to.path
);
}
next();
});
function getMicorApps(menuList, userstore, loader) {
const microApps = [];
function loader(load) {
userstore.$patch((state) => {
state.load = load;
});
}
menuList.forEach((item) => {
microApps.push({
name: item.url.split("/")[1],
entry: item.path,
container: "#sub-app",
activeRule: item.url,
loader,
props: {
routerData: item.children,
commonStore: userstore,
pager
}
});
});
return microApps;
}
- 布局页面,因为涉及到基座也有自己的路由页面,布局页面刚好在路由页面内,需要等待路由页面挂载完成后再启动
qiankun
src/Layout/index.vue
html
<template>
<el-container class="layout-container">
<!-- 左侧菜单栏 -->
<el-aside width="200px">
<div class="logo">QianKun微前端Demo</div>
<el-scrollbar class="customer-menu-scroll-container">
<custom-menu></custom-menu>
</el-scrollbar>
</el-aside>
<!-- 右侧内容区域 -->
<el-container>
<!-- 顶部header -->
<el-header style="text-align: right; font-size: 12px">
<div class="toolbar">
<span>{{ store.userInfo.name }}</span>
</div>
</el-header>
<!-- 页面主要内容区域 -->
<!-- v-loading 为子应用是否在加载中的状态 -->
<el-main v-loading="store.load">
<!-- qiankun子应用挂载容器 -->
<div id="sub-app"></div>
<!-- 基座应用自身路由页面容器 -->
<router-view></router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { onMounted } from "vue";
import { start } from "qiankun";
import customMenu from "./Menu.vue";
import { userStore } from "@/store/index";
const store = userStore();
// 需要等理由页面加载完成后再启动qiankun
// https://qiankun.umijs.org/zh/faq#application-died-in-status-not_mounted-target-container-with-container-not-existed-while-xxx-loading
onMounted(() => {
if (!window.qiankunStarted) {
window.qiankunStarted = true;
start();
}
});
</script>
<style scoped>
/* .....略...... */
</style>
- 基座路由配置
js
import { createWebHistory, createRouter } from "vue-router";
import Layout from "@/Layout/index.vue";
export const localRoutes = [
{
path: "/",
name: "index",
redirect: "home",
component: Layout,
meta: {
name: "本地路由"
},
children: [
{
path: "/home",
name: "home",
component: () => import("@/views/Home.vue"),
meta: {
name: "首页"
}
}
// ...
]
}
];
const routes = [
...localRoutes, // 本地正常路由
// TODO:子应用中404页面需要用到这个路由
{
path: "/404",
name: "notFound",
component: () => import("@/views/404.vue"),
meta: {
name: "404"
}
},
//TODO: 注意:子应用需要根据这个路由进行匹配
{
path: "/:catchAll(.*)",
component: Layout
}
];
const router = createRouter({
history: createWebHistory("/"),
routes
});
export default router;
- 应用见通信呼机,替代乾坤自身的
onGlobalStateChange
setGlobalState
initGlobalState
js
import { Subject } from "rxjs"; // 按需引入减少依赖包大小
const pager = new Subject();
export default pager; // 将pager再子应用注册时候传递给子应用
子应用
- 不限技术栈
1.主要功能
- 接收基座传过来的路由表,生成动态路由,生成操作点权限表,用于页面权限控制
- 配置 404 路由,当进入这个路由需要重定向到基座应用的 404 路由页面
- 接收基座传递来的全局组件、全局 utils 方法、应用见通信呼机等等
2.代码细节(以 vue2 + @vue/cli@5.x为例)
vite 与 webpack 的处理方法不一样,主要是打包结果的webpack_public_path, 注意 vite 开发与生产的差异性
src/main.js
js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import { getSyncRouter } from "@/utils/index";
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
Vue.config.productionTip = false;
let instance = null;
function render(props = {}) {
const { container } = props;
instance = new Vue({
router,
store,
render: (h) => h(App)
}).$mount(container ? container.querySelector("#app") : "#app");
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap(props) {
const allRouter = getSyncRouter(props.routerData, props.name);
allRouter.map((r) => router.addRoute(r)); // 添加动态路由表
/* Vue2.x中,可以将一些通用的东西放到Vue.prototype.$xxx上,以便通用 */
Vue.prototype.$pager = props.pager; // 将呼机挂载到vue原型上
// 监听呼机
props.pager.subscribe((v) => {
// 在子应用注册呼机监听器,这里可以监听到其他应用的广播
console.log(`[sub-app2] 监听到子应用${v.from}发来消息:`, v);
// store.dispatch('app/setToken', v.token) // 在子应用中监听到其他应用广播的消息后处理逻辑
store.commit("SET_MSG", v.data);
});
}
export async function mount(props) {
console.log("[vue-app2] props from main framework", props);
render(props);
}
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = "";
instance = null;
}
- 子应用中给其他子应用或基座应用发消息(传递数据)
通过呼机的next
方法触发,自身并不会收到自身发出的信号,基于发布订阅模式
js
pager.next({
/* ...传递的数据.. */
});
- 子应用 404 页面跳转到基座应用 404 路由
js
router.beforeEach((to, from, next) => {
const allRouter = router.getRoutes();
if (!allRouter.some((i) => i.path === to.path)) {
window.history.replaceState({}, false, "/404");
return;
}
next();
});