Jamey's Jamey's
首页
导航站
  • 学习专栏

    • 《HTML》笔记
    • 《CSS》笔记
    • 《JavaScript》笔记
    • 《Vue》笔记
    • 《Git》笔记
    • 《规范》笔记
    • 《软技能》笔记
    • 《面试》笔记
    • 《持续集成&交付&部署》笔记
  • 踩坑专栏

    • 《Element-UI 实践系列》笔记
    • 《移动端 实践系列》笔记
    • 《综合》笔记
  • 配置专栏

    • 《环境系列》笔记
  • 极空间

    • Docker
  • 影视

    • movie
  • 编辑器笔记

    • 开发编辑器
  • 浏览器笔记

    • Chrome
  • Mac笔记

    • Mac
  • 跨界学习

    • 运营
  • 破解合集

    • 破解
  • 本站

    • 分类
    • 标签
    • 归档
  • 我的

    • 收藏
    • 书单
    • 关于

Jamey

首页
导航站
  • 学习专栏

    • 《HTML》笔记
    • 《CSS》笔记
    • 《JavaScript》笔记
    • 《Vue》笔记
    • 《Git》笔记
    • 《规范》笔记
    • 《软技能》笔记
    • 《面试》笔记
    • 《持续集成&交付&部署》笔记
  • 踩坑专栏

    • 《Element-UI 实践系列》笔记
    • 《移动端 实践系列》笔记
    • 《综合》笔记
  • 配置专栏

    • 《环境系列》笔记
  • 极空间

    • Docker
  • 影视

    • movie
  • 编辑器笔记

    • 开发编辑器
  • 浏览器笔记

    • Chrome
  • Mac笔记

    • Mac
  • 跨界学习

    • 运营
  • 破解合集

    • 破解
  • 本站

    • 分类
    • 标签
    • 归档
  • 我的

    • 收藏
    • 书单
    • 关于
  • 深入系列

  • 专题系列

  • underscore系列

  • ES6系列

  • 模块化

  • 正则表达式

  • 单元测试

  • 微前端

    • 30分钟快速掌握微前端 qiankun 的所有核心技术
      • 一. 引言
        • 1. 微前端是什么?
        • 2. 微前端解决了什么问题?
        • 3. 如何实现微前端?
        • 4. 为什么选择 qiankun?
      • 二. JS 沙箱隔离的设计与实现
        • 1. JS 沙箱简介
        • 2. 快照沙箱 - snapshotSandbox
        • 3. 代理沙箱 - proxySandbox
      • 三. CSS 隔离的设计与实现
        • 1. CSS 隔离简介
        • 2. 动态样式表 - Dynamic Stylesheet
        • 3. 工程化手段 - BEM、CSS Modules、CSS in JS
        • 4. Shadow DOM
        • 5. 运行时转换样式 - runtime css transformer
      • 四. 清除 js 副作用的设计与实现
        • 1. 清除 js 副作用简介
        • 2. 实现清除 js 操作副作用
      • 🌟. 参考资料
    • 基于 qiankun 的微前端应用实践
  • 实用函数

  • Rollup

  • 解决方案

  • 《JavaScript》笔记
  • 微前端
Jamey
2022-07-05
目录

30分钟快速掌握微前端 qiankun 的所有核心技术

# 30分钟快速掌握微前端 qiankun 的所有核心技术

# 一. 引言

# 1. 微前端是什么?

已经了解微前端的朋友可自行跳过本节,简单介绍下微前端,微前端是将前端更加细分化的一种技术方案,类似与后端微服务,下图所示 3 个可独立构建测试部署并可增量升级的不同技术栈应用,可以集成在一个基座应用中一起展示。

javascript_07-08_01

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构具备以下几个核心价值:

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

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

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

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

# 2. 微前端解决了什么问题?

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

# 3. 如何实现微前端?

