Skip to content
This page has been auto-translated and may contain errors.View in English

实战中的自定义 Hook

Hook 这章介绍了基本概念:自定义 Hook 是以 use 开头的函数,将多个 Hook 组合成可复用的逻辑。这章讲的是实战中这样做的样子,课程用一个很有说服力的例子说明:在花了整个章节构建 无头 Toggle 组件 之后,一个自定义 Hook 用少得多的代码替代了它,然后这个组件就被删除了。

关键的区分在于普通的 工具函数。一个获取数据或格式化日期的函数是工具函数。而自定义 Hook 会调用其他 Hook,这就是为什么 Hook 的规则 在它内部也适用。

提取 effect:useEffectOnUpdate

Toggle 组件最后有一块逻辑,它的作用是"当状态改变时运行这个回调,但首次渲染时不运行":一个用 firstRender ref 保护的 useEffect。这不是 Toggle 逻辑,而是 effect 逻辑,在任何地方都有用。把它提取出来会得到一个签名和 useEffect 本身一样的 Hook:

jsx
export default function useEffectOnUpdate(effectFunction, deps) {
  const firstRender = useRef(true)

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false
    } else {
      effectFunction()
    }
  }, deps)
}

调用者写 useEffectOnUpdate(onToggle, [on]),首次渲染的记账逻辑就从他们的代码里消失了。这种包装其他 Hook 的 Hook 有时值得编写,即使被包装的逻辑只有几行,因为这个名字表达了意图。

这个守卫有一个仅在开发环境的细节

render props 标记的一样:StrictMode 会在它的第一次 effect 运行时耗尽 ref,所以回调会在第二次才触发。生产构建不会这样做,更持久的修复方法是对比值和之前的值,而不是跟踪这是否是首次渲染。

提取状态:useToggle

useEffectOnUpdate 什么都不返回,就像 useEffect。大多数自定义 Hook 会返回东西,就像 useState 一样,而返回的东西的形状是一个设计决策。useToggle 把布尔值、翻转函数和变化回调聚在一起:

jsx
export default function useToggle({ initialValue = false, onToggle = () => {} } = {}) {
  const [on, setOn] = useState(initialValue)
  const toggle = () => setOn(prevOn => !prevOn)

  useEffectOnUpdate(onToggle, [on])

  return [on, toggle]
}
jsx
const [on, toggle] = useToggle({ initialValue: true })

每次调用 useToggle 都会得到它自己的 useState,所以两个调用者各自拥有两个独立的布尔值,不管是两个组件还是一个组件调用了这个 Hook 两次。

这里有三个决策,每一个都是你会反复用到的模式:

  • 返回一个数组 当这些值是一个自然配对、调用者会给它们重新命名的时候,就像 useState 一样:一个调用者解构 [on, toggle],另一个解构 [open, toggleOpen]。当有多个值且名字比顺序更重要时,返回一个对象。
  • 接收一个配置对象 而不是位置参数。用 useToggle(false, callback) 的话,一个只想要回调的调用者也被迫先提供布尔值;用 { initialValue, onToggle } 的话他们传自己需要的东西,跳过其余的,每个选项都可以有默认值。
  • 回调默认为 noop() => {}),这样 Hook 可以无条件地调用 onToggle(),不会让那些从没传过回调的调用者崩溃。

收益:删除组件

有了 useToggle,星星小部件完全不需要任何 Toggle 机制了:

jsx
export default function Star({ onChange }) {
  const [on, toggle] = useToggle({ onToggle: onChange })

  return on
    ? <BsStarFill className="star filled" onClick={toggle} />
    : <BsStar className="star" onClick={toggle} />
}

菜单保持它的 复合组件 结构,因为它的各部分还是需要共享的隐式状态,但它的提供者现在从 Hook 获取值:Menu 调用 useToggle,通过它自己的 context 传递 { open, toggleOpen },然后无头 Toggle 组件的文件就被删除了。

这个删除就是这个章节的真正课程。无头组件在很多复合组件可能会共享它时是值得的;一旦逻辑能装进一个 Hook,Hook 就是更简单的工具,删除你为之骄傲的可工作代码是这份工作的常规部分。

这两种模式是互补的:Hook 共享逻辑,无头组件通过 context 和组合在树中共享逻辑。优先考虑 Hook,让组件赚取额外的仪式感。课程用一个独立项目 Component Library++ 来结束这个章节,它把这五章的每个模式都放在一个库里了。

值得早早养成的一个习惯:写自定义 Hook 前,先检查社区是否已经有了。防抖值、媒体查询、本地存储状态、事件监听、上一个值跟踪,这些都在小型库里以经过验证的 Hook 形式存在,读它们的源码是快速吸收习惯用法的方式。当逻辑特定于你的应用时,自己写还是赢了,而像 useCurrentUseruseCartTotal 这样应用特定的 Hook 往往是应用数据逻辑能存在的最干净的地方:组件保持声明式,混乱的部分留在一个有名字、可测试的函数里。

自定义 Hook 共享逻辑,而不共享状态。当两个调用者必须看到同一个值时,Hook 单独做不了:状态必须存在一个地方,在提供者里,Hook 变成读取器,context 章节 里的 useTheme 模式。在脑子里保持"可复用逻辑"和"共享状态"的区分可以防止期望 Hook 像 store 那样工作的经典错误。

两个细节决定了这样的 Hook 是否适合发布。第一个是身份:useToggle 在每次渲染时构建一个新的 toggle 函数,所以一个调用者若把它传给一个 memoized 子组件或放进另一个 Hook 的依赖数组,就每次都交出一个新值。当调用者依赖稳定引用时,在 Hook 内用 useCallback 包住它。

第二个是工具。通过包装 Hook 转发一个 deps 数组在运行时只要数组长度是常数就能工作,但 exhaustive-deps 无法验证它没有看到写出来的数组,除非项目把 Hook 列在规则的 additionalHooks 选项里,否则它完全忽略调用站点的 useEffectOnUpdate(onToggle, [on])。一个依赖包装 Hook 的代码库应该添加那个设置,这样 effects 章节 告诉你要当真的警告就能继续到达你。

Juno把逻辑瓶装起来,在任何地方复用 自定义 Hook 是一个以 use 开头的函数,它基于 useState 这样的 Hook 来打包一段逻辑。一旦 useToggle 存在了,任何组件都可以在一行里获得一个开关值和一个翻转函数,就像 useState 给你一个值和一个 setter 一样。

每个调用它的组件都会得到它自己的状态副本,所以 Hook 是你复用的配方而不是大家共享的值。

Juno把逻辑瓶装起来,在任何地方复用 把重复的有状态逻辑提取进 Hook:useEffectOnUpdate 用于跳过首次渲染的 effect,useToggle 用于带有 onToggle 回调的布尔状态。

返回一个数组当调用者会给自然配对重新命名时,接收带默认值的配置对象而不是位置参数,回调默认为 noop。

在逻辑必须通过树传递之前,优先选择 Hook 而不是无头组件。

Juno把逻辑瓶装起来,在任何地方复用 自定义 Hook 组合 Hook,传递地继承它们的规则,共享逻辑同时每个调用者保留独立状态;当状态必须被共享而不是复制时,配对一个提供者。

两个细节决定了一个是否适合发布:它返回的函数在每次渲染时重建,所以当调用者把它们传给 memoized 子组件或放进依赖数组时用 useCallback 包住它,而转发 deps 数组的包装 Hook 对 exhaustive-deps 保持隐形直到项目在规则的 additionalHooks 选项里命名它。

接下来:路由,URL 开始决定哪些组件渲染。