阅读视图

发现新文章,点击刷新页面。

手写 React 对比 VuReact 编译:真正省下来的是维护成本

📢 前言

很多人讨论 Vue 转 React,第一反应总是“能不能转”“转得快不快”“性能差多少”。

但如果你真的做过迁移,或者真的在 React 里维护过一批复杂组件,你很快会发现,最贵的往往不是第一次把组件写出来,而是之后每一次修改、交接、重构、补功能时,你还要不要重新审一遍 useCallbackuseMemo、依赖数组、事件回调和样式隔离。

所以这篇文章不讨论跑分,也不讨论玄学优化。我只想回答一个更实际的问题:

同一个组件,如果你手写 React,需要亲自维护的东西,是不是明显比“用 Vue 写输入,再交给 VuReact 编译”更多?

我的结论是:是,而且差距不小。VuReact 真正省下来的,不只是迁移动作本身,而是组件进入长期维护期之后,那些原本要由开发者脑补、手填、反复确认的成本。

比较口径说明

为了避免这篇文章变成情绪化宣传,我先把比较口径说清楚。

本文不比较运行时 benchmark,不比较“谁更现代”,也不假装手写 React 只有一种写法。这里比较的是典型工程实现下的维护成本,维度固定为:接口、回调、依赖、样板代码、样式隔离、运行时纯度。

维度 手写 React VuReact 编译路线
props 类型声明 需要手动设计和维护 defineProps / defineEmits 可映射为 TS 类型
事件回调 wiring 需要手动把事件改成 onXxx 编译阶段自动映射
Hook 依赖维护 需要开发者自己判断和补齐 编译阶段自动分析、自动注入
对象/数组 memo 判断 需要自己决定要不要包 useMemo 只对可分析的响应式表达式做优化
样式隔离处理 需要自己选方案并维护一致性 scoped 可直接落成带作用域标识的 CSS
最终产物纯度 取决于你的实现方式 输出就是纯 React,不带 Vue 运行时

也就是说,这篇文章不是在说“手写 React 不好”,而是在说:如果同样的业务目标可以用 Vue 输入 + VuReact 编译完成,那么你本来需要自己承担的维护义务,会少很多。

主证据样本:同一个组件,三种维护方式

我先拿一个综合样本来说话。这个样本不是极端 demo,而是很像真实业务组件:有 props、有 emits、有 ref、有 computed、有顶层箭头函数、有对象方法,还有 scoped 样式。

先看 Vue 输入。你会发现它本质上就是一个很正常的 Vue 3 组件,没有为了“迁移”刻意写成奇怪样子。

<template>
  <section class="counter-card">
    <h1>{{ props.title }}</h1>
    <h2>VuReact + Vue = React ({{ count }})</h2>
    <p>{{ title }}</p>
    <button @click="increment">+1</button>
    <button @click="methods.decrease">-1</button>
  </section>
</template>

<script setup lang="ts">
// @vr-name: HelloWorld
import { computed, ref, watch } from 'vue';

const props = defineProps<{ title?: string }>();
const emits = defineEmits<{ (e: 'update', value: number): void }>();

const step = ref(1);
const count = ref(0);
const title = computed(() => `阶数:x${step.value}`);

const increment = () => {
  count.value += step.value;
  emits('update', count.value);
};

const methods = {
  decrease() {
    count.value -= step.value;
    emits('update', count.value);
  },
};

watch(count, (newVal) => {
  step.value = Math.floor(newVal / 10) || 1;
});
</script>

<style scoped>
.counter-card { border: 1px solid #ddd; padding: 12px; }
</style>

如果这段逻辑让你手写成 React,一个很典型的等价实现,大概会长这样。注意,这不是“唯一正确写法”,而是一个工程上完全合理、也是多数团队都会接受的版本。

import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import './HelloWorld.css';

type IHelloWorldProps = {
  title?: string;
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const [step, setStep] = useState(1);
  const [count, setCount] = useState(0);

  const title = useMemo(() => `阶数:x${step}`, [step]);

  const increment = useCallback(() => {
    setCount((prev) => {
      const next = prev + step;
      props.onUpdate?.(next);
      return next;
    });
  }, [step, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        setCount((prev) => {
          const next = prev - step;
          props.onUpdate?.(next);
          return next;
        });
      },
    }),
    [step, props.onUpdate],
  );

  useEffect(() => {
    setStep(Math.floor(count / 10) || 1);
  }, [count]);

  return (
    <section className="counter-card">
      <h1>{props.title}</h1>
      <h2>VuReact + Vue = React ({count})</h2>
      <p>{title}</p>
      <button onClick={increment}>+1</button>
      <button onClick={methods.decrease}>-1</button>
    </section>
  );
});

再看 VuReact 的编译产物。这里最关键的不是“它也能跑”,而是它并没有牺牲 React 工程质量。你在 React 里想要的 memouseComputed/useVRefuseCallbackuseMemo、类型接口、样式作用域,它都完整落下来了。

import { useComputed, useVRef, useWatch } from '@vureact/runtime-core';
import { memo, useCallback, useMemo } from 'react';
import './HelloWorld-ebf8d8dc.css';

