返回笔记首页

qiankun 深度应用(上)

主题配置

一、qiankun 核心原理

1.1 整体架构

plain
qiankun 架构层级
├── single-spa(基础层)
│   ├── 应用注册管理
│   ├── 生命周期调度
│   └── 路由监听
│
├── import-html-entry(加载层)
│   ├── HTML 解析
│   ├── JS 执行
│   └── CSS 注入
│
├── 沙箱隔离(隔离层)
│   ├── Proxy 沙箱(默认)
│   ├── Snapshot 快照沙箱
│   └── LegacySandbox 遗留沙箱
│
└── 样式隔离(样式层)
    ├── Shadow DOM(严格)
    ├── Scoped CSS(宽松)
    └── Dynamic Stylesheet(动态)

1.2 single-spa 应用注册与生命周期

核心概念: single-spa 是 qiankun 的基础,负责管理多个应用的注册、加载、卸载。

生命周期钩子

javascript
// 子应用必须导出三个生命周期函数

// 1. bootstrap - 应用首次加载时调用,只调用一次
export async function bootstrap() {
  console.log('子应用首次加载')
  // 初始化工作,比如创建 Vue 实例需要的根节点
}

// 2. mount - 应用每次激活时调用
export async function mount(props) {
  console.log('子应用挂载', props)
  // 创建 Vue 实例,挂载 DOM
  render(props)
}

// 3. unmount - 应用每次失活时调用
export async function unmount() {
  console.log('子应用卸载')
  // 销毁 Vue 实例,清理副作用
  instance.$destroy()
}

// 可选:update - 主应用传递的 props 更新时调用
export async function update(props) {
  console.log('子应用更新', props)
}
注册应用
javascript
import { registerMicroApps, start } from 'qiankun'

registerMicroApps([
  {
    name: 'app1', // 应用名称
    entry: '//localhost:8081', // 应用入口
    container: '#subapp-container', // 容器节点
    activeRule: '/app1', // 激活路由
    props: { // 传递给子应用的数据
      data: { user: 'admin' }
    }
  }
])

start() // 启动 qiankun

1.3 import-html-entry 动态加载

工作原理

  1. 获取子应用的 HTML 文本
  2. 解析 HTML,提取 JS 和 CSS
  3. 创建沙箱环境
  4. 执行 JS,注入 CSS
  5. 返回生命周期函数
核心代码逻辑
javascript
// 简化版实现原理

async function importHTML(url) {
  // 1. 获取 HTML
  const html = await fetch(url).then(res => res.text())

  // 2. 解析 HTML
  const { template, scripts, styles } = parseHTML(html)

  // 3. 加载并执行 scripts
  const jsCode = await Promise.all(
    scripts.map(src => fetch(src).then(res => res.text()))
  )

  // 4. 创建沙箱并执行
  const sandbox = new ProxySandbox()
  const exports = sandbox.execScripts(jsCode)

  // 5. 注入样式
  styles.forEach(style => {
    const styleNode = document.createElement('style')
    styleNode.innerHTML = style
    document.head.appendChild(styleNode)
  })

  return {
    template, // 处理后的 HTML 模板
    assetPublicPath, // 资源公共路径
    getExternalScripts, // 获取外部脚本
    getExternalStyleSheets, // 获取外部样式
    execScripts // 执行脚本
  }
}

1.4 JS 沙箱隔离

三种沙箱方案

1.4.1 ProxySandbox(推荐)

原理: 使用 Proxy 代理 window 对象

javascript
class ProxySandbox {
  constructor() {
    this.running = false
    const fakeWindow = Object.create(null)

    this.proxyWindow = new Proxy(fakeWindow, {
      get(target, prop) {
        // 优先从沙箱内取值
        if (prop in target) {
          return target[prop]
        }
        // 否则从真实 window 取值
        return window[prop]
      },
      set(target, prop, value) {
        // 所有设置都记录在沙箱内
        target[prop] = value
        return true
      }
    })
  }

  active() {
    this.running = true
  }

  inactive() {
    this.running = false
  }
}

// 使用示例
const sandbox = new ProxySandbox()
sandbox.active()

// 子应用的代码运行在沙箱内
with(sandbox.proxyWindow) {
  var count = 1 // 这个 count 只在沙箱内
  console.log(window.count) // undefined,不会污染主应用
}

sandbox.inactive()
优势
  • 完全隔离,互不影响
  • 支持多实例
  • 性能好
劣势
  • 不支持 IE11
  • 需要 Proxy 支持

