Skip to content

手写沙箱与生命周期,做一个像样的微前端框架

微前端第二课 · 手写沙箱 + 生命周期钩子

课程地图

模块内容学习产出
查漏补缺修正上节课的两处代码(异步 popstate 的坑 / APP2 的 CSS)让第一课代码真正可用
框架化把散在 main.js 里的代码抽成微前端库(registerApps / start)理解框架的"壳"怎么来的
样式隔离样式注入容器 + CSS 类名加前缀(两级防线)解决样式冲突
window 沙箱Proxy 代理 window:get 先假后真 / set 只写假 window解决作用域冲突与状态恢复
副作用清理装饰者模式重写 addEventListener / setInterval切应用时清空监听与定时器
生命周期子应用暴露 mount / unmount + UMD 打包主应用接管挂载与卸载

INFO

本课一句话目标:把第一课的"原型"升级成"框架"——解决样式、window、全局副作用三类冲突,并让主应用能主动控制子应用的挂载与卸载。 这就是 qiankun 的核心思路(简化版)。

开工前:修正上节课的两处代码

在正式写沙箱之前,本节先复盘了上节课代码里的两个错误——第一课的代码如果不改,后面全是坑。

修正 1:异步 popstate 导致跨应用跳转失效

第一课为了兼容 Vue/React Router,把触发 popstate 改成了异步。问题就出在这里:异步执行时,location.pathname 已经变成新地址了。我们在 createSubApp 里用 location.pathname 取"当前子应用名",取到的却是目标子应用名——于是 targetAppName === currentAppName 永远成立,直接 return,从 APP2 切到 APP1 就跳不过去

修复:在触发事件之前(此时地址还是旧的)把当前子应用名取出来,随事件 detail 一起带过去:

js
// 修正前:异步触发后,监听回调里 location.pathname 已是新地址
window.history.pushState = function (a, b, url) {
  setTimeout(() => {
    window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
  }, 0)
  originalPushState.call(window.history, a, b, url)
}

// 修正后:触发前先取"当前"子应用名,随 detail 一起发出去
window.history.pushState = function (a, b, url) {
  // 此时地址还是旧的,正好拿到"当前"子应用名
  const appName = window.location.pathname.split('/')[1]
  setTimeout(() => {
    window.dispatchEvent(new CustomEvent('popstate', { detail: { url, appName } }))
  }, 0)
  originalPushState.call(window.history, a, b, url)
}

window.addEventListener('popstate', (e) => {
  const detail = e.detail || {}
  // 优先用 detail 里带过来的"当前"子应用名(浏览器前进后退时取不到,回退到 pathname)
  const currentAppName = detail.appName || window.location.pathname.split('/')[1]
  createSubApp(detail.url || window.location.pathname, currentAppName)
})

WARNING

教训:加异步的同时,要重新审视"读取时机"。异步回调里读取全局状态(location、window 等),拿到的可能是更新后的值。

修正 2:APP2 的 CSS 之前放错了

第一课演示时,APP2(React 项目)加载的 CSS 放成了别的文件。换成真正的 React 项目 CSS 后,APP2 的样式才正常。这说明:子应用的 JS 和 CSS 必须一一对应,配置错了表现就是"页面能出来但样式不对"。

框架化改造:从散代码到"微前端库"

第一课的代码全散在 main.js 里。要像 qiankun 那样"引入一个库、调用几个方法就变成主应用",得先把代码抽成一个独立的框架文件。

我们新建一个 微前端.js(这就是我们要"发布到 npm"的框架库),对外只暴露两个方法:

  • registerApps(apps):注册子应用——告诉主应用"有哪些子应用,它们的 JS/CSS 地址是多少";
  • start():启动——重写原生方法、挂监听、创建第一个子应用。
js
// 微前端.js —— 我们开发的"微前端框架库"
const appMap = {}        // 子应用资源表(原第一课的 APP_MAP)
const eventList = []     // 全局事件监听列表
const timerList = []     // 定时器列表

// 注册子应用:把子应用数组登记成 { 名字: { js, css } }
function registerApps(apps) {
  apps.forEach((app) => {
    appMap[app.name] = { name: app.name, js: app.js, css: app.css }
  })
}

