React 之 自定义 Hooks 🚀

自定义 Hooks 🚀

在 React 的世界里,Hooks 就像一把神奇的钥匙,为函数组件打开了状态管理和生命周期的大门。今天我们就来深入探索自定义 Hooks 的奥秘,看看它如何让我们的代码更优雅、更可复用!

一、hooks 详解 🧐

1. 什么是 hooks ?

Hooks 是 React 16.8 引入的新特性,它是一种函数编程思想的实践,允许我们在不编写类组件的情况下,在函数组件中使用状态(State)和其他 React 特性(如生命周期)。简单来说,Hooks 就是 "钩子",能让我们轻松 "钩入" React 的内部特性,让函数组件拥有更强大的能力。

2. hooks 分类

Hooks 主要分为两类:

  • React 内置 Hooks :如useState(管理状态)、useEffect(处理副作用)、useContext(共享上下文)等,这些是 React 官方提供的基础工具。
  • 自定义 Hooks :由开发者根据业务需求封装的 Hooks,命名以use开头,本质是对内置 Hooks 和业务逻辑的组合封装,方便复用。

(前面我们讲解过 React 内置的 hooks 函数,感兴趣的小伙伴可以去翻翻我前面的文章看看)

3. hooks 有什么作用?

  • 让函数组件具备状态管理能力,摆脱类组件的繁琐语法(如this绑定、生命周期函数嵌套)。
  • 将组件中的相关逻辑聚合在一起,而非分散在不同的生命周期函数中(比如类组件中componentDidMountcomponentDidUpdate可能写重复逻辑)。
  • 实现逻辑复用,通过自定义 Hooks 将相同的状态逻辑抽离,供多个组件使用。

4. 为什么需要自定义 hooks ?

在开发中,我们经常会遇到多个组件需要共享相同状态逻辑的场景。比如:多个组件都需要监听鼠标位置、都需要处理本地存储数据、都需要发起相同的 API 请求等。

如果没有自定义 Hooks,我们可能会通过 "复制粘贴代码" 或 "高阶组件""render props" 等方式复用逻辑,但这些方式要么导致代码冗余,要么增加组件层级复杂度。

而自定义 Hooks 就像一个 "逻辑容器",能将这些重复逻辑抽离成独立函数,让组件只关注 UI 渲染,极大提升代码的复用性和可维护性!

二、先来看一个简单案例:响应式显示鼠标的位置 🖱️

1. 需求分析

我们要实现一个包含两个核心功能的小应用:

(1)计数功能:一个计数器,点击按钮可以增加数字。

(2)条件显示鼠标位置:当计数器的值为偶数时,显示鼠标在页面上的实时坐标;为奇数时,不显示。

2. 核心实现(两个文件)

(1)App2.jsx:主组件,负责管理计数器状态和根据计数奇偶性条件渲染鼠标位置组件。

(2)useMouse.js:自定义 Hooks,封装鼠标位置监听的逻辑,提供xy坐标供组件使用(向外暴露的状态和方法放在 return 中返回)。

3. 代码展示

(1)App2.jsx

jsx

javascript 复制代码
import { useState } from 'react';
import { useMouse } from './hooks/useMouse.js';

// 鼠标位置展示组件(纯UI组件)
function MouseMove() {
    // 调用自定义Hook获取鼠标坐标
    const { x, y } = useMouse();
    return (
        <>
            <div>
                鼠标位置:{x}, {y}
            </div>
        </>
    );
}

export default function App() {
    // 定义计数器状态,初始值为0
    const [count, setCount] = useState(0);

    return (
        <>
            {/* 显示当前计数 */}
            {count}
            {/* 点击按钮增加计数(使用函数式更新确保获取最新count) */}
            <button onClick={() => setCount((count) => count + 1)}>
                点击增加
            </button>
            {/* 当count为偶数时,渲染MouseMove组件显示鼠标位置 */}
            {count % 2 === 0 && <MouseMove />}
        </>
    )
}

代码逻辑详解

  • App组件通过useState管理计数器count的状态,点击按钮时通过setCount更新值。
  • 定义了MouseMove组件,它不包含任何业务逻辑,仅通过调用useMouse()获取鼠标坐标并渲染,是一个纯 UI 组件。
  • 通过条件渲染{count % 2 === 0 && <MouseMove />}实现 "偶数显示鼠标位置,奇数不显示" 的需求。
