在 Vue 项目里“无痛”使用 React 组件:以 Veaury + Vite 为例

很多团队会遇到这种真实场景:项目主技术栈是 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 组件,本质上就是:

  1. Vite 配好 Veaury 插件,让 JSX 能编译
  2. applyPureReactInVue(ReactComponent) 把 React 组件变成 Vue 可用组件
  3. 用 props/函数回调完成数据与事件通信
  4. 注意命名、样式隔离、Portal 和性能等边界问题

如果你愿意,我也可以顺手帮你把当前示例做一次"工程化加强":

  • <r-dataSource /> 调整为更稳的命名方式
  • 给 ReactDataSource 增加事件回调 props,并在 Vue 侧接起来
  • 把尺寸计算改成 ResizeObserver(更稳定)
  • 如果需要,再补一个 TSX 版本的类型声明
相关推荐
dangfulin2 小时前
简单的视差滚动效果
前端·css·视差滚动
Forget_85502 小时前
RHEL——web应用服务器TOMCAT
java·前端·tomcat
一只大侠的侠2 小时前
React Native实战:高性能Overlay遮罩层组件封装与OpenHarmony适配
javascript·react native·react.js
java1234_小锋2 小时前
分享一套优质的SpringBoot4+Vue3学生信息管理系统
java·vue.js·spring boot·学生信息
myFirstName3 小时前
离谱!React中不起眼的[]和{}居然也会导致性能问题
前端
我是伪码农3 小时前
Vue 2.11
前端·javascript·vue.js
Amumu121383 小时前
CSS:字体属性
前端·css
凯里欧文4273 小时前
html与CSS伪类技巧
前端
UIUV3 小时前
构建Git AI提交助手:从零到全栈实现的学习笔记
前端·后端·typescript