大家好,欢迎来到今天的讲座。今天我们不聊那些虚头巴脑的架构图,也不讲那些让实习生当场崩溃的晦涩名词。
今天我们要聊的是微前端世界里最核心、最隐秘,也最让人头秃的问题——沙箱。具体点说,就是样式隔离和变量劫持。
如果你在微前端架构里没搞定这两件事,那你就是在给未来的自己挖坑,等着哪天被运维或者产品经理拿着键盘追着打。
来,搬个小板凳坐好。我们要开始“解剖”代码了。
第一部分: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-components 或 emotion。它们的核心思想是:样式不是写死在文件里的,而是运行时生成的。
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 和样式隔离的简易沙箱实现。
注意: 这是一个教学用的玩具实现,生产环境请使用 qiankun 或 micro-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。
第六部分:未来展望
现在的微前端方案,比如 qiankun 和 micro-app,已经把上面这些坑都填得差不多了。
qiankun 使用的是基于 Proxy 的沙箱,并且做了很多性能优化,比如针对 React 的 this 指向做了特殊处理。它还支持样式隔离,默认会提取微应用的样式并添加前缀。
micro-app 则引入了“类 WebComponent”的思想,通过自定义元素和 Shadow DOM 来实现隔离,对 React 和 Vue 都支持得很好。
但是,无论技术怎么发展,核心思想永远不变:隔离。
你要么在 DOM 层面隔离,要么在 CSS 层面隔离,要么在 JS 变量层面隔离。选择哪一种,取决于你的业务场景、团队技术栈以及性能要求。
结语
好了,今天的讲座就到这里。
微前端的沙箱技术,就像是给每个微应用建了一座独立的城堡。CSS 隔离是城堡的围墙,变量劫持是城堡的卫兵。只有围墙够高,卫兵够强,城堡里的居民(代码)才能安居乐业,互不打扰。
不要试图去对抗浏览器,不要试图去对抗 React。要学会欺骗,要学会代理,要学会在混乱中建立秩序。
现在,去写你的沙箱吧。如果崩了,别来找我,我反正早就跑路了。
谢谢大家!