《React自定义Hooks实战:从封装到复用,彻底解放组件逻辑》

0 阅读12分钟

React自定义Hooks实战:从封装到复用,彻底解放组件逻辑

在React开发中,你一定用过useStateuseEffect这些内置Hooks,它们帮我们摆脱了类组件的繁琐,让函数组件也能拥有状态和生命周期。但当项目越来越复杂,你会发现:多个组件会出现重复的状态逻辑和业务处理,比如监听鼠标位置、管理待办事项、请求接口数据等。

这时候,自定义Hooks就该登场了。它不是React官方提供的,而是我们基于内置Hooks封装的“业务工具”——以use开头,呼之即来、复用性极强,能让组件只关注UI渲染,彻底解放重复逻辑。

今天就以两个高频实战案例(鼠标位置监听、待办事项管理)为核心,从“是什么、为什么、怎么封装、怎么用”四个维度,彻底吃透自定义Hooks,新手也能快速上手封装自己的Hooks。

一、先搞懂:自定义Hooks到底是什么?

1. 核心定义

自定义Hooks是遵循React Hooks规则、以use开头的函数,内部可以调用React内置Hooks(如useStateuseEffect),目的是封装可复用的状态逻辑,让组件代码更简洁、更易维护。

2. 两个核心特征(必记,避免踩坑)

  • 命名必须以use开头:这是React的强制约定,不仅能让我们一眼识别出这是Hooks,还能让ESLint自动检查Hooks的使用规则(比如不能在if/for中调用)。
  • 内部可以调用其他Hooks:自定义Hooks的本质是“逻辑封装”,依托内置Hooks实现状态管理、生命周期监听等功能,不能脱离内置Hooks单独存在。

3. 核心价值(为什么要封装自定义Hooks?)

在日常React开发中,我们能直观感受到自定义Hooks的3个核心价值:

  • 逻辑复用:比如useMouse封装的鼠标位置监听逻辑,只要在需要的组件中调用,就能快速获得鼠标坐标,无需重复写事件监听、清除监听的代码。
  • 组件解耦:组件只负责“UI渲染”(HTML+CSS),复杂的业务逻辑(如待办的增删改查、本地存储)都封装在Hooks中,代码职责更清晰,后期维护更简单。
  • 团队资产:封装好的自定义Hooks可以在整个项目中复用,甚至跨项目复用,成为团队的通用工具,提升开发效率。

举个反例:如果不封装待办管理相关的Hooks,就需要把待办的增删改查、本地存储逻辑,全部写在入口组件中,会导致组件代码臃肿、逻辑混乱,其他组件需要用到待办功能时,只能复制粘贴代码——这就是自定义Hooks要解决的核心痛点。

二、实战封装1:useMouse——响应式监听鼠标位置

第一个案例,我们封装useMouse,实现“响应式监听鼠标位置”的功能。这个案例能帮我们掌握自定义Hooks的基础封装逻辑,以及内存泄漏的避免方法,是新手入门自定义Hooks的绝佳案例。

1. 需求分析

实现一个Hooks,能够监听鼠标在页面上的移动,实时返回鼠标的x(水平坐标)和y(垂直坐标),并且在组件卸载时,自动清除事件监听,避免内存泄漏。

2. 完整封装代码(可直接复用)

import { useState, useEffect } from 'react';

// 自定义Hooks:封装响应式鼠标位置监听逻辑
// 命名以use开头,符合React Hooks约定
export const useMouse = () => {
  // 1. 用useState维护鼠标坐标状态,初始值为0
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  // 2. 用useEffect监听mousemove事件(生命周期逻辑)
  useEffect(() => {
    // 定义事件处理函数:更新鼠标坐标
    const update = (event) => {
      setX(event.pageX); // 更新水平坐标
      setY(event.pageY); // 更新垂直坐标
    }

    // 给window绑定mousemove事件,触发时执行update
    window.addEventListener('mousemove', update);

    // 3. 清除副作用:组件卸载时,移除事件监听(避免内存泄漏)
    // useEffect的return函数会在组件卸载时执行
    return () => {
      window.removeEventListener('mousemove', update);
    }
  }, []) // 空依赖数组:只在组件挂载时绑定一次事件,卸载时清除

  // 4. 返回需要暴露给组件的状态(x、y坐标)
  return {
    x,
    y
  }
}