export type IHelloWorldProps = {
  title?: string;
} & {
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const step = useVRef(1);
  const count = useVRef(0);
  const title = useComputed(() => `阶数:x${step.value}`);

  const increment = useCallback(() => {
    count.value += step.value;
    props.onUpdate?.(count.value);
  }, [count.value, step.value, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        count.value -= step.value;
        props.onUpdate?.(count.value);
      },
    }),
    [count.value, step.value, props.onUpdate],
  );

  useWatch(count, (newVal) => {
    step.value = Math.floor(newVal / 10) || 1;
  });
});

这时候真正值得看的,不是“哪段代码更短”,而是“哪些维护动作必须由人来做”。按上面这个样本的可见代码统计:

指标 手写 React Vue 输入 + VuReact
显式优化 API 数量 5 处:memo、2 处 useMemouseCallbackuseEffect 0 处由开发者手写
需要手填的依赖数组项数量 6 项 0 项
与稳定性相关的样板代码行数 约 18 行 0 行由开发者额外维护
需要开发者主动判断的优化点数量 至少 5 个 0 个优化判断点

这个表的意义很直接:VuReact 不是帮你“少写一点 React 语法”,而是帮你少承担一整套组件级维护义务。你不用亲自决定标题该不该 useMemo,不用亲自判断回调依赖要不要补 onUpdate,也不用在每次改业务时重新审一遍数组是不是还正确。

次证据样本:连 slot 到 children 的接口翻译,也会更顺

如果只聊 Hook,你可能会以为这件事只是“少写几个依赖数组”。其实不是。组件接口设计本身,也会因为 VuReact 变得更顺。

以插槽为例,Vue 里的默认插槽会自然映射成 React 的 children,作用域插槽会映射成带参数的函数 children。也就是说,VuReact 帮你省掉的,不只是底层优化,还有内容分发接口的手工翻译成本。

例如:

<slot></slot> 会直接落成 props.children

<slot :item="item" :index="i"></slot> 会落成 props.children?.({ item, index })

这件事看起来小,实际在大型组件库里特别重要。因为你少做的不是一行改写,而是少做一次“我要把 Vue 的内容分发机制手工翻成 React 接口”的设计工作。对于需要交给别人继续维护的组件,这种接口自然度非常值钱。

工程上更关键的一点:产物是纯 React,不是套壳

很多“转换工具”最让人不放心的地方,不在于能不能跑,而在于它最后到底给你留下了什么。

VuReact 在这一点上的边界其实很清楚:官方文档明确强调,编译产物最终为纯 React 应用,不依赖 Vue 运行时,也不是在 React 中嵌入 Vue 容器的套壳方案。

这句话为什么重要?因为它直接决定了后续维护体验。

如果最终产物是双运行时桥接,短期也许能演示,但长期一定会出现调试复杂、性能归因困难、团队协作断层的问题。可如果最终产物就是标准 React 代码,那它就能直接进入你现有的 React 工具链、code review 流程和长期演进路径。

这也是为什么我更愿意用官网那四个词来概括 VuReact:语义感知、渐进迁移、约定驱动、完整特性适配。 它不是在做“表面可运行”,而是在做“可进入工程维护周期的 React 产物”。

为什么这对团队比对个人更重要

个人开发者感受到的是轻松,团队感受到的则是确定性。

对 code review 来说,少一些手工 memo 和依赖数组,意味着 review 的注意力可以更多放回业务本身,而不是反复检查“这里是不是漏依赖了”。对交接来说,新同事看到的是更稳定的输入约定和更标准的输出产物,而不是一堆高度依赖原作者经验的 React 小技巧。

对重构来说,成本差异更明显。手写 React 组件经常让人不敢轻动,因为你一改业务结构,就可能牵动 useMemouseCallbackuseEffect 的依赖关系。VuReact 让这类稳定性工作前移到编译阶段,本质上是在降低重构的心理门槛。

对迁移路线也是一样。你当然可以手写一个组件、十个组件,但当项目规模上来之后,真正难的不是有没有人会写 React,而是有没有办法把大量“手工判断”变成稳定流程。VuReact 的价值,恰恰就在这里。

下一步怎么验证

如果你想判断这是不是适合你的路线,最好的方法不是继续看宣传语,而是直接去看真实产物。

先看官网的 语义编译对照 和 “为什么选 VuReact”,确认它是不是你认同的工程思路;再看 GitHub 和在线演示,判断编译后的 React 项目是不是你愿意接手维护的样子;如果还想继续深挖,可以再读我前面写过的那篇 “证据链” 文章,专门看 Hook 和依赖数组那一层的负担差异。

官网GitHub在线演示(CRM)在线演示(Customer Support Hub)

💬 写在最后

VuReact 的初心一直没有变——让你用熟悉的 Vue 编写 React,同时让项目平滑迁移到 React 生态,降低迁移成本,保留开发体验

它是一款面向 Vue 转 React 编译工具,它能将 Vue 3 代码编译为标准、可维护的纯 React 。

🌐 Github:github.com/vureact-js/… 📃 官方文档:vureact.top

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!Github 仓库点亮 Star ⭐!

