很多团队会遇到这种真实场景:项目主技术栈是 Vue(Vue 3 + Vite),但你想复用一个成熟的 React 组件(比如数据表格、编辑器、图表、流程编排器),或者你正在做渐进式迁移(React → Vue / Vue → React),不想重写一遍 UI。
这篇文章用一个可落地的方案:Veaury (React/Vue 互相桥接库),结合一个真实例子(在 Vue 页面中渲染 React 的 @glideapps/glide-data-grid 数据表格)来讲清楚:
- Vue 里如何挂载 React 组件
- Props / 事件如何传递
- Vite 配置要注意什么
- 组件命名、样式、性能、Portal 等常见坑怎么避
方案对比:Vue 里用 React 的几种方式
1)iframe / 微前端(最隔离)
优点:完全隔离、依赖不冲突;缺点:通信成本高、体验(路由/滚动/弹窗)更复杂。
2)Web Components(较通用)
优点:框架无关;缺点:生态不如框架内组件,复杂交互和上下文(Context/Provide/Inject)要额外处理。
3)运行时桥接(本文主角:Veaury)
优点:开发体验接近"直接用组件";缺点:运行时有一层桥接成本,需要注意组件生命周期、事件与样式边界。
如果你的目标是快速复用 React 组件,同时 Vue 项目不想拆成微前端,Veaury 通常是性价比很高的选择。
核心思路:用 Veaury 把 React 组件包装成 Vue 组件
你当前项目里已经有一份很典型的实现:
- Vue 侧包装:
cpp
<!--
Copyright (c) 2024 妙码学院 @Heyi
All rights reserved.
让进取的人更具职业价值
-->
<script setup lang="ts">
import { applyPureReactInVue } from 'veaury'
// @ts-ignore
import ReactDataSource from './react_app/ReactDataSource'
const RDataSource = applyPureReactInVue(ReactDataSource)
defineProps<{
id: string
}>()
</script>
<template>
<div class="data-source-content-wrapper">
<div class="data-source-render">
<r-dataSource :id="id" />
</div>
</div>
</template>
<style scoped>
.data-source-content-wrapper {
width: 100%;
background-color: var(--color-gray-100);
}
.data-source-render {
height: 100%;
/* height: calc(100% - 40px); */
/* margin: 20px; */
/* padding: 12px; */
background-color: var(--color-white);
border-radius: 8px;
overflow: hidden;
}
</style>
- React 组件实现:
ReactDataSource.jsx
cpp
/*
* Copyright (c) 2024 妙码学院 @Heyi
* All rights reserved.
* 让进取的人更具职业价值
*/
import '@glideapps/glide-data-grid/dist/index.css'
import { DataEditor, GridCellKind, GridColumnIcon } from '@glideapps/glide-data-grid'
import { useEffect, useMemo, useRef, useState } from 'react'
const tempDataPool = [
{
id: '001',
name: '合一',
age: '15',
isOpen: true,
hobby: ['football', 'swimming'],
avatar: ['https://i.pravatar.cc/300'],
notes: '**This is a markdown cell**'
},
{
id: '002',
name: '合二',
age: '18',
isOpen: true,
hobby: ['basketball', 'swimming'],
avatar: ['https://i.pravatar.cc/300'],
notes: 'true'
},
{
id: '003',
name: '合三',
age: '23',
isOpen: false,
hobby: ['basketball'],
avatar: ['https://i.pravatar.cc/300'],
notes: 'true'
},
{
id: '004',
name: '合四',
age: '25',
isOpen: true,
hobby: ['swimming'],
avatar: ['https://i.pravatar.cc/300'],
notes: 'true'
}
]
// Grid columns may also provide icon, overlayIcon, menu, style, and theme overrides
const columns = [
{ title: 'ID', width: 100, icon: GridColumnIcon.HeaderRowID },
{ title: '姓名', width: 100, icon: GridColumnIcon.HeaderTextTemplate },
{ title: '年龄', width: 100 },
{ title: '状态', width: 50 },
{ title: '爱好', width: 200 },
{ title: '头像', width: 200 },
{ title: '笔记', width: 200 }
]
export default function ReactDataSource(props) {
const dsId = props.id
const ref = useRef(null)
const data = useMemo(() => {
const len = dsId === '1' ? 1000000 : dsId === '2' ? 100000 : 10
const tempDataList = new Array(len).fill(0)
return tempDataList.map((item, index) => {
const randomIndex = Math.floor(Math.random() * 4)
const randomItem = tempDataPool[randomIndex]
return {
...randomItem,
id: `00${index}`,
name: `合${Math.random().toString(36).substr(2, 2)}`,
avatar: [`https://i.pravatar.cc/300?img=${index}`]
}
})
}, [dsId])
// If fetching data is slow you can use the DataEditor ref to send updates for cells
// once data is loaded.
const getData = ([col, row]) => {
const person = data[row]
switch (col) {
case 0: {
return {
kind: GridCellKind.RowID,
data: person.id,
allowOverlay: false,
displayData: person.id
}
}
case 1: {
return {
kind: GridCellKind.Text,
data: person.name,
allowOverlay: true,
displayData: person.name,
hasMenu: true
}
}
case 2: {
return {
kind: GridCellKind.Number,
data: person.age,
allowOverlay: true,
displayData: person.age
}
}
case 3: {
return {
kind: GridCellKind.Boolean,
data: person.isOpen,
allowOverlay: true,
displayData: person.isOpen
}
}
case 4: {
return {
kind: GridCellKind.Bubble,
data: person.hobby,
allowOverlay: true,
displayData: person.hobby
}
}
case 5: {
return {
kind: GridCellKind.Image,
data: person.avatar,
allowOverlay: true,
displayData: person.avatar
}
}
case 6: {
return {
kind: GridCellKind.Markdown,
data: person.notes,
allowOverlay: true,
displayData: person.Markdown
}
}
default: {
return {}
}
}
}
// const [data, setData] = useState()
const [editorRect, setEditorRect] = useState({ width: 500, height: 300 })
const { width, height } = editorRect
useEffect(() => {
const calcRect = () => {
const outerContainerDom = ref.current.parentElement.parentElement
if (outerContainerDom) {
const { width, height } = outerContainerDom.getBoundingClientRect()
setEditorRect({ width: width - 12, height: height - 12 })
}
}
calcRect()
window.addEventListener('resize', calcRect, false)
return () => {
window.removeEventListener('resize', calcRect, false)
}
}, [])
return (
<div ref={ref}>
<DataEditor
key={dsId}
width={width}
height={height}
columns={columns}
getCellContent={getData}
rows={data.length}
onCellEdited={(p, q) => console.log(p, q)}
/>
<div id="portal" style={{ position: 'fixed', left: 0, top: 0, zIndex: 9999 }} />
</div>
)
}
- Vite 插件配置:
cpp
...
import veauryVitePlugins from 'veaury/vite/index.js'
...
export default defineConfig({
plugins: [
/* vue(), vueJsx(), */
vueDevTools(),
veauryVitePlugins({ type: 'vue' }),
})
],
}
})
Vue 侧核心代码如下(思路是对的):
vue
<script setup lang="ts">
import { applyPureReactInVue } from "veaury"
import ReactDataSource from "./react_app/ReactDataSource"
const RDataSource = applyPureReactInVue(ReactDataSource)
defineProps<{ id: string }>()
</script>
<template>
<div class="data-source-render">
<r-dataSource :id="id" />
</div>
</template>
一个关键坑:组件名写法建议修正
在 Vue SFC 模板里,推荐用 PascalCase 或 kebab-case 映射组件变量名。比如你定义的是 RDataSource,最稳妥的两种写法:
<RDataSource :id="id" />(SFC 模板支持 PascalCase)<r-data-source :id="id" />(kebab-case 映射RDataSource)
<r-dataSource /> 这种"混合大小写"的标签在 HTML 解析/大小写折叠时更容易踩坑(尤其在某些工具链或复制到 DOM 模板时)。
Vite 配置:让 React 组件在 Vue 项目里能正常编译
你项目里已经配置了 Veaury 的 Vite 插件(这一步通常必需):
ts
// [apps/builder/vite.config.ts](apps/builder/vite.config.ts)
import veauryVitePlugins from "veaury/vite/index.js"
export default defineConfig({
plugins: [
veauryVitePlugins({ type: "vue" }),
],
})
这意味着:在 Vue 工程里,你可以引入 .jsx/.tsx 的 React 组件,并由插件帮你处理必要的编译兼容。
依赖安装(一般需要)
确保有 React 运行时依赖:
bash
pnpm add react react-dom veaury
以及 React 组件本身依赖(例如你的数据表格):
bash
pnpm add @glideapps/glide-data-grid
Props、事件与双向数据:怎么从 Vue 传到 React
1)Props:直接当普通属性传
React 组件 ReactDataSource(props) 里用 props.id,Vue 侧直接绑定:
vue
<RDataSource :id="id" />
2)事件:用函数 props 传回 Vue
你现在的 React 组件里有:
jsx
<DataEditor onCellEdited={(p, q) => console.log(p, q)} />
如果你想把编辑事件抛回 Vue(更常见),可以改成 React 接收一个回调 props:
jsx
export default function ReactDataSource(props) {
const { id, onCellEdited } = props
return (
<DataEditor
key={id}
onCellEdited={(cell, newValue) => onCellEdited?.(cell, newValue)}
// ...
/>
)
}
然后在 Vue 侧传入方法:
vue
<script setup lang="ts">
const onEdited = (cell: any, newValue: any) => {
console.log("Vue got edit:", cell, newValue)
}
</script>
<template>
<RDataSource :id="id" :onCellEdited="onEdited" />
</template>
这种方式对桥接库最友好:React 通过 props 抛事件,Vue 通过传函数接收。
如果你追求 Vue 的
v-model体验,建议在 Vue 侧再包一层组件,把 React 的value/onChange映射成modelValue/update:modelValue,属于"适配层"思路。
尺寸与自适应:React 组件如何感知 Vue 容器大小
你的 React 组件用 ref + getBoundingClientRect() 来计算 DataEditor 尺寸,并在窗口 resize 时更新:
jsx
const ref = useRef(null)
const [editorRect, setEditorRect] = useState({ width: 500, height: 300 })
useEffect(() => {
const calcRect = () => {
const outerContainerDom = ref.current.parentElement.parentElement
if (outerContainerDom) {
const { width, height } = outerContainerDom.getBoundingClientRect()
setEditorRect({ width: width - 12, height: height - 12 })
}
}
calcRect()
window.addEventListener("resize", calcRect)
return () => window.removeEventListener("resize", calcRect)
}, [])
这能工作,但有两个升级点:
- 更推荐用
ResizeObserver监听容器变化(不仅是窗口变化,布局变化也能触发) - 尽量别依赖
parentElement.parentElement这种结构耦合,最好让 Vue 传一个明确的容器引用或用更稳定的 DOM 选择方式
样式与资源:React 组件的 CSS 放哪里
你在 React 文件顶部引入了数据表格的样式:
jsx
import "@glideapps/glide-data-grid/dist/index.css"
这是常见做法:React 组件自带它依赖的全局样式,使用方不需要额外关心。
注意点:
- 如果 Vue 侧是
scoped样式,它不会影响 React 组件内部(这是好事也是限制) - React 依赖的 CSS 多为全局,会影响全局命名空间;如果冲突风险大,需要引入 CSS Modules、BEM 约定或隔离容器选择器
Portal / 弹层:为什么要额外放一个 portal 容器
你在 React 组件里渲染了:
jsx
<div id="portal" style={{ position: "fixed", left: 0, top: 0, zIndex: 9999 }} />
这种写法通常是为了:某些 React 组件(菜单、弹窗、下拉)会通过 Portal 把元素挂到页面更高层级,避免被父容器 overflow: hidden 裁剪。
如果你真的需要 Portal,建议:
- portal 容器 id 做到全局唯一或按组件实例区分
- 更稳的做法是把 portal 挂到
document.body,并且在 unmount 时清理(避免页面多次进入后残留 DOM)
性能注意:大数据量渲染与 key 的含义
你的 React 组件对数据量做了模拟:
jsx
const len = dsId === "1" ? 1000000 : dsId === "2" ? 100000 : 10
并对 DataEditor 使用了 key={dsId}:
jsx
<DataEditor key={dsId} ... />
含义是:当 id 变化时,React 会把 DataEditor 当成"新组件"重建(强制重置内部状态)。这在数据源切换时很有用,但也意味着:
- 切换
id会触发完整重建 - 如果
id变化频繁,可能带来较大开销
建议:仅在你确实需要"彻底重置"时才用 key 强制刷新。
常见坑清单(很实用)
- 组件命名:Vue 模板里优先用
<RDataSource />或<r-data-source /> - 类型:React 组件用
.tsx会更好(对 props、回调、事件参数有类型) - 生命周期:React 的
useEffect会在 Vue 组件卸载时跟着卸载,但你仍要保证事件监听/定时器清理干净 - 全局副作用:React 组件引入的全局 CSS、全局事件、Portal 容器要控制好
- 构建配置:确保 Veaury 的 Vite 插件配置正确,否则 JSX/React 运行时可能会报错
总结
用 Veaury 在 Vue 里使用 React 组件,本质上就是:
- Vite 配好 Veaury 插件,让 JSX 能编译
applyPureReactInVue(ReactComponent)把 React 组件变成 Vue 可用组件- 用 props/函数回调完成数据与事件通信
- 注意命名、样式隔离、Portal 和性能等边界问题
如果你愿意,我也可以顺手帮你把当前示例做一次"工程化加强":
- 把
<r-dataSource />调整为更稳的命名方式 - 给 ReactDataSource 增加事件回调 props,并在 Vue 侧接起来
- 把尺寸计算改成
ResizeObserver(更稳定) - 如果需要,再补一个 TSX 版本的类型声明