6 个 React Ref 代码挑战,你能过几个?
什么时候该用 ref、什么时候该用 state、为什么模块级变量会被多个组件共享、怎么用 ref 操作视频和滚动、以及 forwardRef 怎么用。六道题,每题给出问题代码和修正后的代码。
English version: Six React ref challenges. How many can you get?
挑战 1: 利用 ref 保存 setTimeout
观察下面这段代码,我们渲染了一个输入框,一个 Send 按钮和 Undo 按钮,我们的预期结果是当我们点击 Send3 秒后,弹出 Send!弹窗,如果在 3 秒内点击 Undo 按钮的话,就取消这个弹窗。 但实际情况并不是这样,我们点击 Undo,弹窗 Send!还是会被触发。这是为什么?如何修改?
import { useState } from 'react'
export default function Chat() {
const [text, setText] = useState('')
const [isSending, setIsSending] = useState(false)
let timeoutID = null
function handleSend() {
setIsSending(true)
timeoutID = setTimeout(() => {
alert('Sent!')
setIsSending(false)
}, 3000)
}
function handleUndo() {
setIsSending(false)
clearTimeout(timeoutID)
}
return (
<>
<input disabled={isSending} value={text} onChange={(e) => setText(e.target.value)} />
<button disabled={isSending} onClick={handleSend}>
{isSending ? 'Sending...' : 'Send'}
</button>
{isSending && <button onClick={handleUndo}>Undo</button>}
</>
)
}
解释: 这是因为当我们的组件重新渲染时,所有的本地参数都会被重新初始化,也就是说 timeoutID 还是 null,而不是有值状态. 所以我们需要将 timeoutID 存放到 ref 上,React 在重新渲染的时候会保存这个值。
以下是正确代码:
import { useState, useRef } from 'react'
export default function Chat() {
const [text, setText] = useState('')
const [isSending, setIsSending] = useState(false)
const timeoutRef = useRef(null)
function handleSend() {
setIsSending(true)
timeoutRef.current = setTimeout(() => {
alert('Sent!')
setIsSending(false)
}, 3000)
}
function handleUndo() {
setIsSending(false)
clearTimeout(timeoutRef.current)
}
return (
<>
<input disabled={isSending} value={text} onChange={(e) => setText(e.target.value)} />
<button disabled={isSending} onClick={handleSend}>
{isSending ? 'Sending...' : 'Send'}
</button>
{isSending && <button onClick={handleUndo}>Undo</button>}
</>
)
}
挑战 2:使用 ref 还是 state
这段代码的问题在于 isOnRef.current =!isOnRef.current 并不会触发重新渲染,也就是改变 ref.current 不会重新渲染,所以按钮的文案不会变化。
import { useRef } from 'react'
export default function Toggle() {
const isOnRef = useRef(false)
return (
<button
onClick={() => {
isOnRef.current = !isOnRef.current
}}
>
{isOnRef.current ? 'On' : 'Off'}
</button>
)
}
正确的代码
import { useState } from 'react'
export default function Toggle() {
const [isOn, setIsOn] = useState(false)
return (
<button
onClick={() => {
setIsOn(!isOn)
}}
>
{isOn ? 'On' : 'Off'}
</button>
)
}
挑战 3:利用 ref 解决组件共享问题
当随机点击三个按钮时,只有最后一个按钮会弹出弹窗,原因是一个参数比如 timeoutID 被所有组件共享,导致的。解决方案是使用 ref,使得每个组件有自己的 timeoutID。
let timeoutID
function DebouncedButton({ onClick, children }) {
return (
<button
onClick={() => {
clearTimeout(timeoutID)
timeoutID = setTimeout(() => {
onClick()
}, 1000)
}}
>
{children}
</button>
)
}
export default function Dashboard() {
return (
<>
<DebouncedButton onClick={() => alert('Spaceship launched!')}>
Launch the spaceship
</DebouncedButton>
<DebouncedButton onClick={() => alert('Soup boiled!')}>Boil the soup</DebouncedButton>
<DebouncedButton onClick={() => alert('Lullaby sung!')}>Sing a lullaby</DebouncedButton>
</>
)
}
正确的代码
import { useRef } from 'react'
function DebouncedButton({ onClick, children }) {
const timeoutRef = useRef(null)
return (
<button
onClick={() => {
clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
onClick()
}, 1000)
}}
>
{children}
</button>
)
}
export default function Dashboard() {
return (
<>
<DebouncedButton onClick={() => alert('Spaceship launched!')}>
Launch the spaceship
</DebouncedButton>
<DebouncedButton onClick={() => alert('Soup boiled!')}>Boil the soup</DebouncedButton>
<DebouncedButton onClick={() => alert('Lullaby sung!')}>Sing a lullaby</DebouncedButton>
</>
)
}
挑战 4:播放和暂停视频
要使得<video>播放或者暂停,需要使用 play() or pause() api,需要使用 ref
import { useState, useRef } from 'react'
export default function VideoPlayer() {
const [isPlaying, setIsPlaying] = useState(false)
function handleClick() {
const nextIsPlaying = !isPlaying
setIsPlaying(nextIsPlaying)
}
return (
<>
<button onClick={handleClick}>{isPlaying ? 'Pause' : 'Play'}</button>
<video width="250">
<source
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
type="video/mp4"
/>
</video>
</>
)
}
正确代码
import { useState, useRef } from 'react'
export default function VideoPlayer() {
const [isPlaying, setIsPlaying] = useState(false)
const ref = useRef(null)
function handleClick() {
const nextIsPlaying = !isPlaying
setIsPlaying(nextIsPlaying)
if (nextIsPlaying) {
ref.current.play()
} else {
ref.current.pause()
}
}
return (
<>
<button onClick={handleClick}>{isPlaying ? 'Pause' : 'Play'}</button>
<video
width="250"
ref={ref}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
type="video/mp4"
/>
</video>
</>
)
}
挑战 5:滚动图片轮播
点击 Next 时要让下一张图滚到视野中央。难点在于 setIndex 之后 DOM 还没更新,直接 scrollIntoView 会滚到旧的那张。用 flushSync 强制 React 同步提交更新,再滚动:
import { useRef, useState } from 'react'
import { flushSync } from 'react-dom'
export default function CatFriends() {
const selectedRef = useRef(null)
const [index, setIndex] = useState(0)
return (
<>
<nav>
<button
onClick={() => {
flushSync(() => {
if (index < catList.length - 1) {
setIndex(index + 1)
} else {
setIndex(0)
}
})
selectedRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
})
}}
>
Next
</button>
</nav>
<div>
<ul>
{catList.map((cat, i) => (
<li key={cat.id} ref={index === i ? selectedRef : null}>
<img
className={index === i ? 'active' : ''}
src={cat.imageUrl}
alt={'Cat #' + cat.id}
/>
</li>
))}
</ul>
</div>
</>
)
}
const catList = []
for (let i = 0; i < 10; i++) {
catList.push({
id: i,
imageUrl: 'https://placekitten.com/250/200?image=' + i,
})
}
挑战 6:用 forwardRef 把 ref 传进子组件
函数组件默认不接收 ref。想让父组件聚焦子组件里的输入框,子组件要用 forwardRef 把 ref 转发到真正的 DOM 节点上:
import { forwardRef, useRef } from 'react'
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />
})
export default function Form() {
const inputRef = useRef(null)
function handleClick() {
inputRef.current.focus()
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
)
}
题目来自 react.dev 官方教程“Referencing Values with Refs”和“Manipulating the DOM with Refs”两章末尾的挑战题,代码按 CC BY 4.0 引用;解释是我自己写的。