Vue 的 :deep/:global/:slotted 怎么转成 React ?一份对照指南?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 作用域样式中的穿透选择器(:deep/:global/:slotted)经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉样式 :deep/:global/:slotted 的用法。

编译对照

:global():声明全局样式

:global() 用于在 scoped 样式中声明一段不受作用域限制的全局样式。VuReact 的处理方式:移除 :global() 包装,保留内部选择器原样输出

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="global-class">全局类</div>
  </div>
</template>

<style scoped>
.component {
  :global(.global-class) {
    color: green;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  .global-class {
    color: green;
  }
}

从示例可以看到::global(...) 被完全移除,内部的选择器照常展开,且不添加 scope 属性。这样 .global-class 就是一个全局可用的样式类。


:deep():样式穿透

:deep() 是 scoped 样式中最常用的穿透选择器,用于让父组件的样式能够影响子组件内部的元素。VuReact 的处理策略是::deep(...) 左侧的选择器加上 scope,右侧(:deep 内部)的选择器保持原样

在嵌套规则中使用 :deep()

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="nested-component">深层嵌套组件</div>
  </div>
</template>

<style scoped>
.component {
  :deep(.nested-component) {
    background: yellow;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  & .nested-component {
    background: yellow;
  }
}

从示例可以看到:在嵌套规则中,:deep() 左侧是 .component(加 scope),右侧 .nested-component(不加 scope)。

在单行规则中使用 :deep()

:deep() 也可以在非嵌套的单行规则中使用,左侧部分仍然被 scoped。

  • Vue 代码:
<style scoped>
.parent :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

:deep() 紧贴选择器

  • Vue 代码:
<style scoped>
.parent:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

带组合器的 :deep()

  • Vue 代码:
<style scoped>
.parent > :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] > .btn { color: red; }

:deep() 作为选择器起始

:deep() 位于选择器最左侧时(无左侧部分),VuReact 会直接用 [scopeId] 作为左侧。

  • Vue 代码:
<style scoped>
:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
[data-css-abc123] .btn { color: red; }

处理逻辑:左侧为空时,用 [data-css-abc123] 自身作为 scoped 占位。

:deep() 展开逗号选择器

:deep() 内部可以包含多个逗号分隔的选择器,VuReact 会逐一展开。

  • Vue 代码:
<style scoped>
.a :deep(.x, .y) { color: red; }
</style>
  • VuReact 编译后 CSS:
.a[data-css-abc123] .x, .a[data-css-abc123] .y { color: red; }

从示例可以看到::deep(.x, .y) 被展开为两个独立的选择器 .x.y,各自与左侧 .a[data-css-abc123] 拼接。


4. :slotted():插槽样式

:slotted() 用于为插槽传入的内容设置样式,VuReact 当前的处理方式是简单解包

  • Vue 代码:
<style scoped>
.component {
  :slotted(.slotted-content) {
    display: flex;
  }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  .slotted-content {
    display: flex;
  }
}

从示例可以看到::slotted(...) 被移除,内部选择器 .slotted-content 保留,但不加 scope。完整的 :slotted() 语义支持仍在解决中。


复杂选择器共存

在一个组件中,:global:deep:slotted 可以与标准 scoped 选择器以及伪类(:hover::before 等)混合使用。

  • Vue 代码:
<style scoped>
.component {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  :global(.global-class) { color: green; }
  :deep(.nested-component) { background: yellow; }
  :slotted(.slotted-content) { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  .global-class { color: green; }
  & .nested-component { background: yellow; }
  .slotted-content { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}

共处规则

选择器类型 行为 scope 注入
标准选择器 尾部追加 [data-css-xxx]
伪类/属性选择器 保持原样,插入 scope 在其之前
:global(...) 移除包装,内部不加 scope
:deep(...) 左侧加 scope,内部不加
:slotted(...) 移除包装,内部不加 scope ⚠️(待完善)

编译策略总结

VuReact 的作用域样式穿透选择器编译策略展示了完整的 scoped 选择器转换能力

  1. :global() 转换:移除 :global(...) 包装,内部选择器按全局样式输出,不加 scope
  2. :deep() 转换:将选择器按 :deep(...) 位置切割,左侧加 scope,内部保持穿透能力,支持嵌套、组合器、逗号展开等复杂场景
  3. :slotted() 转换:移除 :slotted(...) 包装,内部选择器保持原样(完整语义实现 WIP)
  4. 伪类兼容:hover::before:not():nth-child() 等伪类保持原样,scope 只插入在伪类之前
  5. 嵌套兼容:与 SCSS/Less 的 & 嵌套语法协作良好

支持的穿透选择器

选择器 状态 说明
:deep() ✅ 完整支持 左侧 scoped + 右侧穿透
:global() ✅ 完整支持 移除包装,全局样式
:slotted() ⚠️ 部分支持 解包处理,完整语义待完善

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移。编译后的 CSS 选择器既保持了 Vue scoped 样式的作用域隔离语义,又能通过 :deep():global() 灵活控制样式穿透范围,让迁移后的应用保持完整的 scoped 样式能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

❌