微前端框架qiankun源码剖析之下篇

引言

承接上文  微前端框架qiankun源码剖析之上篇

注意: 受篇幅限制,本文中所粘贴的代码都是经过作者删减梳理后的,只为讲述qiankun框架原理而展示,并非完整源码。如果需要阅读相关源码可以自行打开文中链接。

四、沙箱隔离

在基于single-spa开发的微前端应用中,子应用开发者需要特别注意的是:

要谨慎修改和使用全局变量上的属性(如window、document等),以免造成依赖该属性的自身应用或其它子应用运行时出现错误;

要谨慎控制CSS规则的生效范围,避免覆盖污染其它子应用的样式;

但这样的低级人为保证机制是无法在大规模的团队开发过程中对应用的独立性起到完善保护的,而qiankun框架给我们提供的最便利和有用的功能就是其基于配置的自动化沙箱隔离机制了。有了框架层面的子应用隔离支持,用户无论是在编写JS代码还是修改CSS样式时都不必再担心代码对于全局环境的污染问题了。沙箱机制一方面提升了微应用框架运行的稳定性和独立性,另一方面也降低了微前端开发者的心智负担,让其只需专注于自己的子应用代码开发之中。

4.1 JS隔离

在JS隔离方面,qiankun为开发者提供了三种不同模式的沙箱机制,分别适用于不同的场景之中。

1. Snapshot沙箱

该沙箱主要用于不支持Proxy对象的低版本浏览器之中,不能由用户手动指定该模式,qiankun会自动检测浏览器的支持情况并降级到Snapshot沙箱实现。由于这种实现方式在子应用运行过程中实际上修改了全局变量,因此不能用于多例模式之中(同时存在多个已挂载的子应用)。

该沙箱实现方式非常简洁,下面我们给出其简化后的实现(源码地址github.com/umijs/qiank…

// 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
export default class SnapshotSandbox implements SandBox {
  private windowSnapshot!: Window;
  private modifyPropsMap: Record = {};
  constructor() {}
  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });
    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });
  }
  inactive() {
    this.modifyPropsMap = {};
    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });
  }
}

沙箱内部存在两个对象变量windowSnapshotmodifyPropsMap ,分别用来存储子应用挂载前原始window对象上的全部属性以及子应卸载时被其修改过的window对象上的相关属性。

Snapshot沙箱会在子应用mount前将modifyPropsMap中存储的属性重新赋值给window以恢复该子应用之前执行时的全局变量上下文,并在子应用unmount后将windowSnapshot中存储的属性重新赋值给window以恢复该子应用运行前的全局变量上下文,从而使得两个不同子应用的window相互独立,达到JS隔离的目的。

2. Legacy沙箱

当用户手动配置sandbox.loose: true时该沙箱被启用。Legacy沙箱同样会对window造成污染,但是其性能比要比snapshot沙箱好,因为该沙箱不用遍历window对象。同样legacy沙箱也只适用于单例模式之中。

下面一起来看一下其简化后的大致实现方式(源码地址github.com/umijs/qiank…

/**
 * 基于 Proxy 实现的沙箱
 * TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
 */
export default class LegacySandbox implements SandBox {
  /** 沙箱代理的全局变量 */
  proxy: WindowProxy;
  /** 沙箱期间新增的全局变量 */
  private addedPropsMapInSandbox = new Map();
  /** 沙箱期间更新的全局变量 */
  private modifiedPropsOriginalValueMapInSandbox = new Map();
  /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
  private currentUpdatedPropsValueMap = new Map();
  constructor() {
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
    const rawWindow = window;
    const fakeWindow = Object.create(null) as Window;
    const setTrap = (p: PropertyKey, value: any, originalValue: any) => {
      if (!rawWindow.hasOwnProperty(p)) {
        // 当前 window 对象不存在该属性,将其记录在新增变量之中
        addedPropsMapInSandbox.set(p, value);
      } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
        // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
        modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
      }
      // 无论何种修改都记录在currentUpdatedPropsValueMap中
      currentUpdatedPropsValueMap.set(p, value);
      // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
      (rawWindow as any)[p] = value;
    };
    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        const originalValue = (rawWindow as any)[p];
        return setTrap(p, value, originalValue, true);
      },
      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window or use window.top to check if an iframe context
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }
        const value = (rawWindow as any)[p];
        return value;
      },
    });
    this.proxy = proxy
  }
  active() {
    // 激活时将子应用之前的所有改变重新赋予window,恢复其运行时上下文
    this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
  }
  inactive() {
    // 卸载时将window上修改的值复原,新添加的值删除
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
  }
  private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
    if (value === undefined && toDelete) {
      delete (this.globalContext as any)[prop];
    } else {
      (this.globalContext as any)[prop] = value;
    }
  }
}

