Vue 3 开发者转型 React 指南:保姆级教程

本文基于实际项目案例,对比 Vue 3 与 React 的核心差异,帮助 Vue 开发者快速上手 React。

前言

作为 Vue 3 开发者,当我第一次接触 React 时,最大的感受是:很多概念都有对应,但写法和思维方式完全不同

本文将结合一个仓库浏览器页面的实战案例,讲解从 Vue 3 到 React 需要掌握的核心概念。

项目背景

我们正在构建一个仓库浏览器页面:

  • 左侧:可折叠的文件树
  • 右侧:代码高亮 / Markdown 预览

先看一下两个框架的代码对比:

Vue 3 版本

vue 复制代码
<template>
  <div class="file-tree">
    <div 
      v-for="node in data" 
      :key="node.id"
      @click="handleClick(node)"
    >
      {{ node.name }}
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  data: FileNode[]
  selectedId: string | null
}

const props = defineProps<Props>()
const emit = defineEmits<{
  select: [node: FileNode]
  toggle: [id: string]
}>()

const handleClick = (node: FileNode) => {
  if (node.type === 'folder') {
    emit('toggle', node.id)
  } else {
    emit('select', node)
  }
}
</script>

React 版本

tsx 复制代码
import React from 'react'

interface FileTreeProps {
  data: FileNode[]
  selectedId: string | null
  onToggle: (id: string) => void
  onSelect: (node: FileNode) => void
}

export const FileTree: React.FC<FileTreeProps> = ({
  data,
  selectedId,
  onToggle,
  onSelect,
}) => {
  const handleClick = (node: FileNode) => {
    if (node.type === 'folder') {
      onToggle(node.id)
    } else {
      onSelect(node)
    }
  }

  return (
    <div className="file-tree">
      {data.map((node) => (
        <div
          key={node.id}
          onClick={() => handleClick(node)}
        >
          {node.name}
        </div>
      ))}
    </div>
  )
}

核心差异:

Vue 3 React
<template> JSX
defineProps 函数参数解构
defineEmits 函数参数(回调函数)
v-for Array.map()
@click onClick

一、useCallback 是什么?

1.1 问题场景

在仓库浏览器中,我们有一个 useFileTree Hook 管理状态:

tsx 复制代码
// useFileTree.ts
export function useFileTree() {
  const [selectedFile, setSelectedFile] = useState<FileNode | null>(null)
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())

  // 这个函数每次渲染都会重新创建
  const selectFile = (file: FileNode) => {
    if (file.type === 'file') {
      setSelectedFile(file)
    }
  }

  const toggleFolder = (folderId: string) => {
    setExpandedFolders((prev) => {
      const next = new Set(prev)
      if (next.has(folderId)) {
        next.delete(folderId)
      } else {
        next.add(folderId)
      }
      return next
    })
  }

  return {
    selectedFile,
    expandedFolders,
    selectFile,    // 每次渲染都是新函数
    toggleFolder,  // 每次渲染都是新函数
  }
}

问题: selectFiletoggleFolder 每次渲染都会生成新的函数引用,导致使用它们的子组件不必要地重新渲染。

1.2 Vue 3 的解决方案

Vue 3 中,你可能不需要特别处理这个问题,因为:

  • refreactive 是响应式的
  • 模板编译器会自动优化

但如果你确实需要缓存函数,Vue 3 也提供了 computedwatch

1.3 React 的解决方案:useCallback

tsx 复制代码
import { useState, useCallback } from 'react'

export function useFileTree() {
  const [selectedFile, setSelectedFile] = useState<FileNode | null>(null)
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())

  // useCallback 缓存函数引用
  const selectFile = useCallback((file: FileNode) => {
    if (file.type === 'file') {
      setSelectedFile(file)
    }
  }, [])  // 依赖数组为空,函数只创建一次

  const toggleFolder = useCallback((folderId: string) => {
    setExpandedFolders((prev) => {
      const next = new Set(prev)
      if (next.has(folderId)) {
        next.delete(folderId)
      } else {
        next.add(folderId)
      }
      return next
    })
  }, [])  // 同样只创建一次

  return {
    selectedFile,
    expandedFolders,
    selectFile,    // 引用稳定,不会触发子组件重渲染
    toggleFolder,  // 引用稳定
  }
}

1.4 useCallback 语法详解

tsx 复制代码
const memoizedFn = useCallback(
  () => {
    // 函数体
  },
  [dependency1, dependency2]  // 依赖数组
)

