React组件库实战 - 第六章. 绝了!自定义 Hooks 让组件库逻辑复用开挂,useEvent 还能破性能陷阱

第六章. 逻辑的抽象:为我们的组件库注入可复用的“行为”

本章目标: 本章假定您已熟练掌握 useStateuseEffect 的基础用法。我们将不再赘述入门概念,而是直击痛点,聚焦于 三种在构建高阶设计系统中至关重要的高级 Hooks 模式。我们将学习如何使用 useReducer 管理复杂状态机,如何规避 useEffect 的性能陷阱,并最终通过一个综合性的 useAutoComplete 业务 Hook,将所有模式融会贯通,真正掌握以 Hooks 为中心的现代 React 架构思想。

6.1. 为什么要用自定义 Hooks?

在深入学习高级模式之前,我们必须先统一思想,回答一个根本性问题:为什么我们需要自定义 Hooks 这样一层额外的抽象?答案,就在于我们对“高质量组件”的追求。

6.1.1. 痛点:臃肿的 UI 组件

一个高质量的组件,应该遵循 单一职责原则。然而,随着交互变得复杂,我们的组件常常会变得“臃肿”——即,将 渲染逻辑 (View)行为逻辑 (Behavior) 混杂在了一起。

让我们通过一个思想实验来揭示这个痛点。

场景: 回顾我们在第三章构建的 DropdownMenu。我们当时直接使用了 Radix UI,它已经为我们完美处理了“点击菜单外部区域自动关闭”的交互。现在,让我们设想一下,如果 没有 Radix,需要我们 手动 实现这个功能,代码会变成什么样?

我们来看一个示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
'use client';

import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/Button';

export function HypotheticalDropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

// ==========================================
// == 行为逻辑 (Behavior) ==
// ==========================================
useEffect(() => {
// 如果菜单没打开,则不执行任何操作
if (!isOpen) return;

function handleClickOutside(event: MouseEvent) {
// 检查点击事件是否发生在 dropdownRef 元素之外
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
console.log('检测到外部点击,正在关闭菜单...');
setIsOpen(false);
}
}

// 在 document 上添加全局事件监听
document.addEventListener('mousedown', handleClickOutside);

// 关键:返回一个清理函数,在 effect 结束时移除事件监听
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]); // 这个 effect 依赖于 isOpen 状态

// ==========================================
// == 渲染逻辑 (View) ==
// ==========================================
return (
<div ref={dropdownRef} className="relative inline-block">
<Button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Close' : 'Open'} Menu
</Button>

{isOpen && (
<ul className="absolute top-full mt-2 p-2 shadow menu dropdown-content z-[1] bg-base-100 rounded-box w-52">
<li><a>Item 1</a></li>
<li><a>Item 2</a></li>
</ul>
)}
</div>
);
}

这段代码能够正常工作。但是,从架构设计的角度看,它存在三个严重的问题:

  1. 职责混淆: HypotheticalDropdown 组件现在同时承担了两个完全不同的职责。它既要负责 如何渲染 一个下拉菜单的 UI(JSX 部分),又要负责 如何管理“点击外部关闭”这个交互行为(useEffectuseRef 部分)。这使得组件的意图变得模糊,复杂度显著增加。

  2. 逻辑难以复用: “点击外部关闭”是一个极其通用的交互模式,在 Popover, Select, Dialog 等无数组件中都会用到。在我们当前的设计中,这段包含 useEffectuseRef 的逻辑被“囚禁”在了 HypotheticalDropdown 组件内部。如果想在 Popover 组件中复用它,唯一的办法就是 复制粘贴。这严重违反了 DRY (Don’t Repeat Yourself) 原则,是滋生 Bug 和增加维护成本的温床。

  3. 可测试性差: 我们如何为“点击外部关闭”这个 纯粹的逻辑 编写一个单元测试?在当前结构下,我们几乎无法做到。我们必须完整地渲染整个 HypotheticalDropdown 组件,模拟 DOM 事件,然后断言 isOpen 状态的变化。我们将无法对这个行为逻辑进行独立的、轻量的、快速的单元测试。