(2)useMouse.js

js

javascript 复制代码
import { useState, useEffect } from 'react';

// 自定义Hook:封装鼠标位置监听逻辑(命名以use开头)
export function useMouse() {
    // 定义状态存储鼠标x、y坐标,初始值为0
    const [x, setX] = useState(0);
    const [y, setY] = useState(0);

    // 副作用:监听鼠标移动事件
    useEffect(() => {
        // 事件处理函数:更新x、y坐标
        const update = (event) => {
            console.log('/////');
            setX(event.pageX); // 更新x坐标为鼠标相对于文档的水平位置
            setY(event.pageY); // 更新y坐标为鼠标相对于文档的垂直位置
        }
        // 绑定mousemove事件,触发时调用update更新坐标
        window.addEventListener('mousemove', update);
        console.log('||||| 挂载'); // 组件挂载时打印

        // 清理函数:组件卸载时移除事件监听,避免内存泄漏
        return () => {
            window.removeEventListener('mousemove', update); // 移除相同的事件处理函数
            console.log('===== 清除'); // 清理时打印
        }
    }, []); // 空依赖数组:仅在组件挂载时执行一次,卸载时执行清理

    // 返回鼠标坐标,供组件使用
    return {
        x,
        y,
    }
}

代码逻辑详解

  • useMouse遵循命名规范(以use开头),内部可以调用内置 Hooks(useStateuseEffect)。

  • useState定义xy状态,分别存储鼠标的水平和垂直坐标。

  • useEffect处理鼠标监听的副作用:

    • 挂载时:绑定mousemove事件,鼠标移动时触发update函数更新xy
    • 卸载时:通过useEffect的返回函数移除mousemove事件监听,避免组件已卸载但事件仍触发的内存泄漏(比如当count为奇数时,MouseMove组件卸载,此时会执行清理函数)。
  • 最后返回包含xy的对象,让组件可以获取鼠标坐标。

4. 效果展示:

  • 当点击按钮使count为 0、2、4 等偶数时,页面会显示 "鼠标位置:x, y",移动鼠标时坐标会实时更新,控制台打印 "||||| 挂载"。
  • 当点击按钮使count为 0、2、4 等偶数时,移动鼠标,控制台打印"/////"
  • count为 1、3、5 等奇数时,鼠标位置不显示,控制台打印 "===== 清除"且不打印"/////"(表示事件监听已移除)。

三、详解自定义 hooks 📚

通过上面的简单案例,我们可以总结出关于自定义 Hooks 的必备知识:

1. 什么时候需要自定义 hooks ?

(1)逻辑复用场景 当多个组件需要共享相同的状态逻辑时,通过自定义 Hook 封装可避免代码重复。上面案例中,useMouse封装了鼠标位置监听的完整逻辑(状态管理、事件绑定 / 解绑),使得MouseMove组件无需重复编写该逻辑。若其他组件(如 "鼠标轨迹绘制组件")也需要获取鼠标位置,可直接复用useMouse

(2)分离 UI 与业务逻辑 当组件中同时包含 UI 渲染和复杂业务逻辑(如事件监听、数据处理)时,自定义 Hooks 可将业务逻辑抽离,让组件只专注于 UI 展示。上面案例中,MouseMove组件仅负责渲染鼠标坐标(纯 UI 逻辑),而鼠标监听的业务逻辑被封装在useMouse中,使组件代码更简洁易维护。

(3)抽象复杂副作用逻辑 当逻辑涉及useStateuseEffect等 Hook 的组合使用(如状态管理 + 副作用处理)时,自定义 Hook 可将其抽象为独立单元,提高可读性。上面案例中useMouse整合了useState(管理 x、y 坐标)和useEffect(鼠标事件监听 / 清理),将分散的逻辑聚合为可复用的单元。

2. 如何自定义 hooks?

(1)遵循命名规范 函数名必须以use开头(如useMouseuseTodos),这是 React 的强制约定,确保 React 能识别 Hook 的调用规则(如只能在组件或其他 Hook 中使用)。

(2)封装核心逻辑 在函数内部可调用其他 Hook(如useStateuseEffect),并通过return暴露需要的状态或方法。案例中useMouse的实现步骤:

  • useState定义xy状态,存储鼠标坐标;
  • useEffect绑定mousemove事件,实时更新坐标;
  • 通过return { x, y }将坐标暴露给组件使用。