对比 Vue 3:

场景 Vue 3 React
缓存计算值 computed useMemo
缓存函数 通常不需要 useCallback
监听变化 watch useEffect

1.5 什么时候用 useCallback?

推荐使用:

  • 作为 props 传递给子组件的回调函数
  • 作为 useEffect 的依赖项
  • 需要保持引用稳定的函数

不需要使用:

  • 仅在当前组件使用的函数
  • 事件处理函数(如 onClick

二、组件传参详解

2.1 父传子:Props

Vue 3 写法:

vue 复制代码
<!-- 父组件 -->
<template>
  <FileTree 
    :data="fileData" 
    :selected-id="selectedId"
  />
</template>

<script setup>
const fileData = ref([...])
const selectedId = ref('1')
</script>

<!-- 子组件 FileTree.vue -->
<script setup lang="ts">
interface Props {
  data: FileNode[]
  selectedId: string | null
}

const props = defineProps<Props>()
</script>

React 写法:

tsx 复制代码
// 父组件
import { FileTree } from './components/FileTree'

const App = () => {
  const [fileData] = useState<FileNode[]>([...])
  const [selectedId] = useState<string | null>('1')

  return (
    <FileTree 
      data={fileData} 
      selectedId={selectedId}
    />
  )
}

// 子组件 FileTree.tsx
interface FileTreeProps {
  data: FileNode[]
  selectedId: string | null
}

export const FileTree: React.FC<FileTreeProps> = ({ 
  data, 
  selectedId 
}) => {
  // 使用 data 和 selectedId
}

2.2 子传父:回调函数

Vue 3 写法:

vue 复制代码
<!-- 父组件 -->
<template>
  <FileTreeNode 
    @select="handleSelect"
    @toggle="handleToggle"
  />
</template>

<script setup>
const handleSelect = (node: FileNode) => {
  console.log('选中了:', node)
}

const handleToggle = (id: string) => {
  console.log('切换文件夹:', id)
}
</script>

<!-- 子组件 -->
<script setup>
const emit = defineEmits<{
  select: [node: FileNode]
  toggle: [id: string]
}>()

const handleClick = () => {
  emit('select', someNode)
}
</script>

React 写法:

tsx 复制代码
// 父组件
const App = () => {
  const handleSelect = (node: FileNode) => {
    console.log('选中了:', node)
  }

  const handleToggle = (id: string) => {
    console.log('切换文件夹:', id)
  }

  return (
    <FileTreeNode 
      onSelect={handleSelect}
      onToggle={handleToggle}
    />
  )
}

// 子组件
interface FileTreeNodeProps {
  onSelect: (node: FileNode) => void
  onToggle: (id: string) => void
}

export const FileTreeNode: React.FC<FileTreeNodeProps> = ({
  onSelect,
  onToggle,
}) => {
  const handleClick = () => {
    onSelect(someNode)  // 直接调用回调函数
  }
}

2.3 复杂数据传递

在仓库浏览器中,我们需要传递嵌套的文件树数据:

Vue 3:

vue 复制代码
<template>
  <FileTreeNode 
    v-for="node in data"
    :key="node.id"
    :node="node"
    :level="0"
    :expanded-folders="expandedFolders"
    :selected-file-id="selectedFileId"
    @toggle="$emit('toggle', $event)"
    @select="$emit('select', $event)"
  />
</template>

React:

tsx 复制代码
export const FileTree: React.FC<FileTreeProps> = ({
  data,
  expandedFolders,
  selectedFileId,
  onToggle,
  onSelect,
}) => {
  return (
    <div>
      {data.map((node) => (
        <FileTreeNode
          key={node.id}
          node={node}
          level={0}
          expandedFolders={expandedFolders}
          selectedFileId={selectedFileId}
          onToggle={onToggle}
          onSelect={onSelect}
        />
      ))}
    </div>
  )
}

2.4 传递方式对比总结

场景 Vue 3 React
父传子 v-bind:prop:prop 直接作为 JSX 属性
子传父 defineEmits + emit 传递回调函数作为 props
事件命名 @event-name onEventName (驼峰)
插槽 <slot> children 或命名插槽 props
Provide/Inject provide/inject Context API

三、其他 Vue 3 → React 核心概念映射

3.1 响应式数据

tsx 复制代码
// Vue 3
const count = ref(0)
const user = reactive({ name: '张三' })

// React
const [count, setCount] = useState(0)
const [user, setUser] = useState({ name: '张三' })

// 更新方式
count.value++                    // Vue 3
setCount(count + 1)              // React

user.name = '李四'              // Vue 3 (reactive 自动响应)
setUser({ ...user, name: '李四' })  // React (需要创建新对象)

3.2 计算属性 vs useMemo

tsx 复制代码
// Vue 3
const doubleCount = computed(() => count.value * 2)

// React
const doubleCount = useMemo(() => count * 2, [count])

3.3 生命周期

tsx 复制代码
// Vue 3
onMounted(() => {
  // 组件挂载后
})

onUnmounted(() => {
  // 组件卸载前
})

watch(selectedFile, (newVal) => {
  // 监听变化
})

// React
useEffect(() => {
  // 组件挂载后
  return () => {
    // 组件卸载前
  }
}, [])  // 空依赖数组 = 只执行一次

useEffect(() => {
  // selectedFile 变化时执行
}, [selectedFile])

3.4 模板 vs JSX

vue 复制代码
<!-- Vue 3 模板 -->
<template>
  <div v-if="isLoading">加载中...</div>
  <div v-else-if="error">出错了: {{ error }}</div>
  <ul v-else>
    <li v-for="item in list" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>
tsx 复制代码
// React JSX
return (
  <div>
    {isLoading ? (
      <div>加载中...</div>
    ) : error ? (
      <div>出错了: {error}</div>
    ) : (
      <ul>
        {list.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    )}
  </div>
)

四、实战:仓库浏览器核心代码解析

4.1 类型定义 (types.ts)

typescript 复制代码
// 文件节点接口 - 两个框架基本一致
export interface FileNode {
  id: string
  name: string
  type: 'file' | 'folder'
  children?: FileNode[]
  content?: string
}

4.2 自定义 Hook (useFileTree.ts)

tsx 复制代码
import { useState, useCallback } from 'react'

export function useFileTree() {
  // 状态管理
  const [selectedFile, setSelectedFile] = useState<FileNode | null>(null)
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())

  // 使用 useCallback 缓存函数
  const selectFile = useCallback((file: FileNode) => {
    if (file.type === 'file') {
      setSelectedFile(file)
    }
  }, [])

  const toggleFolder = useCallback((folderId: string) => {
    setExpandedFolders((prev) => {
      const next = new Set(prev)
      if (next.has(folderId)) {
        next.delete(folderId)
      } else {
        next.add(folderId)
      }
      return next
    })
  }, [])

  return {
    selectedFile,
    expandedFolders,
    selectFile,
    toggleFolder,
  }
}

对应 Vue 3 组合式函数:

typescript 复制代码
// useFileTree.ts (Vue 3 版本)
import { ref } from 'vue'

export function useFileTree() {
  const selectedFile = ref<FileNode | null>(null)
  const expandedFolders = ref(new Set<string>())

  const selectFile = (file: FileNode) => {
    if (file.type === 'file') {
      selectedFile.value = file
    }
  }

  const toggleFolder = (folderId: string) => {
    if (expandedFolders.value.has(folderId)) {
      expandedFolders.value.delete(folderId)
    } else {
      expandedFolders.value.add(folderId)
    }
  }

  return {
    selectedFile,
    expandedFolders,
    selectFile,
    toggleFolder,
  }
}

4.3 文件树节点 (FileTreeNode.tsx)

tsx 复制代码
import React from 'react'
import { Folder, File, ChevronRight, ChevronDown } from 'lucide-react'

interface FileTreeNodeProps {
  node: FileNode
  level: number
  expandedFolders: Set<string>
  selectedFileId: string | null
  onToggle: (id: string) => void
  onSelect: (node: FileNode) => void
}

export const FileTreeNode: React.FC<FileTreeNodeProps> = ({
  node,
  level,
  expandedFolders,
  selectedFileId,
  onToggle,
  onSelect,
}) => {
  const isFolder = node.type === 'folder'
  const isExpanded = expandedFolders.has(node.id)
  const isSelected = selectedFileId === node.id

  const handleClick = () => {
    if (isFolder) {
      onToggle(node.id)
    } else {
      onSelect(node)
    }
  }

  return (
    <div>
      <div
        className={`flex items-center gap-2 p-2 cursor-pointer ${
          isSelected ? 'bg-blue-100' : 'hover:bg-gray-100'
        }`}
        style={{ paddingLeft: `${level * 16}px` }}
        onClick={handleClick}
      >
        {isFolder ? (
          <>
            {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
            <Folder size={16} className="text-yellow-500" />
          </>
        ) : (
          <>
            <span className="w-4" />
            <File size={16} className="text-gray-500" />
          </>
        )}
        <span>{node.name}</span>
      </div>

      {/* 递归渲染子节点 */}
      {isFolder && isExpanded && node.children && (
        <div>
          {node.children.map((child) => (
            <FileTreeNode
              key={child.id}
              node={child}
              level={level + 1}
              expandedFolders={expandedFolders}
              selectedFileId={selectedFileId}
              onToggle={onToggle}
              onSelect={onSelect}
            />
          ))}
        </div>
      )}
    </div>
  )
}

五、常见陷阱与最佳实践

5.1 状态不可变性

tsx 复制代码
// ❌ 错误:直接修改
const toggleFolder = (id: string) => {
  expandedFolders.add(id)  // 不会触发重渲染!
  setExpandedFolders(expandedFolders)
}

// ✅ 正确:创建新对象
const toggleFolder = (id: string) => {
  setExpandedFolders((prev) => {
    const next = new Set(prev)
    if (next.has(id)) {
      next.delete(id)
    } else {
      next.add(id)
    }
    return next  // 返回新 Set
  })
}

5.2 依赖数组

tsx 复制代码
// ❌ 错误:遗漏依赖
useEffect(() => {
  console.log(selectedFile.name)
}, [])  // selectedFile 变化时不会执行

// ✅ 正确:包含所有依赖
useEffect(() => {
  console.log(selectedFile.name)
}, [selectedFile])

// ✅ 如果依赖是对象的某个属性
useEffect(() => {
  console.log(selectedFile.name)
}, [selectedFile?.name])

5.3 Key 的重要性

tsx 复制代码
// ❌ 错误:使用 index 作为 key
{list.map((item, index) => (
  <div key={index}>{item.name}</div>
))}

// ✅ 正确:使用唯一标识
{list.map((item) => (
  <div key={item.id}>{item.name}</div>
))}

六、思维导图:Vue 3 → React 概念映射

scss 复制代码
Vue 3                          React
─────────────────────────────────────────────
<template>                     JSX
v-bind / :prop                 直接作为属性
v-on / @event                  onClick={handler}
v-model                        value + onChange
v-if / v-else                  三元表达式
v-for                          Array.map()
ref()                          useState()
reactive()                     useState() + 对象
computed()                     useMemo()
watch()                        useEffect()
onMounted()                    useEffect(() => {}, [])
onUnmounted()                  useEffect 返回的清理函数
defineProps()                  函数参数解构
defineEmits()                  传递回调函数作为 props
provide/inject                 Context API
<slot>                         children prop

七、总结

从 Vue 3 转型 React,核心变化在于:

  1. 模板语法 → JSX:更灵活但需要手写更多逻辑
  2. 响应式系统 → 状态管理ref/reactiveuseState
  3. 事件系统 → 回调函数emit → 传递函数作为 props
  4. 生命周期 → useEffect:需要理解依赖数组的概念
  5. useCallback/useMemo:性能优化的重要工具

学习建议:

  • 先理解 React 的单向数据流
  • 掌握 useState 和 useEffect
  • 逐步学习 useCallback、useMemo 等优化 Hooks
  • 多看优秀开源项目的代码

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏!

我是海潮,专注前端/全栈技术分享,深耕前端工程化领域 5 年,关注我,一起成长、少踩坑 ✨。

相关推荐
Reart3 小时前
从0解构tinyWeb项目--(Day:10)
前端·后端·架构
牛蛙点点申请出战3 小时前
IconFontViewer -- 一个可以在 Android Studio 中实时预览 IconFont 的插件
android·前端·intellij idea
空中海4 小时前
03 渲染机制、性能优化与现代 React
javascript·react.js·性能优化
ChalesXavier4 小时前
Fetch API 的基本用法
javascript
是上好佳佳佳呀4 小时前
【前端(十三)】JavaScript 数组与字符串笔记
前端·javascript·笔记
巴沟旮旯儿4 小时前
vite项目配置文件和打包
前端·设计模式
彩票管理中心秘书长4 小时前
Pinia 插件架构与组合式函数:如何让你的 Store 长出“超能力”
前端
彩票管理中心秘书长4 小时前
Pinia 比 Vuex 强在哪?我用同一个模块写了两种实现,你自己看
前端