结论:
这个“臃肿的”组件暴露了一个核心的架构问题:当一段行为逻辑具有通用性时,将它与某个特定的 UI 渲染实现绑定在一起,是一种糟糕的设计。

我们需要一种机制,能将这段行为逻辑——“当在指定元素外部发生点击时,执行一个回调函数”——从 UI 组件中 抽离 出来,成为一个独立的、可复用的单元。而这个机制,正是 React Hooks 带来的最大变革之一:自定义 Hooks


6.2. 实战演练:为我们的组件库添砖加瓦

现在,我们将正式进入自定义 Hooks 的实战环节。本节的目标,是亲手编写一系列在现代组件库中不可或缺的、具有代表性的通用 Hooks。我们将从最简单,但也最常用的 useToggle 开始。

6.2.1. 构建 useToggle: 简化开关状态管理

核心理念与应用场景

在 UI 开发中,我们随处可见需要管理的布尔(boolean)开关状态:Modal 的打开/关闭,Accordion 的展开/折叠,SwitchCheckbox 的选中/未选中。

useToggle Hook 的目标,就是将“维护一个布尔值,并提供一个能切换它的函数”这一通用逻辑,封装成一个可复用的单元。

第一步:创建 Hook 文件

我们将在 src/hooks 目录下创建我们的新 Hook 文件。

1
2
# 在项目根目录下执行
touch src/hooks/use-toggle.ts

第二步:定义并实现 Hook

文件路径: src/hooks/use-toggle.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState, useCallback } from 'react';

// 1. 为 Hook 的返回值定义一个清晰的、符合直觉的元组类型
// [当前状态, 切换状态的函数]
type UseToggleReturn = [boolean, () => void];

/**
* 一个用于管理布尔开关状态的 Hook。
* @param defaultValue - 状态的初始值,默认为 false
* @returns 返回一个元组,包含当前的状态值和用于切换状态的函数。
*/
export function useToggle(defaultValue: boolean = false): UseToggleReturn {
// 2. 内部使用 useState 来持有状态
const [value, setValue] = useState(defaultValue);

// 3. (关键) 使用 useCallback 来记忆化 toggle 函数
const toggle = useCallback(() => {
// 使用函数式更新,确保状态更新的可靠性
setValue(prevValue => !prevValue);
}, []); // 空依赖数组,确保 toggle 函数在组件的整个生命周期内引用地址保持不变

// 4. 返回状态值和稳定的切换函数
return [value, toggle];
}

代码深度解析:

  1. 返回元组 [boolean, () => void]: 我们有意地将返回值设计成一个元组,这完全模仿了 React 原生 useState Hook 的 API。这种设计符合开发者的直觉,可以通过数组解构方便地使用:const [isOn, toggleIsOn] = useToggle();
  2. useCallback 的应用: 这是本节的一个 核心知识点。我们没有直接返回一个 () => setValue(v => !v) 的匿名函数。而是使用 useCallback 将其包裹。
    • 原因: 如果不使用 useCallback,那么每次消费 useToggle 的组件重渲染时,useToggle 都会被重新调用,从而创建一个 全新的 toggle 函数实例
    • 痛点: 如果这个 toggle 函数被作为 prop 传递给一个被 React.memo 包裹的子组件,那么即使子组件的其他 props 都没有变化,这个不稳定的 toggle 函数引用也会导致 React.memo 的优化 完全失效,引发不必要的重渲染。
    • 结论: 遵循我们在之前章节讨论的“性能契约”,一个设计良好的自定义 Hook,其返回的函数必须是引用稳定的。useCallback 在这里是必不可少的。

第三步:演示用法与未来展望

为了直观地看到 useToggle 的效果,我们可以快速创建一个测试页面。

文件路径: src/app/hooks-test/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use client';

import { useToggle } from '@/hooks/use-toggle';
import { Button } from '@/components/ui/Button';

