文章介绍了React中的实例钩子模式(Instance Hook Pattern),该模式允许从组件外部控制组件的状态,同时保持组件逻辑的清晰和可重用性。文章以一个Dialog组件为例,详细解释了实例钩子模式的应用,包括共存逻辑、受控API、统一状态、完全可选性、可组合性等方面的特点。同时,文章还探讨了该模式的幕后故事,如Dialog组件和DialogHeader组件的实现,以及如何提高灵活性。最后,文章得出结论,实例钩子模式是React中一个简单的设计模式,允许创建具有受控行为的可重用组件。
例如,表单、对话框、弹出框等场景都可以通过实例钩子模式进行灵活控制和管理。
前言
介绍了 React 中的一种设计模式 —— 实例钩子模式(Instance Hook Pattern),它允许开发者从组件外部控制组件的状态,同时保持组件逻辑的清晰和可重用性。今日前端早读课文章由 @Sahaj 分享,@飘飘翻译。
译文从这开始~~
在构建组件时,保持逻辑清晰且可复用是很重要的。实现这一目标的一个便捷方法是使用 “实例钩子模式”。我最初是在 Ant Design 的 Form.useForm
Hook 中发现了这种模式。我其实不知道它是否有专门的名称,但 “实例钩子模式” 听起来挺高级的。不过由于表单本身就很复杂且多变的,所以让我们用更基础的东西来理解这种模式背后的概念。
【第3218期】React 19 将引入新的客户端hooks
带有附加步骤的自定义 Hook
所以这里的基本思路是:通常来说,组件自行处理自身的状态和逻辑是明智之举。但有时,我们可能希望从外部控制该状态。在 UI 组件库中,有这样的选择总是很好的。这种模式允许这种灵活性。
Instance Hook Pattern 将组件的状态和行为与自定义钩子绑定在一起。你可以将其视为组件的遥控器 —— 它让用户可以控制特定的操作。
让我们通过一个简单的 Dialog 组件来看看这种模式的实际应用。
// SomePage.tsx
import Dialog from "../components/ui/Dialog";
const SomePage = () => {
// NOTE: this custom hook will make it so that all the components on this page will
// re-render when the dialog state changes. This is not ideal, but in return we are getting the flexibility
// to control the dialog's state from anywhere on the page. Later on we'll see how to avoid this overhead.
const dialogInstance = Dialog.useDialog();
return (
<>
<Dialog dialog={dialogInstance} onClickOutside={dialogInstance.close}>
<p>This is a dialogp>
Dialog>
<button onClick={dialogInstance.open}>Open Dialogbutton>
<div>
Dialog is {dialogInstance.isOpen ? "open" : "closed"}
div>
<>
);
};
那么,这与自定义 Hook 有何不同呢?
1、共存逻辑:钩子和组件共存。这确保了 Dialog 组件仅使用 useDialog 提供的特定 API,从而使整个系统更容易维护。
2、受控 API:钩子会返回一组函数和状态,用户可以与之交互。这为用户可以对组件执行的操作设定了明确的界限,从而避免了混乱和意外的行为。
3、统一状态:组件内部也使用钩子提供的状态包(稍后我们将看到其工作原理)。因此,组件的每个部分都可以访问组件的状态及其可用行为。
4、完全可选: 在后面的章节中,我们将看到如何使 dialog 属性成为可选的,从而默认情况下让组件自行管理其状态。
可组合性:为何这种模式如此出色
这种模式的真正威力在于当您拥有一个组件的多个实例时。假设你想在同一页面上管理两个对话框:
// Example with 2 independent dialogs
const SomePage = () => {
const dialog1 = Dialog.useDialog();
const dialog2 = Dialog.useDialog();
return (
<>
<Dialog dialog={dialog1} onClickOutside={dialog1.close}>
<p>Dialog 1p>
Dialog>
<button onClick={() => {
dialog1.open();
dialog2.close();
}}>
Open Dialog 1 but Close Dialog 2
button>
<Dialog dialog={dialog2}>
<p>Dialog 2p>
Dialog>
<button onClick={dialog2.open}>Open Dialog 2button>
<div>
Dialog 1 is {dialog1.isOpen ? "open" : "closed"} and
Dialog 2 is {dialog1.isOpen === dialog2.isOpen && "also"} {dialog2.isOpen ? "open" : "closed"}
div>
<>
);
};
你不仅可以直观地管理两个对话,还可以利用它们的状态创建复杂的交互。
幕后故事
让我们来看看 Dialog 组件是什么样子的。
// Dialog.tsx
import { useDialog } from "./use-dialog";
export type DialogInstance = {
open: () => void;
close: () => void;
toggle: () => void;
isOpen: boolean;
};
const Dialog: React.FC
dialog: DialogInstance, children?: React.ReactNode, onClickOutside?: () => void
}> = ({ dialog, children, onClickOutside }) => {
// Omitting some template and logic stuff for brevity
return (
<dialog className="p-4">
<DialogHeader dialog={dialog} closable title="My Dialog" />
<div className="mt-4">
{children}
div>
dialog>
);
};
// This enables the `Dialog.useDialog()` API
export const Object.assign(Dialog, {
useDialog,
});
// use-dialog.ts
import { DialogInstance } from "./Dialog";
export const useDialog = (): DialogInstance => {
const [isOpen, setIsOpen] = useState(false);
return {
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((op) => !op),
isOpen,
};
};
为了完整性,这里还有 DialogHeader 组件。
// DialogHeader.tsx
import { DialogInstance } from "./Dialog";
import CrossIcon from "../../assets/cross.svg";
const DialogHeader: React.FC dialog: DialogInstance; title: string; closable?: boolean }> = ({
dialog,
title,
closable,
}) => {
return (
<div className="flex items-center justify-space-between p-4">
<h1>{title}h1>
{closable && (
<button onClick={dialog.close}>
<CrossIcon />
button>
)}
div>
);
};
export default DialogHeader;
提高灵活性
有时, Dialog 组件的使用者可能并不需要控制对话的状态。在这种情况下,我们可以让传递 DialogInstance 成为可选的,从而实现更灵活的使用。
要实现这一点,我们可以修改 useDialog 钩子,使其能够自行创建自己的 DialogInstance ,或者在提供的情况下复用已有的 DialogInstance 。
// use-dialog.ts
import { useMemo, useState, useCallback } from "react";
import { DialogInstance } from "./Dialog";
export const useDialog = (dialog?: DialogInstance): DialogInstance => {
// Since we can't render hooks conditionally, this `useState` will always be
// there even when an existing `DialogInstance` is passed in.
const [isOpen, setIsOpen] = useState(false);
// `useCallback` is used to memoize the functions so that they are created only once
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((op) => !op), []);
return useMemo(() => {
return (
dialog ?? {
open,
close,
toggle,
isOpen,
}
);
}, [dialog, isOpen]);
};
此修改使得 Dialog 组件即使未传入 DialogInstance 也能正常运行,在这种情况下,它将默认管理自身的状态。
// Dialog.tsx
import { useDialog } from "./use-dialog";
// ...
const Dialog: React.FC
dialog?: DialogInstance;
children?: React.ReactNode;
onClickOutside?: () => void;
}> = ({ dialog, children, onClickOutside }) => {
const dialogInstance = useDialog(dialog); //
return (
<dialog className="p-4">
<DialogHeader dialog={dialogInstance} closable title="My Dialog" />
<div className="mt-4">{children}div>
dialog>
);
};
// ...
结论
实例钩子模式是 React 中一个简单的设计模式,它允许创建具有受控行为的可重用组件。我喜欢将其视为一种状态包,可以传递到任何地方来控制与之关联的组件。实际上,它与复合组件模式和渲染 props 模式结合得很好,但这又是另一个话题 :)
【第3440期】探索 React 19:性能、开发体验与创新特性的全面提升
关于本文
译者:@飘飘
作者:@Sahaj
译文:https://iamsahaj.xyz/blog/react-instance-hook-pattern/
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。