本文基于实际项目案例,对比 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, // 每次渲染都是新函数
}
}
问题: selectFile 和 toggleFolder 每次渲染都会生成新的函数引用,导致使用它们的子组件不必要地重新渲染。
1.2 Vue 3 的解决方案
Vue 3 中,你可能不需要特别处理这个问题,因为:
ref和reactive是响应式的- 模板编译器会自动优化
但如果你确实需要缓存函数,Vue 3 也提供了 computed 和 watch。
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,核心变化在于:
- 模板语法 → JSX:更灵活但需要手写更多逻辑
- 响应式系统 → 状态管理 :
ref/reactive→useState - 事件系统 → 回调函数 :
emit→ 传递函数作为 props - 生命周期 → useEffect:需要理解依赖数组的概念
- useCallback/useMemo:性能优化的重要工具
学习建议:
- 先理解 React 的单向数据流
- 掌握 useState 和 useEffect
- 逐步学习 useCallback、useMemo 等优化 Hooks
- 多看优秀开源项目的代码
参考资料
如果这篇文章对你有帮助,欢迎点赞收藏!
我是海潮,专注前端/全栈技术分享,深耕前端工程化领域 5 年,关注我,一起成长、少踩坑 ✨。