3. 逐行拆解封装逻辑(新手必看)

  • 状态定义:用useState(0)定义xy两个状态,分别保存鼠标的水平和垂直坐标,初始值都为0。
  • 事件监听:在useEffect中给window绑定mousemove事件,每次鼠标移动时,执行update函数,通过setXsetY更新坐标状态——这是Hooks中处理“副作用”(如事件监听、定时器)的标准方式。
  • 避免内存泄漏(重点) :组件卸载时,若不清除mousemove事件监听,事件回调函数会一直存在,导致内存泄漏(页面占用内存越来越大)。而useEffect的return函数,会在组件卸载时自动执行,我们在这里调用removeEventListener,就能彻底清除监听,避免内存泄漏。
  • 返回状态:将xy封装成对象返回,供组件调用——组件只需调用useMouse(),就能获取到实时的鼠标坐标。

4. 组件中使用useMouse(极简)

封装完成后,组件使用起来非常简单,无需关注事件监听和清除的逻辑,只需要渲染UI即可,以下是极简使用示例:

import { useMouse } from './hooks/useMouse.js';

function MouseMove() {
  // 调用自定义Hooks,获取鼠标坐标
  const { x, y } = useMouse();

  // 只关注UI渲染,无需处理任何业务逻辑
  return (
    <>
      <div>
        鼠标位置:{x} {y}
      </div>
    </>
  )
}

效果:鼠标在页面上移动时,div会实时显示当前鼠标的坐标,组件卸载时,事件监听会自动清除,不会造成内存泄漏。

三、实战封装2:useTodos——完整待办事项管理

第二个案例,我们封装useTodos,实现“待办事项的增删改查+本地存储”的完整功能。这个案例能帮我们掌握更复杂的业务逻辑封装,以及useState函数式更新、本地存储的结合使用,贴合实际项目开发场景。

1. 需求分析

实现一个Hooks,负责管理待办事项的全部逻辑:

  • 初始化:从本地存储(localStorage)加载待办列表;
  • 核心操作:添加待办、切换待办完成状态、删除待办;
  • 持久化:待办列表变化时,自动同步到本地存储,刷新页面数据不丢失;
  • 暴露状态和方法:给组件提供待办列表(todos)和操作方法(addTodotoggleTododeleteTodo)。

2. 完整封装代码(可直接复用)

import { useState, useEffect } from 'react';

// 定义本地存储的key,单独提取,方便维护
const STORAGE_KEY = 'todos';

// 辅助函数:从本地存储加载待办列表
function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY);
  // 有存储数据则解析为数组,无数据则返回空数组
  return storedTodos ? JSON.parse(storedTodos) : [];
}

// 辅助函数:将待办列表保存到本地存储
function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

// 自定义Hooks:封装待办事项管理的完整逻辑
export const useTodos = () => {
  // 1. 初始化状态:从本地存储加载待办列表(useState接收函数,同步计算初始值)
  const [todos, setTodos] = useState(loadFromStorage);

  // 2. 监听todos变化,同步到本地存储(持久化)
  useEffect(() => {
    saveToStorage(todos);
  }, [todos]) // 依赖todos:只要todos变化,就执行保存操作

  // 3. 封装添加待办的方法
  const addTodo = (text) => {
    // 函数式更新:prevTodos是上一次的todos状态,确保拿到最新值
    setTodos(prevTodos => [
      ...prevTodos, // 保留原有待办
      {
        id: Date.now(), // 用时间戳生成唯一ID(避免重复)
        text, // 待办内容
        completed: false // 初始状态:未完成
      }
    ])
  }

  // 4. 封装切换待办完成状态的方法
  const toggleTodo = (id) => {
    setTodos(prevTodos => 
      prevTodos.map(todo => {
        // 找到对应ID的待办,切换completed状态
        if(todo.id === id) {
          return { ...todo, completed: !todo.completed };
        }
        return todo; // 其他待办不变
      })
    )
  }

  // 5. 封装删除待办的方法
  const deleteTodo = (id) => {
    setTodos(prevTodos => 
      // 过滤掉要删除的待办,返回新数组
      prevTodos.filter(todo => todo.id !== id)
    )
  }

  // 6. 返回待办列表和操作方法,供组件使用
  return {
    todos,
    addTodo,
    toggleTodo,
    deleteTodo
  }
}