export default function HooksTestPage() {
// 使用 useToggle,API 和 useState 非常相似
const [isOn, toggle] = useToggle(false);

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-base-100 p-24">
<p className="text-4xl font-bold">
The light is: {isOn ? 'ON' : 'OFF'}
</p>
<div className={`h-24 w-24 rounded-full transition-colors ${isOn ? 'bg-yellow-300' : 'bg-gray-700'}`} />
<Button onClick={toggle} variant="primary">
Toggle Light
</Button>
</main>
);
}

解析:
HooksTestPage 组件中,我们看不到任何 useState(prev => !prev) 的实现细节。所有逻辑都被优雅地封装在了 useToggle 内部。组件的职责变得极其纯粹:消费状态 isOn,并通过 toggle 函数触发状态变更。

未来展望:
我们构建的这个看似简单的 useToggle Hook,现在成为了我们 Prorise UI 工具库中的第一块逻辑积木。在未来,当我们构建 ModalSwitchAccordion 等任何包含“开关”语义的组件时,它们的内部状态管理都将由 useToggle 来驱动,从而保证了逻辑的一致性和代码的简洁性。


6.2.2. 构建 useDebounce: 赋能 AutoComplete 组件

现在,我们来构建一个在处理用户输入、优化性能方面至关重要的自定义 Hook——useDebounce (防抖)。

核心理念与应用场景

痛点: 设想一个 AutoComplete 或搜索框组件。如果我们在用户每次按键 (onChange) 时都立即触发一次 API 请求,当用户快速输入 “react” 这 5 个字母时,就会在极短时间内连续触发 5 次 API 调用(r, re, rea, reac, react)。这会造成:

  • 服务端压力: 产生大量不必要的服务器负载。
  • 网络资源浪费: 前 4 次请求几乎都是无效的。
  • 竞态条件 (Race Condition): search('re') 的响应可能比 search('react') 的响应更晚到达,导致界面最终显示了过时的、错误的结果。

解决方案: “防抖 (Debounce)”是一种经典的性能优化策略。其核心思想是:延迟一个函数的执行,直到某个事件(例如按键)停止触发一段时间后。如果在延迟期间事件再次被触发,则重新开始计时。

useDebounce Hook 的目标,就是将这个复杂的计时器逻辑封装起来。

第一步:创建 Hook 文件

1
2
# 在项目根目录下执行
touch src/hooks/use-debounce.ts

第二步:定义并实现 Hook

这个 Hook 将接收两个参数:需要被防抖的 value,以及一个可选的 delay (延迟时间)。它将返回经过防抖处理后的、稳定的 value

文件路径: src/hooks/use-debounce.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState, useEffect } from 'react';

/**
* 一个用于获取值的防抖版本的 Hook。
* @param value - 需要进行防抖处理的值 (例如,搜索框的输入字符串)。
* @param delay - 延迟时间,单位为毫秒,默认为 500ms。
* @returns 返回经过延迟后、稳定的值。
*/
export function useDebounce<T>(value: T, delay: number = 500): T {
// 1. 创建一个内部 state,用于存储最终的防抖值。
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(
() => {
// 2. 每次 `value` 或 `delay` 变化时,创建一个新的定时器。
const timer = setTimeout(() => {
// 在 `delay` 时间结束后,用最新的 `value` 更新我们的 state。
setDebouncedValue(value);
}, delay);

// 3. (关键) 返回一个清理函数。
// 这个函数会在下一次 effect 重新运行时(即 value 或 delay 再次变化时),
// 或者在组件卸载时被调用。
return () => {
clearTimeout(timer); // 清除上一个未完成的定时器。
};
},
[value, delay] // 4. 依赖项:只有当 value 或 delay 变化时,才重新执行 effect。
);

// 5. 始终返回当前存储的(可能是旧的)防抖值。
return debouncedValue;
}

