React 微前端沙箱样式隔离与变量劫持

大家好,欢迎来到今天的讲座。今天我们不聊那些虚头巴脑的架构图,也不讲那些让实习生当场崩溃的晦涩名词。

今天我们要聊的是微前端世界里最核心、最隐秘,也最让人头秃的问题——沙箱。具体点说,就是样式隔离变量劫持

如果你在微前端架构里没搞定这两件事,那你就是在给未来的自己挖坑,等着哪天被运维或者产品经理拿着键盘追着打。

来,搬个小板凳坐好。我们要开始“解剖”代码了。


第一部分:CSS,那个粘人的前任

首先,我们得聊聊样式。在微前端里,样式就像是那个粘人的前任——你甩都甩不掉,而且它总喜欢把你的东西弄得乱七八糟。

想象一下,主应用是一个巨大的“故宫”,里面住着无数个微应用。微应用A负责“皇帝寝宫”,微应用B负责“御膳房”。

微应用A的程序员是个急性子,他写了个 .btn { background: red; }
微应用B也是个急性子,他也写了个 .btn { background: blue; }

结果呢?主应用加载微应用A时,按钮是红的;加载微应用B时,按钮是蓝的。如果两个应用同时加载,那按钮就会变成紫色,或者干脆崩掉。这就叫样式污染。

1. Shadow DOM:那个把自己关起来的孩子

React 官方推荐过一种原生方案,叫 Shadow DOM。这玩意儿是个什么鬼?简单说,它给 DOM 元素套了一层“绝对领域”。

// 主应用代码
function App() {
  const ref = React.useRef(null);

  React.useEffect(() => {
    // 创建一个 Shadow DOM 容器
    const shadowRoot = ref.current.attachShadow({ mode: 'open' });

    // 在 Shadow DOM 里注入微应用的 HTML 和 CSS
    const div = document.createElement('div');
    div.innerHTML = `<h1>我是微应用的内容</h1>`;
    shadowRoot.appendChild(div);

    // 这里注入的 CSS 不会污染主应用,主应用的 CSS 也进不来
    const style = document.createElement('style');
    style.textContent = `h1 { color: red; }`; // 这个颜色只有这里能用
    shadowRoot.appendChild(style);
  }, []);

  return <div ref={ref} id="micro-app-container"></div>;
}

优点: 彻底隔离。这是浏览器原生的,性能好,安全性高,谁也别想进来。
缺点: React 和 Shadow DOM 的结合简直是噩梦。

为什么?因为 React 的事件冒泡机制和 Shadow DOM 的隔离机制打架了。你在 Shadow DOM 里写的 onClick,点击事件可能根本传不到 React 的组件树里。而且,你很难直接用 CSS Modules 或者 Styled Components 来操作 Shadow DOM 里的样式,因为它们通常生成的是 <style> 标签,而 Shadow DOM 需要的是 <style> 标签插入到 shadowRoot 里。

这就好比你想在 Shadow DOM 里用 CSS Modules,结果发现 CSS Modules 生成的类名虽然加了哈希,但它依然在全局作用域里,虽然没被污染,但也很难动态注入。

2. CSS Modules:给 CSS 加个“户口本”

如果你不想折腾 Shadow DOM,那 CSS Modules 是最经典的选择。它的核心思想很简单:局部作用域

/* Button.module.css */
.button {
  background-color: blue;
  padding: 10px;
}

/* Button.js */
import styles from './Button.module.css';

export default function Button() {
  return <button className={styles.button}>Click me</button>;
}

在 Webpack 打包的时候,styles.button 会被转换成类似 .button_abc123 的名字。

优点: 简单,不需要运行时开销,性能好。
缺点: 它是静态的。你没法在运行时动态修改样式,而且它依赖构建工具。如果你要在一个运行时动态加载的微应用里用 CSS Modules,你得确保它的 Webpack 配置正确,而且不能和主应用的 CSS Modules 冲突。

3. CSS-in-JS:运行时“化妆师”

这是目前微前端里最主流的方案,比如 styled-componentsemotion。它们的核心思想是:样式不是写死在文件里的,而是运行时生成的

import styled from 'styled-components';

const Container = styled.div`
  background-color: ${props => props.color || 'white'};
  padding: 20px;
`;

export default function App() {
  return (
    <Container color="red">
      我是一个微应用
    </Container>
  );
}

原理: 当组件渲染时,CSS-in-JS 库会动态生成一个 <style> 标签,把样式注入到页面的 <head> 里。关键是,它们通常会给这个 <style> 标签加一个随机属性或者 ID,确保每个微应用生成的样式都是唯一的。

