# 微前端

# 1 什么是微前端?

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。即将前端应用拆分成多个可以独立开发部署的子应用,之后由主应用进行聚合控制。

主要有以下几个特点:

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

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

  • 增量升级 在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

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

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。

# 2 转态隔离

状态隔离主要是将各个子应用的 js、css 进行隔离。

# 2.1 iframe

iframe 天然支持 js 和 css 的隔离,但是现在主流微前端框架全部放弃使用 iframe。

引用微前端框架 qiankun (opens new window) 的介绍:

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

1、url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。 2、UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中.. 3、全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。 4、慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

# 2.2 js 沙箱

JS 沙箱简单点说就是,主应用有一套全局环境 window,子应用有一套假的私有的全局环境 Window,子应用所有操作都子应用全局上下文中生效。

# 2.2.1 快照沙箱 - snapshotSandbox

快照沙箱就是在应用沙箱挂载和卸载的时候记录全局变量的快照,在应用切换的时候依据快照恢复环境。

快照代码实现:

let snapshot = {}
let cacheStatus = {}

function mountSnapshotSandbox() {
  snapshot = {}

  // 保存当前快照
  Object.keys(window).forEach(key => {
    snapshot[key] = window[key]
  })

  // 恢复之前加载的子应用修改过的转态
  Object.keys(cacheStatus).forEach(key => {
    window[key] = cacheStatus[key]
  })
}

function unmountSnapshotSandbox() {
  cacheStatus = {}

  // 根据快照恢复 window
  Object.keys(window).forEach(key => {
    if (window[key] !== snapshot[key]) {
      // 保存当前子应用修改的状态,下次再加载时恢复
      cacheStatus[key] = window[key]
      window[key] = snapshot[key]
    }
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

使用:

mountSnapshotSandbox()
window.test = 1
console.log(window.test) // 1

unmountSnapshotSandbox()
console.log(window.test) // undefined

mountSnapshotSandbox()
console.log(window.test) // 1
1
2
3
4
5
6
7
8
9

优点: 兼容几乎所有浏览器

缺点: 无法同时有多个运行时快照沙箱,否则在 window 上修改的记录会混乱,一个页面只能运行一个单实例微应用

# 2.2.2 代理沙箱 - proxySandbox

代理沙箱,是通过 ES6 的 proxy 特性实现的。每个子应用 js 都仅在自己的代理沙箱生效。

代码实现:

const rawAddEventListener = window.addEventListener.bind(window)
const rawSetTimeout = window.setTimeout.bind(window)

function createProxySandbox(fakeWindow = {}) {
  fakeWindow.addEventListener = rawAddEventListener
  fakeWindow.setTimeout = rawSetTimeout

  const _this = this
  _this.proxy = new Proxy(fakeWindow, {
    get(target, p) {
      if (_this.sandboxRunning) {
        return target[p]
      }
      return undefined
    },
    set(target, p, value) {
      if (_this.sandboxRunning) {
        target[p] = value
      }

      return true
    }
  })

  _this.mountProxySandbox = () => {
    _this.sandboxRunning = true
  }

  _this.unmountProxySandbox = () => {
    _this.sandboxRunning = false
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

使用:

const proxySandbox1 = new createProxySandbox()
const proxySandbox2 = new createProxySandbox()

const box1js = `
  window.test = 1;
  console.log('box1 test is ' + window.test);
`
const box2js = `
  window.test = 2;
  console.log('box2 test is ' + window.test);
`
proxySandbox1.mountProxySandbox()
proxySandbox2.mountProxySandbox()

run(box1js, proxySandbox1.proxy) // 1
run(box2js, proxySandbox2.proxy) // 2

proxySandbox1.unmountProxySandbox()
run(box1js, proxySandbox1.proxy) // undefined

function run(jsStr, fakeWindow) {
  eval(`
    ;((function(window) {
      with(window) {
        ${jsStr}
      }
    }))(fakeWindow)
  `)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

优点:

  • 可同时运行多个沙箱
  • 不会污染 window 环境

缺点:

  • 不兼容 ie
  • 使用 var 或 function 声明的变量和函数无法被代理沙箱劫持,只能通过 window.xxx 设置或者不使用 var,直接声明

# 2.3 css 隔离

# 2.3.1 动态样式表

加载 A 子应用时使用 A 的样式表,加载 B 子应用时使用 B 的样式表,

优点:

  • 简单、不会冲突

缺点:

  • 无法多个子应用共存

# 2.3.2 BEM Block-Element-Modifier

不同项目用不同的前缀命名

优点:

  • 简单

缺点:

  • 依赖不同团队之间的约定,容易出错

# 2.3.3 CSS Modules

通过编译时生成不同的选择器名

优点:

  • 避免人工约束,简单

缺点:

  • 需要在子应用构建的时候约束使用工具构建

# 2.3.4 CSS-in-JS

css 写在 js 中,由 js 注入控制

优点:

  • 可以彻底避免冲突

缺点:

  • 运行时开销较大,而且确实完整 css 的功能

# 2.3.5 Shadow DOM

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中,DOM 和 CSS 是完全隔离的

优点:

  • 可以彻底避免冲突

缺点:

  • 使用弹窗组件的时候 DOM 一般会添加到外层 body 上,样式就会失效

# 2.3.6 运行时转换样式 - runtime css transformer

动态运行时地去改变 CSS ,比如 A 应用的一个样式 p.title,转换后会变成 div[data-A] p.title。

div[data-A] 是微应用最外层的容器节点,故保证 A 应用的样式只有在 div[data-A] 下生效。

实现:

// scopedCSS.js
function scopeCss(styleNode, prefix) {
  const css = ruleStyle(styleNode.sheet.cssRules[0], prefix);
  styleNode.textContent = css;
}

function ruleStyle(rule, prefix) {
  const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;

  let { cssText } = rule;

  // 绑定选择器, a,span,p,div { ... }
  cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
    selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
      // 绑定 div,body,span { ... }
      if (rootSelectorRE.test(item)) {
        return item.replace(rootSelectorRE, (m) => {
          // 不要丢失有效字符 如 body,html or *:not(:root)
          const whitePrevChars = [",", "("];

          if (m && whitePrevChars.includes(m[0])) {
            return `${m[0]}${prefix}`;
          }

          // 用前缀替换根选择器
          return prefix;
        });
      }

      return `${p}${prefix} ${s.replace(/^ */, "")}`;
    })
  );

  return cssText;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

使用:

<html lang="en">
  <head>
    <style>
      p.title {
        font-size: 20px;
      }
    </style>
  </head>
  <body data-qiankun-A>
    <p class="title">一行文字</p>

    <script src="scopedCSS.js"></script>
    <script>
      var styleNode = document.getElementsByTagName("style")[0];
      scopeCss(styleNode, "body[data-qiankun-A]");
    </script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

优点:

  • 支持大部分样式隔离需求
  • 解决了 Shadow DOM 方案导致的丢失根节点问题

缺点:

  • 运行时重新加载样式,会有一定性能损耗
最后更新时间: 8/14/2021, 3:33:36 PM