代码深度解析 (工作流):

  1. 用户在输入框中按下第一个键,value 变为 'r'useDebounce Hook 重新执行,useEffect 启动一个 500ms 的定时器,准备在 500ms 后将 debouncedValue 更新为 'r'
  2. 在 100ms 后,用户按下第二个键,value 变为 're'。Hook 再次执行。
  3. 在新的 useEffect 运行 之前,上一个 useEffect清理函数 return () => { clearTimeout(timer); } 被调用。这会取消掉准备更新值为 'r' 的那个定时器。
  4. 新的 useEffect 启动了一个全新的 500ms 定时器,准备在 500ms 后将 debouncedValue 更新为 're'
  5. 这个“清除旧定时器 -> 创建新定时器”的过程,会在用户每次快速按键时重复。
  6. 当用户最终停止输入后,最后一个定时器(例如,value'react' 的那个)将不会被清除,它会在 500ms 后成功触发 setDebouncedValue('react')
  7. 此时,debouncedValue 状态更新,Hook 返回最新的 'react',消费这个 Hook 的组件随之重渲染。

第三步:演示用法

让我们在 hooks-test 页面中,通过一个搜索框的例子来直观地感受 useDebounce 的威力。

文件路径: src/app/hooks-test/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
'use client';

import * as React from 'react';
import { useToggle } from '@/hooks/use-toggle';
import { useDebounce } from '@/hooks/use-debounce'; // 1. 导入 useDebounce
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';

function DebounceExample() {
const [inputValue, setInputValue] = React.useState('');
// 2. 使用 useDebounce Hook,监听 inputValue 的变化,延迟 500ms
const debouncedSearchTerm = useDebounce(inputValue, 500);

// 3. 模拟一个 API 请求的 effect
React.useEffect(() => {
// 只有在 debouncedSearchTerm 有值且发生变化时,才“发送请求”
if (debouncedSearchTerm) {
console.log(`🚀 (API Request) Searching for: "${debouncedSearchTerm}"`);
// 在真实应用中,这里会是 fetch(...) 或其他数据请求逻辑
}
}, [debouncedSearchTerm]); // 关键:effect 依赖的是防抖后的值,而不是 inputValue

return (
<div className="w-full max-w-sm space-y-2">
<h3 className="font-semibold">useDebounce 示例</h3>
<Label htmlFor="search-input">搜索</Label>
<Input
id="search-input"
placeholder="快速输入,然后停顿..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<p className="text-sm">
实时输入值: <span className="font-mono text-primary">{inputValue}</span>
</p>
<p className="text-sm">
防抖后的值 (延迟 500ms): <span className="font-mono text-success">{debouncedSearchTerm}</span>
</p>
</div>
);
}

// 修改主页面组件以包含新示例
export default function HooksTestPage() {
const [isOn, toggle] = useToggle(false);

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-12 bg-base-100 p-24">
{/* ... useToggle 示例 ... */}
<div className="w-full max-w-sm">
<div className="divider" />
</div>
<DebounceExample />
</main>
);
}

运行与验证:
运行 pnpm run dev 并访问 /hooks-test 页面,然后打开浏览器控制台。

  • 当您在搜索框中 快速连续输入 “react” 时,您会看到“实时输入值”在实时变化。
  • 但是,控制台中不会有任何输出
  • 当您 停止输入 500ms 后,您会看到“防抖后的值”更新为 “react”,同时控制台 只打印出一次 API 请求日志:
    🚀 (API Request) Searching for: "react"

这完美地证明了我们的 useDebounce Hook 正在正常工作。它有效地过滤掉了中间过程的无效输入,只在我们真正需要的时候才提供最终的、稳定的值。这个 Hook 将是我们在下一章构建 AutoComplete 组件时不可或缺的核心武器。


6.2.1. (高级) 构建 useControllableState: 统一受控与非受控模式

核心理念与痛点

一个设计精良的、可复用的 UI 组件(如 Switch, Select, Input)通常需要同时支持两种状态管理模式:

  1. 非受控 (Uncontrolled): 组件拥有自己的内部状态,自我管理。使用者只需设置一个 defaultValue,之后便“放任不管”。这种模式简单快捷。
  2. 受控 (Controlled): 组件不维护自身状态。它的值完全由父组件通过 value prop 传入,并通过 onChange 回调将变更通知父组件。这种模式让父组件能够精确控制子组件的行为,适用于复杂的表单和状态联动场景。