(3)设计返回值 根据需求返回状态、方法或对象,方便组件灵活使用。案例中返回包含xy的对象,组件通过const { x, y } = useMouse()直接获取坐标;若逻辑复杂(如待办事项管理),可返回状态和操作方法的集合(如{ todos, addTodo, deleteTodo })。

3. 自定义 hooks 有什么注意事项?

(1)严格遵循 Hook 调用规则

  • 只能在组件函数或其他自定义 Hook 中调用(上面案例中useMouseMouseMove组件中调用,符合规则);
  • 不能在循环、条件判断或普通函数中调用(避免 React 无法保证 Hook 调用顺序的一致性)。

(2)清理副作用,避免内存泄漏 若 Hook 包含副作用(如事件监听、定时器、API 请求),必须在useEffect的清理函数中移除副作用。上面案例中,useMouseuseEffect的返回函数中调用removeEventListener,确保MouseMove组件卸载时(count为奇数时)移除鼠标监听,避免组件已卸载但事件仍触发的内存泄漏(控制台会打印 "===== 清除" 验证)。

(3)保持单一职责 一个自定义 Hook 应专注于解决一个特定问题。上面案例中useMouse仅负责鼠标位置监听,职责单一;若强行加入其他逻辑(如键盘监听)会导致 Hook 臃肿,降低复用性。

四、复杂案例实战:待办事项(Todo List)应用 📝

1. 需求分析:

本案例是一个基于 React 的待办事项应用,核心需求围绕待办事项的全生命周期管理,具体包括:

  • 输入框输入待办内容,点击添加按钮创建新待办;
  • 勾选复选框标记待办事项为 "已完成" 或 "未完成";
  • 点击删除按钮移除特定待办事项;
  • 页面刷新后,已添加的待办数据不丢失(本地持久化);
  • 无待办事项时显示空状态提示。

核心痛点:如何将数据管理逻辑与 UI 渲染逻辑分离,实现代码复用和维护性提升 ------ 这正是自定义 Hook 的核心应用场景。

2. 实现架构设计:

(1)整体架构采用 "UI 组件 + 自定义 Hook" 的分离模式:

  • UI 组件:负责渲染界面和处理用户交互(输入、点击等);
  • 自定义 Hooks(useTodos):封装数据状态、业务逻辑和持久化操作。

(2)核心设计:自定义 hooks 的职责 useTodos作为数据和逻辑的 "中央处理器",承担以下职责:

  • 管理待办事项的响应式状态(todos数组);
  • 提供操作待办的方法(添加、切换状态、删除);
  • 实现数据本地持久化(localStorage读写)。

3. 代码展示(核心代码)

(1)自定义 Hook:useTodos.js

js

javascript 复制代码
// 封装响应式的todos业务逻辑
import { useState, useEffect } from 'react';

// 定义localStorage的键名,用于存储待办数据
const STORAGE_KEY = 'todos';

// 从localStorage加载待办数据
function loadFromStorge() {
    const StoredTodos = localStorage.getItem(STORAGE_KEY);
    // 若有数据则解析为JSON,否则返回空数组
    return StoredTodos ? JSON.parse(StoredTodos) : [];
}

// 将待办数据保存到localStorage
function saveToStorage(todos) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

export const useTodos = () => {
    // 初始化todos状态:从localStorage加载(useState接收函数,确保只执行一次)
    const [todos, setTodos] = useState(loadFromStorge);

    // 监听todos变化,实时保存到localStorage(持久化)
    useEffect(() => {
        saveToStorage(todos);
    }, [todos]); // 依赖todos:只有todos变化时才执行

    // 添加待办事项
    const addTodo = (text) => {
        setTodos([
            ...todos, // 复制现有待办
            { 
                id: Date.now(), // 用时间戳作为唯一ID
                text, // 待办内容
                completed: false // 初始状态为未完成
            }
        ]);
    }

    // 切换待办事项的完成状态
    const toggleTodo = (id) => {
        setTodos(
            todos.map((todo) => {
                // 找到目标待办,反转completed状态
                if (todo.id === id) {
                    return { ...todo, completed: !todo.completed };
                }
                return todo; // 非目标待办不变
            })
        );
    }

    // 删除待办事项
    const deleteTodo = (id) => {
        setTodos(
            todos.filter((todo) => todo.id !== id) // 过滤掉要删除的待办
        );
    }

    // 暴露状态和方法给组件使用
    return {
        todos,
        addTodo,
        toggleTodo,
        deleteTodo,
    }
}

