无界微前端源码解析:沙箱机制

41 阅读3分钟

无界微前端源码解析:沙箱机制

深入分析 iframe 沙箱的创建、patch 和 JS 执行隔离原理。

为什么用 iframe

iframe 是浏览器原生的隔离方案,具备:

  • 完整的 window 对象:独立的全局作用域
  • 原生 JS 隔离:变量不会污染主应用
  • 原生路由隔离:独立的 history 和 location

无界的创新在于:只用 iframe 运行 JS,DOM 渲染在 Shadow DOM 中。

iframe 创建

// packages/wujie-core/src/iframe.ts
export function iframeGenerator(
  sandbox: WuJie,
  attrs: { [key: string]: any },
  mainHostPath: string,
  appHostPath: string,
  appRoutePath: string
): HTMLIFrameElement {
  // 1. 创建 iframe
  const iframe = document.createElement("iframe");
  
  // 2. 设置属性
  setAttrsToElement(iframe, {
    src: mainHostPath,  // 同域,避免跨域问题
    style: "display: none",
    ...attrs,
    [WUJIE_DATA_FLAG]: "",
  });
  
  // 3. 插入文档
  document.body.appendChild(iframe);
  
  // 4. 等待 iframe 加载
  sandbox.iframeReady = stopIframeLoading(iframe).then(() => {
    // 5. 初始化 iframe DOM
    initIframeDom(iframe.contentWindow, sandbox, mainHostPath, appHostPath);
    // 6. 注入变量
    patchIframeVariable(iframe.contentWindow, sandbox, appHostPath);
  });
  
  return iframe;
}

关键点:

  • src 设为主应用域名,保证同域
  • display: none 隐藏 iframe
  • 通过 stopIframeLoading 阻止 iframe 加载主应用内容

阻止 iframe 加载

// packages/wujie-core/src/iframe.ts
function stopIframeLoading(iframe: HTMLIFrameElement) {
  const iframeWindow = iframe.contentWindow;
  const oldDoc = iframeWindow.document;
  
  return new Promise<void>((resolve) => {
    function loop() {
      setTimeout(() => {
        let newDoc;
        try {
          newDoc = iframeWindow.document;
        } catch (err) {
          newDoc = null;
        }
        
        // 等待 document 就绪
        if (!newDoc || newDoc == oldDoc) {
          loop();
          return;
        }
        
        // 停止加载
        iframeWindow.stop ? iframeWindow.stop() : newDoc.execCommand("Stop");
        resolve();
      }, 1);
    }
    loop();
  });
}

初始化 iframe DOM

// packages/wujie-core/src/iframe.ts
function initIframeDom(iframeWindow: Window, wujie: WuJie, mainHostPath: string, appHostPath: string): void {
  const iframeDocument = iframeWindow.document;
  
  // 1. 创建空白文档
  const newDoc = window.document.implementation.createHTMLDocument("");
  const newDocumentElement = iframeDocument.importNode(newDoc.documentElement, true);
  iframeDocument.replaceChild(newDocumentElement, iframeDocument.documentElement);
  
  // 2. 保存原生方法
  iframeWindow.__WUJIE_RAW_DOCUMENT_HEAD__ = iframeDocument.head;
  iframeWindow.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__ = iframeWindow.Document.prototype.querySelector;
  iframeWindow.__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__ = iframeWindow.Document.prototype.createElement;
  
  // 3. 初始化 base 标签
  initBase(iframeWindow, wujie.url);
  
  // 4. patch history
  patchIframeHistory(iframeWindow, appHostPath, mainHostPath);
  
  // 5. patch 事件
  patchIframeEvents(iframeWindow);
  
  // 6. patch window
  patchWindowEffect(iframeWindow);
  
  // 7. patch document
  patchDocumentEffect(iframeWindow);
  
  // 8. patch Node
  patchNodeEffect(iframeWindow);
  
  // 9. patch 相对路径
  patchRelativeUrlEffect(iframeWindow);
}

patch history