async function loadJS(url) { /* 见第 4 节:在代理 window 中执行 */ }
async function loadCSS(url) { /* 见第 3 节:注入子应用容器 */ }
async function createSubApp(url, currentAppName) { /* 见后文 */ }
function rewriteFn() { /* 重写 pushState 等原生方法 */ }
function initEvent() { /* 挂载 popstate 监听 */ }

// 启动:重写原生方法 + 初始化监听 + 加载当前路由的子应用
function start() {
  window.__MICRO_APP__ = true   // 标记微前端环境(第 6 节子应用会用到)
  rewriteFn()
  initEvent()
  createSubApp()
}

export { registerApps, start }

主应用从此只需要三行:

js
// main.js —— 主应用入口
import { registerApps, start } from './微前端.js'

registerApps([
  { name: 'app1', js: 'http://localhost:3000/vue-app.js', css: 'http://localhost:3000/vue-app.css' },
  { name: 'app2', js: 'http://localhost:3000/react-app.js', css: 'http://localhost:3000/react-app.css' }
])

start()

TIP

这就是框架的"壳":用户(主应用)引入库 → 注册子应用 → start(),内部细节全部封装在库里。

问题一:样式冲突(两级防线)

第一课遗留问题的第一个:样式冲突。它其实分两层,要分别解决。

3.1 第一级:子应用之间互相污染

第一课把 CSS 以 style 标签插到 body 里。用 pushState 切到 APP1 后,APP2 的 style 标签还留在 body 里——十几个应用切来切去,body 里会堆满所有子应用的样式,极易冲突。