逻辑详解

  • loadFromStorgesaveToStorage:封装localStorage的读写逻辑,实现数据持久化(页面刷新后数据不丢失)。
  • useTodos内部用useState管理todos状态,初始化时从本地存储加载数据。
  • useEffect监听todos变化,每次变化都调用saveToStorage保存到本地,确保数据实时同步。
  • 提供addTodo(添加)、toggleTodo(切换状态)、deleteTodo(删除)三个方法,通过setTodos更新状态,遵循 "不可变数据" 原则(用扩展运算符、mapfilter创建新数组)。
  • 最后返回todos状态和操作方法,供 UI 组件使用。
(2)UI 组件:App.jsx(主组件)

jsx

javascript 复制代码
import { useState } from 'react';
import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';

export default function App() {
    // 调用自定义Hook获取待办数据和操作方法
    const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

    return (
        <>
            {/* 输入组件:传递addTodo方法用于添加待办 */}
            <TodoInput addTodo={addTodo} />
            {/* 条件渲染:有待办时显示列表,否则显示空状态 */}
            {
                todos.length > 0 ?
                    <TodoList
                        todos={todos}
                        deleteTodo={deleteTodo}
                        toggleTodo={toggleTodo}
                    /> : 
                    (<div>暂无待办事项</div>)
            }
        </>
    )
}

逻辑详解

  • App组件作为入口,通过useTodos()获取待办数据(todos)和操作方法(addTodo等)。
  • 渲染TodoInput组件(负责输入待办)并传递addTodo方法,让输入组件能触发添加操作。
  • 渲染TodoList组件(负责展示待办列表)并传递todosdeleteTodotoggleTodo,让列表组件能展示数据和处理删除 / 切换操作。
  • 通过条件渲染实现 "无待办时显示空提示" 的需求。
(3)UI 组件:TodoInput.jsx(输入组件)

jsx

javascript 复制代码
import { useState } from 'react';

export default function TodoInput({ addTodo }) {
    // 管理输入框文本状态(受控组件)
    const [text, setText] = useState('');

    // 输入框变化时更新text状态
    const handleChange = (e) => {
        setText(e.target.value);
    }

    // 表单提交时添加待办
    const handleSubmit = (e) => {
        e.preventDefault(); // 阻止表单默认提交行为
        if (text.trim() === '') { // 过滤空输入
            return;
        }
        addTodo(text); // 调用父组件传递的addTodo方法添加待办
        setText(''); // 清空输入框
    }

    return (
        <form className='todo-input' onSubmit={handleSubmit}>
            {/* 受控组件:value绑定text,变化触发handleChange */}
            <input type='text' value={text} onChange={handleChange} />
            <button type='submit'>添加</button>
        </form>
    )
}

逻辑详解

  • 纯 UI 组件,仅负责输入框的状态管理和提交逻辑,不关心数据如何存储或处理。
  • 通过useState管理输入框的text状态,实现 "受控组件"(输入框值由 React 状态控制)。
  • 表单提交时调用addTodo(从App组件传递的方法)添加待办,并清空输入框。
(4)UI 组件:TodoList.jsx(列表组件)

jsx

javascript 复制代码
import TodoItem from './TodoItem.jsx';

export default function TodoList({ todos, deleteTodo, toggleTodo }) {
    return (
        <ul className="todo-list">
            {/* 遍历todos数组,为每个待办渲染TodoItem组件 */}
            {todos.map((todo) => (
                <TodoItem 
                 key={todo.id} // 唯一key
                 todo={todo} // 传递单个待办数据
                 deleteTodo={deleteTodo} // 传递删除方法
                 toggleTodo={toggleTodo} // 传递切换状态方法
                />
            ))}
        </ul>
    )
}

逻辑详解

  • 接收todos数组,通过map遍历渲染每个待办项(TodoItem)。
  • 仅负责列表渲染,将单个待办的数据和操作方法传递给TodoItem,自身不处理业务逻辑。
(5)UI 组件:TodoItem.jsx(单个待办项)

jsx