优点: 真正的动态隔离,样式和 JS 绑定在一起,组件写起来很爽。
缺点: 运行时开销。每次渲染都要去计算样式,虽然现在性能已经优化得不错了,但比起 CSS Modules 还是重一点。


第二部分:变量劫持,JS 的“特洛伊木马”

如果说样式隔离是物理上的隔离,那变量隔离就是逻辑上的隔离。在微前端里,我们面临的最大的坑就是 JavaScript 的全局变量污染

微应用A定义了一个变量 window.myData = { name: 'A' }
微应用B也定义了一个变量 window.myData = { name: 'B' }
然后微应用A的代码跑起来了,它读取了 window.myData,结果发现是 B 的数据。这就像是你去借别人的笔,结果拿回来发现上面写的是别人的名字。

1. 快照沙箱:那个偷拍照片的家伙

这是微前端沙箱最早期的形态,代表库有 single-spa 的旧版本。它的原理非常简单粗暴:快照

当微应用 A 加载时,它先把当前的全局状态(也就是 window 对象)拍个照,存起来。
然后,微应用 A 开始疯狂地往 window 上挂载变量,比如 window.aaa = 1, window.bbb = 2
微应用 A 运行结束,卸载时,它把刚才拍的照片拿出来,把 window 上的变量一个个擦除,恢复原样。

代码示例:

class SnapshotSandbox {
  constructor() {
    this.snapshot = {};
    this.modifyPropsMap = {};
  }

  // 初始化:拍照
  active() {
    // 把 window 上的所有属性拷贝一份
    for (let prop in window) {
      if (window.hasOwnProperty(prop)) {
        this.snapshot[prop] = window[prop];
      }
    }
  }

  // 运行时:记录修改
  modify(prop, value) {
    this.modifyPropsMap[prop] = value;
    window[prop] = value;
  }

  // 销毁:恢复快照
  inactive() {
    for (let prop in this.modifyPropsMap) {
      delete window[prop];
    }
  }
}

// 使用
const sandbox = new SnapshotSandbox();
sandbox.active();
sandbox.modify('testVar', 123); // 修改 window.testVar
console.log(window.testVar); // 123

sandbox.inactive();
console.log(window.testVar); // undefined (恢复原状)

优点: 实现简单,容易理解。
缺点: 性能太差。每次激活都要拷贝整个 window 对象,数据量大的时候,页面会卡顿。而且,它只能拦截全局变量的设置,不能拦截读取。如果微应用A读取了一个不存在于快照里的变量(比如 window.localStorage),它读到的还是主应用的数据,这会导致逻辑错误。

2. Proxy 沙箱:那个拿着网线的黑客

为了解决快照沙箱的性能问题,我们引入了 ES6 的 Proxy。Proxy 是一个代理对象,它就像是 window 的大门保安。

当你去访问 window.something 时,保安(Proxy)会先拦住你,问你是谁,你要干什么。如果是微应用 A 请求的,它就把数据给 A;如果是微应用 B 请求的,它就把数据给 B。当你试图设置 window.something 时,保安会把这个操作记录下来,存到一个 Map 里。

代码示例:

class ProxySandbox {
  constructor() {
    // 创建一个 fakeWindow 对象
    this.fakeWindow = {};
    // 记录修改过的属性
    this.modifyPropsMap = new Map();
    // 创建代理
    this.proxy = new Proxy(this.fakeWindow, {
      get: (target, prop) => {
        // 如果是 React 相关的属性,直接返回真正的 window 上的 React
        if (prop === 'React' || prop === 'ReactDOM') {
          return window[prop];
        }
        // 否则返回我们伪造的 fakeWindow 上的属性
        return target[prop];
      },
      set: (target, prop, value) => {
        // 记录这次修改
        this.modifyPropsMap.set(prop, value);
        target[prop] = value;
        return true;
      }
    });
  }

  // 获取代理对象
  getProxy() {
    return this.proxy;
  }
}

// 使用
const sandbox = new ProxySandbox();
const proxyWindow = sandbox.getProxy();

proxyWindow.testVar = 123;
console.log(proxyWindow.testVar); // 123
console.log(window.testVar); // undefined (主应用看不到)

优点: 性能好,按需拦截,不需要拷贝整个对象。
缺点: 只能代理对象,不能代理原型链上的属性。而且,它同样拦截不了 React 等核心库的属性(必须手动放行)。


第三部分:深入 React 与构建工具的“博弈”

到了这一步,你以为万事大吉了?天真。React 的运行机制和 Webpack 的模块机制,会让你的沙箱计划功亏一篑。

1. React 的 Context 陷阱

React 的 Context 机制本来是用来做跨组件传参的,但在微前端里,它是个大麻烦。

