Skip to content
Vue 与 React 实战应用:构建同一个任务管理模块
同一个任务管理需求
任务管理是一个足够小的示例,同时又能够把组件通信、列表渲染、表单绑定和状态更新这几件事一次性串起来。
需求如下:
- 用一个文本输入框添加新任务
- 每条任务有“完成 / 未完成”两种状态,可以切换
- 每条任务可以被删除
- 所有任务以列表形式展示
接下来的实现不会引入路由或状态管理库,全部逻辑都放在组件内部。后文会先给出 Vue 组合式 API 的完整写法,再给出 React Hooks 的版本,然后围绕组件通信方式、表单处理差异和状态更新逐一比对。
Vue 实现:组合式 API
Vue 使用 <script setup> 语法编写单文件组件。组件拆分为三个:AddTask 负责输入与提交,TaskList 承载列表渲染,TaskItem 负责单条任务的展示与操作。
组件拆分与数据声明
最上层组件(这里称为 App)持有任务列表和当前输入值,同时作为所有通信的枢纽。
vue
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import AddTask from './AddTask.vue'
import TaskList from './TaskList.vue'
const tasks = ref([])
function addTask(text) {
tasks.value.push({ id: Date.now(), text, done: false })
}
function toggleTask(id) {
const task = tasks.value.find(t => t.id === id)
if (task) task.done = !task.done
}
function deleteTask(id) {
tasks.value = tasks.value.filter(t => t.id !== id)
}
</script>
<template>
<div>
<AddTask @add-task="addTask" />
<TaskList
:tasks="tasks"
@toggle="toggleTask"
@delete="deleteTask"
/>
</div>
</template>tasks 用 ref([]) 声明,模板中解包后直接当作数组使用,逻辑中则通过 .value 读写。addTask 使用 .push() 直接修改数组——在 Vue 3 的响应式系统里,数组方法已被代理,这种变动能被追踪到。toggleTask 原地修改对象的 done 属性,同样会触发依赖更新。deleteTask 在这里使用 filter 得到新数组,实际上直接 splice 也能工作;这里用 filter 更多是为了让 React 版保持镜像,便于比较。
AddTask 组件内部需要一个本地的输入值,通过 v-model 绑定后,按下回车或点击按钮时向外发出事件。
vue
<!-- AddTask.vue -->
<script setup>
import { ref } from 'vue'
const text = ref('')
const emit = defineEmits(['add-task'])
function submit() {
if (text.value.trim()) {
emit('add-task', text.value.trim())
text.value = ''
}
}
</script>
<template>
<div>
<input
v-model="text"
@keyup.enter="submit"
placeholder="输入任务"
/>
<button @click="submit">添加</button>
</div>
</template>TaskList 负责接收 tasks 并循环渲染 TaskItem。这里只做透传,事件继续向上层抛出。
vue
<!-- TaskList.vue -->
<script setup>
import TaskItem from './TaskItem.vue'
const props = defineProps({
tasks: Array
})
</script>
<template>
<ul>
<TaskItem
v-for="task in tasks"
:key="task.id"
:task="task"
@toggle="$emit('toggle', $event)"
@delete="$emit('delete', $event)"
/>
</ul>
</template>TaskItem 接收 task 属性,勾选和删除操作都以事件形式通知父级。
vue
<!-- TaskItem.vue -->
<script setup>
const props = defineProps({
task: Object
})
const emit = defineEmits(['toggle', 'delete'])
</script>
<template>
<li>
<input
type="checkbox"
:checked="task.done"
@change="emit('toggle', task.id)"
/>
<span :class="{ done: task.done }">{{ task.text }}</span>
<button @click="emit('delete', task.id)">删除</button>
</li>
</template>列表渲染与任务状态切换
v-for="task in tasks" 搭配 :key="task.id" 是 Vue 中标准的长列表渲染方式。TaskItem 内部的复选框没有使用 v-model,而是采用 :checked 绑定状态,再通过 @change 发出 toggle 事件。这么做不是必须的——完全可以用 v-model="task.done" 直接双向绑定到 done,连事件都不必发出。但如果这样写,App 就无法感知是哪条任务发生了变更,也就没法在后期扩展(例如加入持久化逻辑)时在同一入口捕获所有状态变动。因此示例中刻意保持显式的事件通信,也便于和 React 版本对照。
表单提交与子组件事件
表单处理的核心是 v-model。AddTask 中 <input v-model="text" /> 等价于 <input :value="text" @input="text = $event.target.value" />。这种写法省去了手动监听 input 事件并更新状态的模板代码。@keyup.enter 和按钮 @click 最终都走向同一个 submit 函数,通过 emit('add-task', text) 将数据交给父组件。这是 Vue 组件通信中最常见的 props 向下,emit 向上 模式。
React 实现:Hooks
React 使用函数组件和 Hooks,不引入第三方状态管理。组件拆分逻辑和 Vue 版本完全一致:App、AddTask、TaskList、TaskItem 四个角色。
组件拆分与状态声明
App 用 useState 管理 tasks 数组。
jsx
// App.jsx
import { useState } from 'react'
import AddTask from './AddTask'
import TaskList from './TaskList'
export default function App() {
const [tasks, setTasks] = useState([])
function addTask(text) {
setTasks([...tasks, { id: Date.now(), text, done: false }])
}
function toggleTask(id) {
setTasks(tasks.map(task =>
task.id === id ? { ...task, done: !task.done } : task
))
}
function deleteTask(id) {
setTasks(tasks.filter(task => task.id !== id))
}
return (
<div>
<AddTask onAddTask={addTask} />
<TaskList
tasks={tasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
)
}注意三个状态修改函数内部的行为:addTask 创建了全新数组(展开旧数组再加新项),toggleTask 通过 map 返回新数组,且被修改的任务也生成了新对象,deleteTask 使用 filter 得到新数组。React 的 setTasks 必须传入与当前值不同的引用,才能触发重渲染。如果直接 tasks.push(...) 再 setTasks(tasks),因为引用没变,React 会跳过更新。
列表渲染与任务状态切换
TaskList 只负责遍历并透传回调。
jsx
// TaskList.jsx
import TaskItem from './TaskItem'
export default function TaskList({ tasks, onToggle, onDelete }) {
return (
<ul>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
)
}TaskItem 内部用复选框展示 done 状态,变化时调用 onToggle。
jsx
// TaskItem.jsx
export default function TaskItem({ task, onToggle, onDelete }) {
return (
<li>
<input
type="checkbox"
checked={task.done}
onChange={() => onToggle(task.id)}
/>
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
{task.text}
</span>
<button onClick={() => onDelete(task.id)}>删除</button>
</li>
)
}表单提交与回调函数传参
AddTask 需要自己维护输入框的本地状态,同时接收父组件传入的 onAddTask 回调。
jsx
// AddTask.jsx
import { useState } from 'react'
export default function AddTask({ onAddTask }) {
const [text, setText] = useState('')
function submit() {
if (text.trim()) {
onAddTask(text.trim())
setText('')
}
}
return (
<div>
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyUp={e => e.key === 'Enter' && submit()}
placeholder="输入任务"
/>
<button onClick={submit}>添加</button>
</div>
)
}value 和 onChange 的组合就是 React 的受控组件模式。输入框的值完全由 React 状态决定,每次按键都会触发一次 onChange → setText → 重渲染。这在表现上与 Vue 的 v-model 等价,但实现方式不同。
组件通信对比
两个版本的功能完全一致,但把组件树上的通信路径拎出来对比,会发现一套模式上的区别。
Vue 版本中,App 通过 :tasks="tasks" 向 TaskList 下发 props,TaskList 通过 @toggle 和 @delete 向上发出事件。AddTask 发出 @add-task,App 响应并修改 tasks。这套模式可概括为“props 向下,emit 向上”,defineEmits 明确声明了组件向外传递的事件签名。
React 版本没有“事件发射”这个概念。父子组件的通信都是通过 props 完成的:父组件把回调函数(onToggle、onDelete、onAddTask)作为 props 传给子组件,子组件在合适的时机调用它们。数据永远从父组件流向子组件,子组件通过调用回调间接让父组件修改状态。这是 React 单向数据流的标准实现方式。
从书写量上看,Vue 的 emit 比 React 的回调少了一层嵌套感,但本质都是在父组件中定义修改状态的方法,然后交由子组件触发。这两种方式在编写上没有本质差别,区别在于 Vue 的事件机制让子组件更像“触发事件的实体”,而 React 的回调则强调“状态始终属于父组件”。
表单处理差异
表单输入是本次对比中最能反映设计差异的点之一。
Vue 的 v-model 在模板层面实现了双向绑定。对 <input> 来说,它自动生成 :value 和 @input,将用户输入同步回状态。开发者只需要声明一个 ref,把 v-model 指过去,剩下的同步工作由框架在运行时完成。[1][9]
React 不存在这样一条语法糖。输入框必须显式给出 value 和 onChange,状态更新必须手动调用 setText。这就是受控组件。
两种方式各有适用场景。v-model 写起来快,但当需求超出文本框与字符串的范围时(例如需要组合多个输入源、格式校验、防抖),就需要回到 :value + @input 的手动模式。React 的受控组件从一开始就暴露了完整的数据流,任何在 onChange 中插入额外逻辑都很直接,代价是代码量明显更多。另外,React 的每次按键都会触发重渲染,虽然默认情况下性能影响可以忽略,但是在大型表单中通常会考虑非受控组件或引入表单库。
另一个细微差别在于提交后的清空。Vue 中直接 text.value = '' 即清空输入框;React 中需要 setText('')。两者都能实现,但 Vue 的清空行为是在同一个响应式对象上修改属性,React 则是用新值调度一次状态更新。
状态更新差异
状态更新方式的差异贯穿了整个任务模块的编写过程,尤其集中在 toggleTask 和 deleteTask 这两个操作上。
Vue 的 toggleTask:
js
function toggleTask(id) {
const task = tasks.value.find(t => t.id === id)
if (task) task.done = !task.done
}代码读出对象,修改其 done 属性。响应式系统通过 Proxy 拦截了这次赋值操作,从而触发依赖该属性的订阅者重新执行。[9] 开发者不需要关心数组或对象的引用是否变化,唯一要注意的是不能解构 reactive 对象并期望解构后的变量保持响应性。
React 的 toggleTask:
js
function toggleTask(id) {
setTasks(tasks.map(task =>
task.id === id ? { ...task, done: !task.done } : task
))
}setTasks 必须接收一个新数组,且被修改的任务也要返回新对象。这种不可变更新方式让 React 能通过简单的引用比较(===)判断状态是否变化,从而跳过未变化的子树渲染。从 setTasks 参数就能看出,每次状态转换都产出一个明确的新快照。[6]
deleteTask 的实现也反映出类似的差异。Vue 可以直接 splice,也可以用 filter 替换。React 则必须使用 filter(或 slice 等其他返回新数组的方法),因为直接修改原数组再调用 setTasks 不会触发重渲染。
这两个版本在代码的编写思路上也不同:Vue 更接近“定位数据并修改它”,React 更接近“声明一个基于旧状态的新状态”。这种差异会在状态嵌套较深时放大——Vue 可以继续用 . 链修改深层属性,React 则必须小心地进行深拷贝和展开,或者使用 useReducer、Immer 等工具来降低复杂度。
总结:实现路径中的心智模型映射
同样的任务管理模块,Vue 和 React 各自走了一条不同的实现路径。表象上是 API 名称和模板语法的区别,根源则在于两个框架对“状态如何驱动视图”这个问题的回答不一样。
Vue 采用的响应式模型把数据变成可追踪的代理对象,修改变量就是修改原对象,框架自动找出哪些视图需要更新。这意味着开发者的工作重心偏向“操纵数据”——改属性、调方法,副作用由响应式系统代为处理。这种心智模型在组件拆得比较细时,事件机制(emit)进一步强化了“触发动作”的感觉。
React 的不可变状态模型要求每次状态变更都产出新的对象或数组,视图等于状态的函数。开发者始终在描述“下一个状态长什么样”,而不是“去改哪个属性”。回调函数传递模式与此一致:状态修改的入口在父组件,子组件只是调用了父组件预先提供的方法。整个数据流动方向单一,没有“双向”的隐式同步。
从工程感受上看,Vue 的组合式 API 提供了更少的模板代码和更灵巧的内置指令,适合快速将原型变成可运行的交互界面;React 的 Hooks + JSX 则在处理复杂状态转换时带来更清晰、可追溯的更新路径。两者的差异不是优劣之分,而是在各自的约束条件下对“可控”和“便利”的取舍。