Legacy沙箱为一个空对象fakewindow使用proxy代理拦截了其全部的set/get等操作,并在loader中用其替换了window。当用户试图修改window属性时,fakewindow上代理的set操作生效捕获了相关修改,其分别将新增的属性和修改前的值存入addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox这两个Map之中,此外还将所有修改记录在了currentUpdatedPropsValueMap之中,并改变了window对象。

这样当子应用挂载前,legacy沙箱会将currentUpdatedPropsValueMap之中记录的子应用相关修改重新赋予window,恢复其运行时上下文。当子应用卸载后,legacy沙箱会遍历addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox这两个Map并将window上的相关值恢复到子应用运行之前的状态。最终达到了子应用间JS隔离的目的。

3. Proxy沙箱

Proxy沙箱是qiankun框架中默认使用的沙箱模式(也可以通过配置sandbox.loose: false来开启),只有该模式真正做到了对window的无污染隔离(子应用完全不能修改全局变量),因此可以被应用在单/多例模式之中。

Proxy沙箱的原理也非常简单,它将window上的所有属性遍历拷贝生成一个新的fakeWindow对象,紧接着使用proxy代理这个fakeWindow,用户对window操作全部被拦截下来,只作用于在这个fakeWindow之上(源码地址github.com/umijs/qiank…

// 便利window拷贝创建初始代理对象
function createFakeWindow(globalContext: Window) {
  const fakeWindow = {} as FakeWindow;
  Object.getOwnPropertyNames(globalContext)
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
    });
  return { fakeWindow };
}
/**
 * 基于 Proxy 实现的沙箱
 */
export default class ProxySandbox implements SandBox {
  // 标志该沙箱是否被启用
  sandboxRunning = true;
  constructor() {
    const { fakeWindow } = createFakeWindow(window);
    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if(this.sandboxRunning){
          // 修改代理对象的值
          target[p] = value;
          return true; 
        }
      }
      get: (target: FakeWindow, p: PropertyKey): any => {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        if (p === 'window' || p === 'self' || p === 'globalThis') {
          return proxy;
        }
        // 获取代理对象的值
      	const value = target[p];
        return value;
      },
    })
  }
  active() {
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }
  inactive() {
    this.sandboxRunning = false;
  }
}

4.2 CSS隔离

对于CSS隔离的方式,在默认情况下由于切换子应用时,其相关的CSS内外连属性会被卸载掉,所以可以确保单实例场景子应用之间的样式隔离,但是这种方式无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。不过,qiankun也提供了两种可配置生效的内置方式供使用者选择。

1. ShadowDOM

当用户配置sandbox.strictStyleIsolation: true时,ShadowDOM样式沙箱会被开启。在这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。(源码地址github.com/umijs/qiank…

// 在子应用的DOM树最外层进行一次包裹
function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  // 包裹节点
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // 子应用最外层节点
  const appElement = containerElement.firstChild as HTMLElement;
  // 当开启了ShadowDOM沙箱时
  if (strictStyleIsolation) {
    const { innerHTML } = appElement;
    appElement.innerHTML = '';
    let shadow: ShadowRoot;
		// 判断浏览器兼容的创建ShadowDOM的方式,并使用该方式创建ShadowDOM根节点
    if (appElement.attachShadow) {
      shadow = appElement.attachShadow({ mode: 'open' });
    } else {
      // createShadowRoot was proposed in initial spec, which has then been deprecated
      shadow = (appElement as any).createShadowRoot();
    }
    // 将子应用内容挂在ShadowDOM根节点下
    shadow.innerHTML = innerHTML;
  }
	// 。。。。。。
  return appElement;
}

这种方式虽然看起来清晰简单,还巧妙利用了浏览器对于ShadowDOM的CSS隔离特性,但是由于ShadowDOM的隔离比较严格,所以这并不是一种无脑使用的方案。例如:如果子应用内存在一个弹出时会挂在document根元素的弹窗,那么该弹窗的样式是否会受到ShadowDOM的影响而失效?所以开启该沙箱的用户需要明白自己在做什么,且可能需要对子应用内部代码做出一定的调整。

2. Scoped CSS

因为ShadowDOM存在着上述的一些问题,qiankun贴心的为用户提供了另一种更加无脑简便的样式隔离方式,那就是Scoped CSS。通过配置sandbox.experimentalStyleIsolation: true,Scoped样式沙箱会被开启。