3. 核心封装细节拆解(新手避坑)

  • 本地存储封装:将“加载”和“保存”本地存储的逻辑,提取为loadFromStoragesaveToStorage两个辅助函数,代码更简洁、可维护——如果后续需要修改存储方式(比如改用sessionStorage),只需修改这两个函数即可。
  • useState函数式更新:添加、修改、删除待办时,都用了setTodos(prevTodos => { ... })的形式,而不是直接使用todos。这是因为新的待办状态依赖于旧的状态,函数式更新能确保我们拿到的是最新的状态值,避免出现“状态更新不及时”的bug。
  • 持久化逻辑:通过useEffect监听todos的变化,只要todos发生改变(添加、删除、切换状态),就会自动调用saveToStorage,将最新的待办列表保存到本地存储——这样即使页面刷新,待办数据也不会丢失。
  • 唯一ID生成:用Date.now()生成待办的id,这是一种简单高效的唯一ID生成方式(时间戳毫秒级,人类操作间隔远大于1毫秒,不会重复),适合小型项目使用。

4. 组件中使用useTodos(联动组件实战)

封装完成后,我们可以结合TodoInputTodoListTodoItem三个组件,实现完整的待办功能——组件只需专注于UI渲染和事件传递,无需处理任何待办业务逻辑,真正实现逻辑与UI解耦。

(1)App组件(入口组件,整合所有功能)
import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';

export default function App() {
  // 调用useTodos,获取待办列表和操作方法
  const { 
    todos,
    addTodo,
    deleteTodo,
    toggleTodo
  } = useTodos();

  return (
    <>
      {/* 待办输入框:传递addTodo方法,用于添加待办 */}
      <TodoInput onAddTodo={addTodo}/>
      
      {/* 待办列表:有数据则渲染列表,无数据则提示 */}
      {
        todos.length > 0 ?( 
          <TodoList 
            onDelete={deleteTodo} // 传递删除方法
            onToggle={toggleTodo} // 传递切换状态方法
            todos={todos} // 传递待办列表
          />
        ):(
          <div>暂无待办事项</div>
        )
      }
    </>
  )
}
(2)TodoInput组件(输入待办,触发添加)
import { useState } from 'react';

// 接收父组件传递的addTodo方法
export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState(''); // 管理输入框文本状态

  // 表单提交事件:添加待办
  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止表单默认提交行为
    if (!text.trim()) return; // 空文本不添加,避免无效数据
    onAddTodo(text.trim()); // 调用addTodo方法,添加待办
    setText(""); // 清空输入框
  }

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={text}
        onChange={e => setText(e.target.value)} // 实时更新输入框文本
      />
    </form>
  )
}
(3)TodoList和TodoItem组件(渲染待办,触发操作)
// TodoList.jsx:渲染待办列表,遍历生成TodoItem
import TodoItem from './TodoItem.jsx';

export default function TodoList({ todos, onDelete, onToggle }) {
  return (
    <ul className="todo-list">
      {
        todos.map(todo => (
          <TodoItem 
            key={todo.id} // 唯一key避免React警告
            todo={todo} // 传递单个待办数据
            onDelete={onDelete} // 传递删除方法
            onToggle={onToggle} // 传递切换状态方法
          />
        ))
      }
    </ul>
  )
}

