Skip to content

从原理到手写一个微前端框架

微前端第一课 · 原理 + 手写实现 · Vue3 / React

课程地图

模块内容学习产出
背景为什么要用微前端(多系统整合的故事)理解微前端的价值
方案对比iframe 为什么不行掌握微前端要解决的痛点
核心原理打包产物、fetch、eval 三个知识点建立"项目 = 一段可执行的 JS"的心智模型
实战①用 eval 加载远程 JS/CSS(动态组件)手写微前端初步形态
实战②路由切换子应用 + 装饰者模式重写 pushState手写一个完整的微前端骨架
预告沙箱:样式隔离、作用域隔离、副作用清理认识微前端的下半场

INFO

先记住三个术语(后面会反复用到) · 主应用:提供头部、侧边栏、内容区空 div 的"架子" · 子应用:被加载进内容区的独立系统(Vue / React 都可以) · 动态组件:动态拉取目标 JS 并执行、渲染出内容的组件(微前端的初步形态)

背景:为什么需要微前端(先听故事)

我们通过一段职场情景把微前端的诞生讲透了,完整还原如下:

假设你入职一家公司,第一年独立开发了考勤系统。公司越来越大,你又陆续开发了财务系统、库存管理系统、用户关系管理系统——每一个都是独立项目。

慢慢地,三个痛点出现了:

  1. 员工在系统之间切来切去:打完卡要看工资,切到财务系统;管客户,又切到 CRM。体验非常割裂。
  2. 后悔没做成一个大项目:但不可能重写——系统里有 Vue2、Vue3、React,写法完全不同,靠复制粘贴合并根本不现实。
  3. 代码冲突:如果当初真把十几个前端塞进一个项目,代码天天冲突。

更麻烦的是,公司还要给不同角色开发不同站点:客户站点、领导站点、员工站点。这些站点有公共部分——比如领导要考勤,员工也要考勤,考勤系统必须被多个站点复用

于是理想方案呼之欲出:

  • 做一个整合的大项目,把已有系统像积木一样拼进来,不重复开发;
  • 每个系统保持独立开发、独立 git 仓库,哪个大系统需要,直接"接进来"。

INFO

这就是微前端要解决的核心问题:"积木式"整合 + 独立开发。 这也是大厂中后台系统普遍采用微前端架构的原因。

第一版方案:iframe 为什么不行

很多人的第一反应是用 iframe:

主应用只做一个头部 + 侧边栏 + 内容区(iframe)。点侧边栏菜单,就用 iframe 加载对应子系统的网址,子系统就显示在这一块。

这个方案确实"拼"起来了,但用着用着就会发现两个致命问题:

问题原因后果
通讯困难iframe 和主应用属于不同的 window无法像同一个项目那样传递消息、互相改数据,状态共享很难
登录态不共享iframe 是独立加载的在线网站主应用登录了,进每个子系统还要重新登录

WARNING

结论:iframe 能"拼",但拼不"通"。它没法让多个系统像一家人那样协作。

核心原理:三个知识点铺垫

在提出新方案之前,先梳理三个已有知识——这正是理解微前端原理的三把钥匙。

知识点 1:打包后的项目到底是什么

Vue / React 项目执行 npm run build 后得到 dist 目录。打开里面的 HTML 你会发现:里面几乎什么都没有,只加载了一段 JS。所有页面内容,都是执行这段 JS 绘制出来的。

INFO

结论:一个前端项目,本质上就是一段可以执行的 JS。

知识点 2:fetch 可以拉取远程的 JS / CSS

子应用打包好放到服务器上,我们就能通过 URL 用 fetch 把它请求下来,拿到完整的 JS 文本内容。

知识点 3:eval 可以把字符串当代码执行

拉下来的 JS 是一段文本,而 eval(text) 能把它当作代码执行。

三个知识点串起来,新方案就诞生了:

TIP

主应用 fetch 子应用的 JS → eval 执行 → 子应用渲染到内容区。 执行 JS 时用的是主应用的 window,所以完美避开了 iframe 的"不同 window"问题。

