2022 你还不会微前端吗 (下) — 揭秘微前端核心原理(二)

简介: 2022 你还不会微前端吗 (下) — 揭秘微前端核心原理

独立运行时 —— 沙箱

沙箱 的目的是 为了隔离子应用间 脚本样式 的影响,即需要针对子应用的 <style>、<link>、<script> 等类型的标签进行特殊处理,而处理时机分为两种:

  • 初始化加载时,因为初始化加载子应用时,需要 加载其对应的 脚本样式
  • 子应用正在运行时,因为子应用运行时可能会 动态添加 脚本样式

重写 appendChild、insertBefore、removeChild 方法

qiankun 中重写 appendChild、insertBefore、removeChild 等原生方法,以便于可以监听 新添加/删除 的节点,,并对 <style>、<link>、<script> 等标签进行处理。

// qiankun\src\sandbox\patchers\dynamicAppend\common.ts
export function patchHTMLDynamicAppendPrototypeFunctions(
  isInvokedByMicroApp: (element: HTMLElement) => boolean,
  containerConfigGetter: (element: HTMLElement) => ContainerConfig,
) {
  // 只在 appendChild 和 insertBefore 没有被重写时进行重写
  if (
    HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
    HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
    HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
  ) {
    // 重写方法
    HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'head',
    }) as typeof rawHeadAppendChild;
    // 重写方法
    HTMLBodyElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawBodyAppendChild,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'body',
    }) as typeof rawBodyAppendChild;
    // 重写方法
    HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
      rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
      containerConfigGetter,
      isInvokedByMicroApp,
      target: 'head',
    }) as typeof rawHeadInsertBefore;
  }
  // 只在 removeChild 没有被重写时进行重写
  if (
    HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
    HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
  ) {
    // 重写方法
    HTMLHeadElement.prototype.removeChild = getNewRemoveChild(rawHeadRemoveChild, containerConfigGetter, 'head');
    HTMLBodyElement.prototype.removeChild = getNewRemoveChild(rawBodyRemoveChild, containerConfigGetter, 'body');
  }
  // 恢复重写前的方法
  return function unpatch() {
    HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
    HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
    HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
    HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
    HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
  };
}
复制代码

CSS 样式隔离

shadowDom 实现隔离

若开启了 strictStyleIsolation 模式,并且当前环境支持 Shadow DOM,则直接通过 Shadow DOM 来实现隔离效果,有关 Shadow DOM 的内容可参考之前的一篇文章:Web Components —— Web 组件

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;
  // strictStyleIsolation 模式
  if (strictStyleIsolation) {
    if (!supportShadowDOM) {
      console.warn(
        '[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {
      const { innerHTML } = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;
      // 若当前环境支持 Shadow DOM,则通过 Shadow DOM 实现样式隔离
      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();
      }
      shadow.innerHTML = innerHTML;
    }
  }
  // 通过 css.process 处理 css 规则
  if (scopedCSS) {
    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;
}
复制代码

prefix 限定 CSS 规则

CSS 样式分为 内联样式外链样式,而在 qiankun 中选择把外链的方式处理成 <style> 包裹的形式,目的是提供符合 postProcess(styleElement) 处理的数据格式,即符合 css.process(...) 的数据格式,因为外部传入的 postProcess 形参就是包含了 css.process() 的方法:

// qiankun\src\sandbox\patchers\dynamicAppend\common.ts
function convertLinkAsStyle(
  element: HTMLLinkElement,
  postProcess: (styleElement: HTMLStyleElement) => void,
  fetchFn = fetch,
): HTMLStyleElement {
  // 创建 style 标签 
  const styleElement = document.createElement('style');
  const { href } = element;
  // add source link element href
  styleElement.dataset.qiankunHref = href;
  //  通过 fetch 请求 link.href 指向的 css 资源
  fetchFn(href)
    .then((res: any) => res.text())
    .then((styleContext: string) => {
      // 将得到的 css 文本作为文本节点添加到 style 节点中
      styleElement.appendChild(document.createTextNode(styleContext));
      // 方便统一通过 postProcess 进行处理,本质上就是 css.process() 方法
      postProcess(styleElement);
      manualInvokeElementOnLoad(element);
    })
    .catch(() => manualInvokeElementOnError(element));
  return styleElement;
}
复制代码