// packages/wujie-core/src/iframe.ts
function patchIframeHistory(iframeWindow: Window, appHostPath: string, mainHostPath: string): void {
  const history = iframeWindow.history;
  const rawHistoryPushState = history.pushState;
  const rawHistoryReplaceState = history.replaceState;
  
  // 劫持 pushState
  history.pushState = function (data: any, title: string, url?: string): void {
    // 将子应用路径转换为主应用路径
    const baseUrl = mainHostPath + iframeWindow.location.pathname + 
                    iframeWindow.location.search + iframeWindow.location.hash;
    const mainUrl = getAbsolutePath(url?.replace(appHostPath, ""), baseUrl);
    
    rawHistoryPushState.call(history, data, title, url === undefined ? undefined : mainUrl);
    if (url === undefined) return;
    
    // 更新 base 标签
    updateBase(iframeWindow, appHostPath, mainHostPath);
    // 同步路由到主应用
    syncUrlToWindow(iframeWindow);
  };
  
  // replaceState 同理
  history.replaceState = function (data: any, title: string, url?: string): void {
    // ...类似逻辑
  };
}

patch 事件监听

// packages/wujie-core/src/iframe.ts
function patchIframeEvents(iframeWindow: Window) {
  iframeWindow.__WUJIE_EVENTLISTENER__ = new Set();
  
  iframeWindow.addEventListener = function addEventListener(type, listener, options) {
    // 运行插件钩子
    execHooks(iframeWindow.__WUJIE.plugins, "windowAddEventListenerHook", iframeWindow, type, listener, options);
    
    // 记录事件
    iframeWindow.__WUJIE_EVENTLISTENER__.add({ type, listener, options });
    
    // 路由相关事件保留在 iframe
    if (appWindowAddEventListenerEvents.includes(type)) {
      return rawWindowAddEventListener.call(iframeWindow, type, listener, options);
    }
    
    // 其他事件代理到主应用 window
    rawWindowAddEventListener.call(window.__WUJIE_RAW_WINDOW__ || window, type, listener, options);
  };
  
  iframeWindow.removeEventListener = function removeEventListener(type, listener, options) {
    // 运行插件钩子
    execHooks(iframeWindow.__WUJIE.plugins, "windowRemoveEventListenerHook", iframeWindow, type, listener, options);
    
    // 移除记录
    iframeWindow.__WUJIE_EVENTLISTENER__.forEach((o) => {
      if (o.listener === listener && o.type === type && options == o.options) {
        iframeWindow.__WUJIE_EVENTLISTENER__.delete(o);
      }
    });
    
    // 对应移除
    if (appWindowAddEventListenerEvents.includes(type)) {
      return rawWindowRemoveEventListener.call(iframeWindow, type, listener, options);
    }
    rawWindowRemoveEventListener.call(window.__WUJIE_RAW_WINDOW__ || window, type, listener, options);
  };
}

事件分类:

事件类型监听位置示例
路由事件iframepopstate, hashchange
DOM 事件主应用 windowclick, scroll, resize

patch window 属性

// packages/wujie-core/src/iframe.ts
function patchWindowEffect(iframeWindow: Window): void {
  Object.getOwnPropertyNames(iframeWindow).forEach((key) => {
    // 特殊处理 getSelection
    if (key === "getSelection") {
      Object.defineProperty(iframeWindow, key, {
        get: () => iframeWindow.document[key],
      });
      return;
    }
    
    // 代理到主应用 window
    if (windowProxyProperties.includes(key)) {
      processWindowProperty(key);
      return;
    }
    
    // 正则匹配
    windowRegWhiteList.some((reg) => {
      if (reg.test(key) && key in iframeWindow.parent) {
        return processWindowProperty(key);
      }
      return false;
    });
  });
  
  // 处理 onEvent
  const windowOnEvents = Object.getOwnPropertyNames(window)
    .filter((p) => /^on/.test(p))
    .filter((e) => !appWindowOnEvent.includes(e));
  
  windowOnEvents.forEach((e) => {
    Object.defineProperty(iframeWindow, e, {
      get: () => window[e],
      set: (handler) => {
        window[e] = typeof handler === "function" ? handler.bind(iframeWindow) : handler;
      },
    });
  });
  
  // 运行插件钩子
  execHooks(iframeWindow.__WUJIE.plugins, "windowPropertyOverride", iframeWindow);
}

脚本执行

