Back
Featured image of post 微前端基本原理

微前端基本原理

微前端是近几年流行起来的概念,在工作中,我实践应用了微前端技术解决了一些项目中问题。这里计划写几篇文章,总结一下微前端相关的基础理论和实践。

什么是微前端

微前端是一种类似于微服务的架构,它将微服务的理念应用于 Web 开发中,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用独立运行,可以不同的团队独立开发,并具有同时构建和发布的能力。

这种架构可以提供很大的优势,比如简单、解耦的代码库、自治团队、独立发布和增量升级。开发过程大大加快,规模扩大,效率提高。

本质上来说,微前端提供了一种技术,可以将多个独立的 Web 应用聚合到一起,提供统一的访问入口 。我们既可以将大型的 Web 应用拆分为多个小型应用,又可以将多个小型应用聚合为一个应用。

技术价值

  • 技术栈无关,主框架不限制接入应用的技术栈,子应用具备完全自主权

  • 独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 独立运行时,每个子应用之间状态隔离,运行时状态不共享

解决问题

  • 新旧系统的合并,共同维护

  • “巨石应用”分解:功能复杂的 Web 应用,拆分为简单的应用

  • 渐进式技术栈升级:分模块升级一个系统的技术栈

微前端架构

微前端的一般架构模型如下

  • 宿主(主)应用:负责维护子应用的生命周期:挂载、路由、卸载,隔离不同的应用,防止不同的子应用之间互相影响,提供应用间通信能力等。

  • 子应用:独立的 Web 应用, 一般无特殊要求

image/image_1.png

技术选型

Why Not Iframe

优点

  1. iframe 提供了浏览器原生隔离方案,能解决 JS 隔离、样式隔离等问题

  2. 可以在子系统完全不修改的情况下嵌入进来

缺点

  1. 页面加载问题:影响主页面加载,阻塞onload事件,本身加载也很慢,页面缓存过多会导致电脑卡顿。(无法解决)

  2. 布局问题:iframe 必须给一个指定的高度,否则会塌陷。解决办法:子系统实时计算高度并通过 postMessage 发送给主页面,主页面动态设置高度,修改子系统或者代理插入脚本。有些情况会出现多个滚动条,用户体验不佳

  3. UI 不同步,DOM 结构不共享,如弹窗及遮罩层问题:只能在 iframe 范围内垂直水平居中,没法在整个页面垂直水平居中

  4. 浏览器前进/后退问题:iframe 和主页面共用一个浏览历史,iframe 会影响页面的前进后退,大部分时候正常,iframe 多次重定向则会导致浏览器的前进后退功能无法正常使用,不是全部页面都会出现,基本可以忽略。但是 iframe 页面刷新会重置(比如说从列表页跳转到详情页,然后刷新,会返回到列表页),因为浏览器的地址栏没有变化

  5. iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果

  6. iframe 的页面跳转到其他页面出问题,比如两个 iframe 之间相互跳转,直接跳转会只在 iframe 范围内跳转,所以必须通过主页面来进行跳转。不过 iframe 跳转的情况很少

  7. 不同源的系统之间的通讯需要通过 postMessage,存在一定的安全性

  8. Safari 下 iframe 无法设置 localStorage(待确定)

Single SPA

优点

  1. 用户体验好,内容的改变不需要重新加载整个页面,避免了不必要的跳转和重复渲染

  2. 共享 DOM 结构,UI 状态

缺点

  1. JS 和 CSS 隔离问题,容易造成全局污染,尤其是 vue 的全局组件,全局钩子

  2. 需要子系统配合修改 。但是不影响子系统独立开发部署,路由部分对子系统有一些改动,但是不影响功能。

JS 隔离

模块化的应用使得我们不用担心一般变量的冲突问题,所以 JS 隔离的重点 主要在于windowdocument 等全局变量隔离

qiankun

在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零

当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文

mfy

使用 iframe 生成 window 对象,通过 Proxy 将所有操作映射到 iframe 的 window 对象上。

使用 New Function 执行 JS 代码

export async function runScriptInSandbox(sandbox) {
  const { window, document, location, history } = sandbox
  const varNames = Object.keys(injectGlobalVars)

  const resolver = new Function(`
    return function(window, document, location, history${varNames.length ? ', ' + varNames.join(', ') : ''}) {
      ${scriptCode}
    }
  `)

  const varList = varNames.map(name => injectGlobalVars[name])
  return resolver()(window, document, location, history, ...varList)
}