1.4.2 SnapshotSandbox(快照沙箱)

原理: 激活时记录 window 快照,失活时恢复

javascript
class SnapshotSandbox {
  constructor() {
    this.modifyPropsMap = {} // 记录被修改的属性
  }

  active() {
    // 记录当前 window 的快照
    this.windowSnapshot = {}
    for (const prop in window) {
      this.windowSnapshot[prop] = window[prop]
    }

    // 恢复上次的修改
    Object.keys(this.modifyPropsMap).forEach(prop => {
      window[prop] = this.modifyPropsMap[prop]
    })
  }

  inactive() {
    // 记录被修改的属性
    for (const prop in window) {
      if (window[prop] !== this.windowSnapshot[prop]) {
        this.modifyPropsMap[prop] = window[prop]
        // 恢复原始值
        window[prop] = this.windowSnapshot[prop]
      }
    }
  }
}

// 使用示例
const sandbox = new SnapshotSandbox()

sandbox.active()
window.count = 1 // 修改 window

sandbox.inactive() // 恢复 window
console.log(window.count) // undefined
优势
  • 兼容性好,支持 IE11
  • 实现简单
劣势
  • 性能差(遍历 window)
  • 不支持多实例
  • 会污染全局 window

1.4.3 LegacySandbox(遗留沙箱)

原理: Proxy + 快照的结合

javascript
class LegacySandbox {
  constructor() {
    this.addedPropsMap = {} // 新增属性
    this.modifiedPropsOriginalValueMap = {} // 修改的原始值
    this.currentUpdatedPropsValueMap = {} // 当前修改的值

    const { addedPropsMap, modifiedPropsOriginalValueMap, currentUpdatedPropsValueMap } = this

    const fakeWindow = Object.create(null)

    this.proxyWindow = new Proxy(fakeWindow, {
      set(target, prop, value) {
        if (!window.hasOwnProperty(prop)) {
          addedPropsMap[prop] = value
        } else if (!modifiedPropsOriginalValueMap.hasOwnProperty(prop)) {
          modifiedPropsOriginalValueMap[prop] = window[prop]
        }

        currentUpdatedPropsValueMap[prop] = value
        window[prop] = value
        return true
      },
      get(target, prop) {
        return window[prop]
      }
    })
  }

  active() {
    // 恢复环境
    Object.keys(this.currentUpdatedPropsValueMap).forEach(prop => {
      window[prop] = this.currentUpdatedPropsValueMap[prop]
    })
  }

  inactive() {
    // 还原环境
    Object.keys(this.addedPropsMap).forEach(prop => {
      delete window[prop]
    })

    Object.keys(this.modifiedPropsOriginalValueMap).forEach(prop => {
      window[prop] = this.modifiedPropsOriginalValueMap[prop]
    })
  }
}
优势
  • 性能较好
  • 记录了所有变更
劣势
  • 仍会污染 window
  • 不支持多实例

1.5 CSS 样式隔离

三种隔离方案

1.5.1 Shadow DOM(严格隔离)

javascript
// qiankun 配置
start({
  sandbox: {
    strictStyleIsolation: true // 开启严格样式隔离
  }
})

// 实现原理
const container = document.querySelector('#subapp-container')
const shadowRoot = container.attachShadow({ mode: 'open' })

// 子应用内容挂载到 Shadow DOM
shadowRoot.innerHTML = `
  <style>
    .title { color: red; }
  </style>
  <div class="title">子应用标题</div>
`

// 样式完全隔离,不会影响主应用
优势
  • 完全隔离,最彻底
  • 子应用样式不会泄露
  • 主应用样式不会影响子应用
劣势
  • 某些组件库不支持(如 antd 的弹窗)
  • 调试困难
  • 性能略差

1.5.2 Scoped CSS(宽松隔离)

javascript
// qiankun 配置
start({
  sandbox: {
    experimentalStyleIsolation: true // 开启实验性样式隔离
  }
})

// 实现原理:给子应用所有样式加前缀
// 原始样式
.title { color: red; }

// 处理后
div[data-qiankun="app1"] .title { color: red; }

// 容器添加属性
<div id="subapp-container" data-qiankun="app1">
  <div class="title">子应用标题</div>
</div>
优势
  • 兼容性好
  • 性能好
  • 可以使用主应用的全局样式
劣势
  • 隔离不彻底
  • 可能有样式泄露
  • 选择器权重问题

1.5.3 Dynamic Stylesheet(动态样式)

javascript
// 实现原理:应用切换时动态加载/卸载样式