假设主应用定义了一个 UserContext,微应用 A 也定义了一个 UserContext。如果不做隔离,微应用 A 的组件读到的 Context 可能是主应用的,或者反过来。

解决方案:

你不能直接用 React 的 Context,你得劫持 React

// 这是一个简化的示例
const ReactShim = {
  Context: {
    create: (defaultValue) => {
      // 每个微应用创建自己的 Context 对象
      // 这样它们在内存中就是不同的引用,互不干扰
      return {
        Provider: ({ value, children }) => children,
        Consumer: ({ children }) => children
      };
    }
  }
};

// 微应用 A
const AppContext = ReactShim.Context.create({ name: 'A' });

// 微应用 B
const AppContext = ReactShim.Context.create({ name: 'B' });

但这还不够。React 的 useContext hook 也是从全局的 React 对象上读取的。所以,你还得在 Proxy 沙箱里,把 React 对象代理出来,把 useContext 这个方法也改写一下,让它只读取微应用自己的 Context。

2. Webpack 的 __webpack_require__ 欺骗

微应用通常是打包成一个独立的 chunk,通过动态脚本加载的。Webpack 在加载模块时,会用到 __webpack_require__ 这个全局函数。

如果微应用 A 和微应用 B 都有自己的 __webpack_require__,它们会互相覆盖。

解决方案:

我们需要在沙箱里,创建一个假的 __webpack_require__ 函数,在这个函数内部,再去调用真正的 window.__webpack_require__

// 伪代码
const fakeRequire = (moduleId) => {
  // 劫持模块加载逻辑
  // 比如,如果加载的是 'react',就返回微应用自己的 React
  if (moduleId === 'react') {
    return microAppReact;
  }
  // 否则,调用真正的 webpack 加载器
  return window.__webpack_require__(moduleId);
};

window.__webpack_require__ = fakeRequire;

3. 路由劫持:别让你的页面跳到别人的地盘去

微应用通常有自己的路由,比如 /app-a/home。但是,它运行在主应用下面,主应用的路由可能是 /main/dashboard

如果微应用里写了个 <Link to="/home">,React Router 会直接修改 window.history,导致页面跳转到主应用的 /home,而不是微应用的 /app-a/home

解决方案:

你需要劫持 window.history 对象。

// 伪代码
const originalPush = window.history.pushState;
window.history.pushState = function(...args) {
  // 在跳转前,先修改浏览器的 URL
  originalPush.apply(this, args);
  // 然后触发微应用的路由监听
  microAppRouter.push(...args);
};

第四部分:实战演练——手写一个简易沙箱

好了,理论讲够了,我们来点干货。下面是一个结合了 Proxy 和样式隔离的简易沙箱实现。

注意: 这是一个教学用的玩具实现,生产环境请使用 qiankunmicro-app

class MicroAppSandbox {
  constructor() {
    // 1. 创建沙箱环境
    this.fakeWindow = {};
    this.modifyPropsMap = new Map();

    // 2. 定义需要放行的全局属性(React, ReactDOM, window, document 等)
    this.globalWhitelist = [
      'window',
      'document',
      'navigator',
      'location',
      'history',
      'customEvent',
      'CustomEvent',
      'setTimeout',
      'setInterval',
      'Promise',
      'React',
      'ReactDOM',
      'ReactRouter',
    ];

    // 3. 创建 Proxy
    this.proxy = new Proxy(this.fakeWindow, {
      get: (target, prop) => {
        // 如果是白名单属性,直接返回真实的 window 上的值
        if (this.globalWhitelist.includes(prop)) {
          return window[prop];
        }
        // 否则返回沙箱内的值
        return target[prop];
      },
      set: (target, prop, value) => {
        // 记录修改
        this.modifyPropsMap.set(prop, value);
        target[prop] = value;
        return true;
      }
    });
  }

  getProxy() {
    return this.proxy;
  }

  // 4. 注入 CSS 样式
  injectStyles(cssText) {
    const style = document.createElement('style');
    style.textContent = cssText;
    // 为了防止样式冲突,我们可以给 style 标签加一个随机 ID
    style.id = `sandbox-style-${Math.random().toString(36).substr(2, 9)}`;
    document.head.appendChild(style);
  }

  // 5. 清理样式
  clearStyles() {
    const styles = document.querySelectorAll(`style[id^="sandbox-style-"]`);
    styles.forEach(style => style.remove());
  }
}

// --- 使用场景 ---