痛点: 要在 同一个组件 中优雅地实现这两种模式,是一件非常棘手的事情。一个“天真”的实现,通常会在组件内部充斥着大量混乱的 if/else 判断和 useEffect 来同步状态,代码难以维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个设计不良的、试图同时支持两种模式的 Switch 组件 (错误示范)
function BadSwitch({ value: propValue, defaultValue, onChange }) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = propValue !== undefined;
const value = isControlled ? propValue : internalValue;
// ... 此处还需要更多复杂的 useEffect 来同步状态 ...

const handleClick = () => {
if (isControlled) {
onChange(!propValue);
} else {
setInternalValue(!internalValue);
}
};
// ...
}

解决方案: 我们将这个复杂的模式判断逻辑,完全抽离并封装到一个独立的、可复用的自定义 Hook——useControllableState 中。

第一步:创建 Hook 文件

我们将所有通用的自定义 Hooks 都存放在 src/hooks 目录中。

1
2
# 在项目根目录下执行
touch src/hooks/use-controllable-state.ts

第二步:定义 Hook 的“契约” (函数签名) 与实现

useControllableState 的 API 设计目标是,无论内部逻辑多复杂,它向外暴露的接口都应该和 useState 一样简洁直观:返回一个 [state, setState] 的元组。

文件路径: src/hooks/use-controllable-state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useState, useCallback, useRef, useEffect } from 'react';
// 1. 定义 Hook 接收的参数类型

export interface UseControllableStateProps<T> {
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
}

/**
* 一个用于管理受控与非受控状态的 Hook。
* @returns 返回一个类似 useState 的元组 [state, setState]。
*/
export function useControllableState<T>({
value: propValue,
defaultValue,
onChange = () => {},
}: UseControllableStateProps<T>) {
// 2. 判断组件是否受控,并用 useRef 确保该判断在组件生命周期内不变
const { current: isControlled } = useRef(propValue !== undefined);

// 3. 仅在非受控模式下,才使用 useState 来创建和管理内部状态
const [internalState, setInternalState] = useState(defaultValue);

// 4. 决定最终向外暴露的状态值
const state = isControlled ? propValue : internalState;

// 5. 使用 useCallback 封装统一的状态更新函数,以保证引用稳定
const setState = useCallback(
(nextState: T) => {
if (isControlled) {
// 如果是受控模式,调用父级传入的 onChange
onChange(nextState);
} else {
// 如果是非受控模式,更新自己的内部 state
setInternalState(nextState);
}
},
[isControlled, onChange]
);

// 将状态断言为常量,不允许修改
return [state, setState] as const;
}

代码深度解析:

  1. isControlled 的判断: propValue !== undefined 是判断组件是否受控的依据。我们用 useRef 将这个布尔值缓存起来,以防止组件在受控和非受控模式之间意外切换,这是一种更健壮的设计。
  2. state 的确定: const state = isControlled ? propValue : internalState; 这一行是“读”操作的核心。Hook 返回的 state,其数据源是动态的:受控时来自 props,非受控时来自内部 state
  3. setState 的统一: 这是“写”操作的核心。我们创建了一个统一的 setState 函数。在内部,它会检查 isControlled 标志,然后智能地决定是将状态变更的“指令”向上传递给父组件(调用 onChange),还是在组件内部消化(调用 setInternalState)。
  4. useCallback: 我们将 setState 函数用 useCallback 包裹,并传入 [isControlled, onChange] 作为依赖。这履行了自定义 Hook 的“性能契约”,确保了返回的 setState 函数具有稳定的引用,不会在不必要时破坏消费它的子组件的 React.memo 优化。

第三步:正确的实战应用 —— 封装 Radix UI Switch

现在我们已经理解了如何从零构建一个 useControllableState Hook,是时候揭示一个更重要的工程实践了:当一个专业的、久经考验的 Headless UI 库(如 Radix)已经为某个组件提供了完美的受控/非受控实现时,我们应该直接封装它,而不是使用我们自己手写的 Hook。

我们构建 useControllableState 的目的,是为了 理解其内部的架构思想,从而能够读懂并更好地利用像 Radix 这样的库。现在,我们就来实践这一点。

1. 安装 Radix UI Switch 依赖