样式隔离

  1. 控制 CSS 的命名空间:改造成本较高

  2. Shadow DOM:隔离性较好,浏览器原生支持

    • 问题 :样式仅作用于 Shadow host 元素下,会导致全局样式不起作用,如 Antd 的 Modal 组件是挂载在 document.body 的,样式就会失效
  3. Dynamic Stylesheet:在应用切出/卸载后,同时卸载掉其样式表

    • 问题 :子应用和主应用存在样式冲突的风险
      <html>
        <body>
          <main id="subApp">
            // 子应用完整的 html 结构
            <link rel="stylesheet" href="//alipay.com/subapp.css">
            <div id="root">....</div>
          </main>
        </body>
      </html>
      
  4. 工具链改造:生成的 CSS 自动添加前缀,如 app-tcb-layout

    • 问题:改造成本大

总得来说,目前并不存在一种完美的解决方案,在样式隔离上需要根据我们自己的需求做取舍,选择合适的方案。

路由

在微前端中,主应用需要管理子应用的路由切换,通常是通过劫持路由事件的方式来管理子应用的路由

// 监听 state 的变化
window.addEventListener('popstate', () => reactive('popstate'))
window.addEventListener('pushState', () => reactive('pushState'))
window.addEventListener('replaceState', () => reactive('replaceState'))
window.addEventListener('hashchange', () => reactive('hashchange'))

// hash 变化监听
window.addEventListener("hashchange", fn);
window.addEventListener("popstate", fn);

// 劫持 history api 操作
window.history.pushState = fn
window.history.replaceState = fn

App Entry

主应用如果加载子应用也是一个需要仔细考虑的问题,目前主要有两种方式:JS 加载和 HTML 加载。

JS Entry

加载单个 JS 文件作为入口,渲染子应用

优点

  1. 方便加载解析,可以直接通过 script 标签加载,无跨域问题

缺点

  1. 子应用的各类资源需要一起打包为一个 bundle,资源加载效率、缓存利用率变低

  2. 主应用需要预留容器节点,以供子应用挂载使用

<!-- 子应用 index.html -->
<script src="//unpkg/antd.min.js"></script>
<body>
  <main id="root"></main>
</body>

// 子应用入口
ReactDOM.render(<App/>, document.getElementById('root'))

HTML Entry

加载 HTML 文件作为入口,解析 HTML 文件,加载 JS,CSS 资源渲染应用。使用 DOMParser 解析 HTML

优点

  1. 减少应用的改造成本,容易接入

  2. 可以保留子应用完整的环境上下文,与独立开发时体验一致

  3. 支持并发加载资源,应用加载速度快

缺点

  1. 需要加载、解析 HTML 内容,存在跨域问题,实现较困难

  2. 子应用完全独立,公共依赖提取较困难

应用通信

微前端通常不会限制应用采用的框架,如何在不同的应用,框架之间进行通信是一个需要仔细考量的决定。

下面列出了一些常见的跨应用通信方法

1. 自定义事件

通过事件进行通信应该是最简单、通用的方案了

// 监听事件
window.addEventListener('message', (event) => {
  // 处理事件
});

// 触发事件
window.dispatchEvent(new CustomEvent('message', { detail: input.value }))

2. 发布-订阅

通过发布-订阅模式实现通信

import { Observable } from 'windowed-observable';

const observable = new Observable('konoha');
observable.subscribe((ninja) => {
  console.log(ninja)
})

observable.publish('Uchiha Shisui');

3. Web Workers

通过 Web Workers 进行事件通信

import Worky from 'worky'
const worker = new Worky("some-worker.js");

worker.on("eventName", function (some, data) {
  // 处理
});
worker.emit("someEvent", and, some, data);

4. 共享状态

主应用创建 state store,共享给子应用使用,适用于主、子应用技术栈相同的场景。

最后

从某种程度上来说,微前端也是一种架构思想的理论化总结,通过解耦、拆分将大型应用分解为多个小型应用,降低应用的维护难度。

微前端为构建大型 Web 应用带来一种新的解决方案,虽然它还不够完美,但是已经比较成熟,可以应用于生产环境中。

Ref

Licensed under CC BY-NC-SA 4.0