作者: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而言效果极佳!祝抽象化顺利 :)