实现微前端需要解决的技术问题有:

  1. 应用接入
  2. 应用入口
  3. 应用隔离
  4. 样式隔离
  5. 应用通信
  6. 应用路由

# 4. 为什么选择 qiankun?

  1. 在利用 Single SPA 或其它微应用框架构建前端系统中遇到的一些问题,如样式隔离、JS沙箱、资源预加载、JS 副作用处理等等这些你需要的能力全部内置到了 qiankun 里面。
  2. 到目前为止,已经大概有 2000+ 的应用,使用 qiankun 来接入自己的微前端体系。qiankun 在蚂蚁内外受过了大量线上系统的考验,所以它是一个值得信赖的生产可用的解决方案。

短短一年时间,qiankun 已然成为最热门的微前端框架之一,虽然源码一直在更新,但是它的核心技术始终是那么几个:JS 沙箱、CSS 样式隔离、应用 HTML 入口接入、应用通信、应用路由等, 接下来将通过演示 demo 的方式详细说明几种技术的设计与实现。

# 二. JS 沙箱隔离的设计与实现

# 1. JS 沙箱简介

JS 沙箱 简单点说就是,主应用有一套全局环境 window,子应用有一套私有的全局环境 fakeWindow,子应用所有操作都只在新的全局上下文中生效,这样的子应用就好比被一个个箱子装起来与主应用隔离, 因此主应用加载应用便不会造成JS 变量的相互污染、JS 副作用、CSS 样式被覆盖等,每个子应用的全局上下文都是独立的。

# 2. 快照沙箱 - snapshotSandbox

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

  • demo 演示

javascript_07-08_02

  • 实现代码
// 子应用A
mountSnapshotSandbox();
window.a = 123;
console.log("快照沙箱挂载后的a:", window.a); // 123
unmountSnapshotSandbox();
console.log("快照沙箱卸载后的a:", window.a); // undefined
mountSnapshotSandbox();
console.log("快照沙箱再次挂载后的a:", window.a); // 123
1
2
3
4
5
6
7
8
// snapshotSandbox.ts
// 遍历对象key并将key传给回调函数执行
function iter(obj: object, callbackFn: (prop: any) => void) {
  for (const prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      callbackFn(prop);
    }
  }
}

// 挂载快照沙箱
mountSnapshotSandbox()
{
  // 记录当前快照
  this.windowSnapshot = {} as Window;
  iter(window, (prop) => {
    this.windowSnapshot[prop] = window[prop];
  });

  // 恢复之前的变更
  Object.keys(this.modifyPropsMap).forEach((p: any) => {
    window[p] = this.modifyPropsMap[p];
  });
}
// 卸载快照沙箱
unmountSnapshotSandbox()
{
  // 记录当前快照上改动的属性
  this.modifyPropsMap = {};

  iter(window, (prop) => {
    if (window[prop] !== this.windowSnapshot[prop]) {
      // 记录变更,恢复环境
      this.modifyPropsMap[prop] = window[prop];
      window[prop] = this.windowSnapshot[prop];
    }
  });
}
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
36
37
38
  • 优点
    • 兼容几乎所有浏览器
  • 缺点
    • 无法同时有多个运行时快照沙箱,否则在 window 上修改的记录会混乱,一个页面只能运行一个单实例微应用

# 3. 代理沙箱 - proxySandbox

当有多个实例的时候,比如有 A、B 两个应用,A 应用就活在 A 应用的沙箱里面,B 应用就活在 B 应用的沙箱里面,A 和 B 无法互相干扰,这样的沙箱就是代理沙箱, 这个沙箱的实现思路其实也是通过 ES6 的 proxy (opens new window),通过代理特性实现的。

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。 简单来说就是,可以在对目标对象设置一层拦截。无论对目标对象进行什么操作,都要经过这层拦截。

