返回文章列表

从零理解微前端:为什么要把一个前端拆成好几个?

如果你刚接触微前端,第一个问题肯定是:"一个好好的前端项目,为什么要拆开?"

答案藏在"巨石应用"这个词里。当一个前端项目经过三五个团队、两三年迭代,代码量膨胀到几十万行,就会出现以下症状——改一个按钮样式,要跑完整条 CI 流水线;两个团队同时改一个文件,合并冲突能吵半天;想升级某个依赖版本,发现到处都在用,牵一发动全身。

微前端的思路和微服务一脉相承:把一个大应用拆成若干个独立的小应用(微应用),每个团队独立开发、独立部署、最后在浏览器里拼成完整页面

听起来很美。但要把多个独立的前端应用无缝拼在一个页面上,需要解决一系列技术问题。下面我们逐一拆解。


怎么把远程代码"吃"进来?

最原始也最直接的方式:用 fetch 把远程的 JS 文件拉下来,然后 eval 执行。

fetch('https://team-a.example.com/app.js')
  .then(res => res.text())
  .then(code => {
    eval(code); // 执行团队 A 的代码
  });

在这个基础上,你可以根据后端配置或者前端配置,动态决定加载哪些微应用。这就是"动态组件"的思路——不用 npm 安装依赖,运行时直接拉远程代码执行。

当然真实场景不会直接用裸 eval,而是配合 <script> 标签的动态插入、模块沙箱等机制来做,但核心思想是一致的:运行时加载,而非构建时打包


怎么让不同路由加载不同微应用?

拆开后,典型的分工是:/dashboard 归团队 A,/settings 归团队 B。那前端怎么知道当前该加载谁?

核心在于监听路由变化。路由变化有两个来源:

History 路由

也就是 history.pushState 这种前端路由。麻烦在于:浏览器原生 pushState 不会触发 popState 事件。所以如果我们想监听 pushState 行为,就得自己动手重写它:

const originalPushState = history.pushState;
history.pushState = function(...args) {
  // 先执行原始逻辑
  originalPushState.apply(history, args);
  // 然后触发我们自己的路由匹配逻辑
  handleRouteChange(window.location.pathname);
};

// popState 倒是不用重写,直接监听就行
window.addEventListener('popstate', () => {
  handleRouteChange(window.location.pathname);
});

硬导航

用户可能直接改地址栏回车,或者点了一个普通的 <a> 链接导致整页刷新。这类情况相对简单——页面加载时直接读 window.location.pathname 来决定该挂载哪个微应用。


样式怎么隔离?

最大的痛点来了。团队 A 写了一个 h1 { color: red },结果团队 B 的页面标题也红了——全局样式互相污染。

方案一:作用域前缀

给每个微应用的所有选择器前面加一个父级选择器,把影响范围限制在微应用的容器 DOM 里。相当于:

/* 团队 A 写的: */
h1 { color: red; }
/* 运行时自动变成: */
#micro-app-a h1 { color: red; }

大多数样式问题都能这样解决。但遇到 bodyhtml 这种全局标签选择器,或者在 JS 里动态创建的 <style> 标签就比较棘手。另外,有些 CSS 框架的全局 reset 样式也容易不受控。

方案二:Shadow DOM

这是浏览器原生的隔离方案。把微应用塞进一个 Shadow DOM 里,它内部的样式天然不会影响外部,外部的样式也不会渗透进去。这是最严格的隔离。

但 Shadow DOM 有学习成本,而且不是所有第三方库都兼容 Shadow DOM 环境(很多库默认往 document 上挂东西而非 shadow root)。微前端框架 qiankun 用的就是方案一 + 动态样式表管理,而更现代的方案开始在 Shadow DOM 方向探索。


JS 环境怎么隔离?

样式只是"污染",JS 环境的互相干扰才叫灾难。团队 A 往 window 上挂了一个 globalData,团队 B 也挂了一个同名的——后加载的覆盖先加载的,先加载的那个微应用就崩了。

思路是:让每个微应用以为自己拿到的 window 是独立的,但实际底层还是共享同一份。怎么做到?把自执行函数和 Proxy 结合起来。

// 让微应用代码在这个 IIFE 里运行
((window) => {
  eval(microAppCode); // 微应用代码
})(fakeWindow);

但这样 window 就是个空对象,微应用里所有 window.xxx 的读操作全失败了。所以我们需要一个 Proxy,在读的时候"穿透"回真正的 window,在写的时候拦截到微应用自己的对象上:

const original = window;
const microWindow = {};

const fakeWindow = new Proxy(microWindow, {
  get(target, property) {
    // 优先从自己的对象里读,没有就回退到真实 window
    const value = target[property] || original[property];

    // 关键细节:函数需要 bind 回真实的 window
    // 因为 window 上的函数(如 setTimeout)内部可能用到 this
    if (typeof value === 'function') {
      return value.bind(original);
    }
    return value;
  },
  set(target, property, value) {
    // 写操作只写在微应用自己的对象上,不动全局 window
    target[property] = value;
    return true;
  }
});

这个 Proxy 的本质是:读是穿透的(可以访问全局能力)、写是隔离的(不会污染全局)。函数特殊处理 bind 是因为 setTimeoutaddEventListener 等函数内部可能引用 this,不 bind 的话 this 指向会乱。


全局副作用怎么清理?

光隔离还不够。有些东西天然就是全局的——setTimeoutsetInterval、全局事件监听器。微应用 A 挂了个定时器,然后用户切到了微应用 B,A 的定时器还在跑,内存白占着。

所以微前端框架要重写这些全局方法,记录每个微应用创建的定时器和事件,等微应用卸载时统一清理:

  • 重写 setTimeout / setInterval,把返回的 timer ID 存起来
  • 重写 addEventListener,把监听器引用存起来
  • 监听路由变化(popstate),微应用切换时遍历清理

这其实也是微应用"卸载"(unmount)环节的核心工作。


生命周期:让微应用学会"配合"

上面讲的基本都是微前端框架在"管"微应用。但要配合得好,微应用也需要遵循一些约定——暴露固定的生命周期方法。

常见的约定是:

  • mount:微应用激活时调用,挂载 DOM、启动定时器
  • unmount:微应用切走时调用,清理 DOM、清除定时器
  • update(可选):父应用传新数据时调用

为了让框架能方便地拿到这些方法,微应用推荐用 UMD 模块格式打包。因为 UMD 运行时不依赖构建工具就能被 <script> 标签加载,而且直接挂到全局变量上,框架可以直接取到 mount / unmount 方法。ESM 和 CommonJS 通常需要构建环境,不太适合微前端的运行时加载场景。


总结

微前端的本质是对"巨石应用"的一次外科手术。手术刀在六个维度下刀:

  1. 动态加载:运行时拉远程代码,不做构建时打包
  2. 路由分发:重写 pushState + 监听 popState,让不同路由对应不同微应用
  3. 样式隔离:作用域前缀或 Shadow DOM,防止 CSS 互相污染
  4. JS 隔离:Proxy 实现读取穿透 + 写入隔离
  5. 副作用清理:重写全局定时器和事件监听,卸载时统一清
  6. 生命周期约定:微应用暴露 mount/unmount,让框架能调度

理解了这六个问题怎么解决,你就理解了微前端框架在干什么。qiankun、Micro-app、无界等框架虽然实现细节不同,但万变不离这六个核心。