CSS 样式隔离核心本质其实就是 css.process() 方法,而这其实就是通过为每个 css 规则添加 特定的前缀 来实现 样式隔离 的作用:

  • 创建一个临时的 style 节点用来后续处理
  • 通过 process() 方法来处理 style 规则, 即通过 style.sheet 属性来获取所有的规则
  • 通过 ruleStyle() 方法进行转换,即通过正则进行匹配然后替换,如子应用中的 h1{color: red;} 变为 [.appName] h1{color: red;}
  • 将重写后的 css 内容替换到原有的 style 节点中
// qiankun\src\sandbox\patchers\css.ts
let processor: ScopedCSS;
export const QiankunCSSRewriteAttr = 'data-qiankun';
export const process = (
  appWrapper: HTMLElement,
  stylesheetElement: HTMLStyleElement | HTMLLinkElement,
  appName: string,
): void => {
  // 惰性单例模式
  if (!processor) {
    processor = new ScopedCSS();
  }
  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') {
    // 根据当前子应用的 appName 生成自定义前缀
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
    processor.process(stylesheetElement, prefix);
  }
};
export class ScopedCSS {
  private static ModifiedTag = 'Symbol(style-modified-qiankun)';
  private sheet: StyleSheet;
  private swapNode: HTMLStyleElement;
  constructor() {
    const styleNode = document.createElement('style');
    rawDocumentBodyAppend.call(document.body, styleNode);
    this.swapNode = styleNode;
    this.sheet = styleNode.sheet!;
    this.sheet.disabled = true;
  }
  process(styleNode: HTMLStyleElement, prefix: string = '') {
    if (ScopedCSS.ModifiedTag in styleNode) {
      return;
    }
    // style 中文本节点不为空时进行处理
    if (styleNode.textContent !== '') {
      const textNode = document.createTextNode(styleNode.textContent || '');
      this.swapNode.appendChild(textNode);
      const sheet = this.swapNode.sheet as any; // type is missing
      const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
      // 重写 css 内容
      const css = this.rewrite(rules, prefix);
      // eslint-disable-next-line no-param-reassign
      styleNode.textContent = css;
      // cleanup
      this.swapNode.removeChild(textNode);
      (styleNode as any)[ScopedCSS.ModifiedTag] = true;
      return;
    }
   // 省略代码
  }
  // 根据 prefix 来限定 css 选择器
  private rewrite(rules: CSSRule[], prefix: string = '') {
    let css = '';
    rules.forEach((rule) => {
      switch (rule.type) {
        case RuleType.STYLE:
          css += this.ruleStyle(rule as CSSStyleRule, prefix);
          break;
        case RuleType.MEDIA:
          css += this.ruleMedia(rule as CSSMediaRule, prefix);
          break;
        case RuleType.SUPPORTS:
          css += this.ruleSupport(rule as CSSSupportsRule, prefix);
          break;
        default:
          css += `${rule.cssText}`;
          break;
      }
    });
    return css;
  }
}
复制代码

JavaScript 脚本隔离

从如下源码中不难看出,qiankun 中的 JS 沙箱有 LegacySandbox、ProxySandbox、SnapshotSandbox 三种方式,但是其实就分为 代理(Proxy)沙箱快照(Snapshot)沙箱,并且是根据情况来选择创建:

  • 若当前环境支持 window.Proxy,则通过 useLooseSandbox 的值选择 LegacySandboxProxySandbox 方式
  • 若当前环境不支持 window.Proxy,则直接使用 SnapshotSandbox 方式