javascript_07-08_03

  • Proxy vs Object.defineProperty
    Object.defineProperty 也能实现基本操作的拦截和自定义,那为什么用 Proxy 呢?因为 Proxy 能解决以下问题:
  1. 删除或者增加对象属性无法监听到。
  2. 数组的变化无法监听到(vue2 正是使用的 Object.defineProperty 劫持属性,watch 中无法检测数组改变的元凶找到了)
  • demo 演示

    • 简单版本

javascript_07-08_04

  • 实际场景版本

javascript_07-08_05

  • 实现代码
  1. 简单版本
const proxyA = new CreateProxySandbox({});
const proxyB = new CreateProxySandbox({});

proxyA.mountProxySandbox();
proxyB.mountProxySandbox();

(function (window) {
  window.a = "this is a";
  console.log("代理沙箱 a:", window.a); // undefined
})(proxyA.proxy);

(function (window) {
  window.b = "this is b";
  console.log("代理沙箱 b:", window.b); // undefined
})(proxyB.proxy);

proxyA.unmountProxySandbox();
proxyB.unmountProxySandbox();

(function (window) {
  console.log("代理沙箱 a:", window.a); // undefined
})(proxyA.proxy);

(function (window) {
  console.log("代理沙箱 b:", window.b); // undefined
})(proxyB.proxy);
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
  1. 真实场景版本
<!DOCTYPE html>
<html lang="en">
  <body data-qiankun-A>
    <h5>代理沙箱:</h5>
    <button onclick="mountA()">代理沙箱模式挂载a应用</button>
    <button onclick="unmountA()">代理沙箱模式卸载a应用</button>
    <button onclick="mountB()">代理沙箱模式挂载b应用</button>
    <button onclick="unmountB()">代理沙箱模式卸载b应用</button>

    <script src="proxySandbox.js"></script>
    <script src="index.js"></script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13

a 应用js,在 a 应用挂在期间加载的所有 js 都会运行在 a 应用的沙箱(proxyB.proxy)中

// a.js
window.a = "this is a";
console.log("代理沙箱1 a:", window.a);
1
2
3

b 应用js,在 b 应用挂载期间加载所有的 js 都会运行在 b 应用的沙箱(proxyB.proxy)中

// b.js
window.b = "this is b";
console.log("代理沙箱 b:", window.b);
1
2
3
const proxyA = new CreateProxySandbox({});
const proxyB = new CreateProxySandbox({});