class DynamicStylesheet {
  constructor() {
    this.styles = []
  }

  mount(styleNodes) {
    styleNodes.forEach(node => {
      document.head.appendChild(node)
      this.styles.push(node)
    })
  }

  unmount() {
    this.styles.forEach(node => {
      document.head.removeChild(node)
    })
    this.styles = []
  }
}

// 子应用激活时加载样式
app.mount = async () => {
  dynamicStylesheet.mount(styleNodes)
}

// 子应用失活时卸载样式
app.unmount = async () => {
  dynamicStylesheet.unmount()
}
优势
  • 简单有效
  • 性能好
  • 兼容性好
劣势
  • 切换时有闪烁
  • 不适合频繁切换

二、qiankun 实战配置

2.1 主应用配置

完整代码示例

vue
<template>
  <div id="main-app">
    <!-- 主应用导航 -->
    <nav class="main-nav">
      <div class="logo">微前端主应用</div>
      <ul class="menu">
        <li @click="goto('/')">首页</li>
        <li @click="goto('/app1')">子应用1</li>
        <li @click="goto('/app2')">子应用2</li>
        <li @click="goto('/app3')">子应用3</li>
      </ul>
      <div class="user-info">
        <span>用户:{{ username }}</span>
        <button @click="logout">退出</button>
      </div>
    </nav>

    <!-- 主应用内容区 -->
    <div class="main-content">
      <!-- 主应用路由 -->
      <router-view v-if="!isSubApp"></router-view>

      <!-- 子应用容器 -->
      <div id="subapp-container" v-show="isSubApp"></div>
    </div>

    <!-- 加载提示 -->
    <div v-if="loading" class="loading-mask">
      <div class="loading-spinner"></div>
      <div class="loading-text">加载中...</div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { registerMicroApps, start, initGlobalState, setDefaultMountApp } from 'qiankun'

const router = useRouter()
const route = useRoute()

const username = ref('Admin')
const loading = ref(false)

// 判断当前是否在子应用路由下
const isSubApp = computed(() => {
  return route.path.startsWith('/app1') ||
         route.path.startsWith('/app2') ||
         route.path.startsWith('/app3')
})

// 初始化全局状态
const actions = initGlobalState({
  user: {
    name: 'Admin',
    role: 'admin'
  },
  token: 'xxxx-xxxx-xxxx'
})

// 监听状态变化
actions.onGlobalStateChange((state, prev) => {
  console.log('全局状态改变', state, prev)
})

// 注册子应用
const microApps = [
  {
    name: 'app1',
    entry: '//localhost:8081',
    container: '#subapp-container',
    activeRule: '/app1',
    props: {
      // 传递给子应用的数据
      routerBase: '/app1',
      getGlobalState: actions.getGlobalState,
      setGlobalState: actions.setGlobalState
    }
  },
  {
    name: 'app2',
    entry: '//localhost:8082',
    container: '#subapp-container',
    activeRule: '/app2',
    props: {
      routerBase: '/app2',
      getGlobalState: actions.getGlobalState,
      setGlobalState: actions.setGlobalState
    }
  },
  {
    name: 'app3',
    entry: '//localhost:8083',
    container: '#subapp-container',
    activeRule: '/app3',
    props: {
      routerBase: '/app3',
      getGlobalState: actions.getGlobalState,
      setGlobalState: actions.setGlobalState
    }
  }
]

onMounted(() => {
  // 注册微应用
  registerMicroApps(microApps, {
    beforeLoad: [
      app => {
        console.log('before load', app.name)
        loading.value = true
        return Promise.resolve()
      }
    ],
    beforeMount: [
      app => {
        console.log('before mount', app.name)
        return Promise.resolve()
      }
    ],
    afterMount: [
      app => {
        console.log('after mount', app.name)
        loading.value = false
        return Promise.resolve()
      }
    ],
    beforeUnmount: [
      app => {
        console.log('before unmount', app.name)
        return Promise.resolve()
      }
    ],
    afterUnmount: [
      app => {
        console.log('after unmount', app.name)
        return Promise.resolve()
      }
    ]
  })

  // 设置默认子应用
  // setDefaultMountApp('/app1')

  // 启动 qiankun
  start({
    prefetch: true, // 预加载
    sandbox: {
      strictStyleIsolation: false, // 严格样式隔离
      experimentalStyleIsolation: true // 实验性样式隔离
    },
    singular: false // 是否单实例场景
  })
})

const goto = (path) => {
  router.push(path)
}