javascript 复制代码
export default function TodoItem({ todo, deleteTodo, toggleTodo }) {
    return (
        <li className="todo-item">
            {/* 复选框:状态绑定todo.completed,点击触发toggleTodo */}
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
            />
            {/* 待办文本:已完成时添加completed类(可用于样式区分) */}
            <span className={todo.completed ? 'completed' : ''}>
                {todo.text}
            </span>
            {/* 删除按钮:点击触发deleteTodo */}
            <button onClick={() => deleteTodo(todo.id)}>删除</button>
        </li>
    )
}

逻辑详解

  • 接收单个待办数据(todo)和操作方法,渲染复选框、文本和删除按钮。
  • 复选框状态与todo.completed绑定,点击时调用toggleTodo切换状态。
  • 文本根据completed状态添加样式类(如划线效果),删除按钮点击时调用deleteTodo删除当前待办。

4. 效果展示:

  • 输入框输入内容,点击 "添加" 按钮,待办列表会新增一项。
  • 勾选复选框,待办文本会显示为 "已完成" 样式。
  • 点击 "删除" 按钮,对应待办项会从列表中移除。
  • 刷新页面后,所有待办数据依然存在(本地存储生效)。
  • 当列表为空时,页面显示 "暂无待办事项"。

5. 案例总结:

  • 逻辑复用最大化useTodos封装了待办事项的所有核心逻辑(状态管理、CRUD 操作、本地持久化),若其他组件(如 "待办统计组件")需要使用待办数据,可直接调用useTodos,无需重复编写逻辑。
  • UI 与业务彻底分离 :所有 UI 组件(TodoInputTodoList等)仅负责渲染和传递交互,不包含任何数据处理逻辑,代码结构清晰,维护成本低。
  • 副作用集中管理 :本地存储的读写逻辑被封装在useTodosuseEffect中,避免副作用分散在多个组件中,便于统一维护。

五、面试官会问 🤔

  1. 自定义 Hook 和普通函数有什么区别? 自定义 Hook 以use开头,内部可以调用其他 Hook(如useStateuseEffect),且必须遵循 Hook 调用规则(只能在组件或其他 Hook 中调用);普通函数不能调用 Hook,也没有命名限制。
  2. 为什么自定义 Hook 必须以 use 开头? 这是 React 的约定,确保 React 能通过命名识别 Hook,从而验证 Hook 的调用规则(如避免在条件语句中调用),防止出现逻辑错误。
  3. 如何避免自定义 Hook 中的内存泄漏? 若 Hook 包含副作用(如事件监听、定时器),必须在useEffect的清理函数中移除副作用(如removeEventListenerclearTimeout),确保组件卸载时副作用被清除。
  4. 自定义 Hook 如何实现状态隔离? 每个组件调用自定义 Hook 时,React 都会为其创建独立的状态实例,不同组件之间的状态互不干扰(如两个组件调用useMouse,会分别维护自己的xy状态)。
  5. 什么时候应该抽离自定义 Hook? 当多个组件需要共享相同的状态逻辑,或组件中业务逻辑过于复杂(导致 UI 与逻辑混杂)时,就应该抽离为自定义 Hook。

六、结语 🌟

自定义 Hooks 是 React 中 "逻辑复用" 的最佳实践,它让我们的代码从 "重复冗余" 走向 "简洁高效",从 "UI 与逻辑混杂" 走向 "职责清晰分离"。

通过本文的两个案例(鼠标位置监听和待办事项应用),我们可以看到:一个设计良好的自定义 Hook,就像一个 "功能模块",能让组件专注于 UI 渲染,让逻辑专注于业务处理。

希望大家在实际开发中多思考、多实践,将自定义 Hooks 运用到项目中,让代码更优雅、更可维护!🎉

相关推荐
无声20171 天前
Turborepo 的 Docker 化实战
前端·vue.js
用户91743965391 天前
Magnitude:强!一款基于 Al 视觉的 Web 自动化框架
运维·前端·自动化
军军君011 天前
Three.js基础功能学习四:摄像机与阴影
开发语言·前端·javascript·3d·typescript·three·三维
lambo mercy1 天前
python入门
前端·数据库·python
GIS之路1 天前
GDAL 实现矢量数据读写
前端
05大叔1 天前
MybatisPlus
java·服务器·前端
slongzhang_1 天前
edge/Chrome浏览器闪屏/花屏
前端·chrome·edge
千里马-horse1 天前
Rect Native bridging 源码分析--Class.h
javascript·react native·react.js·class
想要一辆洒水车1 天前
npm包开发及私有仓库配置使用
前端