一、引言

微前端是什么?

曾经理解微前端的敌人可自行跳过本节,简略介绍下微前端,微前端是将前端更加细分化的一种技术计划,相似与后端微服务,下图所示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 无奈相互烦扰,这样的沙箱就是代理沙箱,这个沙箱的实现思路其实也是通过 ES6 的 proxy,通过代理个性实现的。

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.jswindow.a = 'this is a';console.log('代理沙箱1 a:', window.a);

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

// b.jswindow.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.tsfunction 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.jsvar bodyNode = document.getElementsByTagName('body')[0];openShadow(bodyNode);
// scopedCss.jsfunction 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.jsfunction 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.jsconst 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官网文档