解决思路:style 标签不插 body,插到子应用挂载的容器 div(#sun)里。切换子应用时容器会被清空,旧样式随之消失。

js
async function loadCSS(url) {
  const res = await fetch(url)
  const text = await res.text()
  const style = document.createElement('style')
  style.textContent = text
  // 插到子应用挂载容器里,而不是 body
  document.getElementById('sun').appendChild(style)
}

WARNING

顺序坑:必须先 JS 后 CSS。 因为执行子应用 JS 时(比如 Vue 挂载)会清空 #sun 里的内容。如果 CSS 先插进去,转头就被 JS 清掉了,样式全丢。 所以 loadJS 要先于 loadCSS(即先执行 JS 完成挂载,再注入样式)。

3.2 第二级:子应用污染主应用

即使插进容器,子应用里如果写了全局样式(比如直接 .app-header 这样不带前缀的类),依然会污染主应用。本节的演示:子应用定义了全局的 .app-header,主应用自己写的 .app-header 直接被顶掉。

解决思路:加载子应用 CSS 时,给所有类选择器加上 #sun 父级前缀——只作用在子应用容器内,自然碰不到主应用:

js
// 解析 CSS 文本:给所有类选择器加 #sun 前缀
// 例:.app-header { ... }  →  #sun .app-header { ... }
function addPrefixToCSS(cssText, parentId) {
  // 简化版算法(完整版建议让 AI 生成,需兼容媒体查询、@keyframes 等场景)
  return cssText.replace(/\.([a-zA-Z_][\w-]*)/g, `#${parentId} .$1`)
}

async function loadCSS(url) {
  const res = await fetch(url)
  let text = await res.text()
  text = addPrefixToCSS(text, 'sun')   // 先加前缀,再注入容器
  const style = document.createElement('style')
  style.textContent = text
  document.getElementById('sun').appendChild(style)
}

处理后审查元素会发现:子应用的样式全变成了 #sun .app-header,主应用的同名类不再受干扰。

3.3 局限与进阶:shadow DOM

WARNING

前缀方案解决不了 body、html、:root 这类全局选择器。 要彻底隔离,可以上 shadow DOM 方案。不过实战中(包括 qiankun)默认也不解决这类问题,除非显式配置严格隔离。 感兴趣可以课后自行扩展。

问题二/三:window 沙箱(代理 window)

第二个遗留问题:多个子应用往同一个 window 上挂同名变量,互相覆盖(作用域冲突);切走之后改动的全局属性不会恢复(状态恢复)。这两个问题,用同一个机制解决——给每个子应用一个独立的"代理 window"

4.1 原理:作用域链 + 形参拦截

把子应用代码包进一个匿名自执行函数,形参也叫 window。根据作用域链规则,代码里写 window.a = 123 时,会就近找到形参 window,而不会碰到全局 window

js
;(function (window) {
  window.a = 123   // 挂到形参 window(我们传入的对象)上,不是全局 window
})({})

4.2 升级:Proxy 代理 window

直接传空对象有个问题:代码里访问 window.location.pathname 时会报错——空对象上没有 location。所以要用 Proxy 代理:取不到的就从真 window 上取,设置的一律写假 window:

js
// 为每个子应用创建独立的"代理 window"
function createProxyWindow() {
  const realWindow = window
  const fakeWindow = {}   // 假 window:子应用挂载的变量都放这里

  // 第 5 节会用到:假 window 里预置重写后的 addEventListener
  // fakeWindow.addEventListener = ...

  return new Proxy(fakeWindow, {
    // 取值:假 window 有 → 用假的;没有 → 从真 window 找
    get(target, property) {
      if (property in target) {
        return target[property]
      }
      const value = realWindow[property]
      // 取到的是方法时,this 要绑定回真 window,否则调用会报错
      if (typeof value === 'function') {
        return value.bind(realWindow)
      }
      return value
    },
    // 赋值:只写假 window,绝不污染真 window
    set(target, property, value) {
      target[property] = value
      return true
    }
  })
}

执行子应用 JS 时,把代理 window 作为形参传入;同时兼容 UMD 产物里可能出现的 globalself

js
async function loadJS(url) {
  const res = await fetch(url)
  const text = await res.text()

  const proxyWindow = createProxyWindow()   // 每个子应用一个独立沙箱

  // 用 IIFE + 形参拦截 window / global / self 的访问
  ;(function (window, global, self) {
    eval(text)
  })(proxyWindow, proxyWindow, proxyWindow)
}

TIP

效果:子应用 1 挂的 window.a 落在自己的假 window 上;切到子应用 2,它读写的是另一个假 window,互不影响; 切走时假 window 被丢弃,状态恢复问题也顺带解决。 而 window.location 这类真 window 才有的东西,通过 get 兜底依然可用。

4.3 一个易错点:方法调用要 bind 回真 window

从代理里取到方法(比如 alert)直接调用时,this 会指向假 window,导致"非法的调用"(Illegal invocation)报错。所以 get 里遇到函数必须 bind(realWindow)

WARNING

遇到 Illegal invocation 报错,先检查是不是忘了给取出的方法绑定 this。

问题四:全局监听与定时器清理

第四个遗留问题:子应用挂的全局监听、setTimeout / setInterval,切走之后还在运行,会影响下一个子应用。关键难点是——你没法从 window 上直接查出"有哪些定时器"。所以思路反过来:挂载时登记,切换时清空

5.1 装饰者模式重写 addEventListener / setInterval

又是装饰者模式:保留原功能 + 把"用于移除的东西"登记进数组。

js
// 重写 addEventListener:记录事件类型和处理函数
function rewriteAddEventListener() {
  const original = realWindow.addEventListener
  return function (type, handler) {
    original.call(realWindow, type, handler)     // 原功能:正常绑定
    eventList.push({ type, handler })            // 新功能:登记,便于移除
  }
}

// 重写 setInterval:记录定时器 id
const originalSetInterval = window.setInterval
window.setInterval = function (handler, interval) {
  const timer = originalSetInterval(handler, interval)  // 原功能:正常计时
  timerList.push(timer)                                  // 新功能:登记,便于清除
  return timer
}
// setTimeout 同理,这里略

5.2 切换子应用前:统一清空

js
// 加载下一个子应用之前,清空上一个子应用的全局副作用
function clearEffects() {
  // 清空定时器
  timerList.forEach((timer) => clearInterval(timer))
  timerList.length = 0
  // 移除全局监听
  eventList.forEach(({ type, handler }) => {
    window.removeEventListener(type, handler)
  })
  eventList.length = 0
}

5.3 踩坑:popstate 监听被清掉了!

如果 addEventListener 重写在真 window 上,那么主应用自己的 popstate 监听也会被登记、被清空——结果切子应用时路由监听没了,切换失效。

解决:把重写后的 addEventListener 放到"假 window"上。子应用在沙箱里调用 window.addEventListener 时,命中的是假 window 上的版本,登记进数组;主应用在真 window 上挂的 popstate 监听不受影响。

js
// 在 createProxyWindow 里,给假 window 预置重写后的 addEventListener
fakeWindow.addEventListener = function (type, handler) {
  realWindow.addEventListener(type, handler)   // 原功能(直接调真 window 的原方法)
  eventList.push({ type, handler })            // 新功能:登记
}

WARNING

注意 setInterval 不能走假 window——代码里调用 setInterval 时一般不带 window 前缀,取不到假 window。 所以 setInterval 的重写必须留在真 window(全局)上。

生命周期钩子:mount / unmount + UMD 打包

最后一步,让框架更专业:主应用要能主动控制子应用的挂载和卸载(而不是简单粗暴地"顶掉"),并为后续异步挂载做准备。这需要改造子应用 + 用 UMD 打包。

6.1 子应用改造:暴露 mount / unmount

子应用入口 JS 改造:定义好挂载逻辑,然后判断自己是不是跑在微前端环境里——是独立运行就自己挂载,是微前端环境就把 mount / unmount 暴露出去

js
// 子应用入口(main.js)
function mountApp(props) {
  // 把子应用内容挂载到 #sun
  const root = document.createElement('div')
  root.innerHTML = '...子应用内容...'
  document.getElementById('sun').appendChild(root)
  // props:主应用可以顺便传一些信息过来
}

// 独立运行(npm run dev 直接调试)→ 直接挂载
if (!window.__MICRO_APP__) {
  mountApp()
} else {
  // 微前端环境 → 暴露生命周期方法,由主应用接管
  window.app1 = {
    mount: mountApp,
    unmount: () => {
      // 卸载逻辑:移除挂载内容、清理副作用
    }
  }
}

6.2 UMD 打包

普通打包的 JS 是立即执行的,主应用没法单独拿到 mount / unmount。要打包成 UMD 格式(Vite 的 lib 模式):运行时会把这些方法挂到 window[名字] 上,主应用就能取到了。

js
// 子应用的 vite.config.js —— UMD(库模式)打包配置
export default {
  build: {
    lib: {
      entry: 'src/main.js',
      name: 'app1',           // 挂到 window 上的名字,要和应用名一致
      formats: ['umd']
    }
  }
}

6.3 主应用接管:loadJS 后主动 mount

js
async function createSubApp(url, currentAppName) {
  const path = normalizeUrl(url || window.location.pathname)
  const targetAppName = path.split('/')[1]

  // 切到不同子应用时,先卸载当前子应用(同一子应用内切路由则不卸载)
  if (targetAppName !== currentAppName) {
    const prev = window[appMap[currentAppName] && appMap[currentAppName].name]
    if (prev && prev.unmount) prev.unmount()
  }
  if (targetAppName === currentAppName) return

  const resources = appMap[targetAppName]
  if (!resources) return

  await loadJS(resources.js)          // 执行子应用代码,挂载到 window[名字]
  await loadCSS(resources.css)

  // 从 window 上取出子应用暴露的生命周期钩子,主动挂载
  const module = window[resources.name]
  if (module && module.mount) {
    module.mount({ from: '主应用' })   // 还可以顺便传信息给子应用
  }
}

TIP

这样就兼容了两类子应用:做了生命周期改造的(有 mount / unmount,主应用接管)和 没改造的(没有 mount,走原来的直接挂载逻辑)。 qiankun 也是同样的思路——几乎所有成熟微前端框架都要求子应用 UMD 化、暴露生命周期钩子。

总结:我们离 qiankun 还有多远

两节课下来,我们手写了一个"比较接近、但比较简陋"的微前端框架,核心能力已经齐了:

能力我们的实现对应 qiankun
子应用注册 / 启动registerApps + startregisterMicroApps + start
路由切换重写 pushState + popstate 监听内部路由劫持与激活规则
样式隔离注入容器 + CSS 加前缀严格隔离(前缀 / shadow DOM)
JS 沙箱Proxy 代理 windowProxy 沙箱(多实例、快照等)
副作用清理装饰者模式登记 + 切换清空全局劫持与自动清理
生命周期mount / unmountbootstrap / mount / unmount / update

INFO

原理已经吃透,接下来学 qiankun 这类成熟框架就只是"熟悉它的 API 和细节"了。 下集预告:qiankun 框架的使用。

术语表 & 附录完整代码

术语表

术语定义
沙箱(JS 沙箱)让子应用运行在独立环境中,隔离 window、样式与副作用,互不干扰
代理 window(Proxy)用 Proxy 包装的假 window:get 先查假 window 再兜底真 window,set 只写假 window
作用域链变量查找规则:从最近的作用域逐层向外,IIFE 形参可以"拦截"全局变量
装饰者模式保留原方法功能,同时额外登记副作用(事件 / 定时器)以便清理
UMD 打包库格式打包,运行时会把它 export 的东西挂到 window[名字] 上
生命周期钩子子应用暴露的 mount / unmount 等方法,由主应用在合适时机调用

附录:微前端.js 完整代码(合并本课全部内容)

js
// ========== 状态 ==========
const appMap = {}
const eventList = []
const timerList = []

// ========== 注册与启动 ==========
function registerApps(apps) {
  apps.forEach((app) => {
    appMap[app.name] = { name: app.name, js: app.js, css: app.css }
  })
}

function start() {
  window.__MICRO_APP__ = true
  rewriteFn()
  initEvent()
  createSubApp()
}

// ========== 加载工具 ==========
function createProxyWindow() {
  const realWindow = window
  const fakeWindow = {}

  // 重写后的 addEventListener 放在假 window 上(避免清掉主应用自己的监听)
  fakeWindow.addEventListener = function (type, handler) {
    realWindow.addEventListener(type, handler)
    eventList.push({ type, handler })
  }

  return new Proxy(fakeWindow, {
    get(target, property) {
      if (property in target) return target[property]
      const value = realWindow[property]
      return typeof value === 'function' ? value.bind(realWindow) : value
    },
    set(target, property, value) {
      target[property] = value
      return true
    }
  })
}

async function loadJS(url) {
  const res = await fetch(url)
  const text = await res.text()
  const proxyWindow = createProxyWindow()
  ;(function (window, global, self) {
    eval(text)
  })(proxyWindow, proxyWindow, proxyWindow)
}

function addPrefixToCSS(cssText, parentId) {
  return cssText.replace(/\.([a-zA-Z_][\w-]*)/g, `#${parentId} .$1`)
}

async function loadCSS(url) {
  const res = await fetch(url)
  let text = await res.text()
  text = addPrefixToCSS(text, 'sun')   // 类名加前缀,防止污染主应用
  const style = document.createElement('style')
  style.textContent = text
  document.getElementById('sun').appendChild(style)  // 注入容器而非 body
}

function normalizeUrl(url) {
  return url.startsWith('http') ? new URL(url).pathname : url
}

// ========== 副作用清理 ==========
function clearEffects() {
  timerList.forEach((timer) => clearInterval(timer))
  timerList.length = 0
  eventList.forEach(({ type, handler }) => window.removeEventListener(type, handler))
  eventList.length = 0
}

// ========== 创建子应用 ==========
async function createSubApp(url, currentAppName) {
  const path = normalizeUrl(url || window.location.pathname)
  const targetAppName = path.split('/')[1]

  if (targetAppName !== currentAppName) {
    // 切到别的子应用:先卸载上一个 + 清理副作用
    const prev = window[appMap[currentAppName] && appMap[currentAppName].name]
    if (prev && prev.unmount) prev.unmount()
    clearEffects()
  }
  if (targetAppName === currentAppName) return

  const resources = appMap[targetAppName]
  if (!resources) return

  await loadJS(resources.js)
  await loadCSS(resources.css)

  const module = window[resources.name]
  if (module && module.mount) module.mount()   // 兼容已改造的子应用
}

// ========== 路由:重写与监听 ==========
function rewriteFn() {
  const originalPushState = window.history.pushState
  window.history.pushState = function (a, b, url) {
    const appName = window.location.pathname.split('/')[1]  // 触发前取当前应用名
    setTimeout(() => {
      window.dispatchEvent(new CustomEvent('popstate', { detail: { url, appName } }))
    }, 0)
    originalPushState.call(window.history, a, b, url)
  }

  // setInterval 不带 window 前缀调用,必须重写在真 window(全局)上
  const originalSetInterval = window.setInterval
  window.setInterval = function (handler, interval) {
    const timer = originalSetInterval(handler, interval)
    timerList.push(timer)
    return timer
  }
}

function initEvent() {
  window.addEventListener('popstate', (e) => {
    const detail = e.detail || {}
    const currentAppName = detail.appName || window.location.pathname.split('/')[1]
    createSubApp(detail.url || window.location.pathname, currentAppName)
  })
}

export { registerApps, start }

本博客内容基于 MIT 许可证发布,欢迎合理转载。