颜力刚Ligang Yan

6 个 React Ref 代码挑战,你能过几个?

什么时候该用 ref、什么时候该用 state、为什么模块级变量会被多个组件共享、怎么用 ref 操作视频和滚动、以及 forwardRef 怎么用。六道题,每题给出问题代码和修正后的代码。

reactrefjavascript

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 引用;解释是我自己写的。