function mountA() {
  proxyA.mountProxySandbox();

  fetch("./a.js")
    .then((response) => response.text())
    .then((scriptText) => {
      const sourceUrl = `//# sourceURL=a.js\n`;
      window.proxy = proxyA.proxy;
      eval(
        `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
      );
    });
}

function unmountA() {
  proxyA.unmountProxySandbox();
  fetch("./a.js")
    .then((response) => response.text())
    .then((scriptText) => {
      const sourceUrl = `//# sourceURL=a.js\n`;
      eval(
        `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
      );
    });
}

function mountB() {
  proxyB.mountProxySandbox();

  fetch("./b.js")
    .then((response) => response.text())
    .then((scriptText) => {
      const sourceUrl = `//# sourceURL=b.js\n`;
      window.proxy = proxyB.proxy;
      eval(
        `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
      );
    });
}

function unmountB() {
  proxyB.unmountProxySandbox();

  fetch("./b.js")
    .then((response) => response.text())
    .then((scriptText) => {
      const sourceUrl = `//# sourceURL=b.js\n`;
      eval(
        `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
      );
    });
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

代理沙箱代码

// proxySandbox.ts
function CreateProxySandbox(fakeWindow = {}) {
  const _this = this;
  _this.proxy = new Proxy(fakeWindow, {
    set(target, p, value) {
      if (_this.sandboxRunning) {
        target[p] = value;
      }

      return true;
    },
    get(target, p) {
      if (_this.sandboxRunning) {
        return target[p];
      }
      return undefined;
    },
  });

  _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
  • 优点
  1. 可同时运行多个沙箱
  2. 不会污染 window 环境
  • 缺点
  1. 不兼容 ie
  2. 在全局作用域上通过 var 或 function 声明的变量和函数无法被代理沙箱劫持,因为代理对象 Proxy 只能识别在该对象上存在的属性,通过 var 或 function 声明的变量开辟了新的地址,自然无法被 Proxy 劫持,比如
const proxy1 = new CreateProxySandbox({});
proxy1.mountProxySandbox();
(function (window) {
  mountProxySandbox();
  var a = "this is proxySandbox1";
  function b() {}
  console.log("代理沙箱1挂载后的a, b:", window.a, window.b); // undefined undefined
})(proxy1.proxy);

proxy1.unmountProxySandbox();
(function (window) {
  console.log("代理沙箱1卸载后的a, b:", window.a, window.b); // undefined undefined
})(proxy1.proxy);
1
2
3
4
5
6
7
8
9
10
11
12
13

一种解决方案是不用 var 和 function 声明全局变量和全局函数,比如

var a = 1; // 失效
a = 1; // 有效
window.a = 1; // 有效

function b() {} // 失效
b = () => {}; // 有效
window.b = () => {}; // 有效
1
2
3
4
5
6
7

javascript_07-08_06

# 三. CSS 隔离的设计与实现

# 1. CSS 隔离简介

页面中有多个微应用时,要确保 A 应用的样式不会影响 B 应用的样式,就需要对应用的样式采取隔离。

# 2. 动态样式表 - Dynamic Stylesheet

javascript_07-08_07

# 3. 工程化手段 - BEM、CSS Modules、CSS in JS

通过一系列约束和编译时生成不同类名、JS 中处理 CSS 生成不同类名来解决隔离问题

javascript_07-08_08

# 4. Shadow DOM

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树种 —— 它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样,隐藏的 DOM 样式和其余 DOM 是完全隔离的,类似于 iframe 的样式隔离效果。

javascript_07-08_09

移动端框架 Ionic 的组件样式隔离就是采用的 Shadow DOM 方案,保证相同组件的样式不会冲突。

javascript_07-08_10

  • demo 演示

javascript_07-08_11

  • 代码实现
<!DOCTYPE html>
<html lang="en">
  <body data-qiankun-A>
    <h5>样式隔离:</h5>
    <p class="title">一行文字</p>

    <script src="scopedCSS.js"></script>
    <script src="index.js"></script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
// index.js
var bodyNode = document.getElementsByTagName("body")[0];
openShadow(bodyNode);
1
2
3
// scopedCss.js
function openShadow(domNode) {
  var shadow = domNode.attachShadow({ mode: "open" });
  shadow.innerHTML = domNode.innerHTML;
  domNode.innerHTML = "";
}
1
2
3
4
5
6
  • 优点
    1.完全隔离 CSS 样式

  • 缺点
    2.在使用一些弹窗组件的时候(弹窗很多情况下都是默认添加到了 document.body )这个时候它就跳过了阴影边界,跑到了主应用里面,样式就丢了

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

动态运行时地去改变 CSS ,比如 A 应用的一个样式 p.title,转换后会变成 div[data-qiankun-A] p.title,div[data-qiankun-A] 是微应用最外层的容器节点,故保证 A 应用的样式只有在 div[data-qiankun-A] 下生效。

  • demo 演示

javascript_07-08_12

  • 代码实现
<!-- index.html -->
<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
19
// 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
  • 优点
    1.支持大部分样式隔离需求
    2.解决了 Shadow DOM 方案导致的丢失根节点问题

  • 缺点
    1.运行时重新加载样式,会有一定性能损耗

# 四. 清除 js 副作用的设计与实现

# 1. 清除 js 副作用简介

子应用在 沙箱 中使用 window.addEventListener、setInterval 这些 需异步监听的全局api 时,要确保子应用在移出时也要移出对应的监听事件,否则会对其他应用造成副作用。

# 2. 实现清除 js 操作副作用

  • demo 演示

javascript_07-08_13

  • 代码实现
<!DOCTYPE html>
<html lang="en">
  <body>
    <h5>清除window副作用:</h5>
    <button onclick="mountSandbox()">挂载沙箱并开启副作用</button>
    <button onclick="unmountSandbox(true)">卸载沙箱并关闭副作用</button>
    <button onclick="unmountSandbox()">普通卸载沙箱</button>

    <script src="proxySandbox.js"></script>
    <script src="patchSideEffects.js"></script>
    <script src="index.js"></script>
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
let mountingFreer;
const proxy2 = new CreateProxySandbox({});

function mountSandbox() {
  proxy2.mountProxySandbox();

  // 在沙箱环境中执行的代码
  (function (window, self) {
    with (window) {
      // 记录副作用
      mountingFreer = patchSideEffects(window);
      window.a = "this is proxySandbox2";
      console.log("代理沙箱2挂载后的a:", window.a); // undefined

      // 设置屏幕变化监听
      window.addEventListener("resize", () => {
        console.log("resize");
      });

      // 定时输出字符串
      setInterval(() => {
        console.log("Interval");
      }, 500);
    }
  }.bind(proxy2.proxy)(proxy2.proxy, proxy2.proxy));
}

/**
 * @param isPatch 是否关闭副作用
 */
function unmountSandbox(isPatch = false) {
  proxy2.mountProxySandbox();
  console.log("代理沙箱2卸载后的a:", window.a); // undefined
  if (isPatch) {
    mountingFreer();
  }
}
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
36
37
// patchSideEffects.js
const rawAddEventListener = window.addEventListener;
const rawRemoveEventListener = window.removeEventListener;

const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;

function patch(global) {
  const listenerMap = new Map();
  let intervals = [];

  global.addEventListener = (type, listener, options) => {
    const listeners = listenerMap.get(type) || [];
    listenerMap.set(type, [...listeners, listener]);
    return rawAddEventListener.call(window, type, listener, options);
  };

  global.removeEventListener = (type, listener, options) => {
    const storedTypeListeners = listenerMap.get(type);
    if (
      storedTypeListeners &&
      storedTypeListeners.length &&
      storedTypeListeners.indexOf(listener) !== -1
    ) {
      storedTypeListeners.splice(storedTypeListeners.indexOf(listener), 1);
    }
    return rawRemoveEventListener.call(window, type, listener, options);
  };

  global.clearInterval = (intervalId) => {
    intervals = intervals.filter((id) => id !== intervalId);
    return rawWindowClearInterval(intervalId);
  };

  global.setInterval = (handler, timeout, ...args) => {
    const intervalId = rawWindowInterval(handler, timeout, ...args);
    intervals = [...intervals, intervalId];
    return intervalId;
  };

  return function free() {
    listenerMap.forEach((listeners, type) =>
      [...listeners].forEach((listener) =>
        global.removeEventListener(type, listener)
      )
    );
    global.addEventListener = rawAddEventListener;
    global.removeEventListener = rawRemoveEventListener;

    intervals.forEach((id) => global.clearInterval(id));
    global.setInterval = rawWindowInterval;
    global.clearInterval = rawWindowClearInterval;
  };
}

function patchSideEffects(global) {
  return patch(global);
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

# 🌟. 参考资料

  • 微前端连载 6/7:微前端框架 - qiankun 大法好 (opens new window)
  • qiankun 官方文档 (opens new window)
#微前端
上次更新: 2022/07/20, 15:39:52
Jest 单元测试环境搭建
基于 qiankun 的微前端应用实践

← Jest 单元测试环境搭建 基于 qiankun 的微前端应用实践→

Theme by Vdoing | Copyright © 2017-2023 Jamey | blog 闽ICP备19022664号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式