// packages/wujie-core/src/iframe.ts
export function insertScriptToIframe(scriptResult, iframeWindow, rawElement?) {
  const { src, module, content, crossorigin, async, attrs, callback, onload } = scriptResult;
  const scriptElement = iframeWindow.document.createElement("script");
  const { replace, plugins, proxyLocation } = iframeWindow.__WUJIE;
  
  // 1. 通过 jsLoader 处理代码
  const jsLoader = getJsLoader({ plugins, replace });
  let code = jsLoader(content, src, getCurUrl(proxyLocation));
  
  // 2. 内联脚本包装
  if (content && !module) {
    // 关键:将 window/self/global/location 替换为代理对象
    code = `(function(window, self, global, location) {
      ${code}
    }).bind(window.__WUJIE.proxy)(
      window.__WUJIE.proxy,
      window.__WUJIE.proxy,
      window.__WUJIE.proxy,
      window.__WUJIE.proxyLocation,
    );`;
  }
  
  // 3. 设置脚本内容
  scriptElement.textContent = code || "";
  
  // 4. 插入执行
  const container = rawDocumentQuerySelector.call(iframeWindow.document, "head");
  container.appendChild(scriptElement);
  
  // 5. 执行下一个脚本
  const execNextScript = () => !async && container.appendChild(nextScriptElement);
}

核心技巧:通过 IIFE 包装,将全局变量替换为代理对象:

// 原始代码
window.foo = 'bar';
document.getElementById('app');

// 包装后
(function(window, self, global, location) {
  window.foo = 'bar';  // 实际操作 proxy
  document.getElementById('app');  // 实际操作 proxyDocument
}).bind(window.__WUJIE.proxy)(
  window.__WUJIE.proxy,
  window.__WUJIE.proxy,
  window.__WUJIE.proxy,
  window.__WUJIE.proxyLocation,
);

注入变量

// packages/wujie-core/src/iframe.ts
function patchIframeVariable(iframeWindow: Window, wujie: WuJie, appHostPath: string): void {
  // 沙箱实例
  iframeWindow.__WUJIE = wujie;
  
  // 公共路径
  iframeWindow.__WUJIE_PUBLIC_PATH__ = appHostPath + "/";
  
  // 子应用接口
  iframeWindow.$wujie = wujie.provide;
  
  // 原始 window
  iframeWindow.__WUJIE_RAW_WINDOW__ = iframeWindow;
}

子应用可通过 window.$wujie 访问:

// 子应用中
window.$wujie.bus.$emit('event', data);  // 事件通信
window.$wujie.props;                      // 获取 props
window.$wujie.location;                   // 代理的 location

销毁清理

// packages/wujie-core/src/sandbox.ts
public async destroy() {
  await this.unmount();
  
  // 清理事件
  this.bus.$clear();
  
  // 清理代理
  this.proxy = null;
  this.proxyDocument = null;
  this.proxyLocation = null;
  
  // 清理 DOM
  if (this.el) {
    clearChild(this.el);
    this.el = null;
  }
  
  // 清理 iframe
  if (this.iframe) {
    const iframeWindow = this.iframe.contentWindow;
    
    // 移除所有事件监听
    if (iframeWindow?.__WUJIE_EVENTLISTENER__) {
      iframeWindow.__WUJIE_EVENTLISTENER__.forEach((o) => {
        iframeWindow.removeEventListener(o.type, o.listener, o.options);
      });
    }
    
    // 移除 iframe
    this.iframe.parentNode?.removeChild(this.iframe);
    this.iframe = null;
  }
  
  // 从缓存中删除
  deleteWujieById(this.id);
}

小结

无界的 iframe 沙箱机制:

  1. 同域 iframe:避免跨域问题,保证 JS 隔离
  2. 阻止加载:创建空白 iframe,不加载主应用内容
  3. patch 劫持:history、事件、window 属性全面劫持
  4. IIFE 包装:脚本执行时替换全局变量为代理对象
  5. 事件分流:路由事件留在 iframe,DOM 事件代理到主应用

下一篇我们将分析 CSS 隔离的实现。


📦 源码版本:wujie v1.0.22

上一篇:架构总览

下一篇:CSS 隔离