React04:事件处理与受控表单

第 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) 读值 非受控场景,提交时取

🏢 企业实战要点

  1. 命名铁律 :组件内部处理函数叫 handleXxx(handleChange / handleSubmit / handleDel),通过 props 传给子组件的回调叫 onXxx(onChange / onDelete)。review 时看到 click1、doSubmit2 一律打回。
  2. 统一 onChange 泛型模板背下来 (第 5 节),新表单 5 分钟出骨架;别给每个字段写一个 setName/setCity/setBio------那是 review 高频意见。
  3. onChange 里别做重活 :每敲一个字都会执行。筛选、联想、搜索请求要防抖 (第 8 课自定义 Hook 里实现 useDebouncedValue),同步写在 onChange 里只有「禁用按钮、简单格式化」这类微操作。
  4. 事件处理函数保持在 10 行内,复杂逻辑抽出去;JSX 里直接写内联箭头函数没问题,但里面塞 20 行业务逻辑会被 review 打回。
  5. 表单文件拆分 :超过 3 个字段就拆 form 相关代码到 hooks/useXxxForm.ts(状态 + 校验)和 components/XxxForm.tsx(视图),页面文件只做编排;类型 interface XxxForm 放 types.ts 供 api 层复用。
  6. 必用 type 标注独立事件函数 :onChange={handleChange} 好写,function handleChange(e: ChangeEvent<HTMLInputElement>) 是硬要求------类型不标,联合元素类型一改就是一堆 any 污染。
  7. 真实坑:onClick={fn()} 带括号导致渲染期执行 + 无限重渲染,报错信息 Too many re-renders;另一个高频坑是 checkbox 读了 value 拿到 "on"。
  8. 别用 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 开发者最容易踩的「它默认会立即执行」的坑。

相关推荐
熊猫钓鱼>_>1 小时前
端侧高斯泼溅(3DGS)渲染与性能调优初探:把“实景重建“跑在手机上【鸿蒙心迹】
开发语言·3d·华为·智能手机·harmonyos
亿元程序员1 小时前
用AI做了一款微信小游戏,却发现上传不了
前端
量化炼金 (CodeAlchemy)1 小时前
【Python】pandas vs polars 简单性能对比测试
开发语言·python·pandas
苦瓜汤补钙1 小时前
Matlab 2022b(中文)的使用教程——Win11
开发语言·matlab
Ai-_Man1 小时前
我需要批量导出Poe左边栏所有对话,而不是一条具体的对话内容,你们AI导出鸭可以吗?
开发语言·前端·javascript·人工智能·小程序
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - Web Storage 本地存储 & 文件 API & 拖放 API
前端·html·html5
恋猫de小郭1 小时前
Flutter 官方表示:AI 时代代码越来越容易写,为什么还要一套代码库?
android·前端·flutter
福大大架构师每日一题1 小时前
rust 1.99.1 正式更新:聚焦 UEFI、嵌入式、x86_64 FFI 与 macOS 链接体验的三项修复
开发语言·macos·rust
Pin_Qing1 小时前
缓存能活多久,现在是个要算的参数
java·大数据·开发语言·人工智能·程序人生