延续 Day01,今天一口气覆盖「受控表单、受控 vs 非受控、useRef 获取 DOM、UUID/时间、组件通信、useEffect、自定义 Hook」,并用「请求接口的评论列表」综合案例收口。每个知识点都配项目里的真实案例代码。
一、DOM 是什么
DOM = Document Object Model(文档对象模型)。浏览器加载 HTML 后,把每个标签变成一个 JS 对象,按父子关系组成一棵树。JS 操作页面,本质就是操作这棵树上的节点。
1<html> ← 根节点
2 <body> ← 对象 { tagName: 'BODY', children: [...] }
3 <div>
4 <input /> ← ref.current 拿到的就是这个对象
5 </div>
6 </body>
7</html>
每个节点能用 DOM API 读写:
1el.focus() // 方法
2el.value // 属性
3el.getBoundingClientRect() // 尺寸
4document.createElement('div') // 创建节点
和 React 的关系:平时写 JSX,React 帮你算「虚拟 DOM」再去改真实 DOM,你不用碰;少数 React 没封装的事(聚焦、滚动、测尺寸、读 file),才用 useRef 直接拿真实 DOM 节点。
二、受控表单绑定(回顾)
Day01 已接触过文本输入的受控绑定。核心:state 是唯一数据源,value 绑定 state,onChange 更新 state。
1import { useState } from 'react'
2
3function ControlledForm() {
4 const [text, setText] = useState('')
5
6 return (
7 <form onSubmit={(e) => { e.preventDefault(); alert(text) }}>
8 <input
9 type="text"
10 value={text}
11 onChange={(e) => setText(e.target.value)}
12 />
13 {/* 受控的优势:每一次按键都能即时派生 */}
14 <p>大写:{text.toUpperCase()}</p>
15 <p>字符数:{text.length}</p>
16 </form>
17 )
18}
受控两要素:value={state} + onChange={setState}。每按一次键都会 setState → 重新渲染 → value 流回 input,形成闭环。
三、受控 vs 非受控
3.1 受控组件(state 驱动)
如上 ControlledForm。优势:能即时读取、派生、转换、校验输入。
3.2 非受控组件(DOM 驱动 + ref)
DOM 本身是数据源,用 useRef 在需要时(如提交)才读取;输入过程不触发重新渲染。
1import { useRef } from 'react'
2
3function UncontrolledForm() {
4 const inputRef = useRef(null)
5
6 const handleSubmit = (e) => {
7 e.preventDefault()
8 alert(inputRef.current.value) // 提交时才读 DOM
9 }
10
11 return (
12 <form onSubmit={handleSubmit}>
13 {/* 非受控用 defaultValue 设初值,不是 value */}
14 <input type="text" ref={inputRef} defaultValue="默认值" />
15 <button type="submit">提交</button>
16 </form>
17 )
18}
注意:非受控用 defaultValue 设初值。若写 value 又不写 onChange,input 会变只读并报警告。
3.3 file 输入:永远只能非受控
出于安全限制,JS 无法用 value 设置用户选择的文件,只能通过 ref 读取。
1function FileInput() {
2 const fileRef = useRef(null)
3 const handleSubmit = (e) => {
4 e.preventDefault()
5 const file = fileRef.current.files[0]
6 alert(file ? `已选择:${file.name}` : '未选择文件')
7 }
8 return (
9 <form onSubmit={handleSubmit}>
10 <input type="file" ref={fileRef} />
11 <button type="submit">提交</button>
12 </form>
13 )
14}
3.4 对比表
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据源 | React state | DOM 本身 |
| 读取值 | 直接读 state | ref.current.value |
| 每次按键是否重渲染 | 是 | 否 |
| 实时校验/转换 | 容易 | 不方便 |
| 初值写法 | value | defaultValue |
| 典型场景 | 大多数表单 | file、一次性读取、集成非 React 库 |
3.5 何时用哪个
- 默认受控:需要即时校验、派生、受 state 驱动的输入。
- 用非受控:file 输入、提交时才读一次的简单表单、集成第三方库。
四、为什么不建议直接操作 DOM
React 是「state 驱动 UI」的,直接改 DOM 会和 React 打架:
- 覆盖问题:React 按 state 重新渲染时,可能把你手动改的 DOM 冲掉,UI 与 state 不一致。
- 破坏数据流:
state → UI的声明式模型被绕过,「谁偷偷改了 DOM」难追踪。 - 失去 React 优化:批量更新、并发渲染、diff 都基于「React 接管 DOM」,自己改 DOM 用不上。
1// ❌ 直接改样式,下次渲染可能被冲掉
2ref.current.style.color = 'red'
但不是绝对禁止。这些场景必须用 ref 拿真实 DOM:聚焦/失焦、滚动、测尺寸、读 file、集成非 React 库、动画。
原则:能用 state 解决就用 state;实在不行才用 ref,且尽量只读不写。
五、React 中获取 DOM:useRef
5.1 基本用法
1const ref = useRef(null)
2// ...
3<input ref={ref} />
4// ref.current 即该 DOM 节点
5.2 三个典型场景
1import { useEffect, useRef, useState } from 'react'
2
3function DomRef() {
4 const inputRef = useRef(null)
5 const boxRef = useRef(null)
6 const [size, setSize] = useState('')
7
8 // ① 挂载后自动聚焦:DOM 操作必须放 useEffect,不能放渲染期
9 useEffect(() => {
10 inputRef.current.focus()
11 }, [])
12
13 // ② 命令式聚焦(按钮触发)
14 const handleFocus = () => inputRef.current.focus()
15
16 // ③ 读 DOM 信息(尺寸)
17 const handleMeasure = () => {
18 const rect = boxRef.current.getBoundingClientRect()
19 setSize(`宽 ${Math.round(rect.width)}px × 高 ${Math.round(rect.height)}px`)
20 }
21
22 return (
23 <div>
24 <input ref={inputRef} placeholder="挂载后自动聚焦" />
25 <button onClick={handleFocus}>手动聚焦</button>
26 <div ref={boxRef} style={{ width: 200, height: 80, background: '#eee' }}>
27 量一下我的尺寸
28 </div>
29 <button onClick={handleMeasure}>测量</button>
30 <p>{size}</p>
31 </div>
32 )
33}
5.3 关键点
- DOM 操作(focus、读尺寸等)要放在
useEffect里,不要在渲染期做,否则可能读到null。 ref.current在挂载前是null,使用前最好判空。
六、综合案例:发表评论(从零重写)
把今天学的「受控表单 + state 不可变 + 列表渲染」串成一个完整功能。文件结构:
1src/comment2/
2├── CommentApp.jsx # 容器:state + handleAdd/handleDelete + 排序
3├── CommentTabs.jsx # 最热/最新 Tab + 高亮
4├── CommentInput.jsx # 受控输入 + 发布按钮
5├── CommentList.jsx # 渲染列表 + 空状态
6├── CommentItem.jsx # 单条评论,自己的才显示删除
7└── comment.module.css
6.1 容器状态
1const [comments, setComments] = useState(initialComments)
2const [activeTab, setActiveTab] = useState('hottest')
3const [inputValue, setInputValue] = useState('')
6.2 发表评论主链路(受控表单串起来)
1const handleAdd = () => {
2 const content = inputValue.trim()
3 if (!content) return // ① 校验非空
4
5 const newComment = {
6 id: Date.now(),
7 uid: currentUserId,
8 author: '我',
9 content,
10 date: new Date().toISOString().slice(0, 10),
11 likes: 0,
12 }
13 setComments([newComment, ...comments]) // ② 头部插入(不可变)
14 setInputValue('') // ③ 清空输入框
15}
6.3 受控 input 做成可复用子组件
父 CommentApp 持有 state,子 CommentInput 通过 props 接收 value / onChange / onSubmit,把受控绑定透传给内部 input:
1// 父:把 state 和逻辑通过 props 接进去
2<CommentInput
3 value={inputValue}
4 onChange={setInputValue}
5 onSubmit={handleAdd}
6/>
7
8// 子:受控绑定 + 把 event 解包成纯字符串
9<input
10 value={value}
11 onChange={(e) => onChange(e.target.value)}
12/>
13<button onClick={onSubmit} disabled={!value.trim()}>发布</button>
数据流闭环:输入 → onChange 拿值 → 父 setState → 重渲染 → value 流回 input。
和「自包含受控」的区别:state 放父级(状态提升),子组件只管长相、谁用谁传 state。
6.4 权限控制(只删自己的)
1{isOwn && <button onClick={() => onDelete(comment.id)}>删除</button>}
七、清空内容 + 聚焦:受控与 ref 的配合
评论发表后,想「清空输入框 + 光标自动回到框里」,这两个动作机制完全不同,正好把今天两块知识合起来:
| 动作 | 机制 | 在哪做 | 为什么 |
|---|---|---|---|
| 清空内容 | 受控表单 | 父 setInputValue('') | input 受控,value 来自父 state,只能改 state |
| 聚焦 | useRef + DOM | 子 inputRef.current.focus() | 聚焦是 DOM 行为,state 表达不了,只能用 ref |
1// 子组件 CommentInput
2const inputRef = useRef(null)
3
4const handleSubmit = () => {
5 onSubmit() // → 父发表 + setInputValue('') 清空
6 inputRef.current.focus() // → DOM 聚焦
7}
关键:ref 建在 input 渲染处(子),清空在 state 所在地(父) —— 这就是「状态在父、DOM 在子」时,两套机制各司其职的典型场景。
八、组件样式复用:不同输入框不同样式
组件复用时,常遇到「同一个组件在不同地方需要不同样式」。三种主流做法:
8.1 className prop 合并(最常用)
1import classNames from 'classnames'
2import styles from './comment.module.css'
3
4function CommentInput({ value, onChange, className }) {
5 return (
6 <input className={classNames(styles.input, className)} ... />
7 )
8}
9
10// 调用方传自己的 CSS Module 类
11<CommentInput className={myStyles.warnInput} />
8.2 variant 变体 prop(样式是固定的几种)
1<input
2 className={classNames(styles.input, {
3 [styles.warn]: variant === 'warn',
4 [styles.large]: variant === 'large',
5 })}
6/>
7
8<CommentInput variant="warn" />
8.3 内联 style(动态/一次性)
1<CommentInput style={{ borderColor: hasError ? 'red' : undefined }} />
| 场景 | 选 |
|---|---|
| 每个调用方样式不同、固定 | className |
| 只有 2~3 种固定风格 | variant |
| 动态值(错误态颜色等) | style |
实际项目常 className + style 一起用。
九、UUID 与时间处理
生成唯一 id 和格式化时间,是实战里高频的小工具。案例:src/components/UuidAndTime.jsx。
9.1 UUID
1import { v4 as uuidv4 } from 'uuid' // 第三方库(需 npm i uuid)
2const id1 = crypto.randomUUID() // 浏览器内置(推荐,零依赖),返回标准 v4
3const id2 = uuidv4() // uuid 库,需要 v1/v7 等版本时装
为什么不用 Date.now() 做 id:① 快速连点可能同毫秒撞 id;② 单调递增、可预测。
心智模型:React 只管 UI;工具类需求先看平台有没有内置(crypto、Date、Intl),不够再上专门的小库(uuid / dayjs)。
9.2 时间处理
1const now = new Date()
2now.toISOString() // '2026-07-05T...' 带 T/Z
3now.toLocaleString() // 本地化
4
5// 手动拼 YYYY-MM-DD HH:mm:ss
6const pad = (n) => String(n).padStart(2, '0')
7const s = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
8 `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
9
10// 相对时间
11function timeAgo(date) {
12 const diff = (Date.now() - new Date(date).getTime()) / 1000
13 if (diff < 60) return '刚刚'
14 if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
15 if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
16 return `${Math.floor(diff / 86400)} 天前`
17}
坑:Date 月份从 0 开始,所以 getMonth() + 1。复杂格式化建议上 dayjs(dayjs(d).format('YYYY-MM-DD HH:mm')、dayjs(d).fromNow())。
十、组件通信
四个层次,按关系复杂度递进。核心:没有「子改父」、没有「兄弟直连」,一切都靠 props 搭桥。
10.1 父 → 子:props
父把数据当 props 传下去,子直接读。
1<Child count={count} />
2function Child({ count }) {
3 return <p>{count}</p>
4}
10.2 子 → 父:回调(props 传函数)
父把函数当 props 传下去,子调用它、把数据作为参数传回。
1// 案例 src/components/ChildToParent.jsx
2function Sender({ onSendNum }) {
3 return <button onClick={() => onSendNum(1)}>给父 +1</button>
4}
5function ChildToParent() {
6 const [num, setNum] = useState(0)
7 const handleNum = (n) => setNum((c) => c + n) // ① 父定义函数
8 return <Sender onSendNum={handleNum} /> // ② 把函数传下去
9}
数据是作为函数参数传上去的;子从不直接碰父的 state。
10.3 兄弟:状态提升
兄弟之间没有直接通道,把共享 state 提升到共同父组件。本质 = 两个父子通信拼起来:A→父(回调)、父→B(props)。
1// 案例 src/components/LiftingState.jsx
2function LiftingState() {
3 const [name, setName] = useState('')
4 return (
5 <>
6 <NameInput value={name} onChange={setName} /> {/* A 写 */}
7 <Greeting name={name} /> {/* B 读 */}
8 </>
9 )
10}
10.4 跨层级:Context
解决 props 钻取(数据要穿过多层、中间层被迫转发)。三步:createContext → <Provider> → useContext。
1// 案例 src/components/ContextDemo.jsx
2const ThemeContext = createContext('light') // ① 创建
3function ThemeProvider({ children }) {
4 const [theme, setTheme] = useState('light')
5 return (
6 <ThemeContext.Provider value={{ theme, toggle }}> {/* ② 顶层提供 */}
7 {children}
8 </ThemeContext.Provider>
9 )
10}
11function ThemedBox() {
12 const { theme } = useContext(ThemeContext) // ③ 任意后代直接取
13 return <div>当前主题:{theme}</div>
14}
中间层完全不碰 theme —— 这就是 Context 省掉的「层层透传」。
10.5 通信全家桶对照表
| 关系 | 机制 | 案例 |
|---|---|---|
| 父 → 子 | props(传数据) | Parent / PropsDemo |
| 子 → 父 | props(传函数,子调用传参) | ChildToParent |
| 兄弟 | 状态提升到共同父 | LiftingState |
| 跨层级 | Context(Provider + useContext) | ContextDemo |
十一、useEffect 副作用
11.1 什么是副作用
组件渲染(算 JSX)是纯的。之外的「额外动作」都是副作用:发请求、定时器、事件订阅、改 DOM、读写 localStorage。这些用 useEffect 包起来,在渲染完成后才跑。
教程原话:「不是由事件引起、而是由渲染本身引起的操作」—— 事件用
onClick,渲染完自动要做的用useEffect。
11.2 依赖数组三种写法
1useEffect(() => {...}) // 不传:每次渲染后都跑(少用,易死循环)
2useEffect(() => {...}, []) // 空数组:只在挂载后跑一次
3useEffect(() => {...}, [count]) // 有依赖:挂载后 + count 变化时跑
11.3 案例:标题同步 + 计时器(src/components/UseEffectDemo.jsx)
1function Timer() {
2 const [seconds, setSeconds] = useState(0)
3 useEffect(() => {
4 const id = setInterval(() => setSeconds((s) => s + 1), 1000)
5 return () => clearInterval(id) // ← 清理函数:卸载时清定时器
6 }, [])
7 return <p>{seconds} 秒</p>
8}
9
10function UseEffectDemo() {
11 const [count, setCount] = useState(0)
12 useEffect(() => {
13 document.title = `点击 ${count} 次` // count 变才同步标签页标题
14 }, [count])
15 return <button onClick={() => setCount((c) => c + 1)}>+1</button>
16}
11.4 清除副作用(cleanup)
在 useEffect 里 return 一个函数,它在卸载时和下次 effect 执行前自动跑。
1// 案例 src/components/CleanupDemo.jsx —— 鼠标监听
2useEffect(() => {
3 const handleMove = (e) => setPos({ x: e.clientX, y: e.clientY })
4 window.addEventListener('mousemove', handleMove)
5 return () => window.removeEventListener('mousemove', handleMove) // 清理
6}, [])
| 副作用 | 清理 |
|---|---|
setInterval / setTimeout | clearInterval / clearTimeout |
addEventListener | removeEventListener |
| 订阅 | 取消订阅 |
fetch | AbortController.abort() |
11.5 和 class 生命周期的对应
| 写法 | 类比 class 生命周期 |
|---|---|
useEffect(fn, []) | componentDidMount + componentWillUnmount |
useEffect(fn, [a]) | + componentDidUpdate(a 变时) |
useEffect(fn) | + 每次 componentDidUpdate(几乎不用) |
函数组件本身没有「生命周期」概念,
useEffect用依赖数组来表达「什么时机做事」。
十二、自定义 Hook
12.1 是什么
use 开头、内部调用别的 Hook、**返回数据(不是 JSX)**的函数 —— 用来抽出和复用「带状态的逻辑」。命名 useXxx,放 src/hooks/。每次调用各自独立 state(逻辑复用,不是状态共享)。
12.2 案例:useMousePosition(抽逻辑)
1// src/hooks/useMousePosition.js
2function useMousePosition() {
3 const [pos, setPos] = useState({ x: 0, y: 0 })
4 useEffect(() => {
5 const handleMove = (e) => setPos({ x: e.clientX, y: e.clientY })
6 window.addEventListener('mousemove', handleMove)
7 return () => window.removeEventListener('mousemove', handleMove)
8 }, [])
9 return pos // 返回数据,不是 JSX
10}
11// 用:const pos = useMousePosition()
12.3 案例:useLocalStorage(组合 Hook)
组合 useState + useEffect,对外暴露和 useState 一样的接口,但值会持久化。
1// src/hooks/useLocalStorage.js
2function useLocalStorage(key, initialValue) {
3 const [value, setValue] = useState(() => {
4 const saved = localStorage.getItem(key)
5 return saved !== null ? JSON.parse(saved) : initialValue
6 })
7 useEffect(() => {
8 localStorage.setItem(key, JSON.stringify(value))
9 }, [key, value])
10 return [value, setValue]
11}
12.4 案例:useToggle(封装 useState)
1// src/hooks/useToggle.js
2function useToggle(initial = false) {
3 const [value, setValue] = useState(initial)
4 const toggle = (next) => {
5 setValue((prev) => (typeof next === 'boolean' ? next : !prev))
6 // ↑ prev 就是 value(同一个当前值),由 React 传入
7 }
8 return [value, toggle]
9}
关于
prev:它就是value(同一个当前 state),只是 React 在setValue内部调用你的函数时把它当参数传进来。新值依赖旧值(取反、累加、追加)时,都用函数式更新setValue((prev) => ...)。
十三、Hook 使用规范
13.1 两条铁律
- 只在顶层调用 —— 不能放
if/ 循环 / 嵌套函数里 - 只在函数组件或自定义 Hook 里调用 —— 不能在普通函数 / 事件处理函数里
项目 .oxlintrc.json 把 react/rules-of-hooks 设成了 error,违规直接报红。
13.2 为什么这么设计
React 靠**「Hook 的调用顺序」**给每个 Hook 配对 state(第一个 → state[0],第二个 → state[1]……)。因为 JS 不给每次函数调用自动分配 id,顺序是唯一稳定的标识。一旦把 Hook 放进条件里,某次渲染可能少执行一行 → 顺序错乱 → state 配对错位。
这是「简洁 vs 灵活」的折中:React 选了零样板(写
useState(0)就行),代价是必须遵守两条铁律。
十四、综合案例:请求接口的评论列表(src/comment3/)
教程三要求:① 请求接口获取列表 ② 自定义 Hook 封装请求 ③ 每项抽成独立组件。
14.1 json-server 搭建模拟接口
1npm install --save-dev json-server@0.17.4 # 装一次
2npm run server # 启动:json-server --watch db.json --port 3004
db.json 里放一个 list 数组,访问 http://localhost:3004/list 即可拿到。
14.2 useComments 自定义 Hook 封装请求
1// src/comment3/useComments.js
2function useComments(url) {
3 const [comments, setComments] = useState([])
4 const [loading, setLoading] = useState(true)
5 const [error, setError] = useState(null)
6
7 useEffect(() => {
8 let cancelled = false // 清除副作用:卸载后别再 setState
9 const fetchData = async () => {
10 setLoading(true)
11 try {
12 const res = await fetch(url)
13 if (!res.ok) throw new Error(`请求失败:${res.status}`)
14 const data = await res.json()
15 if (!cancelled) setComments(data)
16 } catch (e) {
17 if (!cancelled) setError(e.message)
18 } finally {
19 if (!cancelled) setLoading(false)
20 }
21 }
22 fetchData()
23 return () => { cancelled = true }
24 }, [url])
25
26 return { comments, loading, error }
27}
14.3 CommentItem 抽离独立组件
1// src/comment3/CommentItem.jsx
2function CommentItem({ comment }) {
3 return (
4 <li className={styles.item}>
5 <div className={styles.avatar}>{comment.author.slice(0, 1)}</div>
6 <div className={styles.body}>
7 <div className={styles.header}>
8 <span className={styles.author}>{comment.author}</span>
9 <span className={styles.date}>{comment.date}</span>
10 </div>
11 <p className={styles.content}>{comment.content}</p>
12 <span className={styles.likes}>👍 {comment.likes}</span>
13 </div>
14 </li>
15 )
16}
14.4 CommentApp 使用
1function CommentApp() {
2 const { comments, loading, error } = useComments('http://localhost:3004/list')
3 return (
4 <div className={styles.container}>
5 {loading && <p>⏳ 加载中...</p>}
6 {error && <p>❌ 出错了:{error}</p>}
7 {!loading && !error && (
8 <ul>
9 {comments.map((c) => <CommentItem key={c.id} comment={c} />)}
10 </ul>
11 )}
12 </div>
13 )
14}
请求细节全封进 Hook,组件只管「拿数据 → 渲染」。
十五、总结
今天学到的:
- 受控表单:
value+onChange,state 驱动 UI 的闭环 - 受控 vs 非受控:数据源、重渲染、实时校验、file 例外
- 为什么不建议直接操作 DOM:覆盖、数据流、优化
useRef获取 DOM:聚焦、测尺寸,DOM 操作放useEffect- 综合案例①:从零重写「发表评论」(受控表单 + 不可变 + 列表渲染)
- 清空 + 聚焦:受控
setState清空、useRef聚焦,状态在父 / DOM 在子各司其职 - 组件样式复用:
className/variant/style - UUID 与时间:
crypto.randomUUID、Date格式化、相对时间 - 组件通信四件套:父→子 props、子→父回调、兄弟状态提升、跨层级 Context
useEffect:副作用在渲染后跑;依赖数组控制时机;return 清理函数防泄漏- 自定义 Hook:
useXxx复用带状态的逻辑(鼠标、本地存储、切换、请求) - Hook 规范:只在顶层 + 只在组件/自定义 Hook;背后是调用顺序对应 state
- 综合案例②:json-server 搭接口 +
useComments封装请求 +CommentItem抽组件
下一步可以继续学习:
useMemo/useCallback性能优化useRef进阶(forwardRef / useImperativeHandle)- React Router 路由
- 状态管理库(zustand / redux)
- TypeScript 化
文章为 React 12 天学习计划第 2 天笔记,代码位于 react-basic 项目(src/components/、src/hooks/、src/comment2/、src/comment3/)。