第 04 课 · 事件处理与受控表单
上一课你已经会写 useState 了,这一课解决两件事:事件怎么绑 (@click 没了)和表单怎么写 (v-model 没了)。这两块是 Vue 开发者转 React 后写中后台 CRUD 页面时每天都要碰的东西,也是 AI 生成代码后你最容易 review 出问题的地方。
🎯 本课目标
- 用
onClick驼峰绑定写出 Vue@click的全部等价物:无参、带参、.prevent、.stop - 掌握受控组件
value + onChange三行写法,能说清它和v-model的本质差异 - 用「统一 onChange + 泛型 updateField」模板一次接管整张表单(input/textarea/select/checkbox/radio)
- 知道什么时候改用非受控 +
useRef(文件上传、仅提交时取值) - 按企业规范命名事件处理函数(
handleXxx/onXxx),避免常见的绑定错误
1. 事件绑定:从 @click 到 onClick
Vue 里你这样写
html
<template>
<button @click="sayHi">打招呼</button>
<button @click="removeItem(item.id)">删除</button>
<a href="/home" @click.prevent="goHome">首页</a>
<div @click="onOuter"><button @click.stop="onInner">内层</button></div>
</template>
React 里这样写
tsx
import { useState } from 'react'
interface Item { id: number; name: string }
export default function Demo() {
const [items, setItems] = useState<Item[]>([{ id: 1, name: '苹果' }])
// 企业规范:处理函数一律 handleXxx 开头
function handleSayHi() {
console.log('hi')
}
function handleRemove(id: number) {
setItems(prev => prev.filter(it => it.id !== id))
}
return (
<div>
{/* 无参:传函数引用,不带括号 */}
<button onClick={handleSayHi}>打招呼</button>
{/* 带参:箭头函数包一层 ------ 对照 @click="removeItem(item.id)" */}
{items.map(item => (
<button key={item.id} onClick={() => handleRemove(item.id)}>
删除 {item.name}
</button>
))}
{/* .prevent:手动调用 e.preventDefault() ------ 对照 @click.prevent */}
<a
href="/home"
onClick={e => {
e.preventDefault()
console.log('已拦截默认跳转')
}}
>
首页
</a>
{/* .stop:手动调用 e.stopPropagation() ------ 对照 @click.stop */}
<div onClick={() => console.log('外层被冒泡触发')}>
<button
onClick={e => {
e.stopPropagation()
console.log('只有内层触发')
}}
>
内层
</button>
</div>
</div>
)
}
关键差异与注意点
onClick是驼峰,值是花括号里的函数 ,不是字符串:onClick={handleClick},而不是onClick="handleClick()"。- 最大的坑:
onClick={handleClick()}带了括号 。这不是「绑定」,是「渲染时立即执行一遍,把返回值(undefined)当成 handler」。如果函数里有setState,页面直接进入无限循环报Too many re-renders。Vue 里@click="handleClick()"是合法的,肌肉记忆要重写。 - 带参必须包箭头函数 :
onClick={() => del(item.id)}。对照 Vue,@click="del(item.id)"的括号语义在 React 里从「点击时调用」变成了「渲染时调用」,所以一定要再包一层。 - 既带参又要事件对象 :
onClick={e => del(item.id, e)},没有$event这种隐式变量。 - Vue 的
.prevent / .stop / .once / .enter修饰符全部消失,改成手写:e.preventDefault()、e.stopPropagation()、标志位、e.key === 'Enter'。没有.sync、.lazy之类的语法糖。
2. 事件对象与 TS 类型
Vue 里你这样写
ts
function onInput(e: Event) {
console.log((e.target as HTMLInputElement).value)
}
React 里这样写
React 自己维护了一套合成事件 (SyntheticEvent),类型都在 react 包里,按「元素类型 + 事件名」取:
tsx
import { useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'
export default function SearchBox() {
const [keyword, setKeyword] = useState('')
// 企业最常见写法:显式标注事件类型,泛型参数写「目标元素类型」
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setKeyword(e.target.value) // e.target 自动收窄为 input,value 有提示
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault() // 对照 @submit.prevent
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') console.log('回车搜索:', keyword) // 对照 @keyup.enter
}
return (
<form onSubmit={handleSubmit}>
<input value={keyword} onChange={handleChange} onKeyDown={handleKeyDown} />
</form>
)
}
关键差异与注意点
e.preventDefault()/e.stopPropagation()完全照用,API 名字一模一样。- 常用类型速记:
ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(输入类)、MouseEvent<HTMLButtonElement>(点击类)、FormEvent<HTMLFormElement>(表单提交)、FocusEvent<HTMLInputElement>(聚焦)。 - 写
React.ChangeEvent<...>也可以(企业老代码常见),但项目开了verbatimModuleSyntax时推荐import type { ChangeEvent } from 'react'后直接写ChangeEvent<...>。 - 不标类型也能跑 (
onChange={e => ...}时 TS 会从 JSX 推断出来),但抽成独立函数就必须标,这是企业 code review 的硬要求。 - 合成事件不是原生事件:拿原生对象用
e.nativeEvent;React 17+ 事件委托挂在根节点而不是document上------面试一句话即可,业务上几乎感知不到。
3. 受控组件:v-model 的一行变三行
Vue 里你这样写
vue
<script setup lang="ts">
import { ref } from 'vue'
const title = ref('')
</script>
<template>
<input v-model="title" />
<p>{{ title.length }} 个字</p>
</template>
React 里这样写
tsx
import { useState } from 'react'
export default function TitleEditor() {
const [title, setTitle] = useState('')
return (
<>
{/* v-model="title" 的 React 三行:value + onChange + setState */}
<input
value={title} {/* 1. state 决定显示什么 */}
onChange={e => setTitle(e.target.value)} {/* 2. 输入时把新值写回 state */}
/>
<p>{title.length} 个字</p> {/* 3. 派生内容直接算,不需要 watch */}
</>
)
}
关键差异与注意点
v-model是语法糖,React 把糖拆开了 :<input :value="title" @input="title = $event.target.value" />就是v-model的真身,React 每次都写这个真身。- 为什么 React 这么设计 :
v-model让「用户输入」和「程序赋值」两条修改数据的路都走title,双向绑定的同步时机对框架是黑盒;React 坚持单向数据流 ------value永远由 state 决定,想改变输入框就必须改 state,数据从哪来、什么时候变,一眼可查。代价就是多两行。 - 只写
value不写onChange会怎样 :输入框变成只读,打字打不进去,控制台警告You provided a value prop to a form field without an onChange handler。因为 state 没变,视图就永远不变------这恰好是受控组件的本质。 - 实时联动不需要 watch :字数统计、实时预览、按钮禁用(
disabled={!title.trim()})都是渲染时直接算的表达式,state 一变整个组件重渲染,联动是「免费」的。
4. 各类控件的受控写法
Vue 里你这样写
html
<textarea v-model="bio" />
<select v-model="city"><option value="bj">北京</option></select>
<input type="checkbox" v-model="agree" />
<input type="radio" value="男" v-model="gender" />
React 里这样写
tsx
import { useState } from 'react'
export default function Controls() {
const [bio, setBio] = useState('')
const [city, setCity] = useState('bj')
const [agree, setAgree] = useState(false)
const [gender, setGender] = useState('保密')
return (
<>
{/* textarea:没有「自闭合 + 插值」写法,用 value 受控 */}
<textarea value={bio} onChange={e => setBio(e.target.value)} />
{/* select:value 放在 <select> 上,不是 option 上(对照 selected) */}
<select value={city} onChange={e => setCity(e.target.value)}>
<option value="bj">北京</option>
<option value="sh">上海</option>
</select>
{/* checkbox:用 checked,读 e.target.checked(boolean) */}
<label>
<input type="checkbox" checked={agree} onChange={e => setAgree(e.target.checked)} />
同意协议
</label>
{/* radio:同一组 name,用 checked 表达选中 */}
{['男', '女', '保密'].map(g => (
<label key={g}>
<input
type="radio"
name="gender"
value={g}
checked={gender === g}
onChange={e => setGender(e.target.value)}
/>
{g}
</label>
))}
</>
)
}
关键差异与注意点
textarea在 Vue 里是双标签内容,React 里必须用value,子内容写死不随 state 变。select的value放<select>上;多选加multiple,此时value是数组。checkbox取e.target.checked,取成value会拿到"on",企业代码里真有人这么错。radio不是v-model的单字段映射,而是「同 name 一组 +checked={form.gender === g}」,语义上更啰嗦但更直白。value只在受控时有「选中值」的含义;初始值用defaultValue(详见第 6 节)。
5. 多字段统一处理:企业表单最常用模板
Vue 里你这样写
ts
const form = reactive({ nickname: '', city: '', agree: false })
// 直接 form.nickname = xxx,每个字段各写各的
React 里这样写
五个控件共用一个 onChange + 一个泛型 updateField,这份模板建议直接背下来(本课 Demo 3 完整可运行):
tsx
import { useState, type ChangeEvent } from 'react'
interface RegisterForm {
nickname: string
city: string
bio: string
gender: string
agree: boolean
}
const INITIAL_FORM: RegisterForm = { nickname: '', city: '上海', bio: '', gender: '保密', agree: false }
type FieldEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
export default function RegisterPage() {
const [form, setForm] = useState<RegisterForm>(INITIAL_FORM)
// 泛型模板第 1 步:key 和 value 类型强绑定,setField('agree', 'yes') 直接编译报错
function updateField<K extends keyof RegisterForm>(key: K, value: RegisterForm[K]) {
setForm(prev => ({ ...prev, [key]: value })) // 不可变更新:整个对象换新
}
// 泛型模板第 2 步:一个 onChange 接管所有控件
function handleChange(e: ChangeEvent<FieldEl>) {
const el = e.target
const key = el.name as keyof RegisterForm
const value = el instanceof HTMLInputElement && el.type === 'checkbox' ? el.checked : el.value
updateField(key, value as RegisterForm[typeof key])
}
return (
<form onSubmit={handleSubmit}>
<input name="nickname" value={form.nickname} onChange={handleChange} />
<select name="city" value={form.city} onChange={handleChange}>
<option value="上海">上海</option>
<option value="北京">北京</option>
</select>
<textarea name="bio" value={form.bio} onChange={handleChange} />
<label>
<input type="checkbox" name="agree" checked={form.agree} onChange={handleChange} />
同意条款
</label>
<button>注册</button>
</form>
)
}
关键差异与注意点
- 前提是控件的
name和 state 字段名一致 ,el.name才能当 key 用。 setForm(prev => ({ ...prev, [key]: value }))是浅拷贝换新对象 ,别写成form.nickname = x------直接改原对象 React 检测不到变化(第 3 课讲过为什么)。- 泛型
<K extends keyof RegisterForm>的价值在 code review:字段改名后所有调用点编译期报错,而不是运行时静默失败。 - 字段多(>10 个)或校验复杂时,企业里用 react-hook-form + zod 走非受控路线;但受控 + 统一 onChange 仍是中小表单的主流写法,面试和日常都得会。
- 提交函数
handleSubmit(e: FormEvent<HTMLFormElement>)第一行固定e.preventDefault(),对照@submit.prevent。
6. 非受控 + useRef:另一种取值方式
Vue 里你这样写
vue
<script setup lang="ts">
import { ref } from 'vue'
const inputRef = ref<HTMLInputElement>()
function submit() {
console.log(inputRef.value?.value) // 想什么时候读就什么时候读
}
</script>
<template><input ref="inputRef" /></template>
React 里这样写
tsx
import { useRef, useState, type FormEvent } from 'react'
export default function UploadForm() {
const nameRef = useRef<HTMLInputElement>(null) // 挂到 DOM 元素上
const fileRef = useRef<HTMLInputElement>(null)
const [results, setResults] = useState<string[]>([])
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const name = nameRef.current?.value.trim() || '(空)' // 提交时才读
const file = fileRef.current?.files?.[0] // 文件只能这么读
setResults(prev => [`${name}|${file?.name ?? '未选择'}`, ...prev])
}
return (
<form onSubmit={handleSubmit}>
{/* 没有 value/onChange:值存在 DOM 里,React 不参与,输入 0 次重渲染 */}
<input ref={nameRef} defaultValue="张三" />
<input ref={fileRef} type="file" />
<button>提交</button>
</form>
)
}
关键差异与注意点
- 受控 :state 是数据源,每次输入都重渲染,能实时校验/联动/预览;非受控 :DOM 是数据源,React 只在需要时用
ref.current读一次,输入过程零重渲染。 - 选型口诀 :要在 UI 里用到输入值(预览、字数、禁用按钮)→ 受控;只在提交时取值、或值根本没法用 state 表达(文件上传)→ 非受控。
- 非受控用
defaultValue给初值,不要写value(写了就成了受控,还缺onChange,直接警告)。 ref.current读到的是此刻的 DOM ,永远最新------这也是它和useState的本质区别(state 是渲染快照,ref 是可变盒子,第 3 课提过)。- 同一个组件渲染次数只随受控输入增长:这是受控「贵」的最直观体感,也是第 7 课性能优化的引子。
📊 Vue ↔ React 对照速查表
| Vue 3 写法 | React 写法 | 一句话差异 |
|---|---|---|
@click="fn" |
onClick={fn} |
驼峰 + 传函数引用,不是字符串 |
@click="del(item.id)" |
onClick={() => del(item.id)} |
必须包箭头函数,直接调用会在渲染时执行 |
@click.prevent |
onClick={e => { e.preventDefault() ... }} |
修饰符全没了,手动调用 |
@click.stop |
onClick={e => { e.stopPropagation() ... }} |
同上 |
@keyup.enter |
onKeyDown={e => e.key === 'Enter' && ...} |
自己判断按键 |
$event |
(e) => ... 的参数 e |
事件对象就是参数,没有隐式变量 |
v-model="x" |
value={x} + onChange={e => setX(e.target.value)} |
一行变三行,单向数据流 |
v-model on checkbox |
checked + e.target.checked |
boolean 走 checked |
v-model on select |
value 放 <select> 上 |
选中值由父级控制 |
reactive(form) + 各字段散改 |
一个 state 对象 + 泛型 updateField |
企业统一 onChange 模板 |
@submit.prevent |
onSubmit={e => { e.preventDefault() }} |
preventDefault 照用 |
ref<HTMLInputElement>() 读值 |
useRef<HTMLInputElement>(null) 读值 |
非受控场景,提交时取 |
🏢 企业实战要点
- 命名铁律 :组件内部处理函数叫
handleXxx(handleChange/handleSubmit/handleDel),通过 props 传给子组件的回调叫onXxx(onChange/onDelete)。review 时看到click1、doSubmit2一律打回。 - 统一 onChange 泛型模板背下来 (第 5 节),新表单 5 分钟出骨架;别给每个字段写一个
setName/setCity/setBio------那是 review 高频意见。 onChange里别做重活 :每敲一个字都会执行。筛选、联想、搜索请求要防抖 (第 8 课自定义 Hook 里实现useDebouncedValue),同步写在 onChange 里只有「禁用按钮、简单格式化」这类微操作。- 事件处理函数保持在 10 行内,复杂逻辑抽出去;JSX 里直接写内联箭头函数没问题,但里面塞 20 行业务逻辑会被 review 打回。
- 表单文件拆分 :超过 3 个字段就拆
form相关代码到hooks/useXxxForm.ts(状态 + 校验)和components/XxxForm.tsx(视图),页面文件只做编排;类型interface XxxForm放types.ts供 api 层复用。 - 必用
type标注独立事件函数 :onChange={handleChange}好写,function handleChange(e: ChangeEvent<HTMLInputElement>)是硬要求------类型不标,联合元素类型一改就是一堆any污染。 - 真实坑:
onClick={fn()}带括号导致渲染期执行 + 无限重渲染,报错信息Too many re-renders;另一个高频坑是 checkbox 读了value拿到"on"。 - 别用
document.getElementById取表单值 :非受控也要走useRef;直接摸 DOM 是 review 一票否决项(测试、SSR 都会出问题)。
💼 面试高频
1. 受控组件和非受控组件的区别?怎么选?
受控:值由 React state 驱动,value + onChange,每次输入都重渲染,适合需要实时校验、联动、预览的场景;非受控:值存在 DOM 里,defaultValue + useRef,提交时读取,适合文件上传、只需提交时取值、集成第三方 DOM 库。企业里表单主体用受控,文件/富文本等特殊控件用非受控。
2. React 的事件和原生 DOM 事件有什么区别?
React 用的是合成事件(SyntheticEvent):在原生事件之上包了一层,统一了浏览器差异,提供了 preventDefault/stopPropagation 等一致 API。事件通过委托挂在根容器上(React 17 起不再挂 document)。日常开发感知不到差异,需要原生对象时用 e.nativeEvent。
3. React 为什么没有 v-model?
v-model 是「值绑定 + 监听输入」的语法糖,本质是双向数据流。React 坚持单向数据流:视图由 state 决定,想改变视图必须通过 setState 走一遍可追溯的更新流程。把糖拆成 value + onChange 两行,换来的是数据来源清晰、便于调试和 time-travel 类工具。受控组件就是这个理念的落地。
4. Vue 的 .stop、.prevent 修饰符在 React 里怎么写?
没有修饰符,事件对象就是普通参数:.stop 写 e.stopPropagation(),.prevent 写 e.preventDefault(),.enter 写 if (e.key === 'Enter'),.once 自己用 ref/标志位控制。写法变长,但逻辑显式,可读性和可调试性更强。
5. onClick={handleClick()} 和 onClick={handleClick} 的区别?
前者是渲染时立即调用 handleClick 并把返回值(通常 undefined)作为 handler,点击时什么都不发生;如果函数内有 setState 会触发渲染 → 再执行 → 死循环,报 Too many re-renders。后者传的是函数引用,点击时才调用。带参数时用 onClick={() => handleClick(id)}。
📌 小结 + 下节预告
这一课你把 Vue 的 @click 全家桶和 v-model 都翻译成了 React 写法:事件 = 驼峰属性 + 函数 ,表单 = value + onChange 的单向闭环 ,再配上「统一 onChange 泛型模板」就能应付大部分中后台表单;文件上传这类场景切到 useRef 非受控。
但注意「一键插入常用语」和「实时预览」都靠 state 变化驱动------如果某些逻辑需要在数据变化后 做事(请求、打点、改标题),该用什么?下一课讲 useEffect:Vue 里 onMounted + watch + onUnmounted 三件套的 React 合体,以及 Vue 开发者最容易踩的「它默认会立即执行」的坑。