在这种模式下,qiankun会遍历子应用中所有的CSS选择器,通过对选择器前缀添加一个固定的带有该子应用标识的属性选择器的方式来限制其生效范围,从而避免子应用间、主应用与子应用的样式相互污染。(源码地址github.com/umijs/qiank…

export const QiankunCSSRewriteAttr = 'data-qiankun';
// 在子应用的DOM树最外层进行一次包裹
function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  // 包裹节点
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // 子应用最外层节点
  const appElement = containerElement.firstChild as HTMLElement;
  // 。。。。。。
  // 当开启了Scoped CSS沙箱时
  if (scopedCSS) {
    // 为外层节点添加qiankun自定义属性,其值设定为子应用id标识
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }
		// 获取子应用中全部样式并进行处理
    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }
  return appElement;
}

qiankun首先对子应用最外层的包裹节点(一般为div节点)添加一个属性名为data-qiankun,值为appInstanceId的属性。接着遍历处理子应用中的所有样式(源码地址github.com/umijs/qiank…

export const process = (
  appWrapper: HTMLElement,
  stylesheetElement: HTMLStyleElement | HTMLLinkElement,
  appName: string,
): void => {
  // lazy singleton pattern
  if (!processor) {
    processor = new ScopedCSS();
  }
	// !!!注意,对于link标签引入的外联样式不支持。qiankun在初期解析使用的import-html-entry在解析html模版时会将所有外联样式拉取并转换为style标签包裹的内联样式,所以这里不再处理link的外联样式。
  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }
  const mountDOM = appWrapper;
  if (!mountDOM) {
    return;
  }
	// 获取包裹元素标签
  const tag = (mountDOM.tagName || '').toLowerCase();
  if (tag && stylesheetElement.tagName === 'STYLE') {
    // 生成属性选择器前缀,准备将其添加在选择器前(如div[data-qiankun=app1])
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
    processor.process(stylesheetElement, prefix);
  }
};
// 。。。。。。
process(styleNode: HTMLStyleElement, prefix: string = '') {
  if (styleNode.textContent !== '') {
    // 获取相关css规则rules
    const textNode = document.createTextNode(styleNode.textContent || '');
    this.swapNode.appendChild(textNode);
    const sheet = this.swapNode.sheet as any; // type is missing
    const rules = arrayify(sheet?.cssRules ?? []);
    // 重写这些CSS规则,将前缀添加进去
    const css = this.rewrite(rules, prefix);
    // 用重写后的CSS规则覆盖之前的规则
    styleNode.textContent = css;
    // 标志符,代表该节点已经处理过
    (styleNode as any)[ScopedCSS.ModifiedTag] = true;
    return;
  }
	// 监听节点变化
  const mutator = new MutationObserver((mutations) => {
    for (let i = 0; i < mutations.length; i += 1) {
      const mutation = mutations[i];
      // 忽略已经处理过的节点
      if (ScopedCSS.ModifiedTag in styleNode) {
        return;
      }
      // 如果新增了未处理过的子节点(代表了用户新注入了一些属性),那么会再次重写所有的CSS规则以确保新增的CSS不会污染子应用外部
      if (mutation.type === 'childList') {
        const sheet = styleNode.sheet as any;
        const rules = arrayify(sheet?.cssRules ?? []);
        const css = this.rewrite(rules, prefix);
        styleNode.textContent = css;
        (styleNode as any)[ScopedCSS.ModifiedTag] = true;
      }
    }
	});
  // 注册监听
  mutator.observe(styleNode, { childList: true });
}
// 具体CSS规则重写方式
private rewrite(rules: CSSRule[], prefix: string = '') {
  // 。。。。。。
  // 这里省略其实现方式,整体实现思路简单但步骤很繁琐,主要就是对字符串的正则判断和替换修改。
  // 1. 对于根选择器(html/body/:root等),直接将其替换为prefix
  // 2. 对于其它选择器,将prefix放在最前面( selector1 selector2, selector3 =》 prefix selector1 selector2,prefix selector3)
}

可以看到,qiankun通过为子应用的外层包裹元素注入属性并将子应用全部样式的作用范围都限制在该包裹元素下(通过添加指定的属性选择器作为前缀)实现了scoped样式沙箱隔离。需要注意的是,如果用户在运行时对内联样式进行修改,qiankun是可以侦测到并帮助用户限制其作用范围,但如果用户在运行时引入了新的外联样式或者自行创建了新的内联标签,那么qiankun并不会做出反应,相关的CSS规则还是可能会污染全局样式。

五、通信方式

对于微前端来说,除了应用间的隔离外,应用间的通信也是非常重要的部分。这里,single-spa提供了从主应用向子应用传递customProps的方式实现了最基础的参数传递。但是真实的开发场景需要的信息传递是非常复杂的,静态的预设参数传递只能起到很小的作用,我们还需要一种更加强大的通信机制来帮助我们开发应用。