// qiankun\src\loader.ts
export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  省略代码
  let sandboxContainer;
  if (sandbox) {
    // 创建沙箱
    sandboxContainer = createSandboxContainer(
      appInstanceId,
      // FIXME 应该在重新挂载时使用严格的沙盒逻辑: https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
      global,
      speedySandbox,
    );
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }
  省略代码
}
// qiankun\src\sandbox\index.ts
export function createSandboxContainer(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  scopedCSS: boolean,
  useLooseSandbox?: boolean,
  excludeAssetFilter?: (url: string) => boolean,
  globalContext?: typeof window,
  speedySandBox?: boolean,
) {
  let sandbox: SandBox;
  if (window.Proxy) {
    sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
  } else {
    sandbox = new SnapshotSandbox(appName);
  }
  省略代码
}
复制代码

代理(Proxy)沙箱

为了避免 多个子应用 操作或者修改 基座应用 的全局对象 window,而导致微应用间运行状态可能相互影响的问题,Proxy 沙箱 本质就是基于 Proxy 来实现代理:

  • 通过 createFakeWindow(window) 将原 window 上的一些 descriptor.configurabletrue 拷贝到新对象 fakeWindow
  • 通过 new Proxy(fakeWindow, {...}) 的方式创建代理对象
  • 读取属性时优先从 proxy 上查找,若没有查到则再到原始的 window 上查找
  • 设置属性时会设置到 proxy 对象里,即不会修改原始的 window 实现隔离
// qiankun\src\sandbox\proxySandbox.ts
export default class LegacySandbox implements SandBox {
      省略代码
    constructor(name: string, globalContext = window) {
      const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
      省略代码
      const proxy = new Proxy(fakeWindow, {
          set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
            if (this.sandboxRunning) {
              this.registerRunningApp(name, proxy);
              // 必须保留它的描述,而该属性之前存在于 globalContext 中
              if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
                const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
                const { writable, configurable, enumerable, set } = descriptor!;
                // 这里只有可写属性可以被覆盖,忽略 globalContext 的访问器描述符,因为触发它的逻辑没有意义(这可能会使沙箱转义)强制通过数据描述符设置值
                if (writable || set) {
                  Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
                }
              } else {
                target[p] = value;
              }
              // 将属性同步到 globalContext
              if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
                this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p);
                // @ts-ignore
                globalContext[p] = value;
              }
              updatedValueSet.add(p);
              this.latestSetProp = p;
              return true;
            }
            if (process.env.NODE_ENV === 'development') {
              console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
            }
            // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
            return true;
          },
          get: (target: FakeWindow, p: PropertyKey): any => {
            this.registerRunningApp(name, proxy);
            if (p === Symbol.unscopables) return unscopables;
            // 避免使用 window.window 或 window.self 逃离沙箱环境去触碰真实 window
            if (p === 'window' || p === 'self') {
              return proxy;
            }
            // 使用 globalThis 关键字劫持 globalWindow 访问
            if (p === 'globalThis') {
              return proxy;
            }
            if (
              p === 'top' ||
              p === 'parent' ||
              (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
            ) {
              // 如果主应用程序在 iframe 上下文中,允许逃离沙箱
              if (globalContext === globalContext.parent) {
                return proxy;
              }
              return (globalContext as any)[p];
            }
            // proxy.hasOwnProperty 将首先调用 getter,然后将其值表示为 globalContext.hasOwnProperty
            if (p === 'hasOwnProperty') {
              return hasOwnProperty;
            }
            if (p === 'document') {
              return document;
            }
            if (p === 'eval') {
              return eval;
            }
            const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext;
            const value = actualTarget[p];
            // 冻结值应该直接返回
            if (isPropertyFrozen(actualTarget, p)) {
              return value;
            }
            /* 某些dom api必须绑定到native window,否则会导致异常:'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
            */
            const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
            return getTargetValue(boundTarget, value);
          },
          has(target: FakeWindow, p: string | number | symbol): boolean {
            return p in unscopables || p in target || p in globalContext;
          },
          getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
            /*
             由于原始窗口中 top/self/window/mockTop 的描述符是可配置的,但在代理目标中不可配置,需要从目标中获取它以避免 TypeError
             */
            if (target.hasOwnProperty(p)) {
              const descriptor = Object.getOwnPropertyDescriptor(target, p);
              descriptorTargetMap.set(p, 'target');
              return descriptor;
            }
            if (globalContext.hasOwnProperty(p)) {
              const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
              descriptorTargetMap.set(p, 'globalContext');
              // 如果属性不作为目标对象的自有属性存在,则不能将其报告为不可配置
              if (descriptor && !descriptor.configurable) {
                descriptor.configurable = true;
              }
              return descriptor;
            }
            return undefined;
          },
          ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
            return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
          },
          defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
            const from = descriptorTargetMap.get(p);
            /*
             Descriptor 必须通过 Object.getOwnPropertyDescriptor(window, p) 来自本地窗口时定义到本地窗口,否则会导致 TypeError 非法调用
             */
            switch (from) {
              case 'globalContext':
                return Reflect.defineProperty(globalContext, p, attributes);
              default:
                return Reflect.defineProperty(target, p, attributes);
            }
          },
          deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {
            this.registerRunningApp(name, proxy);
            if (target.hasOwnProperty(p)) {
              // @ts-ignore
              delete target[p];
              updatedValueSet.delete(p);
              return true;
            }
            return true;
          },
          // 确保 `window instanceof Window` 在微应用中返回 true
          getPrototypeOf() {
            return Reflect.getPrototypeOf(globalContext);
          },
        });
    }
}



