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);
          },
        });
    }
}



目录
相关文章
|
22天前
|
人工智能 前端开发 JavaScript
前端架构思考 :专注于多框架的并存可能并不是唯一的方向 — 探讨大模型时代前端的分层式微前端架构
随着前端技术的发展,微前端架构成为应对复杂大型应用的流行方案,允许多个团队使用不同技术栈并将其模块化集成。然而,这种设计在高交互性需求的应用中存在局限,如音视频处理、AI集成等。本文探讨了传统微前端架构的不足,并提出了一种新的分层式微前端架构,通过展示层与业务层的分离及基于功能的横向拆分,以更好地适应现代前端需求。
|
16天前
|
前端开发 API UED
深入理解微前端架构:构建灵活、高效的前端应用
【10月更文挑战第23天】微前端架构是一种将前端应用分解为多个小型、独立、可复用的服务的方法。每个服务独立开发和部署,但共同提供一致的用户体验。本文探讨了微前端架构的核心概念、优势及实施方法,包括定义服务边界、建立通信机制、共享UI组件库和版本控制等。通过实际案例和职业心得,帮助读者更好地理解和应用微前端架构。
|
22天前
|
前端开发 API UED
拥抱微前端架构:构建灵活、高效的前端应用
【10月更文挑战第17天】微前端架构是一种将前端应用拆分为多个小型、独立、可复用的服务的方法,每个服务可以独立开发、部署和维护。本文介绍了微前端架构的核心概念、优势及实施步骤,并分享了业界应用案例和职业心得,帮助读者理解和应用这一新兴架构模式。
|
27天前
|
存储 监控 前端开发
掌握微前端架构:构建未来前端应用的基石
【10月更文挑战第12天】随着前端技术的发展,传统的单体应用架构已无法满足现代应用的需求。微前端架构通过将大型应用拆分为独立的小模块,提供了更高的灵活性、可维护性和快速迭代能力。本文介绍了微前端架构的概念、核心优势及实施步骤,并探讨了其在复杂应用中的应用及实战技巧。
|
1月前
|
存储 监控 前端开发
掌握微前端架构:构建可扩展的前端应用
【10月更文挑战第6天】随着前端应用复杂性的增加,传统单体架构已难以满足需求。微前端架构通过将应用拆分为独立模块,提升了灵活性与可维护性。本文介绍微前端的概念、优势及实施步骤,包括定义边界、创建共享UI库、设置通信机制等,并探讨其在SPA扩展、大型项目模块化及遗留系统现代化中的应用。通过实战技巧如版本控制、配置管理和监控日志,帮助团队高效协作,保持应用灵活性。微前端架构为构建大型前端应用提供有效解决方案,适合希望提升项目可扩展性的开发者参考。
|
1月前
|
编解码 前端开发 JavaScript
前端:Rem 及其转换原理
Rem是一种用于前端开发的相对字体大小单位,它基于根元素的字体尺寸来定义文本大小,有助于实现响应式布局和可维护性。Rem的转换原理是通过相对于HTML根元素的字体大小来设置子元素的字体大小,从而实现统一的比例调整,提高页面的适应性和灵活性。此方法简化了跨浏览器和设备的布局调整,增强了用户体验。
|
27天前
|
缓存 JavaScript 前端开发
拿下奇怪的前端报错(三):npm install卡住了一个钟- 从原理搞定安装的全链路问题
本文详细分析了 `npm install` 过程中可能出现的卡顿问题及解决方法,包括网络问题、Node.js 版本不兼容、缓存问题、权限问题、包冲突、过时的 npm 版本、系统资源不足和脚本问题等,并提供了相应的解决策略。同时,还介绍了开启全部日志、使用替代工具和使用 Docker 提供 Node 环境等其他处理方法。
346 0
|
2月前
|
前端开发 JavaScript 架构师
了解微前端,深入前端架构的前世今生
该文章深入探讨了微前端架构的起源、发展及其解决的问题,并详细讲解了微前端在现代Web应用中的实现方式与优势,帮助读者理解微前端的设计理念和技术细节。
|
28天前
|
存储 安全 前端开发
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
124 0
|
2月前
|
移动开发 前端开发 JavaScript
浅谈前端路由原理hash和history
该文章详细解析了前端路由的两种模式——Hash模式与History模式的工作原理及其实现方式,并通过实例代码展示了如何在实际项目中运用这两种路由模式。