这里,qiankun在框架内部预先设计实现了完善的发布订阅模式,降低了开发者的上手门槛。我们首先来看一下qiankun中的通信是如何进行的。

// ------------------主应用------------------
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
// 在当前应用监听全局状态,有变更触发 callback
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
// 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
actions.setGlobalState(state);
// 移除当前应用的状态监听,微应用 umount 时会默认调用
actions.offGlobalStateChange();
// ------------------子应用------------------
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });
  props.setGlobalState(state);
}

接下来,让我们一起来看一下它是如何实现的。(源码地址github.com/umijs/qiank…

import { cloneDeep } from 'lodash';
import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces';
// 全局状态
let globalState: Record = {};
// 缓存相关的订阅者
const deps: Record = {};
// 触发全局监听
function emitGlobal(state: Record, prevState: Record) {
  Object.keys(deps).forEach((id: string) => {
    if (deps[id] instanceof Function) {
      // 依次通知订阅者
      deps[id](cloneDeep(state), cloneDeep(prevState));
    }
  });
}
// 初始化
export function initGlobalState(state: Record = {}) {
  if (state === globalState) {
    console.warn('[qiankun] state has not changed!');
  } else {
    const prevGlobalState = cloneDeep(globalState);
    globalState = cloneDeep(state);
    emitGlobal(globalState, prevGlobalState);
  }
  // 返回相关方法,形成闭包存储相关状态
  return getMicroAppStateActions(`global-${+new Date()}`, true);
}
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
  return {
    /**
     * onGlobalStateChange 全局依赖监听
     *
     * 收集 setState 时所需要触发的依赖
     *
     * 限制条件:每个子应用只有一个激活状态的全局监听,新监听覆盖旧监听,若只是监听部分属性,请使用 onGlobalStateChange
     *
     * 这么设计是为了减少全局监听滥用导致的内存爆炸
     *
     * 依赖数据结构为:
     * {
     *   {id}: callback
     * }
     *
     * @param callback
     * @param fireImmediately 是否立即执行callback
     */
    onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      if (!(callback instanceof Function)) {
        console.error('[qiankun] callback must be function!');
        return;
      }
      if (deps[id]) {
        console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
      }
      / 注册订阅
      deps[id] = callback;
      if (fireImmediately) {
        const cloneState = cloneDeep(globalState);
        callback(cloneState, cloneState);
      }
    },
    /**
     * setGlobalState 更新 store 数据
     *
     * 1. 对输入 state 的第一层属性做校验,只有初始化时声明过的第一层(bucket)属性才会被更改
     * 2. 修改 store 并触发全局监听
     *
     * @param state
     */
    setGlobalState(state: Record = {}) {
      if (state === globalState) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      const changeKeys: string[] = [];
      const prevGlobalState = cloneDeep(globalState);
      globalState = cloneDeep(
        Object.keys(state).reduce((_globalState, changeKey) => {
          if (isMaster || _globalState.hasOwnProperty(changeKey)) {
            changeKeys.push(changeKey);
            return Object.assign(_globalState, { [changeKey]: state[changeKey] });
          }
          console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
          return _globalState;
        }, globalState),
      );
      if (changeKeys.length === 0) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      // 触发全局监听
      emitGlobal(globalState, prevGlobalState);
      return true;
    },
    // 注销该应用下的依赖
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

可以看到在initGlobalState函数的执行中完成了一个发布订阅模式的创建工作,并返回了相关的订阅/发布/注销方法。接着qiankun将这些返回的方法通过生命周期函数mount传递给子应用,这样子应用就能够拿到并使用全局状态了,从而应用间的通信就得以实现了。此外offGlobalStateChange会在子应用unmount时自动调用以解除该子应用的订阅,避免内存泄露。(第三节子应用加载中的代码已经提到,源码参见github.com/umijs/qiank…

六、结语

qiankun在single-spa的基础上进行了二次封装,分别从子应用加载方式、应用间沙箱隔离、应用间通信这三个方面着手,通过自己的方式降低了用户的使用门槛,简便了微前端项目的开发改造成本,从而成为目前为止最为流行的微前端框架。

优化点 single-spa qiankun
子应用加载方式 用户自行编码配置子应用加载方式 用户只需配置子应用入口URL
应用间沙箱隔离 无隔离机制 内置了三种JS沙箱和两种CSS沙箱
应用间通信 主应用通过customProps向子应用传递静态参数 内置了一整套基于发布订阅的通信模式

本文通过对于qiankun源码的粗略解读,希望读者可以获取到自己所需的知识,得到些许的进步。编码的路程漫长且艰辛,诸位共同努力!

更多关于微前端框架qiankun剖析的资料请关注脚本之家其它相关文章!

你可能感兴趣的:(微前端框架qiankun源码剖析之下篇)