目录
相关文章
|
1月前
|
Web App开发 前端开发 JavaScript
前端应用实现 image lazy loading 的原理介绍
前端应用实现 image lazy loading 的原理介绍
45 0
|
2天前
|
自然语言处理 前端开发 Java
深入浅出JVM(六)之前端编译过程与语法糖原理
深入浅出JVM(六)之前端编译过程与语法糖原理
|
1月前
|
前端开发 数据可视化 JavaScript
探索前端可视化开发:低代码平台原理与实践
【4月更文挑战第7天】本文探讨了低代码平台在前端开发中的应用,介绍了其模型驱动、组件化和自动化部署的原理,强调了提升效率、降低技术门槛、灵活适应变更和保证一致性等优势。建议开发者明确适用场景,选择合适平台,并培养团队低代码技能,同时规划与现有技术栈的融合,实施持续优化治理。低代码平台正改变开发格局,为业务创新和数字化转型提供新途径。
58 0
|
2天前
|
JavaScript 前端开发
深入了解前端框架Vue.js的响应式原理
本文将深入探讨Vue.js前端框架的核心特性之一——响应式原理。通过分析Vue.js中的数据绑定、依赖追踪和虚拟DOM等机制,读者将对Vue.js的响应式系统有更深入的理解,从而能够更好地利用Vue.js构建灵活、高效的前端应用。
|
5天前
|
前端开发 测试技术
前端自动化测试中的快照测试原理
快照测试用于前端自动化测试,通过比较当前应用状态与预存预期快照来检测UI变化。流程包括设置测试环境、捕获屏幕快照、保存预期快照、比较快照及处理差异。当快照比较出现差异时,测试工程师审查判断是否为预期变化或错误,确保应用一致性。这种方法在重构、样式更改和跨浏览器测试时提供有效回归测试,减少手动验证工作。
|
12天前
|
JavaScript 前端开发 数据安全/隐私保护
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(二)
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(二)
|
12天前
|
JavaScript 前端开发 Python
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(一)
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(一)
|
15天前
|
移动开发 前端开发 应用服务中间件
前端——html拖拽原理
前端——html拖拽原理
19 0
|
16天前
|
前端开发 JavaScript
前端 富文本编辑器原理——从javascript、html、css开始入门(二)
前端 富文本编辑器原理——从javascript、html、css开始入门
26 0
前端 富文本编辑器原理——从javascript、html、css开始入门(二)
|
16天前
|
前端开发 JavaScript 索引
前端 富文本编辑器原理——从javascript、html、css开始入门(一)
前端 富文本编辑器原理——从javascript、html、css开始入门
36 0