首先,为我们的项目添加 @radix-ui/react-switch 依赖。

1
pnpm install @radix-ui/react-switch

2. 创建并实现 Switch 组件

我们创建 Switch.tsx 文件,并在其中封装 Radix 的原语,为其注入 daisyUI 和 Tailwind 的样式。

文件路径: src/components/ui/Switch.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
'use client';

import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch'; // 1. 导入 Radix Switch 原语

import { cn } from '@/lib/utils';

// 2. 封装 Radix Switch.Root 和 Switch.Thumb
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
// 基础样式设置宽度高度圆角边框等
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent',
// 过渡动画
'transition-colors',
// 焦点样式
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100',
// 禁用样式
'disabled:cursor-not-allowed disabled:opacity-50',
// 使用 data-state 伪类来控制选中时的颜色
'data-[state=checked]:bg-primary',
'data-[state=unchecked]:bg-base-300',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
// Thumb 的样式主要控制白色滑块的外观和位置
'pointer-events-none block h-5 w-5 rounded-full bg-base-100 shadow-lg ring-0',
// 过渡动画
'transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;

export { Switch };

代码深度解析:

  • 封装 Radix: 我们的 Switch 组件现在是一个对 SwitchPrimitives.Root 的样式化封装。它内部包含了 SwitchPrimitives.Thumb(那个可以滑动的“拇指”)。
  • data-state 驱动样式: Radix 组件的一个核心特性是,它会根据内部状态,在 DOM 元素上添加 data-state 属性。例如,当 Switch 被选中时,Root 元素会得到 data-state="checked"。我们正是利用这一点,通过 Tailwind 的 data-* 变体(data-[state=checked]:...)来精确地控制选中和未选中时的样式。
  • daisyUI 的协同: 我们巧妙地将 daisyUItoggle 类作为基础样式应用在 Root 上,以获取其基础尺寸和形状,然后再通过 data-state 变体来覆盖颜色,实现了两者的完美融合。

现在,请观察 Radix SwitchProps 类型。它原生就支持 checked, defaultChecked, onCheckedChange 这三个属性。

这揭示了一个核心要点
Radix UI Switch内部,已经为我们实现了一套与我们手写的 useControllableState逻辑上完全等价 的功能!

我们之所以要花时间去构建 useControllableState,其 真正的教学目的 不是为了让我们在每个组件中都去使用它,而是为了“揭秘”:通过亲手实现这个高级模式,我们现在能够深刻地理解像 Radix 这样的专业库是如何设计它们的 API 的,以及它们在幕后为我们处理了多么复杂的逻辑。

结论:

  • 当有专业的 Radix 原语可用时(如 Switch, Checkbox, Select),永远优先封装 Radix 原语,因为它不仅包含了受控/非受控逻辑,还提供了完整的可访问性和键盘交互。
  • 当我们构建一个 没有现成 Radix 原语可用 的、独特的、需要支持受控/非受控模式的自定义组件时(例如一个自定义的评分组件 StarRating),我们自己构建的 useControllableState Hook 就将派上大用场。

通过这一节,我们不仅学会了如何正确地构建一个 Switch 组件,更重要的是,我们建立了一种架构决策能力:知道何时应该“集成”,何时需要“创造”


6.3. 副作用管理进阶:useEvent 模式与性能陷阱

在本章中,我们致力于将“行为”从“视图”中分离。然而,一个设计优秀的自定义 Hook,不仅要封装逻辑,更要 保证自身的性能,确保它不会成为消费它的组件的性能瓶颈。

本节,我们将深入探讨 React 中一个经典且棘手的性能问题,并学习一种前沿的 Hooks 模式来完美地解决它。

6.3.1. 痛点重现:当 React.memo 遇上事件回调

让我们通过一个最简单的计数器案例,来重温这个无处不在的性能陷阱。

场景: 我们有一个 Counter 父组件,它管理着 count 状态。它渲染一个 Display 组件来显示计数值,以及一个 ResetButton 组件来重置计数。为了优化性能,ResetButton 是一个被 React.memo 包裹的“昂贵”组件。

