普通视图

发现新文章,点击刷新页面。
昨天以前首页

React 自定义 Hook 实战:从鼠标追踪到待办事项管理

作者 ohyeah
2026年1月3日 14:46

在现代前端开发中,React 的自定义 Hook 已成为提升代码复用性、逻辑抽象能力和可维护性的核心手段。本文将通过两个典型示例——鼠标位置追踪本地存储的待办事项管理,深入剖析如何利用自定义 Hook 将复杂业务逻辑封装为可复用、可测试、高内聚的模块,并探讨其背后的 React 响应式编程思想。


一、Demo 1:使用 useMouse 封装鼠标位置追踪

1.1 初始实现的问题

最初,开发者可能直接在组件内部使用 useStateuseEffect 来监听鼠标移动事件:

function MouseMove() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    const update = (event) => {
      setX(event.pageX);
      setY(event.pageY);
    };
    window.addEventListener('mousemove', update);
    return () => {
      window.removeEventListener('mousemove', update);
    };
  }, []);

  return <div>鼠标位置: {x} {y}</div>;
}

这种写法虽然功能完整,但存在明显缺陷:

  • 逻辑耦合:UI 渲染与事件监听混杂,组件职责不清;
  • 难以复用:若其他组件也需要获取鼠标坐标,需重复编写相同逻辑;
  • 潜在内存泄漏风险:若未正确清理事件监听器(如忘记返回清理函数),组件卸载后仍会执行回调,事件监听/定时器 不会因为函数组件卸载而自动销毁,当卸载组件后又开启组件,相当于是又进行了一次事件监听,多次重复导致内存泄漏。

1.2 提炼为自定义 Hook:useMouse

为解决上述问题,我们将鼠标追踪逻辑提取至独立的 useMouse Hook:

// src/hooks/useMouse.js
import { useState, useEffect } from "react";

export const useMouse = () => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    const update = (event) => {
      setX(event.pageX);
      setY(event.pageY);
    };
    window.addEventListener('mousemove', update);
    return () => {
      window.removeEventListener('mousemove', update);
    };
  }, []);
   
  return { x, y };
  // 把要向外暴露的状态和方法返回
};

关键设计点解析:

  1. 状态封装
    使用 useState 管理 xy 坐标,对外仅暴露只读值,避免外部直接修改状态。
  2. 副作用隔离
    useEffect 负责添加/移除全局事件监听器。依赖数组为空([]),确保仅在组件挂载时注册一次监听器,并在卸载时自动清理,彻底规避内存泄漏。
  3. 单一职责原则
    useMouse 只关注“获取鼠标位置”这一核心能力,不涉及任何 UI 渲染或业务判断,高度内聚。
  4. 可组合性
    返回对象 { x, y },便于在任意组件中解构使用,符合 React 的声明式风格。

1.3 在组件中使用

App.jsx 中,只需一行代码即可接入鼠标位置数据:

function MouseMove() {
  const { x, y } = useMouse();
  return <div>鼠标位置: {x} {y}</div>;
}

此时 MouseMove 组件完全退化为纯展示层,逻辑与视图分离,极大提升了可读性和可维护性。


二、Demo 2:构建完整的待办事项系统 —— useTodos

相比鼠标追踪,待办事项管理涉及状态管理、持久化存储等多个维度,是检验自定义 Hook 能力的绝佳场景。

2.1 整体架构拆解

整个系统由以下部分组成:

  • Hook 层useTodos —— 核心逻辑容器

  • 组件层

    • TodoInput:输入新任务
    • TodoList:渲染任务列表
    • TodoItem:单个任务项(含完成状态切换与删除)

这种分层结构体现了典型的“逻辑下沉,UI 上浮”原则:复杂状态流转由 Hook 处理,组件仅负责调用方法与展示数据。

2.2 useTodos Hook 深度解析

// src/hooks/useTodos.js
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos';

function loadFromStorage() {
  const stored = localStorage.getItem(STORAGE_KEY);
  return stored ? JSON.parse(stored) : [];
}

function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

export const useTodos = () => {
  const [todos, setTodos] = useState(loadFromStorage);

  useEffect(() => {
    saveToStorage(todos);
  }, [todos]);
  // todos改变 进行本地存储

  const addTodo = (text) => {
    setTodos([
      ...todos,
      { id: Date.now(), text, completed: false }
    ]);
  };

  const toggleTodo = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return { todos, addTodo, toggleTodo, deleteTodo };
};

