为什么你应该编写自己的 React Hooks

2025-06-07

为什么你应该编写自己的 React Hooks

总结

自定义 React hooks 可以提供一个很好的位置来在命令式代码和声明式代码之间划定界限。

在这个例子中,我们将研究如何将必要的复杂性提取到可组合、封装、可重用的对象中,同时保持组件的清洁和声明性。


可组合性

一个棘手的问题:除了组件之外,在什么地方可以使用 React Hooks?答案当然是在其他 Hooks 中。

你可能知道,当你编写自己的 hooks 时,你编写的是遵循React Hooks 约定的普通 JavaScript 函数。它们没有特定的签名;它们没有什么特别之处,你可以随意使用它们。

随着应用程序的构建、功能添加和实用性的提升,组件的复杂性也随之增加。经验可以帮助你规避可避免的复杂性,但这也只是暂时的。一定程度的复杂性是必要的。

将一些混乱但必要的逻辑分散在组件周围,并将其包装在具有清晰 API 和单一用途的钩子中,这是一种很棒的感觉。

我们来看一个简单的秒表组件。以下是 codesandbox 中的实现,可以参考一下。

这是代码。

function App() {
  return (
    <div className="App">
      <Stopwatch />
    </div>
  )
}

function Stopwatch() {
  const [isCounting, setIsCounting] = React.useState(false)
  const [runningTime, setRunningTime] = React.useState(0)

  const intervalId = React.useRef()

  const startCounting = () =>
    (intervalId.current = setInterval(intervalCallback(), 0))

  const stopCounting = () => clearInterval(intervalId.current)

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(runningTime + new Date().getTime() - startTime)
  }

  React.useEffect(() => stopCounting, [])

  const handleStartStop = () => {
    isCounting ? stopCounting() : startCounting()
    setIsCounting(!isCounting)
  }

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(0)
  }

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

组件的快速解释

让我们快速浏览一下代码,以便我们都能理解。

我们首先使用几个useState钩子来跟踪计时器是否运行以及运行了多长时间。

const [isCounting, setIsCounting] = React.useState(false)
const [runningTime, setRunningTime] = React.useState(0)
Enter fullscreen mode Exit fullscreen mode

接下来我们有几个函数,通过设置和清除间隔来启动和停止计时器。我们将间隔 ID 存储为 Ref,因为我们需要一些状态,但我们不关心它是否会触发重新渲染。

我们不使用它setInterval来做任何计时,我们只是需要它重复调用一个函数而不阻塞。

  const intervalId = React.useRef()

  const startCounting = () =>
    (intervalId.current = setInterval(intervalCallback(), 0))

  const stopCounting = () => clearInterval(intervalId.current)
Enter fullscreen mode Exit fullscreen mode

计时逻辑位于回调函数中,该回调函数由此函数返回并传递给。秒表启动时,setInterval它会关闭。startTime

 const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(runningTime + new Date().getTime() - startTime)
  }
Enter fullscreen mode Exit fullscreen mode

这里我们需要使用useEffect返回一个清理函数,防止组件卸载时出现内存泄漏。

  React.useEffect(() => stopCounting, [])
Enter fullscreen mode Exit fullscreen mode

最后,我们为启动/停止和重置按钮定义了几个处理程序。

  const handleStartStop = () => {
    isCounting ? stopCounting() : startCounting()
    setIsCounting(!isCounting)
  }

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(0)
  }
Enter fullscreen mode Exit fullscreen mode

很简单,但这个组件处理了多个问题。
这段代码知道太多了。它知道如何开始和停止计时,以及如何在页面上布局。我们知道应该重构它,但让我们思考一下为什么。

我们可能想要提取这个逻辑的主要原因有两个,这样我们就可以添加不相关的功能这样我们就可以添加使用相同功能的类似组件。

第一个原因是,当我们需要添加更多功能时,我们不希望组件变得失控,难以理解。我们希望封装这个计时器逻辑,这样新的、不相关的逻辑就不会混入其中。这符合单一职责原则