// 主应用代码
function MainApp() {
  const [appContainer, setAppContainer] = React.useState(null);
  const [sandbox, setSandbox] = React.useState(null);

  React.useEffect(() => {
    // 创建沙箱
    const newSandbox = new MicroAppSandbox();
    setSandbox(newSandbox);

    // 模拟微应用的 HTML 和 CSS
    const microAppHtml = `
      <div id="micro-app-content">
        <h1>我是微应用的内容</h1>
        <button id="micro-btn">点击我</button>
      </div>
    `;
    const microAppCss = `
      #micro-app-content {
        color: red;
        font-size: 20px;
      }
      #micro-btn {
        background: blue;
        color: white;
      }
    `;

    // 注入样式
    newSandbox.injectStyles(microAppCss);

    // 创建容器并挂载
    const container = document.createElement('div');
    container.innerHTML = microAppHtml;
    setAppContainer(container);

  }, []);

  React.useEffect(() => {
    if (appContainer) {
      // 获取沙箱代理后的 window
      const sandboxWindow = sandbox.getProxy();

      // 重新挂载 React
      // 注意:这里只是为了演示,实际微应用通常有自己独立的 React 实例
      // 我们需要把 React 对象替换成沙箱里的 React
      const { createRoot } = sandboxWindow.React;
      const root = createRoot(appContainer);

      // 渲染微应用组件
      root.render(
        sandboxWindow.React.createElement('div', null, 
          sandboxWindow.React.createElement('h1', null, '微应用内容'),
          sandboxWindow.React.createElement('button', { onClick: () => {
            console.log('微应用内部变量:', sandboxWindow.myVar);
            sandboxWindow.myVar = Math.random();
          }}, '操作沙箱变量')
        )
      );
    }
  }, [appContainer, sandbox]);

  return (
    <div>
      <h2>主应用</h2>
      <div id="app-root" style={{ border: '1px solid black', padding: '20px' }}>
        {appContainer && <div ref={el => appContainer = el}></div>}
      </div>
      <div style={{ marginTop: '20px' }}>
        主应用的变量: {window.myVar}
      </div>
    </div>
  );
}

export default MainApp;

第五部分:那些年我们踩过的坑

写到这里,你以为你懂了?不,现实比代码更骨感。

坑一:this 指向丢失

在 Proxy 沙箱里,当你使用 setTimeout 或者 addEventListener 时,回调函数里的 this 指向会出问题。

// 在沙箱里
sandboxWindow.setTimeout(() => {
  console.log(this); // 这里的 this 是什么?
}, 1000);

因为 setTimeout 是从 window 拿的,而 this 在箭头函数里是固定的,但在普通函数里,它指向调用者。如果调用者是 Proxy,那 this 就指向 Proxy 对象,而不是 window。这会导致很多基于 this 的代码逻辑错误。

解决方案: 在劫持 setTimeout 时,强制把 this 指向 window

window.setTimeout = function(fn, delay) {
  return sandboxWindow.fakeWindow.setTimeout.call(window, fn, delay);
};

坑二:第三方库的硬编码

有些第三方库,比如 moment.js,它内部会硬编码 window.location 或者 window.navigator。即使你劫持了 window,它可能还是会从 window 的原始属性上读取,因为它可能缓存了引用。

解决方案: 没有完美的解决方案。通常的做法是:微应用尽量使用不依赖全局变量的库,或者升级库的版本。

坑三:样式优先级

即使你用了 CSS-in-JS,如果微应用里的样式写得太狠,比如用了 !important,或者选择器权重太高(比如直接写 .app-a .app-b),依然会覆盖主应用。

解决方案: 在 CSS-in-JS 的底层实现里,强制给生成的类名加一个前缀,或者使用 Shadow DOM。


第六部分:未来展望

现在的微前端方案,比如 qiankunmicro-app,已经把上面这些坑都填得差不多了。

qiankun 使用的是基于 Proxy 的沙箱,并且做了很多性能优化,比如针对 Reactthis 指向做了特殊处理。它还支持样式隔离,默认会提取微应用的样式并添加前缀。

micro-app 则引入了“类 WebComponent”的思想,通过自定义元素和 Shadow DOM 来实现隔离,对 React 和 Vue 都支持得很好。

但是,无论技术怎么发展,核心思想永远不变:隔离

你要么在 DOM 层面隔离,要么在 CSS 层面隔离,要么在 JS 变量层面隔离。选择哪一种,取决于你的业务场景、团队技术栈以及性能要求。

结语

好了,今天的讲座就到这里。

微前端的沙箱技术,就像是给每个微应用建了一座独立的城堡。CSS 隔离是城堡的围墙,变量劫持是城堡的卫兵。只有围墙够高,卫兵够强,城堡里的居民(代码)才能安居乐业,互不打扰。

不要试图去对抗浏览器,不要试图去对抗 React。要学会欺骗,要学会代理,要学会在混乱中建立秩序。

现在,去写你的沙箱吧。如果崩了,别来找我,我反正早就跑路了。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注