核心机制详解:

(1)初始化与持久化同步
  • 延迟初始化useState(loadFromStorage) 利用函数式初始化,避免每次渲染都读取 localStorage,提升性能。
  • 自动持久化useEffect 监听 todos 变化,一旦状态更新立即写入 localStorage,实现“状态即存储”的无缝体验。

注意:此处使用 Date.now() 作为 ID 虽简便,但在高频操作下可能冲突。

(2)不可变更新原则

所有状态变更均通过创建新数组实现:

  • addTodo:使用展开运算符 [...todos, newTodo]
  • toggleTodomap 返回新数组,仅修改目标项
  • deleteTodofilter 排除指定 ID 项

这保证了 React 能正确触发重渲染,同时避免意外修改原始状态。

(3)API 设计清晰

返回对象包含:

  • 状态todos(当前任务列表)
  • 行为addTodo, toggleTodo, deleteTodo(纯函数,无副作用)

调用者无需关心内部实现,只需按约定传参即可操作状态。

2.3 组件层协作流程

TodoInput:任务创建入口

// src/components/TodoInput.jsx
export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    onAddTodo(text.trim());
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text"
        value={text}
        onChange={e => setText(e.target.value)}
      />
    </form>
  );
}
  • 通过 onAddTodo 回调将新任务文本传递给父组件(即 useTodos.addTodo
  • 表单提交后清空输入框,提供良好 UX

TodoListTodoItem:状态展示与交互

// TodoList.jsx
export default function TodoList({ todos, onDelete, onToggle }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem 
          key={todo.id}
          todo={todo}
          onDelete={onDelete}
          onToggle={onToggle}
        />
      ))}
    </ul>
  );
}