第二个原因是为了简单地重用,而无需重复我们自己。

附注:如果所讨论的代码不包含任何钩子,我们可以将其提取到普通函数中。

事实上,我们需要将其提取到我们自己的钩子中。

我们这样做吧。

const useClock = () => {
  const [isCounting, setIsCounting] = React.useState(false)
  const [runningTime, setRunningTime] = React.useState(0)

  const intervalId = React.useRef()

  const startCounting = () =>
    (intervalId.current = setInterval(intervalCallback(), 0))

  const stopCounting = () => clearInterval(intervalId.current)

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(runningTime + new Date().getTime() - startTime)
  }

  React.useEffect(() => stopCounting, [])

  const handleStartStop = () => {
    isCounting ? stopCounting() : startCounting()
    setIsCounting(!isCounting)
  }

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(0)
  }

  return { runningTime, handleStartStop, handleReset }
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们正在一个对象中返回时钟和处理程序的运行时间,我们会像这样立即在我们的组件中对其进行解构。

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useClock()

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

到目前为止一切顺利。它运行正常(codesandbox demo),最直接的好处是我们的组件变得完全声明式,这正是 React 组件应有的样子。一种理解方式是,组件同时描述了它的最终状态,也就是所有可能的状态。它之所以是声明式的,是因为它只是声明了组件的状态,而不是进入这些状态所需的步骤。

添加计时器

假设我们不仅需要一个正计时的秒表,还需要一个倒计时的计时器。

我们需要Stopwatch计时器中 95% 的逻辑,这应该很容易,因为我们刚刚提取了它。

我们首先想到的可能是传递一个标志,并在需要的地方添加条件逻辑。以下是相关部分可能的样子。

const useClock = ({ variant }) => {
  // <snip>

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    if (variant === 'Stopwatch') {
      return () =>
        setRunningTime(runningTime + new Date().getTime() - startTime)
    } else if (variant === 'Timer') {
      return () =>
        setRunningTime(runningTime - new Date().getTime() + startTime)
    }
  }

  // <snip>
}

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    variant: 'Stopwatch',
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

function Timer() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    variant: 'Timer',
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

好的,这行得通(codesandbox demo),但我们可以看到它已经变得越来越难读了。如果我们再多加几个这样的“特性”,它就会失控了。

更好的方法可能是提取出独特的部分,给它命名(并不总是那么容易)并将其传递到我们的钩子中,就像这样。

const useClock = ({ counter }) => {
  // <snip>

  const intervalCallback = () => {
    const startTime = new Date().getTime()

    return () => setRunningTime(counter(startTime, runningTime))
  }

  // <snip>
}

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    counter: (startTime, runningTime) =>
      runningTime + new Date().getTime() - startTime,
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

