8

一、引言

微前端是什么?

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

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

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

  • 技术栈无关

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

  • 独立开发、独立部署

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

  • 增量升级

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

  • 独立运行时

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

演示一个微前端项目,其中菜单、地图都是微应用,菜单是vue项目,地图是h5项目,地图可独立运行,集成到基座中时原本入口的 html 会转换成 divhtml 里的 css 会被转换成 stylejs 会转换成字符串并通过 eval 函数直接执行。

微前端解决了什么问题?

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

如何实现微前端?

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

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

为什么选择qiankun?

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

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

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

2.1 JS沙箱简介

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

2.2 快照沙箱 - snapshotSandbox

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

  • demo演示

  • 实现代码
  // 子应用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
// 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];
    }
  });
}
  • 优点

    • 兼容几乎所有浏览器
  • 缺点

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

2.3 代理沙箱 - proxySandbox

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

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

  • Proxy vs Object.defineProperty

Object.defineProperty 也能实现基本操作的拦截和自定义,那为什么用 Proxy 呢?因为 Proxy 能解决以下问题:

  1. 删除或者增加对象属性无法监听到
  2. 数组的变化无法监听到vue2 正是使用的 Object.defineProperty 劫持属性,watch 中无法检测数组改变的元凶找到了)
  • demo演示

简单版本

实际场景版本

  • 实现代码
  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. 真实场景版本
<!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>

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

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

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

// b.js
window.b = 'this is b';
console.log('代理沙箱 b:', window.b);
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);`)
    });
}

代理沙箱代码

// 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. 不会污染window环境
  • 缺点
  1. 不兼容ie
  2. 在全局作用域上通过 varfunction 声明的变量和函数无法被代理沙箱劫持,因为代理对象 Proxy 只能识别在该对象上存在的属性,通过 varfunction 声明声明的变量是开辟了新的地址,自然无法被 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)

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

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

function b() {} // 失效
b = () => {} // 有效
window.b = () => {} // 有效

三、CSS隔离的设计与实现

3.1 CSS隔离简介

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

3.2 动态样式表 - Dynamic Stylesheet

3.3 工程化手段 - BEM、CSS Modules、CSS in JS

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

3.4 Shadow DOM

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

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

  • 代码实现
<!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>
// index.js
var bodyNode = document.getElementsByTagName('body')[0];
openShadow(bodyNode);
// scopedCss.js
function openShadow(domNode) {
  var shadow = domNode.attachShadow({ mode: 'open' });
  shadow.innerHTML = domNode.innerHTML;
  domNode.innerHTML = "";
}
  • 优点
  1. 完全隔离CSS样式
  • 缺点
  1. 在使用一些弹窗组件的时候(弹窗很多情况下都是默认添加到了 document.body )这个时候它就跳过了阴影边界,跑到了主应用里面,样式就丢了

3.5 运行时转换样式 - runtime css transformer

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

  • demo演示

  • 代码实现
<!-- 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>
// 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. 解决了 Shadow DOM 方案导致的丢失根节点问题
  • 缺点
  1. 运行时重新加载样式,会有一定性能损耗

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

4.1 清除js副作用简介

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

4.2 实现清除js操作副作用

  • demo演示

  • 代码实现
<!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>
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();
  }
}
// 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);
}

未完待续

下期会接着从应用接入的设计与实现通信的设计与实现应用路由监听的设计与实现继续探秘微前端技术,敬请期待,如果觉得本文内容对您有帮助,请点个赞支持,你们的支持就是偶更新滴动力!

参考资料:

微前端连载 6/7:微前端框架 - qiankun 大法好

qiankun官方文档


李霖
31 声望1 粉丝