图:本课实现的微前端整体架构示意

动手第一步:用 eval 加载一个"远程组件"

4.1 准备一个静态服务器

用 Node 起一个最简单的静态服务器(开放跨域),端口 3000static 目录作为静态资源目录。

js
// server.js —— 简化实现
const http = require('http')
const fs = require('fs')
const path = require('path')

http.createServer((req, res) => {
  // 允许跨域,保证子应用 JS/CSS 能被主应用请求到
  res.setHeader('Access-Control-Allow-Origin', '*')

  const filePath = path.join(__dirname, 'static', req.url)
  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404)
      res.end('Not Found')
      return
    }
    const ext = path.extname(filePath)
    const mime = {
      '.js': 'text/javascript',
      '.css': 'text/css',
      '.html': 'text/html'
    }
    res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' })
    res.end(data)
  })
}).listen(3000, () => {
  console.log('server running at http://localhost:3000')
})

4.2 打包子应用并部署

bash
# 在子应用项目里执行
npm run build

dist 里打包出的 JS 和 CSS 复制到服务器的 static 目录。然后访问 http://localhost:3000/xxx.js,就能直接拿到 JS 文本。

WARNING

关键点:挂载节点 id 必须一致。 子应用的入口 JS 会把内容挂载到某个固定 id 的 div 上(本节是 #sun)。主应用准备的内容区 div,id 必须和它保持一致,执行子应用 JS 时内容才能渲染进正确位置。

4.3 核心代码:动态加载 JS + CSS

一个最简单的 HTML 演示页:

html
<div id="app">
  <h1>我是主应用</h1>
  <div id="sun"><!-- 子应用渲染到这里 --></div>
</div>
js
// 核心逻辑:请求子应用 JS,用 eval 执行
async function loadRemoteComponent() {
  // 1. 请求子应用打包出的 JS 文件
  const res = await fetch('http://localhost:3000/vue-app.js')

  // 2. 把响应流转换成文本(拿到的是字符串)
  const jsText = await res.text()

  // 3. eval 把字符串当代码执行 → 子应用挂载到 #sun
  eval(jsText)
}

// CSS 同理,但不能用 eval,而是创建 style 标签
async function loadRemoteStyle() {
  const res = await fetch('http://localhost:3000/vue-app.css')
  const cssText = await res.text()

  const style = document.createElement('style')
  style.textContent = cssText
  document.body.appendChild(style)
}

执行之后,子应用的页面内容就出现在内容区了——甚至路由都能正常工作

4.4 为什么这样能避开 iframe 的问题?

因为子应用的 JS 是拿到主应用里执行的。它本质上是"我(主应用)在自己的页面里执行了一段代码,这段代码把我的页面渲染了出来"——所以主应用和子应用共用同一个 window

共用 window,通讯、状态共享、登录态共享才有了基础(具体怎么做,是后面沙箱要解决的问题)。

4.5 里程碑:动态组件(微前端的初步形态)

做到这一步,你已经"发明"了微前端的初步形态——动态组件

INFO

动态组件 = 动态地引入目标 JS 代码并执行,渲染出想要的内容。

WARNING

但注意:它只是"加载了一个远程组件",还不算真正的微前端。微前端是很多独立项目集合在一个大项目里。 所以下一步,要加路由,让主应用能随着地址变化切换不同子应用。

进阶:用路由切换子应用

5.1 先明确两个概念

  • 主应用:提供侧边栏、头部,以及一个内容区空 div 的架子。
  • 子应用:被加载进内容区的系统,可以独立运行、独立调试(这是必须的,因为团队要并行开发)。

5.2 路由切换的基本逻辑

逻辑其实很简单:

INFO

监听地址变化 → 取出路径 → 匹配「哪个地址对应哪个子应用」→ fetch 子应用 JS/CSS → 执行渲染。

5.3 两种跳转方式,两种监听策略

路由变化分两种情况,这是本课最容易卡住的地方:

跳转方式特点监听策略
输入 URL 回车 / 原生 a 标签跳转整页重新加载,main.js 重新执行项目初始化时直接读 location.pathname
Vue / React 的 history 路由跳转页面不刷新,main.js 不会重新执行监听 popstate(需配合重写 pushState)

先把第一种搞定:初始化时读取路径,解析出子应用名,加载对应资源。

js
// APP_MAP:开发者配置的「路径 → 子应用资源」映射
const APP_MAP = {
  'app1': {
    js: 'http://localhost:3000/vue-app.js',
    css: 'http://localhost:3000/vue-app.css'
  },
  'app2': {
    js: 'http://localhost:3000/react-app.js',
    css: 'http://localhost:3000/react-app.css'
  }
}

// 加载并执行子应用 JS
async function loadJS(url) {
  const res = await fetch(url)
  const text = await res.text()
  eval(text) // 字符串 → 代码
}

// 加载子应用 CSS(创建 style 标签注入)
async function loadCSS(url) {
  const res = await fetch(url)
  const text = await res.text()
  const style = document.createElement('style')
  style.textContent = text
  document.body.appendChild(style)
}

// 创建子应用
async function createSubApp() {
  const path = window.location.pathname

  // 子应用可能还有自己的路由,比如 /app1/about
  // 所以只取第一段路径作为子应用名
  const appName = path.split('/')[1]

  const resources = APP_MAP[appName]
  if (!resources) return // 主应用里没有这个子应用

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

// 方式一:直接访问 / 刷新 → 页面重新加载 → 执行一次即可
createSubApp()

输入 /app1/app2,甚至 /app1/about,都能正确加载。但 history 模式路由跳转不刷新页面createSubApp() 不会再执行——这就要处理第二种方式。

5.4 补课:Vue / React 路由的本质

先搞明白一件事:在 Vue / React 项目里跳转路由(router-link、router.push、router.go),本质都是在调用浏览器的 history.pushState / history.go不会刷新页面

而浏览器有一个规范:

WARNING

原生 pushState 不会触发 popstate 事件;只有 history.go(-1)、浏览器前进后退等才会触发 popstate。

所以"监听 popstate"这条路,直接监听是收不到 pushState 跳转通知的。怎么办?——重写 pushState,让它主动触发 popstate

5.5 装饰者模式重写 pushState

"保留方法原功能,再加上新能力"——这正是设计模式里的装饰者模式。三步走:

  1. 先存下原方法(保留原功能);
  2. 重写方法;
  3. 重写后的方法里:加新功能 + 调用原方法。
js
// 1. 保留原方法
const originalPushState = window.history.pushState

// 2. 重写:加新功能(触发 popstate)+ 保留原功能(照常跳转)
window.history.pushState = function (a, b, url) {
  // 新功能:手动触发 popstate,并把目标地址放进事件 detail
  window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
  // 原功能
  originalPushState.call(window.history, a, b, url)
}

// 3. 监听 popstate,收到通知就加载对应子应用
window.addEventListener('popstate', (e) => {
  const url = e.detail ? e.detail.url : window.location.pathname
  createSubApp(url)
})

TIP

自定义事件携带数据,要放在 detail 字段里。

5.6 四个坑与修复

直接跑上面的代码,会发现四个问题,逐个修:

坑 1:popstate 里读 location.pathname 拿到的是旧地址

pushState 跳转的瞬间,事件回调里 location.pathname 还是上一个地址。解决:跳转时把目标地址通过 detail 带过去(上面的代码已经做了)。

坑 2:子应用内部路由跳转会带来完整的 http 地址

子应用自己也有路由(比如切到 /app1/about),主应用收到的可能是完整的 http://.../app1/about。需要归一化成路径:

js
// 把完整地址转成 pathname
function normalizeUrl(url) {
  if (url.startsWith('http')) {
    return new URL(url).pathname
  }
  return url
}

坑 3:同一子应用内切路由,重复加载了 JS

/app1 切到 /app1/about,是同一个子应用的内部路由,不该重新加载。解决:比较目标子应用名和当前子应用名,相同就直接返回:

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

  // 同一子应用内部的路由切换,不重复加载
  if (targetAppName === currentAppName) return

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

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

坑 4:Vue / React Router 也劫持了 pushState

Vue / React 的路由库自己也会重写 pushState。如果我们同步地 dispatch popstate,可能在页面跳转完成前就去加载子应用,导致各种奇怪问题。解决:异步触发——先让原页面跳转,再 dispatch:

js
window.history.pushState = function (a, b, url) {
  // 异步触发:先让页面完成跳转,再通知子应用加载
  setTimeout(() => {
    window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
  }, 0)
  originalPushState.call(window.history, a, b, url)
}

到此,路由切换就完整了:既兼容子应用内部路由,又兼容 URL 直达和 pushState 跳转。一个比较完整的微前端骨架搭建完成。

还没完:沙箱 —— 微前端的下半场

"大功告成"了吗?还没有。本节明确指出,目前这套实现还有 4 个问题,这正是下一课"沙箱"要解决的:

#问题现象
1样式冲突加载子应用 2 的 CSS 后,主应用本身的样式被"冲得稀巴烂";子应用之间、子应用与主应用若有相同类名,会互相干扰
2作用域冲突子应用 1 往 window 挂 a 变量,子应用 2 也挂 a,同一个 window 上互相覆盖
3状态恢复子应用 1 修改了 window 上的某个原有属性,切到子应用 2 时应恢复原样
4全局副作用清理子应用挂载的全局监听、setTimeout / setInterval,切走时要卸载,否则影响下一个子应用

INFO

所谓沙箱,就是让每个子应用拥有独立的 window 环境——在子应用里怎么改 window,都不影响其他子应用。 这也是 qiankun 等微前端框架核心要解决的问题之一。 下集预告:构建一个简单的沙箱环境。

术语表

术语定义
微前端将多个独立的前端应用整合进一个主应用的架构模式
主应用提供整体骨架(头部 / 侧边栏 / 内容区),负责加载和切换子应用
子应用独立开发、独立部署、可独立运行的业务系统
动态组件动态拉取目标 JS 并执行、渲染出内容的组件(微前端初步形态)
popstate浏览器历史记录变化时触发的事件
装饰者模式在不修改原方法的前提下,为其添加新能力的设计模式
沙箱为子应用提供独立环境,隔离 window、样式与副作用的机制

附录:完整代码清单

server.js(静态服务器)

js
const http = require('http')
const fs = require('fs')
const path = require('path')

http.createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*')
  const filePath = path.join(__dirname, 'static', req.url)
  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404)
      res.end('Not Found')
      return
    }
    const ext = path.extname(filePath)
    const mime = {
      '.js': 'text/javascript',
      '.css': 'text/css',
      '.html': 'text/html'
    }
    res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' })
    res.end(data)
  })
}).listen(3000)