function Timer() {
  const { runningTime, handleStartStop, handleReset } = useClock({
    counter: (startTime, runningTime) =>
      runningTime - new Date().getTime() + startTime,
  })

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

Enter fullscreen mode Exit fullscreen mode

太棒了,它成功了(codesandbox demo),而且我们的useClock钩子保持了干净整洁的状态。由于我们给其中一个“柔软”的部分命名,它或许比原来的版本更具可读性。

Stopwatch然而,我们对和组件引入的更改Timer使其声明性降低。新的命令式代码指示了它如何工作,而不是声明它做什么。

为了解决这个问题,我们可以把这段代码放到其他几个 hooks 中。这充分展现了 React hooks API 的魅力:它们是可组合的。

const useStopwatch = () =>
  useClock({
    counter: (startTime, runningTime) =>
      runningTime + new Date().getTime() - startTime,
  })

function Stopwatch() {
  const { runningTime, handleStartStop, handleReset } = useStopwatch()

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}

const useTimer = () =>
  useClock({
    counter: (startTime, runningTime) =>
      runningTime - new Date().getTime() + startTime,
  })

function Timer() {
  const { runningTime, handleStartStop, handleReset } = useTimer()

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

好多了(codesandbox demo),我们的组件又恢复到完全声明的状态,并且我们的命令式代码被很好地封装了。

为了说明为什么这是一件好事,让我们看看在不弄乱代码的情况下添加更多功能是多么容易。

添加开始时间

我们不希望计时器从零开始倒计时,所以让我们添加一个初始时间。

function App() {
  return (
    <div className="App">
      <Stopwatch />
      <Timer initialTime={5 * 1000} />
    </div>
  )
}

const useClock = ({ counter, initialTime = 0 }) => {
  const [isCounting, setIsCounting] = React.useState(false)
  const [runningTime, setRunningTime] = React.useState(initialTime)

  // <snip>

  const handleReset = () => {
    stopCounting()
    setIsCounting(false)
    setRunningTime(initialTime)
  }

  return { runningTime, handleStartStop, handleReset }
}

const useTimer = initialTime =>
  useClock({
    counter: (startTime, runningTime) =>
      runningTime - new Date().getTime() + startTime,
    initialTime,
  })

function Timer({ initialTime }) {
  const { runningTime, handleStartStop, handleReset } = useTimer(initialTime)

  return (
    <>
      <h1>{runningTime}ms</h1>
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

还不错(codesandbox)。我们只是添加了一个 prop,并将其传递给了我们的useClockhook。

添加定时器通知

现在,我们希望 Timer 组件能够在时间到时通知我们。叮,叮!

我们将向钩子添加一个useState钩子useClock来跟踪我们的计时器何时用完。

此外,在useEffect钩子内部,我们需要检查时间是否到了,停止计数并设置isDone为真。

我们还在重置处理程序中将其切换回 false。

const useClock = ({ counter, initialTime = 0 }) => {
  // <snip>
  const [isDone, setIsDone] = React.useState(false)

  // <snip>

  React.useEffect(() => {
    if (runningTime <= 0) {
      stopCounting()
      setIsDone(true)
    }
  }, [runningTime])

  // <snip>

  const handleReset = () => {
    // <snip>
    setIsDone(false)
  }

  return { runningTime, handleStartStop, handleReset, isDone }
}

function Timer({ initialTime }) {
  const { runningTime, handleStartStop, handleReset, isDone } = useTimer(initialTime)

  return (
    <>
      {!isDone && <h1>{runningTime}ms</h1>}
      {isDone && <h1>Time's Up!</h1>}
      <div>
        <button onClick={handleStartStop}>Start/Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

成功了(codesandbox演示)。注意,我们不需要 touch,useTimer因为我们只是isDone在同一个对象中传递了标志。


最后,我们得到了很好的声明组件,现在可以很容易地添加样式。

我们的钩子也变得非常干净,因为我们没有添加条件逻辑,而是注入了使它们独一无二的逻辑。

将事物移到它们自己的模块中,并使用Material-UI添加一些面向样式的组件后StopwatchTimer看起来像这样。

function Stopwatch() {
  const { runningTime, ...other } = useStopwatch()

  return (
    <Clock>
      <TimeDisplay time={runningTime} />
      <Buttons {...other} />
    </Clock>
  )
}

function Timer({ initialTime }) {
  const { runningTime, isDone, ...other } = useTimer(initialTime)

  return (
    <Clock>
      {!isDone && <TimeDisplay time={runningTime} />}
      {isDone && <TimeContainer>Time's Up!</TimeContainer>}
      <Buttons {...other} />
    </Clock>
  )
}

Enter fullscreen mode Exit fullscreen mode

这是最终结果。


结论

自定义 React Hooks 既简单又有趣!而且,它还能很好地将命令式代码隐藏在可复用、可组合的函数中,同时保持组件简洁,并清晰地声明你希望应用程序呈现的样子。太棒了!

文章来源:https://dev.to/namick/why-you-should-be-writing-your-own-react-hooks-23an
PREV
我的 VSCode 配置
NEXT
在 8 天内使用 Next JS、TailwindCss 和 Firebase 构建了一个社交媒体网站