【翻译】React Hook Factory--如何通过编程方式创建自定义钩子

0 阅读3分钟

原文链接:tylur.blog/react-hook-…

作者:tylur

React中的自定义钩子是工具箱里的一件超级利器。它们能完美封装响应式逻辑,供任意数量的组件复用,这正是React函数式能力的闪光点所在。

若你长期编写自定义钩子,可能遇到过反复定义相同类型钩子的情况。通常这并无大碍,但有时对抽象化版本的需求会变得迫切。

我反复写过的那个钩子

这或许让你觉得眼熟:

import type { Context } from "react";
import { useContext } from "react";

export const MyContext = createContext<MyPageState | null>(null);

export const useMyState = () => {
  const context = useContext(MyContext);
  if (!context) {
    throw new Error(`MyContext.Provider was not found in tree`);
  }
  return context;
};

若您不熟悉:这是一种相对常见的模式(尤其在TypeScript代码库中),即在与上下文交互前,先检查组件树中是否存在Provider。

现在你可能使用第三方状态库,这完全没问题。这种模式对于组合任何类型的重复性自定义钩子都很实用,而上下文就是一个绝佳的示例。本文将探讨如何以更少冗余的方式实现这种 useContext 模式。

介绍钩子工厂

钩子本质上是函数,因此正如我们可以嵌套和组合函数一样,我们也能组合钩子。假设我们想创建一个名为 useCounter 的钩子,并为其提供一个自定义函数来改变计数值。

import { useState } from "react";

const makeCounterHook = (changeFn: (current: number) => number) => {
  return (initialVal: number) => {
    const [count, setCount] = useState(initialVal);

    const increment = () => setCount((current) => changeFn(current));
    const decrement = () => setCount((current) => -changeFn(-current));
    const reset = () => setCount(initialVal);

    return { count, increment, decrement, reset };
  };
};

const useCounter = makeCounterHook((current) => current + 1);
const usePlusTwoCounter = makeCounterHook((current) => current + 2);

const Counter = () => {
  const { count, increment, decrement, reset } = usePlusTwoCounter();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
};

你可以分别编写这三个钩子函数,但关键在于组合模式的实现方式。

回到上下文示例

因此在实际场景中,当我们在整个代码库中重复使用安全使用上下文模式时,现在可以运用该模式将其抽象为单一的工厂函数!

import type { Context } from "react";
import { useContext } from "react";

/**
 * Helper to make a useContext hook that is generic for your specific
 * context type where it will check to be sure it is a descendant of your
 * Context.Provider and throw an error if not.
 *
 * @example
 *   export const MyPageStateContext = createContext<MyPageState | null>(
 *     null,
 *   );
 *   export const useMyPageState = makeMyUseContext({
 *     MyPageStateContext,
 *   });
 */
export const makeSafeUseContext = <T>(
  contextObj: Record<string, Context<T | null>>,
): (() => T) => {
  const entries = Object.entries(contextObj);
  if (entries.length !== 1) {
    throw new Error("Context object must have a single key value pair");
  }
  const [[name, context]] = entries;
  return (): T => {
    const currContext = useContext(context);
    if (!currContext) {
      throw new Error(`${name}.Provider was not found in tree`);
    }
    return currContext;
  };
};

我选择将这个特定的API实现为一个对象,其中键值对即为上下文定义。这样做是为了在运行时能够获取变量名称用于错误消息。你也可以采用与上下文分离的命名方式来实现:

import type { Context } from "react";
import { useContext } from "react";

export const makeSafeUseContext = <T>(
  context: Context<T | null>,
  name: string,
): (() => T) => {
  return (): T => {
    const currContext = useContext(context);
    if (!currContext) {
      throw new Error(`${name}.Provider was not found in tree`);
    }
    return currContext;
  };
};

这使得实现稍微简单一些,也更易于阅读。我只是懒得在每次使用工厂时都写makeMyUseContext(MyPageStateContext, ‘MyPageStateContext’)

能力越大,责任越大

我建议谨慎使用这种模式。当大量钩子以相同方式定义时,它极其有用。但若众多相似钩子存在细微差异,请直接内联实现。强行将钩子工厂套用在略有差异的钩子集上,如同用职责过载的组件制造陷阱——这正是抽象概念的滥用。

在合适的场景下,这种设计确实很棒。毕竟像zustand这类状态库正是通过这种方式程序化生成useStore钩子的。

寻找恰当的抽象层次可能令人头疼,但对于安全使用myContexts而言效果极佳!祝抽象化顺利 :)