// TodoItem.jsx:渲染单个待办项,绑定操作事件
export default function TodoItem({todo, onToggle, onDelete}) {
  return (
    <li className="todo-item">
      {/* 复选框:绑定切换待办状态事件 */}
      <input 
        type="checkbox" 
        checked={todo.completed}  
        onChange={() => onToggle(todo.id)}
      />
      {/* 待办文本:完成状态添加completed样式 */}
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      {/* 删除按钮:绑定删除事件 */}
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  )
}

5. 效果说明

  • 用户在输入框输入文本,提交后会添加一条待办事项,同时同步到本地存储;
  • 点击复选框,可切换待办的完成状态,状态变化会自动保存到本地存储;
  • 点击Delete按钮,可删除对应的待办事项,删除后数据也会同步更新;
  • 刷新页面,待办数据会从本地存储加载,不会丢失;
  • 所有业务逻辑(增删改查、本地存储)都封装在useTodos中,组件只负责UI渲染和事件传递,代码简洁、职责清晰。

四、自定义Hooks的核心规则(必遵守,否则出bug)

无论是内置Hooks还是自定义Hooks,都必须遵守React的Hooks规则,否则会导致组件状态混乱、渲染异常等问题,结合实战场景,重点强调3条核心规则:

1. 只能在函数组件或自定义Hooks中调用

不能在普通函数、if/for循环、嵌套函数中调用Hooks。比如:

// ❌ 错误:在if语句中调用useState(自定义Hooks内部调用除外)
function App() {
  if (true) {
    const [count, setCount] = useState(0);
  }
  // ...
}

// ✅ 正确:在函数组件顶层或自定义Hooks中调用
function useMouse() {
  const [x, setX] = useState(0); // 正确,自定义Hooks内部可调用
  // ...
}

2. 调用顺序必须固定

每次组件渲染时,Hooks的调用顺序必须保持一致。比如不能这次先调用useState,下次先调用useMouse——这会导致React无法正确识别每个Hooks的状态。

3. 不要在Hooks中使用非Hooks规则的代码

自定义Hooks内部可以调用其他Hooks,但不能包含组件渲染相关的代码(如JSX),也不能在Hooks中直接操作DOM(除非在useEffect中)。

五、自定义Hooks实战总结(新手必记)

1. 封装思路(三步搞定)

  1. 明确需求:确定要封装的业务逻辑(比如鼠标监听、待办管理),明确需要暴露给组件的状态和方法;
  2. 逻辑实现:用内置Hooks(useStateuseEffect等)实现核心逻辑,处理副作用(如事件监听、本地存储),避免内存泄漏;
  3. 暴露接口:将组件需要的状态和方法,以对象或数组的形式返回,供组件调用。

2. 核心避坑点

  • 命名必须以use开头,否则ESLint会报错,也无法被识别为Hooks;
  • 处理副作用(事件监听、定时器)时,一定要在useEffect的return函数中清除,避免内存泄漏;
  • 当新状态依赖旧状态时,一定要用useState的函数式更新(setState(prev => ...)),确保拿到最新状态;
  • 自定义Hooks只负责封装逻辑,不负责UI渲染,UI渲染交给组件处理,实现逻辑和UI的解耦。

3. 适用场景

只要多个组件存在重复的状态逻辑,都可以封装成自定义Hooks,常见场景:

  • 事件监听类:鼠标位置、滚动位置、窗口大小监听;
  • 业务逻辑类:待办管理、购物车管理、用户信息管理;
  • 接口请求类:封装接口请求、loading状态、错误处理;
  • 工具类:本地存储、定时器、防抖节流等。

自定义Hooks是React开发中的“效率神器”,也是面试高频考点。它不仅能提升代码复用率、简化组件逻辑,还能让你的代码更具可维护性。建议把上面的代码复制到本地运行,亲手修改、调试,感受自定义Hooks的魅力——从模仿封装useMouseuseTodos开始,逐步封装自己的Hooks,让开发更高效!