Part 3 React Hooks
约 2917 字大约 10 分钟
2025-07-01
1 useReducer
useReducer
是一个 React 提供的状态管理 Hook,与useState
不同的是,useReducer
适用于较为复杂状态。
我们使用一个简单的计数器应用来比较useState
和useReducer
的区别。
export default function App() {
const [count, setCount] = useState(0)
const handleIncrement = () => setCount(count + 1)
const handleDecrement = () => setCount(count - 1)
return (
<>
<h1>Count: {count}</h1>
<button onClick={handleIncrement}>增加</button>
<button onClick={handleDecrement}>减少</button>
</>
)
}
这是使用useState
实现的计数器。h1
标签展示当前计数,两个按钮分别让其增加 1 或者减少 1。
然后我们使用useReducer
实现它:
export default function App() {
const counterReducer = (state: number, action: { type: string }) => {
switch (action.type) {
case "increment":
return state + 1
case "decrement":
return state - 1
default:
throw new Error("Unknown action type")
}
}
const [count, dispatchCount] = useReducer(counterReducer, 0)
const handleIncrement = () => {
dispatchCount({ type: "increment" })
}
const handleDecrement = () => {
dispatchCount({ type: "decrement" })
}
return (
<>
<h1>Count: {count}</h1>
<button onClick={handleIncrement}>增加</button>
<button onClick={handleDecrement}>减少</button>
</>
)
}
useReducer
函数接收一个内部操作函数countReducer
和状态初始值作为前两个参数,并返回一个数组。数组的第一个元素是状态,第二个元素是对外暴露的操作函数dispatchCount
。
内部操作函数countReducer
接收两个参数:状态state
和操作action
。然后内部使用switch
语句判断该如何操作state
。
当外部需要对状态进行操作的时候,调用外部操作函数dispatchCount
并传入action
参数即可。
可能这个例子并不能很好地展示useReducer
的优越性,那让我们再为这个计数器添加一些功能。比如,限制count
为非负整数、每次增减count
都有 0.5 的概率增减 2 等等。这些要求使用useState
并不好实现,或者说并不直观。而使用useReducer
的话:
const counterReducer = (state: number, action: { type: string }) => {
switch (action.type) {
case "increment":
if (Math.random() <= 0.5) return state + 2
return state + 1
case "decrement":
if (state <= 0) return 0
if (Math.random() <= 0.5) return state - 2
return state - 1
default:
throw new Error("Unknown action type")
}
}
当组件状态比较复杂、更新逻辑包含多个分支、状态字段较多时,使用useReducer
会比useState
更清晰、可维护。
2 useRef
useRef
提供了一个在组件生命周期内存储可变状态的容器。使用一个初始值创建 ref 容器,该值会被存储在ref.current
中。
const ref = useRef(initValue)
2.1 存储非响应式变量
useRef
返回一个对象{ current: initValue }
,修改ref.current
不会导致组件重新渲染。
export default function App() {
const countRef = useRef(0)
const handleClick = () => {
countRef.current += 1
console.log(`Count: ${countRef.current}`)
}
return (
<>
<h1>Count: {countRef.current}</h1>
<button onClick={handleClick}>Count + 1</button>
</>
)
}
这是一个简化版计数器,点击按钮会使countRef.current
自增,并且在控制台中打印结果。
点击按钮发现,页面上的h1
元素并不会按照我们预期中的更新,即使handleClick
函数已经正常打印出当前的 count 值。
这样我们就有了一个容器存储那些需要在组件中持久存储但不会引发组件渲染的数据,如 Timer ID、计数器等。
2.2 保留上一帧状态
ref 容器中的变量不随组件的更新而重置,这可以用来保留组件的状态。
有时我们需要对前后两帧的状态做比较。例如有一个计时器,它可以记录两次点击按钮之间的时间差。
export default function App() {
const [time, setTime] = useState(0)
const prevTimeRef = useRef(0)
const handleClick = () => {
prevTimeRef.current = time
setTime(Date.now())
}
return (
<>
<h1>上次时间:{prevTimeRef.current || "尚未记录"}</h1>
<h1>当前时间:{time || "尚未记录"}</h1>
<h1>
时间差:
{time === 0 || prevTimeRef.current === 0
? "无数据"
: time - prevTimeRef.current} ms
</h1>
<button onClick={handleClick}>点击记录时间</button>
</>
)
}
乍一看useRef
似乎平平无奇,但是如果我们使用普通变量来保存上一次时间的话,就会发现无论怎样点击,prevTime
都始终为 0。
export default function App() {
const [time, setTime] = useState(0)
let prevTime = 0
const handleClick = () => {
prevTime = time
setTime(Date.now())
}
return (
<>
<h1>上次时间:{prevTime || "尚未记录"}</h1>
<h1>当前时间:{time || "尚未记录"}</h1>
<h1>
时间差:
{time === 0 || prevTime === 0 ? "无数据" : time - prevTime} ms
</h1>
<button onClick={handleClick}>点击记录时间</button>
</>
)
}
这是因为 React 的函数式组件的特性导致。组件的每次渲染都是一个新的函数调用,因此函数(组件)内的普通变量都是临时的,不具备持久状态。
2.3 访问 DOM 元素
useRef
的这个作用就和 Vue 中的useRef
很相似了。
在 HTML 标签中声明一个ref
属性,并将其指向inputRef
容器即可。
export default function App() {
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => {
if (inputRef.current) {
inputRef.current?.focus()
}
}
return (
<>
<input type="text" ref={inputRef} />
<button onClick={focusInput}>点击聚焦文本框</button>
</>
)
}
2.4 访问子组件函数
有时我们希望访问子组件的中的变量或者函数,这在通常情况下是不允许的,因为子组件对父组件是黑盒状态。
和 DOM 元素一样,我们也在子组件的 HTML 标签上指定ref
属性并指向childRef
容器。
export default function App() {
// ... existing code
const childRef = useRef(null)
return (
<>
<button onClick={handleClick}>点击调用子组件函数</button>
<Child ref={childRef} />
</>
)
}
要访问子组件,我们还需要对子组件进行一些处理。将子组件改为函数表达式,接收两个参数props
和ref
,并且以回调的形式传入forwardRef()
函数。这样父组件才能正确访问到子组件。
const Child = forwardRef(function (props, ref) {
// ... existing code
})
访问到子组件还不够,因为其中的函数并不默认开放。我们还需要使用useImperativeHandle
函数来将我们希望能被父组件调用的函数暴露出去,类似于 Vue 3 的defineExpose
:
const Child = forwardRef(function (props, ref) {
useImperativeHandle(ref, () => ({
childFunc() {
alert("子组件函数被调用")
},
}))
return <div>子组件</div>
})
这样我们要从父组件中调用子组件方法,只需要childRef.current.childFunc()
即可:
export default function App() {
const childRef = useRef()
const handleClick = () => {
if (childRef.current) {
childRef.current.childFunc()
}
}
return (
<>
<button onClick={handleClick}>点击调用子组件函数</button>
<Child ref={childRef} />
</>
)
}
通过组合使用useRef
forwardRef
和useImperativeHandle
,我们可以优雅地向父组件暴露子组件的方法,从而在必要时让父组件“主动”控制子组件行为。
3 useEffect
React 要求函数式组件都是纯函数,同样的输入应该得到同样的输出。这也就意味着组件无法给外部造成任何副作用。
副作用 Side Effect
副作用指的是那些不直接参与组件渲染逻辑、但会影响外部系统或状态的操作,例如:发送网络请求、操作 DOM、设置定时器、打印日志、订阅事件等。这些操作应该放在 useEffect 中进行。
如果我们希望能在组件加载或者组件更新(非用户操作触发的)执行一些副作用,就可以使用useEffect
。useEffect
在组件渲染完成后才会执行。
使用useEffect
创建一个副作用函数:
export default function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
}
useEffect(() => {
console.log("组件被加载")
})
return (
<>
<h1>Count: {count}</h1>
<button onClick={handleClick}>点击自增</button>
</>
)
}
React 严格模式
开发模式下,在组件首次加载完成后,你会在控制台中看到useEffect
被执行了两次。这是 React 18 在严格模式下的行为,它在开发模式中有意调用两次useEffect
,以检查副作用是否安全。
在生产模式下只会执行一次。
如果我们想要控制useEffect
的执行时机的话,就需要传入一个依赖数组作为第二个参数。依赖数组中存放着一些状态,当这些状态发生改变的时候useEffect
函数就被执行。
useEffect(() => {
console.log("组件被加载")
}, [])
这里的依赖数组是一个空数组,也就是说任何状态发生改变的时候useEffect
都不会被执行。
useEffect(() => {
console.log("组件被加载")
}, [count])
这里依赖数组表明useEffect
将会监视count
状态,当count
发生改变时useEffect
将被执行。
就像 Vue 3 的watchEffect
函数。
4 useMemo
我们知道当父组件更新时,子组件也会随之更新。这就产生了一个问题:如果父组件中的某状态并不影响子组件,且子组件中的逻辑复杂或者计算量大,就会带来不必要的性能损失。
如这个例子所示:
function Child({ input }) {
let res = 0
res = input * 2
return (
<>
<h2>输入:{input}</h2>
<h2>结果:{res}</h2>
</>
)
}
export default function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
}
const [input, setInput] = useState(0)
return (
<>
<h1>Count: {count}</h1>
<button onClick={handleClick}>点击自增</button>
<hr />
<input
type="number"
value={input}
onChange={e => setInput(parseInt(e.target.value))}
/>
<Child input={input} />
</>
)
}
父组件分为两部分:计数器和子组件Child
。计数器的count
并不影响子组件,因为子组件只需要input
的值。当我们每次点击按钮使count
自增时,子组件就会重新渲染并重新开始计算。这一点我们可以在子组件中添加日志打印得到证实。
因此我们可以使用useMemo
来缓存不希望被更新的操作。
function Child({ input }) {
const res = useMemo(() => {
console.log("子组件开始计算")
return input * 2
}, [input])
return (
<>
<h2>输入:{input}</h2>
<h2>结果:{res}</h2>
</>
)
}
useMemo
的用法和useEffect
类似,都是接收一个回调函数和依赖数组作为参数。不同的是,useMemo
有返回值,可以输出计算结果。
当某个计算结果与组件的所有状态无关,或者只依赖于某些状态时,我们可以使用useMemo
缓存这段计算的值,从而提高性能,避免不必要的重复执行。
5 useCallback
更进一步地,上面的useMemo
的例子中只缓存了计算的值,但是仍未解决子组件会被重新渲染的问题。为了继续优化性能,我们希望能缓存整个子组件,而非其中的某个值。
React 提供了memo()
函数(并非useMemo
)来解决这个问题。
查看以下示例:
function Button({ onBtnClick }) {
console.log("子组件被渲染")
return <button onClick={onBtnClick}>点击查看当前 Count</button>
}
export default function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
}
const handleShow = () => {
alert("子组件按钮被点击了")
}
return (
<>
<h1>Count: {count}</h1>
<button onClick={handleClick}>点击自增</button>
<hr />
<Button onBtnClick={handleShow} />
</>
)
}
父组件包含除了计数器外,还有一个子组件Button
,并将handleShow
函数作为自定义事件onBtnClick
传入。当点击按钮时,count
自增;当点击Button
时,会弹出对话框展示当前count
值。
当我们点击按钮自增时,会发现Button
被重新渲染。这就发生了无效渲染。
为解决这一问题,我们可以使用React.memo
(不是useMemo
)将Button
组件设为缓存组件:
const Button = React.memo(function ({ onBtnClick }) {
console.log("子组件被渲染")
return <button onClick={onBtnClick}>点击查看当前 Count</button>
})
然后再次尝试,发现并不起效:当count
自增时,子组件仍被重新渲染。
这是因为我们传入的自定义事件函数onBtnClick
导致的。简单说就是:Button
组件确实被缓存了,但是父组件被更新后,会重新创建出一片新的内存空间用于存储新父组件中的函数和变量;这就使得前后的onBtnClick
并非同一个,即使它们用的是同一个名字,因为其在内存中的位置是不同的,因此导致了Button
组件重新加载。
于是 React 提供了useCallback
来实现函数的缓存:
const handleShow = useCallback(() => {
alert("子组件按钮被点击了")
}, [count])
和useMemo
类似接收两个参数:第一个为回调函数,第二个是监听的状态。
这样修改完成后,count
自增时,子组件就不会被重新渲染。