// TodoItem.jsx
export default function TodoItem({ todo, onDelete, onToggle }) {
  return (
    <li>
      <input 
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
}
  • 单向数据流todosApp 传入,TodoItem 仅消费数据
  • 事件委托:点击复选框或删除按钮时,调用 onToggle / onDelete,最终触发 useTodos 内部状态更新
  • Key 唯一性:使用 todo.id 作为 key,确保 React Diff 算法高效更新列表

2.4 App 组件:胶水层整合

// App.jsx
export default function App() {
  const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

  return (
    <>
      <TodoInput onAddTodo={addTodo} />
      {todos.length > 0 ? (
        <TodoList 
          todos={todos} 
          onDelete={deleteTodo} 
          onToggle={toggleTodo} 
        />
      ) : (
        <div>暂无待办事项</div>
      )}
    </>
  );
}

App 组件几乎不包含业务逻辑,仅负责:

  1. 调用 useTodos 获取状态与方法
  2. 将方法作为 props 传递给子组件
  3. 根据 todos.length 控制空状态显示

这种“瘦容器”模式使应用结构清晰,易于扩展(例如未来加入筛选、编辑等功能)。


三、自定义 Hook 的价值与最佳实践

3.1 为什么需要自定义 Hook?

  • 逻辑复用:跨组件共享状态逻辑
  • 关注点分离:将副作用、数据获取、状态管理从 UI 组件中剥离
  • 测试友好:Hook 可独立于组件进行单元测试
  • 团队协作:形成可沉淀的“前端资产库”,新人可快速接入

3.2 编写高质量 Hook 的准则

  1. 命名规范:以 use 开头(如 useTodos),这是 React 的约定,也是 ESLint 规则的要求。
  2. 返回结构清晰:通常返回对象,便于按需解构;避免返回数组导致顺序依赖。
  3. 避免副作用外泄:Hook 内部处理所有订阅/清理,调用者无需关心生命周期。
  4. 考虑性能优化:对返回的函数使用 useCallback 包裹(本例因简单省略,复杂场景需注意)。

结语

通过 useMouseuseTodos 两个案例,我们见证了自定义 Hook 如何将“面条式代码”转化为模块化、可维护的现代 React 应用。它不仅是语法糖,更是一种架构思维——鼓励开发者将复杂问题分解为独立、可组合的逻辑单元。

在实际项目中,你可以继续延伸这一模式:封装网络请求(useFetch)、表单验证(useForm)、主题切换(useTheme)等通用能力。当你的 Hook 库逐渐丰富,你会发现:优秀的前端工程,始于对逻辑的敬畏,成于对复用的追求

本文所有代码均可直接运行,建议读者动手实践,尝试为 useTodos 添加“编辑任务”或“按状态筛选”功能,进一步巩固自定义 Hook 的设计能力。

防抖与节流:前端性能优化的两大利器

作者 ohyeah
2026年1月2日 13:50

在现代 Web 开发中,用户交互越来越频繁,而每一次交互都可能触发复杂的逻辑处理或网络请求。如果不加以控制,这些高频操作会带来严重的性能问题。为此,防抖(Debounce)节流(Throttle) 成为了前端开发中不可或缺的性能优化手段。

本文将结合一段实际代码和详细注释,深入浅出地讲解防抖与节流的核心思想、实现方式以及适用场景,并重点解析其中的关键逻辑。


一、为什么需要防抖和节流?

设想这样一个场景:用户在搜索框中输入关键词,每按一次键就发起一次 AJAX 请求获取搜索建议。如果用户快速输入“react”,那么会依次触发 rrereareacreact 五次请求。

  • 问题1:请求开销大
    每次请求都需要消耗带宽、服务器资源,甚至可能造成接口限流。
  • 问题2:用户体验差
    如果请求响应慢,旧的请求结果可能会覆盖新的输入内容,导致显示错乱。

因此,我们需要一种机制来减少不必要的执行次数,只保留关键的操作。这就是防抖和节流要解决的问题。

防抖:在一定时间内,只执行最后一次操作。
节流:每隔固定时间,最多执行一次操作。


二、防抖(Debounce)——“只认最后一次”

1. 核心思想

无论执行多少次,只执行最后一次。

就像王者荣耀中的“回城”技能:如果你在回城过程中被攻击,回城会被打断并重新计时。只有当你完整地等待一段时间后,回城才会真正生效。

2. 代码实现与闭包应用

// 高阶函数 参数或者返回值是函数 (返回值是函数 -> 闭包)
function debounce(fn, delay) {
  var id; // 自由变量,闭包保存
  return function(args) {
    if (id) clearTimeout(id); // 清除已有定时器,重新计时
    var that = this; // 保存 this 上下文
    id = setTimeout(function() {
      fn.call(that, args); // 延迟执行原函数 并绑定正确的this和参数
    }, delay);
    // 这样只有最后一次触发后等待delay毫秒后才会真正执行
  };
}

关键点解析:

  • 闭包的作用id 是一个自由变量,被返回的函数所引用,从而在多次调用之间保持状态。这使得每次触发都能访问并清除上一次的定时器。
  • clearTimeout(id) :确保只有最后一次触发后的 delay 时间才会真正执行函数。
  • this 和参数传递:通过 callapply 确保原函数在正确的上下文中执行,并传入正确的参数。

3. 使用示例

const inputb = document.getElementById('debounce');
let debounceAjax = debounce(ajax, 200);
inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

用户快速输入时,只有停止输入 200ms 后,才会发送最终的完整关键词请求,极大减少了无效请求。


三、节流(Throttle)——“冷却期内不执行,但最后补一次”

1. 核心思想

每隔一定时间,最多执行一次。

但注意:我们实现的是带尾随执行(trailing)的节流,即在冷却期结束后,如果期间有触发,会补一次执行

就像技能有 CD(冷却时间),但如果你在 CD 期间一直按技能,CD 结束后会自动释放一次。

2. 代码实现与“尾随执行”逻辑

function throttle(fn, delay) {
  let last, deferTimer; // last上一次执行事件  deferTimer延迟执行的定时器
  return function() {
    let that = this;  
    let _args = arguments; // 类数组对象 保存所有参数
    let now = +new Date(); // 拿到当前时间戳  +强制类型转换 毫秒数

    if (last && now < last + delay) {
      // 处于冷却期 上次执行时间存在 且当前时间还没到下次允许执行的时间
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function() {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 已过冷却期,立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}

重点解析 if (last && now < last + delay) 分支:

  • 条件成立含义:已经执行过至少一次(last 存在),且当前时间距离上次执行不足 delay 毫秒 → 正处于冷却期。
  • 但不能忽略这次触发!因为这可能是用户最后一次有效操作(比如完整输入了“react”)。
  • 所以我们设置一个延迟定时器,计划在冷却期结束后执行。
  • clearTimeout(deferTimer) 的作用:用户可能在冷却期内多次触发,但我们只关心最后一次,所以每次都要清除旧的定时器,只保留最新的。

3. 为什么需要“尾随执行”?

核心原因:避免丢失最后一次有效操作。

假设用户想搜 “react”,在 200ms 内快速打完,而节流 delay = 500ms:

  • 简单节流(无尾随)

    • r(0ms)→ 立即执行
    • re(100ms)→ 被忽略
    • rea(150ms)→ 被忽略
    • react(200ms)→ 被忽略
      → 用户停止输入 但永远不会发送'react' 搜索框显示的是r的结果 而不是用户真正想搜的react
  • 带尾随的节流

    • r(0ms)→ 立即执行(last = 0
    • re(100ms)→ 冷却期,设 timer(600ms 执行)
    • rea(150ms)→ 更新 timer(650ms)
    • react(200ms)→ 更新 timer(700ms)
      → 用户停止输入后,在 700ms 自动执行 ajax('react')结果正确

4. 什么时候不需要尾随?

按钮防连点:用户点击“提交”按钮,你希望 2 秒内只能点一次。
这种情况下,不需要在 2 秒后自动再提交一次!此时应使用无尾随的简单节流


四、防抖 vs 节流:如何选择?

特性 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后 delay ms 执行 每隔 delay ms 最多执行一次
是否保证最后一次 ✅ 是 ✅(带尾随时)
典型场景 搜索建议、窗口 resize 滚动加载、鼠标移动、按钮点击(防连点)
类比 回城技能(被打断重计时) 技能 CD(冷却后可再放)
  • 搜索建议 → 用防抖:用户输入是连续的,我们只关心最终结果。
  • 滚动加载 → 用节流:用户持续滚动,我们需要定期检查是否到底部,不能等到停止滚动才加载。

五、总结

防抖和节流虽然都是用于限制函数执行频率,但它们的触发逻辑和适用场景截然不同

  • 防抖强调“只执行最后一次”,适用于用户意图明确、操作连续的场景,如搜索、表单校验。
  • 节流强调“定期执行”,适用于高频但需周期性响应的场景,如滚动、拖拽、游戏帧更新。

而我们在实现节流时,特别加入了尾随执行(trailing) 机制,这是为了兼顾性能与用户体验——既避免了过度请求,又确保不会丢失用户的最终操作。

正如注释中所说:
“核心原因:避免丢失最后一次有效操作。”

通过合理运用闭包、定时器和上下文绑定,我们不仅实现了功能,还保证了代码的健壮性和可复用性。这些技巧,正是前端工程师在性能优化道路上的必备武器。


小提示:在实际项目中,Lodash 等工具库已提供了成熟的 debouncethrottle 实现,支持更多选项(如 leadingtrailing 开关)。但理解其底层原理,才能在复杂场景中灵活应对。

希望本文能帮助你更清晰地掌握防抖与节流的本质。欢迎在评论区分享你的使用经验!

深入理解 React 中的 useRef:不只是获取 DOM 元素

作者 ohyeah
2026年1月1日 20:05

在 React 函数组件的世界中,useRef 是一个常被提及但又容易被误解的 Hook。很多初学者第一次接触它时,往往只把它当作“获取 DOM 元素”的工具;而随着使用深入,又可能会疑惑:为什么不能用 useRef 替代 useState 来避免不必要的重渲染?本文将结合两个典型示例,系统梳理 useRef 的核心机制、使用场景与常见误区,帮助你真正掌握这个“默默奉献”的 Hook。


一、useRef 的基本能力:持久化引用对象

useRef 的本质是创建一个可变且持久化的引用对象。它的返回值是一个普通 JavaScript 对象,结构为 { current: initialValue }。关键在于:

  • 每次组件重新渲染时,useRef 返回的是同一个对象引用
  • 修改 .current 属性不会触发组件重新渲染
  • 它不参与 React 的响应式更新机制,因此被称为“非响应式存储”。

这与 useState 形成鲜明对比:useState 的状态变更会触发 UI 更新,而 useRef 的变更则“静默”发生。


二、场景一:获取 DOM 元素并操作

最常见的 useRef 用法是绑定到 JSX 元素上,从而在组件逻辑中直接操作 DOM。

import { useRef, useEffect, useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后,inputRef.current 指向真实的 <input> 元素
    inputRef.current.focus();
  }, []);

  return (
    <>
      <input ref={inputRef} />
      {count}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  );
}

在这个例子中:

  • 初始渲染时,inputRef.currentnull,因为 DOM 尚未生成;
  • React 在完成 DOM 挂载后,会自动将对应的 DOM 节点赋值给 ref.current
  • useEffect(依赖项为空数组)在首次挂载后执行,此时 inputRef.current 已是有效的 <input> 元素,调用 .focus() 实现自动聚焦。

值得注意的是:即使 inputRef.currentnull 变为 DOM 节点,组件也不会重新渲染。这正是 useRef 的设计初衷——提供一种不干扰 React 渲染流程的方式来访问或存储数据。


三、场景二:存储可变值以避免状态重置

除了操作 DOM,useRef 还非常适合用于在多次渲染之间持久化存储可变值,尤其是在处理副作用(如定时器)时。

考虑以下错误写法:

// ❌ 错误:使用普通变量存储定时器 ID
let intervalId = null;

function start() {
  intervalId = setInterval(() => {
    console.log('tick~~~');
  }, 1000);
}

function stop() {
  clearInterval(intervalId); // 可能为 null!
}

问题在于:每当 count 状态更新,整个函数组件会重新执行,let intervalId = null 会被再次初始化,导致之前保存的定时器 ID 丢失。结果是:

  • 多次点击“开始”会创建多个定时器;
  • “停止”按钮无法清除旧的定时器;
  • 造成内存泄漏逻辑混乱

正确的做法是使用 useRef

import { useEffect, useRef, useState } from 'react';

export default function App() {
  const intervalId = useRef(null);
  const [count, setCount] = useState(0);

  function start() {
    intervalId.current = setInterval(() => {
      console.log('tick~~~');
    }, 1000);
  }

  function stop() {
    clearInterval(intervalId.current);
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      {count}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  );
}

这里的关键在于:

  • useRef(null) 在组件整个生命周期内始终返回同一个对象
  • 即使 count 更新导致组件重新渲染,intervalId.current 依然保留着上次设置的定时器 ID;
  • 因此 clearInterval 能正确清除目标定时器,避免资源泄露。

四、useRef 与 useState 的核心区别

虽然 useRefuseState 都能“存值”,但它们的设计目标截然不同:

特性 useState useRef
是否触发重渲染 ✅ 是 ❌ 否
值变更是否被 React 跟踪 ✅ 是(通过调度更新) ❌ 否
适用场景 驱动 UI 变化的状态 存储不需触发 UI 更新的可变数据
初始值 每次调用 useState(initial) 仅在首次生效 useRef(initial) 的初始值也仅在首次生效,但后续 .current 可任意修改

常见误区:能否用 useRef 替代 useState?

不能。

假设你试图用 useRef 存储计数器值以“避免重渲染”:

const countRef = useRef(0);
// ...
<button onClick={() => countRef.current++}>+1</button>
<p>{countRef.current}</p>

你会发现:点击按钮后,页面上的数字不会更新!因为 React 并不知道 countRef.current 发生了变化,自然不会重新执行渲染逻辑。

结论

  • 如果你需要同时存储值并更新 UI,必须使用 useState
  • 如果你只需要在不触发重渲染的前提下保存中间状态或引用(如定时器 ID、前一次的 props、滚动位置等),才应使用 useRef

五、useRef 的典型应用场景总结

  1. 访问 DOM 节点
    如聚焦输入框、测量元素尺寸、触发动画等。

  2. 持久化存储可变值

    • 定时器/延时器 ID(setInterval / setTimeout
    • WebSocket 实例
    • 第三方库的实例(如地图、图表对象)
    • 上一次的 props 或 state(用于对比)
  3. 跨渲染保持状态而不触发更新
    例如记录组件是否已挂载(在异步回调中判断是否还能安全 setState)。


结语

useRef 虽然名字里有 “ref”,但它远不止是“获取 DOM 的工具”。它是一个轻量级、非响应式的持久化容器,在需要“记住某些东西但又不想打扰 React 渲染流程”时大显身手。

正确使用 useRef,能让你的组件更高效、更健壮;而误用它(如试图替代状态管理),则会导致 UI 不更新或逻辑错乱。理解其“非响应式”和“引用持久化”的两大特性,是掌握这一 Hook 的关键。

在实际开发中,当你遇到以下情况时,不妨想想 useRef

  • “我需要在组件里保存一个值,但它变了不需要刷新页面。”
  • “我想在挂载后操作某个 DOM 元素。”
  • “我的定时器怎么关不掉了?是不是 ID 丢了?”

答案,往往就在 useRef 之中。

❌
❌