我不知道的 dumi(02)— 跨框架组件渲染:React 组件如何跑在 Vue 文档里
假设团队维护一个 React 组件库,文档用 dumi 构建。某天产品线提出需求:需要在 Vue 技术栈的项目文档中,嵌入这些 React 组件的实时预览。
一、一个看似矛盾的需求
假设团队维护一个 React 组件库,文档用 dumi 构建。某天产品线提出需求:需要在 Vue 技术栈的项目文档中,嵌入这些 React 组件的实时预览。
这听起来像是要在同一个页面里同时运行两套框架的渲染引擎——React 有自己的虚拟 DOM 和协调机制,Vue 也有自己的响应式系统和模板编译器。它们对 DOM 的控制方式完全不同,直接混用几乎必然冲突。
但 dumi 2.0 确实支持跨框架组件渲染。它是怎么做到的?
问题的关键在于——不是让两个框架”共享” DOM,而是给每个框架一个独立的 DOM 子树。
二、适配器模式:用 Web Components 当”翻译官”
dumi 的跨框架渲染基于一个核心思路:用 Web Components 作为框架之间的桥接层。
Web Components 是浏览器原生的组件化标准,不依赖任何框架。通过 customElements.define() 注册的自定义元素,可以在任何 HTML 中使用——无论宿主环境是 Vue、React 还是纯 HTML。
dumi 的做法是将 React 组件封装为 Web Components:
class ReactAdapter extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
const mountPoint = document.createElement('div');
shadowRoot.appendChild(mountPoint);
const root = ReactDOM.createRoot(mountPoint);
root.render(React.createElement(ReactComponent, this.getProps()));
}
disconnectedCallback() {
// 清理 React 渲染树,避免内存泄漏
}
getProps() {
return JSON.parse(this.getAttribute('props') || '{}');
}
}
customElements.define('react-button', ReactAdapter);
注册完成后,在 Vue 文档中就可以直接使用:
<template>
<div>
<h2>React Button 组件预览</h2>
<react-button :props="JSON.stringify({ type: 'primary', label: '点击' })" />
</div>
</template>
说白了,Web Components 在这里扮演的角色就像一个”翻译官”——外部看到的是一个标准的 HTML 自定义元素,内部运行的是 React 渲染引擎。宿主框架(Vue)不需要知道这个元素内部是怎么实现的,只需要像使用普通 HTML 元素一样使用它。
三、隔离的三个维度:CSS、状态、事件
让两个框架在同一个页面共存,最大的挑战不是”启动两个渲染引擎”——而是隔离。如果 React 组件的样式影响了 Vue 的布局,或者 React 的事件冒泡到 Vue 的事件处理器中,页面就会出问题。
dumi 在三个维度做了隔离:
CSS 隔离:Shadow DOM
上面的代码中,this.attachShadow({ mode: 'open' }) 创建了一个 Shadow DOM。Shadow DOM 是浏览器提供的原生样式隔离机制——Shadow DOM 内部的样式不会泄露到外部,外部的样式也不会影响内部。
这意味着 React 组件的 CSS(无论是 CSS Modules、styled-components 还是普通全局样式)都被封印在 Shadow DOM 内部,不会和 Vue 文档的样式产生冲突。
但 Shadow DOM 也有一个常见陷阱——外部的 CSS 变量(Custom Properties)可以穿透 Shadow DOM 边界。这在主题定制场景下是优点(可以统一控制主题色),但也可能导致意外的样式影响。
/* 宿主页面 */
:root {
--primary-color: blue;
}
/* Shadow DOM 内部的 React 组件 */
.button {
color: var(--primary-color);
} /* 会读取到宿主的 blue */
状态隔离:独立作用域
dumi 的组件预览使用 react-live 库提供的 LiveProvider 组件。每个预览实例都有自己独立的 scope——注入的变量(如 React、useState)在不同预览之间互不干扰。
更关键的是,dumi 对预览代码的执行使用了 new Function() 而非直接 eval()。new Function() 创建的函数在全局作用域而非当前作用域中执行,这在一定程度上避免了预览代码意外访问到 dumi 内部变量。
事件隔离:Shadow DOM 的事件边界
Shadow DOM 有一个重要的事件行为:Shadow DOM 内部触发的事件,在冒泡出 Shadow Root 时会被”重定向”(retarget)——事件的 target 会被替换为 Shadow Host 元素本身。
// Shadow DOM 内部的 React 按钮被点击
// 事件冒泡到 Shadow Root 时:
// event.target → <react-button>(而不是内部的 <button>)
// Vue 的事件处理器看到的是自定义元素被点击,不会感知到内部的 React DOM 结构
这个重定向机制天然地隔离了两个框架的事件系统——Vue 的事件委托不会意外捕获到 React 内部元素的事件细节。
四、iframe 沙箱:更强的隔离方案
Web Components + Shadow DOM 能处理大多数场景,但有些情况需要更强的隔离。比如:
(1) 组件依赖全局变量(如 window.CONFIG、document.title)
(2) 组件会修改全局样式(如 Ant Design 的 ConfigProvider 全局注入)
(3) 组件使用了 Portal,渲染目标是 document.body
这些场景下,Shadow DOM 的隔离不够——需要 JavaScript 执行环境级别的隔离。dumi 提供了 iframe 模式:
````jsx iframe
import React, { useState } from 'react';
export default () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
};
```
标记 `iframe` 后,dumi 会为这个代码块生成一个独立的 iframe 页面。iframe 拥有完全独立的 `window`、`document`、CSS 上下文——比 Shadow DOM 的隔离更彻底,但性能开销也更大(每个 iframe 都是一个独立的浏览文档)。
换句话说,dumi 提供了两级隔离方案:**Shadow DOM 是"轻量隔离",iframe 是"完全隔离"**。选择哪个取决于组件的侵入性——如果组件只修改自己的 DOM 子树,Shadow DOM 足够;如果组件会"逃逸"到全局环境,就需要 iframe。
---
## 五、跨框架通信:postMessage 桥接
隔离之后,下一个问题是通信。如果 Vue 文档中的 React 组件需要响应宿主页面的操作(比如切换主题、传递配置),怎么办?
对于 Web Components 方案,通信相对简单——通过自定义元素的属性(attributes)传递数据:
```javascript
// Vue 侧:修改属性
document.querySelector('react-button').setAttribute('props', JSON.stringify({ theme: 'dark' }));
// React 适配器侧:监听属性变化
class ReactAdapter extends HTMLElement {
static get observedAttributes() {
return ['props'];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'props') {
this.rerender(JSON.parse(newVal));
}
}
}
```
对于 iframe 方案,通信必须通过 `window.postMessage`:
```javascript
// 宿主页面 → iframe
const iframe = document.querySelector('iframe.dumi-preview');
iframe.contentWindow.postMessage({ type: 'theme-change', theme: 'dark' }, '*');
// iframe 内部监听
window.addEventListener('message', (event) => {
if (event.data.type === 'theme-change') {
setTheme(event.data.theme);
}
});
```
`postMessage` 是浏览器为跨域/跨文档通信设计的标准 API。它是异步的、序列化的(数据会经过结构化克隆),不会创建引用共享——天然适合隔离环境之间的通信。
---
## 六、性能与取舍
跨框架渲染的代价是什么?
**(1)** **额外的运行时体积**:页面需要同时加载 React 和 Vue 的运行时。React 的 `react` + `react-dom` 约 40KB(gzip),Vue 3 约 30KB(gzip)。对文档站点来说可以接受,但对生产应用需要慎重考虑。
**(2)** **首屏渲染开销**:Web Components 的注册和 Shadow DOM 创建有额外开销。如果页面有大量跨框架组件预览,建议使用懒加载——只在组件进入视口时才初始化渲染。
**(3)** **调试复杂度**:Shadow DOM 内部的元素在 DevTools 中需要展开 Shadow Root 才能看到。iframe 的调试更复杂,需要切换到对应 iframe 的执行上下文。
在实际项目中,跨框架渲染主要用于**文档展示场景**——让一个框架的组件库能在另一个框架的文档中展示实时预览。如果是生产应用中需要混用框架(如 React 应用中嵌入 Vue 组件),通常有更轻量的方案(如 Module Federation 或直接用 Web Components 封装)。
---
## 七、总结
dumi 的跨框架组件渲染,核心思路是**用 Web Components 做桥接,用 Shadow DOM / iframe 做隔离,用属性传递 / postMessage 做通信**。
如果你只记住一句话:**跨框架渲染不是让两个框架共享 DOM,而是给每个框架一个隔离的 DOM 子树,然后用标准的浏览器 API 桥接它们。** Web Components 就是那个"标准 API"——它不属于任何框架,所以它可以连接所有框架。
---
**延伸阅读:**
- [MDN — Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components)
- [MDN — Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM)
- [dumi 官方文档 — 跨框架支持](https://d.umijs.org/)
- [react-live — GitHub](https://github.com/FormidableLabs/react-live)
---
**本系列其他文章:**
- 上一篇:[从 Markdown 到文档站点:dumi 的 SSG 流程与约定式路由](/posts/dumi-01/)
**相关主题:**
- 如果你对 React 组件的渲染机制感兴趣,可以看:[我不知道的 React 系列](/series/React/)