主应用路由完整版(合并所有修复)

js
// ========== 1. 资源映射 ==========
const APP_MAP = {
  'app1': {
    js: 'http://localhost:3000/vue-app.js',
    css: 'http://localhost:3000/vue-app.css'
  },
  'app2': {
    js: 'http://localhost:3000/react-app.js',
    css: 'http://localhost:3000/react-app.css'
  }
}

// ========== 2. 加载工具 ==========
async function loadJS(url) {
  const res = await fetch(url)
  const text = await res.text()
  eval(text)
}

async function loadCSS(url) {
  const res = await fetch(url)
  const text = await res.text()
  const style = document.createElement('style')
  style.textContent = text
  document.body.appendChild(style)
}

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

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

  if (targetAppName === currentAppName) return // 同一子应用内切路由

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

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

// ========== 4. 方式一:页面加载 ==========
createSubApp()

// ========== 5. 方式二:重写 pushState + 监听 popstate ==========
const originalPushState = window.history.pushState

window.history.pushState = function (a, b, url) {
  setTimeout(() => {
    window.dispatchEvent(new CustomEvent('popstate', { detail: { url } }))
  }, 0)
  originalPushState.call(window.history, a, b, url)
}

window.addEventListener('popstate', (e) => {
  const url = e.detail ? e.detail.url : window.location.pathname
  createSubApp(url)
})

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