上手 qiankun:主应用/子应用改造与四种通讯方案
微前端第三课 · qiankun 实战
课程地图
| 模块 | 内容 | 学习产出 |
|---|---|---|
| 主应用改造 | 安装 qiankun,registerMicroApps 注册子应用(name / activeRule / container / entry) | 主应用三行代码启动微前端 |
| 子应用改造 | 暴露 bootstrap / mount / unmount 三个钩子 + UMD 打包(Vue / React 两套配置) | 子应用可以被主应用接管 |
| 容器踩坑 | 主应用容器 vs 子应用自身目标容器,两种加载方案的容器最佳实践 | 解决"页面出不来 / 样式丢失" |
| 路由前缀 | 用 props 把 activeRule 动态下发给子应用,genRouter(baseURL) 动态建路由 | 主应用改前缀,子应用不用动 |
| 沙箱调整 | experimentalStyleIsolation 样式隔离的原理与局限 | 知道样式为什么被"加前缀" |
| 通讯四方案 | initGlobalState / Pinia / EventBus / 直接挂 window | 父子应用双向传值 |
INFO
本课一句话目标:把前两课手写的"简化版 qiankun"换成真 qiankun,走一遍真实的接入流程——主应用怎么配、子应用怎么改、坑在哪、数据怎么传。
开篇:原理吃透了,该用真框架了
前两节课我们洞悉了微前端的原理:动态加载、路由切换、沙箱、生命周期,甚至手搓了一个"简化版 qiankun"。但实战中我们几乎没机会自己开发一套微前端——一般直接用成熟框架。所以这节课就讲 qiankun(乾坤)这个框架的使用,以及本节实战中总结的一些技巧。
INFO
学到这里你的优势:因为前两课亲手实现过原理,这节课看到的每个 API 你都知道它背后在做什么。 比如 experimentalStyleIsolation,就是第二课我们自己做的"给样式加独一无二父元素前缀"。
微前端天然分为主应用和子应用。改造流程分两步:先调整主应用(安装、注册子应用、启动),再调整子应用(暴露生命周期钩子、UMD 打包)。
主应用改造:注册子应用的四个必填配置
2.1 安装 qiankun
本节演示环境里已经装好了,你自己只需要:
npm install qiankun // 主应用安装然后从 qiankun 里引入注册子应用的方法 registerMicroApps:
2.2 注册子应用:数组里的每个对象就是一套规则
调用 registerMicroApps,传入一个数组。数组里每个元素是一个子应用对象,包含它的具体规则。要注册一个子应用,四个配置是必须的:
| 配置 | 作用 |
|---|---|
| name | 子应用的名字,必须有 |
| activeRule | 激活规则——地址切换到什么时候显示这个子应用(如 /app1) |
| container | 挂载目标——子应用挂到主应用的哪个容器(如主应用里 id 为 sun 的 div) |
| entry | 子应用的资源地址,支持两种写法(见下) |
2.3 entry 的两种写法
写法一:直接给 JS 和 CSS 地址。把子应用打包后的产物丢到服务器上(就像平常项目打包交给后端上线),把 JS/CSS 地址填进来。地址可以去掉前面的 http://,qiankun 会自动拼接;有多个 JS 就都写进数组:
// 主应用 main.js —— 方式一:加载子应用的 JS + CSS
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'app1', // ① 子应用名
activeRule: '/app1', // ② 激活规则:地址切到 /app1 时显示
container: '#sun', // ③ 挂载目标:主应用里 id 为 sun 的 div
entry: { // ④ 资源地址:JS + CSS 方案
scripts: ['//localhost:8080/app1/js/app.xxx.js'], // 可去掉 http://
styles: ['//localhost:8080/app1/css/app.xxx.css']
}
},
{
name: 'app2',
activeRule: '/app2',
container: '#sun',
entry: { // 多个 JS 就都写在数组里
scripts: ['//localhost:3001/static/js/bundle.js'],
styles: ['//localhost:3001/static/css/main.css']
}
}
])写法二:直接给在线地址。如果子应用除了当子应用,也能独立上线、独立访问,那就直接把它的线上页面地址填进来。比如第二个子应用是 React 项目,本地 npm run dev 跑起来后,直接把开发地址填上:
// 方式二:在线地址方案(子应用可独立访问)
{
name: 'app2',
activeRule: '/app2',
container: '#sun',
entry: '//localhost:3001' // React 子应用的本地开发地址(在线地址)
}2.4 start 的时机:必须在主应用挂载完成之后
注册子应用只是"登记",还没启动微前端。最后一步是调用 start() 开启。但这里有个很多新手会踩的坑——如果注册完立刻 start:
registerMicroApps([...])
start() // ❌ 有风险:此时主应用可能还没挂载完成,容器 #sun 还没渲染出来万一主应用挂载要很久,start 时容器还没渲染,子应用就挂不上去。所以实战中保险的做法是在主应用根组件的 onMounted 之后调用 start,确保 id 为 sun 的 div 已经存在:
// 主应用 App.vue —— 推荐写法
import { onMounted } from 'vue'
import { start } from 'qiankun'
export default {
setup() {
onMounted(() => {
start() // ✅ 主应用渲染完成、容器就绪后再启动
})
}
}WARNING
实战注意:不要在主应用模块顶层直接 start()。 你没法确保主应用挂载完成,容器还没渲染出来就启动,子应用一定挂不上去。
子应用改造:生命周期钩子 + UMD 打包
主应用改完了,接下来改子应用。为什么要写生命周期钩子?其实第二课就做过类似操作——为了方便主应用控制子应用的挂载和卸载:子应用给主应用一个 mount 方法,主应用调用它来挂载;再给一个 unmount 方法,主应用调用它来卸载。
3.1 qiankun 要求的三个生命周期钩子
qiankun 要求子应用必须通过 export 暴露三个生命周期钩子,缺一不可:
| 钩子 | 触发时机 |
|---|---|
| bootstrap | 只会执行一次(首次进入时),后面再次切换到该应用不再触发 |
| mount | 每次切进来都会触发 |
| unmount | 卸载(切走)时触发 |
Vue 3 子应用示例:
// 子应用 src/main.js —— Vue 3 版生命周期钩子
import { createApp } from 'vue'
import App from './App.vue'
let app = null
// bootstrap:只执行一次(首次进入)
export async function bootstrap() {
console.log('app1 bootstrap')
}
// mount:每次切进来都执行,把 Vue 实例挂到容器
export async function mount(props) {
app = createApp(App)
app.mount(props.container) // 挂载到主应用指定的容器
}
// unmount:切走时执行,销毁实例
export async function unmount() {
app && app.unmount()
app = null
}React 子应用同理:它的启动页面是 index.js,把 bootstrap / mount / unmount 三个方法分别暴露出去即可。
3.2 UMD 打包:qiankun 的硬性要求
配合生命周期钩子方案,必须进行 UMD 打包(qiankun 硬性要求)。UMD 打包和 Vue 还是 React 无关,只取决于你的项目是 Webpack 打包还是 Vite 打包,按对应工具修改打包配置。
- Vue 2 + Webpack:像改普通 webpack 配置一样,设置 output 的 library 和 libraryTarget 为 umd;
- Vue 3 + Vite:用 Vite 的 lib 模式(第二课已演示过);
- React(create-react-app 创建):比较特殊,见下。
React 项目怎么改 UMD 打包(create-react-app 创建的项目)
用 npx create-react-app 创建的项目没有地方给你改打包配置(就像 Vue 2 + Webpack 一样被隐藏了),需要额外装一个库:@craco/craco。
第一步:安装并把启动指令全部替换成 craco:
npm install @craco/craco
// package.json —— scripts 全部换成 craco
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test"
}第二步:写一份 craco.config.js,说明怎么修改 webpack 的打包配置。改成 UMD 核心就两点——打包名字(library)和打包目标(libraryTarget: 'umd'):
// 子应用 craco.config.js —— React 项目改成 UMD 打包
module.exports = {
webpack: {
configure(config) {
config.output.library = 'app2' // 打包名字:挂到 window 上的名字
config.output.libraryTarget = 'umd' // 打包目标:UMD
// 下面两项可加可不加(可去掉)
config.output.globalObject = 'window'
config.output.chunkFilename = '[name].chunk.js'
return config
}
}
}3.3 一个特殊场景:Vue 3 + Vite 的子应用很难用"开发地址"
Vue 3 项目的开发模式很难改成加载 UMD:就算在 vite.config.js 里把打包模式改成 UMD,npm run dev 跑开发模式时依然不走 UMD(只有 build 打包时才走)。本节也没找到让开发模式走 UMD 的方案。
所以对于 Vue 3 + Vite 子应用,做法是:在 node 服务器上准备一个 HTML,这个 HTML 加载打包后的 JS 和 CSS。在服务器代码里定义一个接口(如 /view-app),访问它就返回这个 HTML——浏览器加载这个 HTML,HTML 再去加载 JS/CSS。这不就是一个"在线地址"了吗?
// node 服务器 server.js —— 为 Vue3+Vite 子应用提供"线上地址"
const http = require('http')
const fs = require('fs')
// 打包后的子应用 HTML:内部用 <script> 和 <link> 加载打包后的 JS / CSS
const app1Html = fs.readFileSync('./app1/index.html', 'utf-8')
http.createServer((req, res) => {
if (req.url.startsWith('/view-app')) { // 访问这个接口返回 HTML
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(app1Html)
return
}
// ... 其他静态资源(JS / CSS)的路由
}).listen(8080)事实上,正常项目上线就是这样工作的:打包后的 HTML 交给服务器,浏览器请求时返回 HTML,HTML 再加载 JS/CSS。所以这个方案就是"模拟线上"。
INFO
小结:子应用改造就两件事——① 暴露三个生命周期钩子;② 按项目工具改成 UMD 打包(React 走 @craco/craco,Vue 走对应配置)。
踩坑专题:容器到底怎么填
本节演示到一半,发现页面不出来、报 301、样式丢失——问题就出在容器的填法上。先明确两个容易混淆的概念:
| 概念 | 是什么 |
|---|---|
| 主应用容器 | 注册子应用时填的 container,指向主应用里的某个 div(如 #sun) |
| 子应用自身目标容器 | 子应用挂载时说明的容器(如子应用挂到 id 为 sun 的 div 上)。这个 div 得有人提前准备好 |
关键是:**这个"子应用自身目标容器"(#sun)到底写在哪?**答案取决于你用的是哪种加载方案。
4.1 方案一(JS + CSS):把子应用目标容器写进"HTML 项"
用 JS+CSS 方案加载子应用时,qiankun 支持 HTML 配置项。最佳实践:先准备一个主应用容器,再在主应用容器里用 HTML 说明子应用自身目标容器:
// 主应用 index.html —— JS+CSS 方案的最佳实践
// 容器 #sun 里再写一层子应用的挂载目标 #app1
<div id="sun">
<div id="app1"></div> <!-- 子应用自身目标容器(HTML 项) -->
</div>// 注册时:container 用主应用容器,子应用目标写在 HTML 里
{
name: 'app1',
activeRule: '/app1',
container: '#sun',
entry: {
scripts: ['//localhost:8080/app1/js/app.xxx.js'],
styles: ['//localhost:8080/app1/css/app.xxx.css'],
html: '<div id="app1"></div>' // 子应用挂载目标,放在 HTML 项里
}
}4.2 方案二(在线地址):把子应用目标容器直接写进主应用容器里
在线地址方案是直接写一个字符串,没有 HTML 这一项给你写。所以必须把子应用的挂载目标(#sun div)提前写进主应用容器里:
// 主应用 index.html —— 在线地址方案的最佳实践
// 两个子应用都挂到 #sun,就把 #sun 直接写在主应用容器里
<div id="sun"></div> <!-- 子应用挂载目标直接写在这里 -->{
name: 'app2',
activeRule: '/app2',
container: '#sun', // 容器里已经写好子应用的挂载目标 div
entry: '//localhost:3001'
}4.3 最常见的坑:两个容器用同一个 ID
很多同学粗略看文档就写——container 用 id 为 sun 的 div,子应用挂载目标也写 id 为 sun。这样一定会出问题:
- Vue 项目:挂不出来;
- React 项目:样式丢失。
WARNING
铁律:主应用容器和子应用目标容器一定不能是同一个 ID。 用在线地址方案,就在容器里直接写好子应用目标;用 JS+CSS+HTML 方案,就把子应用目标写在 HTML 项里。 千万不要"container 和挂载目标都用 #sun"。
路由前缀:activeRule 动态下发
开发微前端项目时很多人有个困惑:子应用开发时,不知道主应用给我的 activeRule 是多少,怎么决定自己的路由前缀?
比如主应用给 APP1 定的 activeRule 是 /app1,那么只有地址切到 /app1 时才会显示 APP1,子应用自己跑的时候也得带 /app1 前缀。问题来了:主应用哪天"灵机一动"把 /app1 改成 /appX,子应用这边还是 /app1,就匹配不上、加载不出来了。
5.1 核心思路:主应用通过 props 把 activeRule 传给子应用
qiankun 注册子应用时有一个很核心的配置 props——就像父组件给子组件传消息一样,主应用可以往里面放任何信息,包括 activeRule:
// 主应用:注册时通过 props 把 activeRule 传给子应用
{
name: 'app1',
activeRule: '/app1',
container: '#sun',
entry: { scripts: [...], styles: [...] },
props: {
activeRule: '/app1' // 告诉子应用:主应用给你定的激活前缀是这个
}
}5.2 子应用在 mount 阶段接收 props
子应用的 mount 阶段会接收到这些 props。取出来当 baseURL 用。但这里还有个问题:router 不允许动态修改 baseURL——路由已经创建好了,想动态改不行。所以要把路由创建封装成方法:
// 子应用 src/router/index.js —— 路由创建封装成 genRouter(baseURL)
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
// 不马上创建路由,而是封装成方法,接收 baseURL
export default function genRouter(baseURL) {
return createRouter({
history: createWebHistory(baseURL), // 路由前缀由主应用下发的 baseURL 决定
routes: [
{ path: '/', component: Home }
]
})
}5.3 main.js:挂载时取出 props 的 activeRule,创建路由
// 子应用 src/main.js —— 动态路由 + 生命周期钩子
import { createApp } from 'vue'
import App from './App.vue'
import genRouter from './router'
let app = null
let router = null
export async function bootstrap() {}
export async function mount(props) {
const activeRule = props.activeRule || '/' // 主应用通过 props 下发的激活前缀
router = genRouter(activeRule) // 创建路由时指定 baseURL
app = createApp(App)
app.use(router)
app.mount(props.container)
}
export async function unmount() {
app && app.unmount()
app = null
router = null
}INFO
效果:以后主应用想改激活地址,只需要改主应用传给子应用的 props,子应用代码一行都不用动。 这就是"主应用和子应用路由前缀解耦"的标准做法。
沙箱机制:样式隔离怎么开
接入 qiankun 后,如果主应用和子应用都启用了样式,子应用可能把主应用的样式污染掉(演示里主应用的 header 被影响)。qiankun 的沙箱配置里有一项专门处理这个问题:
// 主应用 main.js —— 开启第一重样式隔离
start({
sandbox: {
experimentalStyleIsolation: true // 样式隔离:给子应用样式加"前缀"
}
})6.1 原理:给样式加"独一无二的父元素选择器"
开启后刷新页面,你会发现主应用的 header 不再被子应用影响了。看浏览器控制台里子应用的样式,自动被加了后缀/前缀——比如 .app-header 变成了 div[data-qiankun="app2"] .app-header:
/* 隔离前(子应用原始样式) */
.app-header { background: #000 }
/* 隔离后(qiankun 自动处理) */
div[data-qiankun="app2"] .app-header { background: #000 }这不就是第二课我们自己做的"给样式加一个独一无二的父元素选择器"吗?qiankun 在子应用挂载时给容器加了 data-qiankun="app2" 属性,再把子应用的所有样式选择器加上这个父元素前缀,实现隔离。
6.2 局限:body、router 这类选择器隔离不到
WARNING
这种隔离没办法解决子应用对 body、router 的修改。 比如子应用的 index.css 里写了 body { ... },这种修改不会被隔离——因为 body 不在子应用的挂载范围之内。
如果你要最严格的隔离,连 body、router 的修改都能隔开,理论上要用 shadow DOM(阴影根):qiankun 里对应的配置是 strictIsolation,开启后子应用的 style 标签和整个 HTML 都会放进一个 shadow root 里。但本节提醒:新版本 qiankun 里这个配置有 bug,且即将被移除,不推荐加。
INFO
大多数情况配 experimentalStyleIsolation: true 就够用了——因为子应用一般不会去做 body / router 这种全局样式的修改。
父子应用通讯:四种方案
这是 qiankun 里最容易搞蒙、也最复杂的地方。场景是:主应用想给子应用发消息,消息改变时子应用能收到;子应用想修改主应用的数据/状态。本节给出了四种方案,从推荐到不推荐:
7.1 方案一:qiankun 自带全局状态系统(initGlobalState)
**适合简单的数据共享。**主应用通过 initGlobalState 创建全局状态,返回一个 actions,里面有三个方法:
| 方法 | 作用 |
|---|---|
| onGlobalStateChange(cb, fireImmediately) | 监听状态改变(任何地方改了都会触发) |
| setGlobalState(state) | 设置/修改状态 |
| offGlobalStateChange() | 移除监听(关闭全局状态) |
主应用创建一个全局状态文件:
// 主应用 src/global-state.js —— 创建全局状态
import { initGlobalState } from 'qiankun'
const actions = initGlobalState({
sonName: 'app1', // 当前子应用的名字
menuStates: 1 // 主应用的一个状态
})
export default actions主应用怎么"用"这个状态(别扭点在这里)
它没有"获取"方法——这是它设计得别扭的地方。想展示状态,必须配合 Vue 的 ref / reactive:定义响应式数据渲染到页面,再通过 onGlobalStateChange 监听变化,手动把新值赋给响应式数据:
// 主应用 App.vue —— 配合 ref/reactive 使用全局状态
import { ref, onMounted } from 'vue'
import actions from './global-state'
const sonName = ref('')
const menuStates = ref(-1)
actions.onGlobalStateChange((newValue, oldValue) => {
// new 是保留字,参数别叫 new!这里叫 newValue
console.log('收到全局状态变化', newValue, oldValue)
sonName.value = newValue.sonName // 手动赋给响应式数据
menuStates.value = newValue.menuStates // 再由响应式数据引发页面更新
})WARNING
两个易错点: ① 监听回调的参数名不能用 new(保留字,本节排查了半天报错),改叫 newValue; ② 修改状态时 setGlobalState 的 key 必须和初始化时一致——传错 key(比如 menuStates 写成别的)会报 states has not change。
子应用怎么改主应用的状态
把 actions 和 setGlobalState 通过 props 传给子应用:
// 主应用:注册时把 actions / setGlobalState 通过 props 传给子应用
import actions from './global-state'
registerMicroApps([
{
name: 'app1',
activeRule: '/app1',
container: '#sun',
entry: { scripts: [...], styles: [...] },
props: {
activeRule: '/app1',
actions: actions, // 全局状态的 actions
setGlobalState: actions.setGlobalState // 或直接给设置方法
}
}
])子应用在 mount 阶段解构接收;但Vue 创建时不允许直接往组件里传 props(和 React 不同),所以最简单的方式是挂到 window 上:
// 子应用 src/main.js —— mount 阶段接收并挂到 window
export async function mount(props) {
const { setGlobalState } = props
// Vue 创建时不能直接传 props 给组件,挂到 window 上让组件随时取
window.__setGlobalState = setGlobalState
// 子应用一挂载,马上把主应用的状态改掉(举例)
setGlobalState({ menuStates: 2 })
// ... 创建应用挂载
}
// 子应用任意组件里(如 Home.vue 的按钮点击)
function handleClick() {
window.__setGlobalState({ menuStates: 3 }) // 子应用修改主应用状态
}INFO
方案一特点:适合简单数据共享。它本身不具备响应式——必须触发监听,再在监听里手动去修改响应式数据,比较绕。 但优点是:主应用和子应用可以跨框架(Vue、React 都能用)。
7.2 方案二:Pinia / Vuex / Redux 全局状态管理
**具有响应式、用起来最爽,但有框架限制。**原理:主应用定义好全局 store,把整个 store 通过 props 传给子应用,子应用直接调用 store 的方法改数据,主应用马上更新,不需要监听、不需要手动赋值。
主应用定义 Pinia store:
// 主应用 src/stores/global-store.js —— 定义 Pinia 仓库
import { defineStore } from 'pinia'
export const useGlobalStore = defineStore('global', {
state: () => ({
sonName: 'app1',
menuStates: 1
}),
actions: {
setSonName(name) { this.sonName = name },
setMenuStates(states) { this.menuStates = states }
}
})// 主应用 main.js —— 注册 Pinia,并把 store 传下去
import { createPinia } from 'pinia'
import { useGlobalStore } from './stores/global-store'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
// 注意:必须在 Pinia 注册之后再调用 useGlobalStore
const globalStore = useGlobalStore()
registerMicroApps([
{
name: 'app1',
activeRule: '/app1',
container: '#sun',
entry: { scripts: [...], styles: [...] },
props: {
activeRule: '/app1',
globalStore: globalStore // 把整个 store 传给子应用
}
}
])子应用从 props 里取出 store 直接调用方法:
// 子应用 src/main.js —— 从 props 接收 store,挂 window 供组件使用
export async function mount(props) {
const { globalStore } = props
// 一挂载:子应用直接调用主应用 store 的方法改状态
globalStore.setSonName('app1')
window.__globalStore = globalStore // 组件里随时取用
}
// 子应用组件里
function handleClick() {
window.__globalStore.setMenuStates(3) // 响应式,主应用页面立即更新
}WARNING
Pinia 两个易错点: ① 必须在 Pinia 注册(app.use(createPinia()))之后再调用 useGlobalStore,否则报错; ② 不能解构 store——const { sonName } = globalStore 会丢掉响应式(本节演示时习惯 React 的解构写法,结果页面不更新),必须 globalStore.sonName 这样用。
INFO
方案二特点:响应式,主应用一改子应用马上更新,无需监听。 但要求主应用和子应用必须是同一个框架、同一个版本(比如全是 Vue 3,或主 Vue 3 子 React 就跑不了)。 如果你能保证全家桶统一,就用这套。
7.3 方案三:自定义 EventBus 体系
不太推荐。它和方案一本质很像(都没有响应式),完全可以被方案一替代。本质就是自己实现一套观察者模式(监听模式):
// 主应用 src/event-bus.js —— 自定义 EventBus(观察者模式)
const eventList = {}
// 注册监听:事件名 + 回调
export function on(eventName, callback) {
if (!eventList[eventName]) eventList[eventName] = []
eventList[eventName].push(callback)
}
// 触发监听:从列表里取出对应事件的所有回调执行
export function emit(eventName, ...args) {
const callbacks = eventList[eventName] || []
callbacks.forEach((cb) => cb(...args))
}
// 主应用:挂到 window 或通过 props 传给子应用
window.__eventBus = { on, emit }// 子应用使用
// 监听:props.eventBus.on('xxx', callback)
// 触发:props.eventBus.emit('xxx', data)懂一点设计模式的同学一眼就能看懂。它也无法触发页面更新,和自带方案类似,一般用自带的就好了。
7.4 方案四:直接挂 window(极端情况)
**更不推荐,但极端情况下可以用。**只是临时传一两个数据给子应用,可以主应用把数据挂到 window 上,子应用直接拿;或者反过来。
// 主应用:直接挂 window
window.__microAppData = { theme: 'dark' }
// 子应用:直接拿
const theme = window.__microAppData.themeWARNING
注意:用这种方案必须去掉沙箱(sandbox: false),否则 window 不共享、拿不到。 临时传一两个数据可以用,但不要依赖它做正经业务。
7.5 四种方案怎么选
| 方案 | 响应式 | 跨框架 | 推荐度 | 适用场景 |
|---|---|---|---|---|
| initGlobalState | 否(需监听后手动赋值) | ✅ 可以 | ⭐⭐⭐ 推荐 | 简单数据共享、跨框架项目 |
| Pinia / Vuex / Redux | ✅ 是 | ❌ 需同框架同版本 | ⭐⭐ 看条件 | 全家桶统一、数据量大的项目 |
| 自定义 EventBus | 否 | ✅ 可以 | ⭐ 一般不推荐 | 可被方案一替代 |
| 直接挂 window | 否 | ✅ 可以(需去沙箱) | ⚠极端情况 | 临时传一两个数据 |
总结:qiankun 能力全景
三节课下来,从原理到手写框架再到真实框架,qiankun 的核心能力和我们手写的简化版一一对应上了:
| 能力 | 手写版(前两课) | qiankun(本课) |
|---|---|---|
| 子应用注册 / 启动 | registerApps + start | registerMicroApps + start(onMounted 后调用) |
| 子应用入口 | js / css 地址 | entry 支持 JS+CSS、在线地址、HTML 三种形态 |
| 生命周期 | mount / unmount | bootstrap(一次)/ mount(每次)/ unmount(卸载) |
| 路由激活 | pathname 第一段匹配 | activeRule 规则 + props 动态下发前缀 |
| 样式隔离 | CSS 类名加 #sun 前缀 | experimentalStyleIsolation(data-qiankun 前缀)/ shadow DOM |
| JS 沙箱 | Proxy 代理 window | Proxy 沙箱(多实例、快照等),可配置关闭 |
| 通讯 | (未实现) | initGlobalState / 状态管理库 / EventBus / window |
INFO
学完三节课的完整认知:原理层——微前端是"动态加载 JS + 沙箱 + 生命周期";框架层——qiankun 把这些封装成 registerMicroApps / start / 生命周期钩子 / props 通讯的标准 API。 以后看任何微前端框架(micro-app、无界等)都能秒懂。
术语表 & 附录完整代码
术语表
| 术语 | 定义 |
|---|---|
| qiankun(乾坤) | 基于 single-spa 的成熟微前端框架,提供子应用注册、沙箱、样式隔离、通讯等开箱即用能力 |
| activeRule | 激活规则:地址匹配到该规则时加载对应的子应用 |
| entry | 子应用的资源入口,支持 JS+CSS 地址、在线地址、HTML 三种形态 |
| 生命周期钩子 | bootstrap(一次)/ mount(每次进入)/ unmount(卸载),由主应用在合适时机调用 |
| UMD 打包 | 库格式打包,把 export 的内容挂到 window[名字] 上,qiankun 硬性要求 |
| 主应用容器 / 子应用目标容器 | 注册时的 container(主应用容器)与子应用自身挂载的容器,二者不能是同一个 ID |
| experimentalStyleIsolation | qiankun 的样式隔离:给子应用样式选择器加 data-qiankun 父元素前缀 |
| initGlobalState | qiankun 自带的全局状态系统:onGlobalStateChange / setGlobalState / offGlobalStateChange |
| props | 注册子应用时传给子应用的配置,mount 阶段接收,用于传 activeRule、通讯方法等 |
| EventBus | 基于观察者模式的事件监听体系(on / emit),可做应用间通讯,但可被自带方案替代 |
附录 A:主应用完整代码(main.js)
// ========== 主应用 main.js(合并本课主应用全部内容) ==========
import { createApp } from 'vue'
import { onMounted } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { registerMicroApps, start, initGlobalState } from 'qiankun'
import { useGlobalStore } from './stores/global-store'
// ---- 主应用本身先启动,Pinia 注册后才能取 store ----
const app = createApp(App)
app.use(createPinia()) // 先注册 Pinia
app.mount('#app')
const globalStore = useGlobalStore() // ✅ 必须在 Pinia 注册之后再调用
const actions = initGlobalState({ sonName: 'app1', menuStates: 1 })
// ---- 注册子应用 ----
registerMicroApps([
{
name: 'app1',
activeRule: '/app1',
container: '#sun',
entry: {
scripts: ['//localhost:8080/app1/js/app.xxx.js'],
styles: ['//localhost:8080/app1/css/app.xxx.css'],
html: '<div id="app1"></div>' // 子应用目标容器写进 HTML 项
},
props: {
activeRule: '/app1', // 路由前缀动态下发
actions, // 方案一:全局状态
setGlobalState: actions.setGlobalState,
globalStore // 方案二:Pinia store
}
},
{
name: 'app2',
activeRule: '/app2',
container: '#sun',
entry: '//localhost:3001', // 在线地址方案(容器里直接写好 #sun)
props: { activeRule: '/app2' }
}
])
// ---- 启动(必须等主应用挂载完成) ----
onMounted(() => {
start({
sandbox: {
experimentalStyleIsolation: true // 样式隔离
}
})
})附录 B:子应用完整代码(main.js,Vue 3 版)
// ========== 子应用 src/main.js(合并本课子应用全部内容) ==========
import { createApp } from 'vue'
import App from './App.vue'
import genRouter from './router'
let app = null
let router = null
export async function bootstrap() {
// 只会执行一次
}
export async function mount(props) {
const { activeRule, actions, setGlobalState, globalStore } = props
// 路由前缀:主应用通过 props 下发
router = genRouter(activeRule || '/')
// 通讯方法挂到 window,供组件随时取用(Vue 创建时不能直接传 props 给组件)
window.__setGlobalState = setGlobalState
window.__globalStore = globalStore
window.__actions = actions
// 子应用一挂载,主动修改主应用状态(演示)
globalStore && globalStore.setSonName('app1')
setGlobalState && setGlobalState({ menuStates: 2 })
app = createApp(App)
app.use(router)
app.mount(props.container)
}
export async function unmount() {
app && app.unmount()
app = null
router = null
}附录 C:React 子应用 UMD 打包(craco.config.js)
// ========== React 子应用 craco.config.js(@craco/craco) ==========
// 1. npm install @craco/craco
// 2. scripts 全部换成 craco start / craco build / craco test
module.exports = {
webpack: {
configure(config) {
config.output.library = 'app2' // 打包名字(挂到 window 上)
config.output.libraryTarget = 'umd' // 打包目标:UMD
config.output.globalObject = 'window'
return config
}
}
}