说到在前端世界里搞“拼盘式”开发,很多人第一反应是——这能行吗?当然行,而且有时候你不得不行。
想象一下这个场景:你们团队里一半人用React,另一半人还在维护老项目的Vue逻辑,现在要合并成一个页面。Vue的CDN脚本被硬塞进了React的DOM树里,结果控制台立马给你表演一个“重复渲染”的花活,React的useState和Vue的响应式系统当场撞车,浏览器CPU风扇转得比离心机还快。
这时候,最理性的做法不是去骂骂咧咧地重写所有逻辑,而是给Vue组件穿上一层“隔离服”,把它当成一个纯原生的自定义元素(Custom Element)喂给React。React不认Vue的那一套,但React认document.createElement和原生DOM。
先看看那个让人头疼的“重复渲染”到底是怎么回事
很多开发者第一次尝试在React里直接挂Vue CDN时,代码大概长这样:
<!-- 一个典型的“作死”场景 -->
<div id="root">
<!-- React在这里渲染 -->
<div id="vue-app">
<h1>{{ message }}</h1>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
// React 渲染器
const root = ReactDOM.createRoot(document.getElementById('root'));
// 同时,Vue 试图挂载到同一个祖先节点上
const app = Vue.createApp({
data() { return { message: 'Hello Vue' } }
});
app.mount('#vue-app');
</script>
听起来很美好对吧?两个框架和平共处。但只要你刷新页面,或者React触发一次重新渲染,控制台就会弹出类似这样的警告:
Vue: The computed property "message" is already defined in React's reconciliation process...
或者更直接的:
Warning: Cannot update a component from inside the function body of a different component.
问题的根源其实很朴素。Vue 3 虽然做了很大的隔离优化,但它依然依赖自己的响应式代理(Reactive Proxy)去监听 DOM 变化。当 React 接管了页面生命周期,它会在 Virtual DOM Diff 的过程中强制更新 DOM 节点。这时,Vue 的监听器会发现:“等等,我控制的这个节点怎么突然变了?” 而 React 又会觉得:“你为什么又来动我的树?”
这就是典型的控制权争抢。两个框架都想当这个页面的“皇帝”,结果就是谁也没当成,还搞出了一堆副作用。
为什么把Vue封装成原生Web组件能解决冲突?
Web Components(Web组件)是浏览器原生的标准,它不隶属于任何框架。一旦Vue的逻辑被封装进一个 Custom Element 里,它在React眼里就不再是一个“Vue应用”,而仅仅是一个 <my-vue-widget> 标签。
React 只会把它当作普通的 DOM 节点处理,不会去窥探它内部的结构。Vue 也不需要去响应 React 的 Virtual DOM 变化,因为它只对自己内部的 Shadow DOM 或局部 DOM 负责。
这种黑盒化的处理方式,是解决混用冲突的金钥匙。
手把手教你:用原生JS封装Vue组件
我们不需要复杂的构建工具,只需要一个简单的 HTML 文件,外加 Vue 和 React 的 CDN 链接。
第一步:定义Vue的逻辑,并将其转化为Web Component
我们需要创建一个类,继承自 HTMLElement。在这个类里,我们加载 Vue,并管理它的挂载和卸载。
class VueReactWidget extends HTMLElement {
constructor() {
super();
this.vueApp = null;
}
// 当元素被添加到DOM时触发
connectedCallback() {
// 防止重复挂载
if (this.vueApp) return;
// 获取从React传进来的props数据
const initialData = JSON.parse(this.getAttribute('data-props') || '{}');
// 创建一个临时的挂载点,避免污染父级DOM
this.innerHTML = `<div id="vue-container"></div>`;
const mountNode = this.querySelector('#vue-container');
// 引入Vue(这里假设Vue全局变量已存在)
const { createApp, ref, computed } = window.Vue;
const app = createApp({
setup() {
// 这里写你的Vue逻辑
const count = ref(initialData.count || 0);
const doubled = computed(() => count.value * 2);
const increment = () => count.value++;
// 暴露方法给React通过postMessage或属性调用
return { count, doubled, increment };
},
template: `
<div class="vue-box">
<h3>Vue计算结果: {{ doubled }}</h3>
<p>当前计数: {{ count }}</p>
<button @click="increment">Vue按钮</button>
</div>
`
});
// 挂载
app.mount(mountNode);
this.vueApp = app;
}
// 当元素从DOM移除时触发,必须销毁Vue实例防止内存泄漏
disconnectedCallback() {
if (this.vueApp) {
this.vueApp.unmount();
this.vueApp = null;
}
}
}
// 注册为全局自定义元素
window.customElements.define('vue-react-widget', VueReactWidget);
这里有个关键点:disconnectedCallback。在React环境中,组件可能会因为状态变化而被频繁卸载和重新挂载。如果不手动 unmount,Vue的实例会一直挂在内存里,随着React的渲染次数增加,内存占用会直线上升,最终导致页面卡死。
第二步:在React中通过createElement调用这个组件
React 不直接支持自定义元素作为 JSX 标签(虽然新版的 jsxRuntime 有所改善,但为了保险起见,我们显式使用 createElement)。
const { createElement, useState, useEffect } = React;
const { createRoot } = ReactDOM;
function App() {
const [count, setCount] = useState(0);
// 监听Vue组件发来的事件(如果需要双向通信)
useEffect(() => {
const handleVueEvent = (e) => {
console.log('Vue组件触发了:', e.detail);
setCount(e.detail.newCount);
};
document.addEventListener('vue-action', handleVueEvent);
return () => document.removeEventListener('vue-action', handleVueEvent);
}, []);
// 将数据作为属性传给Vue组件
const vueProps = JSON.stringify({ count: count });
// 核心:用原生方式创建自定义元素
const vueWidget = createElement('vue-react-widget', {
'data-props': vueProps,
className: 'wrapper'
});
return createElement('div', { className: 'app-container' },
createElement('h1', null, 'React与Vue的和平共处'),
createElement('p', null, `React状态计数: ${count}`),
createElement('button', {
onClick: () => setCount(c => c + 1)
}, 'React按钮'),
// 这里插入Vue封装的组件
vueWidget
);
}
// 渲染React应用
const root = createRoot(document.getElementById('root'));
root.render(createElement(App));
第三步:样式隔离(可选但推荐)
为了防止Vue的样式污染React页面,或者React的样式误伤Vue组件,我们可以在自定义元素的 connectedCallback 里创建一个 Shadow DOM。
connectedCallback() {
if (this.shadowRoot) return;
// 开启 Shadow DOM 隔离
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<div id="vue-container"></div>
<style>
.vue-box { padding: 10px; border: 1px solid #ccc; background: #f9f9f9; }
button { margin-top: 5px; cursor: pointer; }
</style>`;
const mountNode = shadow.querySelector('#vue-container');
// ... 后续挂载Vue逻辑不变,只是挂载点换成了 shadow.querySelector ...
}
这样,Vue组件就像住进了一个独立的“公寓”里,里面的样式和结构对外部完全透明,React再也找不到它的麻烦。
通信问题:React和Vue怎么说话?
封装成独立组件后,它们不再是同一个上下文。你不能直接调用 this.vueComponent.someMethod(),因为它们根本没有共享实例引用。
这时候,事件总线(Event Bus) 或者 Custom Events 是最好的桥梁。
在Vue组件内部:
// Vue内部触发事件
const event = new CustomEvent('vue-action', {
detail: { newCount: this.count }
});
this.dispatchEvent(event);
在React的 useEffect 中监听:
useEffect(() => {
const handler = (e) => {
console.log('收到Vue的消息:', e.detail);
};
// 监听整个document上的自定义事件
document.addEventListener('vue-action', handler);
return () => {
document.removeEventListener('vue-action', handler);
};
}, []);
这种方式解耦了两者,React不需要知道Vue的存在,Vue也不需要知道React的状态管理。它们只是两个独立的模块,通过标准的浏览器事件机制进行数据交换。
为什么不用iframe?
你可能会问,用iframe把Vue包起来不是更简单吗?
确实更简单,但iframe有致命的缺点:性能差、SEO不友好、样式穿透困难、用户体验割裂(比如滚动条分离)。而Web Component方案完全运行在同一页面上下文,没有额外的窗口开销,对搜索引擎来说也是一个合法的DOM节点。
总结:给你的项目加一层“防火墙”
把Vue CDN脚本直接扔进React页面,就像是把油和水倒进同一个杯子,怎么搅都会分层。而通过原生JS封装成Web Component,本质上是在这个杯子里放了一个密封的试管。React和Vue分别在试管的两头运作,互不干扰,却又能通过试管壁(Custom Events)传递信息。
这种做法的核心优势在于:
- 生命周期解耦:React控制挂载时机,Vue只在被插入DOM时才初始化。
- 样式隔离:Shadow DOM杜绝了CSS冲突。
- 内存安全:通过
disconnectedCallback及时销毁Vue实例,避免内存泄漏。
下次再遇到这种“老项目React化”或者“多框架并存”的尴尬局面,别急着重写业务逻辑。试着给Vue穿上一件“原生JS的外套”,让它变成一个普通的HTML标签,你会发现,React和Vue竟然可以这么彬彬有礼地相处。
代码的世界里,很多时候冲突不是因为技术不行,而是因为我们不够“宽容”——给彼此一点独立的空间,反而能融合得更好。