第一步:创建问题场景

文件路径: src/components/dev/PerformancePitfall.tsx (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
'use client';
import { useState, memo } from 'react';
import { Button } from '@/components/ui/Button';

// 1. 这是一个被 memo 包裹的、昂贵的子组件
const ResetButton = memo(({ onReset }: { onReset: () => void }) => {
console.log('... 昂贵的重置按钮(ResetButton)组件正在被渲染 ...');
return <Button onClick={onReset} variant="outline">Reset</Button>;
});
ResetButton.displayName = 'ResetButton';


export function Counter() {
const [count, setCount] = useState(0);
console.log('父组件(Counter)正在被渲染');

// 2. 一个在父组件中的事件处理函数
const handleReset = () => {
setCount(0);
};

return (
<div className="w-full max-w-sm space-y-4 rounded-lg bg-base-200 p-6">
<p className="text-center text-4xl font-bold">{count}</p>
<div className="flex justify-center gap-4">
<Button onClick={() => setCount(c => c + 1)}>Increment</Button>
{/* 3. 将函数作为 prop 传递给 memoized 子组件 */}
<ResetButton onReset={handleReset} />
</div>
</div>
);
}

第二步:暴露问题

在您的 hooks-test 页面中使用这个 Counter 组件,并打开控制台。

  • 首次渲染时,CounterResetButton 都会被渲染,正常。
  • 现在,点击 “Increment” 按钮count 状态更新,Counter 父组件重渲染,这符合预期。
  • 问题来了: 您会发现,控制台 每一次 都会打印出:
    ... 昂贵的重置按钮(ResetButton)组件正在被渲染 ...

ResetButtonReact.memo 包裹,并且它不依赖 count,它的 onReset prop 的“功能”也从未改变。那为什么 React.memo 失效了?

原因: React.memo 进行的是 浅比较。在 Counter 组件每次因 count 变化而重渲染时,const handleReset = () => { ... } 这一行代码都会创建一个 全新的函数对象。虽然新旧函数的功能完全 40666 一样,但它们在内存中的 引用地址 是不同的。因此,React.memo 认为 onReset 这个 prop 发生了变化,从而导致了不必要的重渲染。

6.3.2. 标准解决方案 useCallback (及其局限性)

解决上述问题的“标准答案”,是使用 useCallback 来稳定函数的引用。

文件路径: src/components/dev/PerformancePitfall.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ... imports ...
import { useState, memo, useCallback } from 'react'; // 引入 useCallback

// ... ResetButton 组件不变 ...

export function Counter() {
const [count, setCount] = useState(0);
console.log('父组件(Counter)正在被渲染');

// 使用 useCallback 包裹 handleReset
// 空依赖数组 [] 意味着这个函数只在组件首次渲染时创建一次
const handleReset = useCallback(() => {
setCount(0);
}, []);

// ... return ...
}

再次测试,问题解决了!点击 “Increment” 时,ResetButton 不再重渲染。

但是,新的痛点随之而来。

新需求: 我们希望 handleReset 在重置前,能 console.log 出当前的 count 值。

1
2
3
4
5
const handleReset = useCallback(() => {
// 🔴 陷阱:这里的 count 是第一次渲染时的 count,即 0
console.log(`准备从 ${count} 重置...`);
setCount(0);
}, []); // ESLint 会警告你需要将 count 添加到依赖数组

问题: 由于依赖数组是空的,useCallback 内部的函数形成了一个“陈旧的闭包”,它捕获的是组件首次渲染时的 count 值(永远是 0)。

“修复”这个问题: 我们遵循 ESLint 的建议,将 count 加入依赖数组。

1
2
3
4
const handleReset = useCallback(() => {
console.log(`准备从 ${count} 重置...`);
setCount(0);
}, [count]); // ✅ 逻辑正确了,总能读到最新的 count

然而,我们回到了原点! 现在,count 每一次变化,useCallback 都会因为依赖变化而返回一个 新的 handleReset 函数实例React.memo 再一次失效了。

我们陷入了一个两难的困境:要么接受陈旧的闭包,要么接受不稳定的引用。

6.3.3. 高级解决方案:构建并使用 useEvent Hook

要打破这个困境,我们需要一个“鱼与熊掌兼得”的工具:一个 引用永久稳定,但内部逻辑 总能访问到最新 state/props 的函数。

这正是 React 官方在未来版本中提出的 useEffectEvent Hook 所要解决的问题。现在,我们来亲手实现一个功能等价的 Polyfill——useEvent

文件路径: src/hooks/use-event.ts (此文件在 6.1.2 节已创建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useLayoutEffect, useRef, useCallback } from 'react';


export function useEvent<T extends (...args: any[]) => any>(handler: T): T {
// 1. 创建一个 ref 容器
// useRef 创建一个可变的 "盒子",它可以在组件的整个生命周期内持久存在。
// 我们将传入的事件处理函数 handler 立即存入这个盒子。
const handlerRef = useRef<T>(handler);

// 2. 使用 useLayoutEffect 同步更新 ref
// useLayoutEffect 会在每次组件渲染完成、浏览器绘制到屏幕之前同步执行。
// 这意味着,每次父组件(如 Counter)因为状态变化而重新渲染时,
// 这个 effect 都会立即执行,将最新版本的 handler 函数(包含了最新的 count 状态)
// 存入 handlerRef.current 中。
// 为什么用 useLayoutEffect 而不是 useEffect?
// 因为它能确保在任何事件触发回调之前,ref 中的函数一定是最新的,避免了极端的边界情况。
useLayoutEffect(() => {
handlerRef.current = handler;
});

// 3. 返回一个引用地址永久稳定的函数
// useCallback(fn, []) 的作用是:在组件初次渲染时创建一个函数,
// 并且在后续的渲染中,永远返回这同一个函数实例(因为依赖数组 [] 是空的)。
// 这个返回的函数就是我们最终在组件中使用的那个 handleReset 函数。它的内存地址永远不会变。
return useCallback((...args: any[]) => {
// 4. 执行 ref 中最新的函数
// 当这个稳定函数(例如 handleReset)被调用时,它并不执行自己定义时的逻辑。
// 而是去读取 handlerRef.current 中存储的那个“最新”的 handler 函数,并执行它。
// 这就巧妙地实现了:函数引用是旧的(稳定的),但执行的逻辑是最新的。
return handlerRef.current(...args);
}, []) as T;
}

解析: useEvent 的精髓在于,它巧妙地利用 useRef 作为中介,将函数的“引用” 函数的“实现”离开。它返回的函数引用是永久不变的,但这个函数在执行时,总是能通过 ref.current 调用到最新的那个函数实现。

现在,让我们用这个终极武器来解决 Counter 组件的困境。

文件路径: src/components/dev/PerformancePitfall.tsx (最终修复)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ... imports ...
import { useEvent } from '@/hooks/use-event'; // 导入我们自己的 Hook

// ... ResetButton 组件不变 ...

export function Counter() {
const [count, setCount] = useState(0);
console.log('父组件(Counter)正在被渲染');

// 使用 useEvent 来包装 handleReset
const handleReset = useEvent(() => {
// 这里的 count 总能读取到最新的值
console.log(`准备从 ${count} 重置...`);
setCount(0);
});
// handleReset 函数的引用现在是永久稳定的!

return (
<div className="w-full max-w-sm space-y-4 rounded-lg bg-base-200 p-6">
<p className="text-center text-4xl font-bold">{count}</p>
<div className="flex justify-center gap-4">
<Button onClick={() => setCount(c => c + 1)}>Increment</Button>
<ResetButton onReset={handleReset} />
</div>
</div>
);
}

最终验证:
现在,再次测试 Counter 组件。

  • 点击 “Increment”:Counter 重渲染,但 ResetButton 不再重渲染
  • 点击 “Reset”:控制台打印出正确的 准备从 [当前计数值] 重置...,然后 count 变为 0。

我们成功地解决了“陈旧闭包”和“引用不稳定”的两难问题。useEvent 是一个在处理复杂副作用、自定义 Hooks 和性能优化时,极其强大的高级模式。