const logout = () => {
  console.log('退出登录')
  actions.setGlobalState({ user: null, token: null })
}
</script>

<style scoped>
#main-app {
  width: 100%;
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.main-nav {
  display: flex;
  align-items: center;
  padding: 0 24px;
  height: 64px;
  background: #001529;
  color: white;
}

.logo {
  font-size: 18px;
  font-weight: bold;
  margin-right: 50px;
}

.menu {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
  flex: 1;
}

.menu li {
  padding: 0 20px;
  cursor: pointer;
  transition: all 0.3s;
}

.menu li:hover {
  color: #1890ff;
}

.user-info {
  display: flex;
  align-items: center;
  gap: 16px;
}

.user-info button {
  padding: 6px 16px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.main-content {
  flex: 1;
  overflow: auto;
  background: #f0f2f5;
}

#subapp-container {
  width: 100%;
  height: 100%;
}

.loading-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.loading-spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #1890ff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loading-text {
  margin-top: 20px;
  color: white;
  font-size: 16px;
}
</style>

主应用路由配置

javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/Home.vue')
  },
  // 子应用路由通配
  {
    path: '/app1/:pathMatch(.*)*',
    name: 'App1',
    component: () => import('../views/SubApp.vue')
  },
  {
    path: '/app2/:pathMatch(.*)*',
    name: 'App2',
    component: () => import('../views/SubApp.vue')
  },
  {
    path: '/app3/:pathMatch(.*)*',
    name: 'App3',
    component: () => import('../views/SubApp.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

2.2 子应用配置

完整代码示例

javascript
// main.js
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import routes from './router'

let instance = null
let router = null

// 渲染函数
function render(props = {}) {
  const { container, routerBase } = props

  // 创建路由
  router = createRouter({
    history: createWebHistory(routerBase || '/app1'),
    routes
  })

  // 创建 Vue 实例
  instance = createApp(App)
  instance.use(router)

  // 挂载
  const containerEl = container
    ? container.querySelector('#app')
    : document.querySelector('#app')
  instance.mount(containerEl)
}

// 判断是否在 qiankun 环境
if (!window.__POWERED_BY_QIANKUN__) {
  // 独立运行
  render()
}

// 导出 qiankun 生命周期
export async function bootstrap() {
  console.log('[app1] bootstrap')
}

export async function mount(props) {
  console.log('[app1] mount', props)

  // 注册全局状态监听
  props.onGlobalStateChange?.((state, prev) => {
    console.log('[app1] 全局状态变化', state, prev)
  })

  // 获取全局状态
  const globalState = props.getGlobalState?.()
  console.log('[app1] 全局状态', globalState)

  render(props)
}

export async function unmount() {
  console.log('[app1] unmount')
  instance.unmount()
  instance = null
  router = null
}

export async function update(props) {
  console.log('[app1] update', props)
}

子应用 Webpack 配置

javascript
// vue.config.js
const { name } = require('./package.json')

module.exports = {
  devServer: {
    port: 8081,
    headers: {
      'Access-Control-Allow-Origin': '*' // 允许跨域
    }
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`
    }
  }
}

子应用 publicPath 配置

javascript
// public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}

// main.js 顶部引入
import './public-path'

2.3 完整运行示例

目录结构

plain
微前端项目
├── main-app/          # 主应用
│   ├── src/
│   │   ├── main.js
│   │   ├── App.vue
│   │   └── router/
│   ├── package.json
│   └── vue.config.js
│
├── sub-app1/          # 子应用1
│   ├── src/
│   │   ├── main.js
│   │   ├── App.vue
│   │   ├── public-path.js
│   │   └── router/
│   ├── package.json
│   └── vue.config.js
│
├── sub-app2/          # 子应用2
└── sub-app3/          # 子应用3

启动命令

bash
# 主应用
cd main-app
npm install
npm run serve  # http://localhost:8080

# 子应用1
cd sub-app1
npm install
npm run serve  # http://localhost:8081

# 子应用2
cd sub-app2
npm install
npm run serve  # http://localhost:8082

# 子应用3
cd sub-app3
npm install
npm run serve  # http://localhost:8083

访问 http://localhost:8080 即可看到微前端效果。

三、关键问题解决(下一篇)

由于篇幅限制,关键问题解决、简历撰写等内容将在下一部分详细说明。

包含内容

  • 路由劫持与同步
  • 全局状态共享
  • 样式隔离方案
  • 静态资源路径问题
  • 子应用独立运行配置
  • 简历描述模板
  • SOP 标准回答
  • 难点与亮点分析