普通视图

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

React 之 自定义 Hooks 🚀

作者 xiaoxue_
2026年1月3日 17:15

自定义 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

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

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的对象,让组件可以获取鼠标坐标。

QQ20260103-184336.png

4. 效果展示:

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

QQ202613-165111.gif

三、详解自定义 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

// 封装响应式的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

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

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

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

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删除当前待办。

QQ20260103-170824.png

4. 效果展示:

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

QQ202613-1709.gif

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 运用到项目中,让代码更优雅、更可维护!🎉

React Hooks :useRef、useState 与受控/非受控组件全解析

作者 xiaoxue_
2026年1月2日 01:25

Hooks 函数之 useRef 与 useState,受控组件与非受控组件

大家好呀~ 今天咱们来聊聊 React 中非常重要的两个 Hooks 以及表单处理的两种方式 ——useRef、useState,还有受控组件与非受控组件。这些可是 React 开发中的基础又核心的知识点哦,掌握它们能让咱们的代码更优雅、更高效~ 😊

一、useRef 📌

1. 什么是 useRef

useRef 是 React 中用于持久化数据(跨组件渲染周期保存值)和直接操作 DOM 元素的 Hook,核心作用是 “在组件生命周期内保持一个可变的引用”。简单说,它就像一个 “保险箱”,可以存东西,而且不管组件怎么重新渲染,这个 “保险箱” 本身不会变,里面的东西却能随时拿出来用~

2. useRef 的特性

  • 返回值:调用 useRef(initialValue) 会返回一个不可变的容器对象(ref 对象),该对象只有一个属性 current,用于存储实际值。
  • 跨渲染周期保持引用:useRef 返回的 ref 容器对象在组件的整个生命周期内引用地址不变(即使组件多次重新渲染,它还是同一个对象)。
  • 修改 current 不会触发重渲染:直接修改 refContainer.current 的值,不会导致组件重新渲染,这是它与 useState 的核心区别。

代码举例:

咱们来看 App2.jsx 中的例子:

jsx

import { useRef, useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0); // 响应式状态,修改会触发重渲染
  console.log('组件渲染了...'); // 每次count变化都会打印,证明重渲染
  
  // 创建ref对象,初始值为null
  const inputRef = useRef(null); 
  console.log('ref容器对象:', inputRef); // 每次渲染打印的都是同一个对象(引用不变)

  // 组件挂载完成后执行(类似vue的onMounted)
  useEffect(() => {
    console.log('input DOM元素:', inputRef.current); // 此时current已指向input元素
    inputRef.current.focus(); // 操作DOM,让输入框自动聚焦
  }, []);

  return (
    <>
      {/* 将ref对象绑定到input元素 */}
      <input ref={inputRef} />
      <p>count: {count}</p>
      {/* 点击按钮修改count,触发组件重渲染 */}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  )
}

代码效果详解:

  • 当点击 count ++ 按钮时,count 状态变化会触发组件重渲染,控制台会打印 “组件渲染了...”。
  • 但每次打印的 inputRef 都是同一个对象(引用地址不变),这体现了 “跨渲染周期保持引用” 的特性。
  • useEffect 钩子在组件挂载后执行,此时 inputRef.current 已经指向了 input DOM 元素,通过 focus() 方法实现了自动聚焦,展示了 useRef 操作 DOM 的能力。
  • 即使多次修改 inputRef.current(比如手动修改它的值),也不会触发组件重渲染,这和 useState 完全不同~

注意观察,光标自动聚焦于输入框、组件渲染了而inputRef容器对象始终不变、修改inputRef.current的值不会触发重新渲染:

QQ202612-0438.gif

3. useRef 与 useState 比较 💡

特性 useRef useState
本质 用于存储 “持久化的可变值”(非响应式) 用于管理组件的响应式状态
触发重渲染 修改 current 不会触发重渲染 调用 setXxx 会触发重渲染
跨渲染周期 容器对象引用不变,current 可修改 每次渲染会创建新的状态变量(值可能相同)
适用场景 操作 DOM、存储定时器 ID、缓存数据等 管理影响 UI 展示的状态(如表单输入、开关状态等)

hooks函数中的useState 在我前面的文章里讲解过,不清楚或者感兴趣的小伙伴可以去翻出来看看。

咱们再看一个对比案例:

jsx

// App.jsx(用普通变量存定时器ID,失败案例)
export default function App() {
  let intervalId = null; // 每次渲染都会重置为null
  const [count, setCount] = useState(0);

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

  function stop() {
    clearInterval(intervalId); // 无法停止!因为intervalId已被重置
  }

  return <>{/* 按钮省略 */}</>;
}

注意观察:点击开始按钮,系统自动分配一个ID值,点击停止,可以停止因为此时ID值未被修改;再次点击开始按钮,点击count++,然后再点击停止,此时无法停止,因为ID因count值修改触发useState响应式状态修改导致重新渲染而被重置了。

QQ202612-02344.gif

// App2.jsx(用useRef存定时器ID,成功案例)
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); // 成功停止!因为current保留了定时器ID
  }

  return <>{/* 按钮省略 */}</>;
}

注意观察:点击开始按钮,系统自动分配一个ID值,点击停止,可以停止因为此时ID值未被修改;再次点击开始按钮,点击count++,然后再点击停止,此时亦可以停止,因为useRef 声明的ID不会因组件渲染而重置,即ID值仍未被修改。

QQ202612-02935.gif

这个例子完美体现了两者的区别:普通变量会在组件重渲染时重置,而 useRef 的 current 能持久化存储值,这就是为什么管理定时器、计时器这类跨渲染周期的值时,useRef 是更好的选择~

二、受控组件与非受控组件 📝

在 React 中,表单元素(如 input、textarea、select 等)的状态管理方式分为受控组件和非受控组件,两者的核心区别在于状态由谁来管理。

1. 受控组件(Controlled Components)

(1)定义

组件的状态(value)由 React 的状态(useState)管理,表单元素的值完全受 React 控制。就像老板(React 状态)直接指挥员工(表单元素),员工做什么都得听老板的~

(2)工作原理
  • 通过 value 属性将 React 状态与表单元素绑定(单向绑定)。
  • 通过 onChange 事件监听输入变化,实时更新 React 状态。
  • 表单元素的值始终与 React 状态保持一致,形成 “数据驱动视图”。
(3)特点
  • 状态完全由 React 控制:表单的值始终和 value 状态保持一致,不会出现 “失控” 的情况。
  • 实时可操作性:可以随时通过修改 value 状态来改变表单值(比如重置输入、强制填写格式等)。
  • 适合场景:需要实时验证(如输入长度限制)、表单联动(如一个输入框变化影响另一个)、实时展示输入内容的场景。

2. 非受控组件(Uncontrolled Components)

(1)定义

组件的状态由 DOM 自身管理,React 不直接控制表单元素的值,而是通过 ref 访问 DOM 元素获取值。就像老板(React)不直接指挥员工(表单元素),但需要时会去 “查岗”(通过 ref 获取值)~

(2)工作原理
  • 使用 useRef 创建一个 ref 对象,绑定到表单元素。
  • 不通过 onChange 实时更新状态,而是在需要时(如提交表单)通过 ref.current.value 读取 DOM 中的值。
(3)特点
  • 状态由 DOM 管理:输入的值直接存在 DOM 中,React 不跟踪实时变化。
  • 按需获取值:只有在需要时(如提交、点击按钮)才通过 ref 读取值,减少了状态更新的频率。
  • 适合场景:一次性读取值(如表单提交)、性能敏感场景(避免频繁状态更新)、文件上传(input type="file" 必须用非受控方式)。

3. 受控组件与非受控组件比较 🆚

维度 受控组件 非受控组件
状态管理者 React 状态(useState) DOM 自身
获取值方式 直接从状态变量获取 通过 ref.current.value 获取
实时性 实时更新状态,可即时响应 不实时更新,按需读取
适用场景 实时验证、表单联动、动态反馈 一次性提交、文件上传、性能优化

如何选择?

  • 大部分场景优先用受控组件,因为它能更好地体现 React “数据驱动” 的思想,状态可控性更强。
  • 当需要操作文件(file input)、追求性能(减少状态更新)或只需要一次性获取值时,用非受控组件更合适。

4.实战演练:

废话不多说,先看代码:

jsx

// 从 React 库中导入所需的两个 Hook:
// useState:用于创建和管理组件的响应式状态
// useRef:用于创建 DOM 元素引用,实现对原生 DOM 的直接访问
import {
  useState,
  useRef
} from 'react'

// 定义并导出 App 函数式组件,作为整个表单的根组件
export default function App() {

  // 初始化受控组件的响应式状态
  // value:存储第一个输入框的输入内容,初始值为空字符串
  // setValue:更新 value 状态的方法,调用后会触发组件重渲染,同步更新关联的 UI
  const [value, setValue] = useState('');

  // 创建一个 ref 引用对象,初始值为 null
  // 后续将绑定到第二个输入框,用于获取该输入框的原生 DOM 元素
  const inputRef = useRef(null);

  // 定义表单提交的处理函数,e 是浏览器原生的表单提交事件对象
  const doLogin = (e) => {
    // 阻止浏览器表单提交的默认行为(默认会刷新页面,破坏 React 单页应用的运行状态)
    e.preventDefault();
    // 通过 ref 引用对象的 current 属性,获取第二个输入框的 DOM 元素,读取其 value 属性(用户输入内容)并打印
    console.log(inputRef.current.value);
  }

  // 渲染组件的 UI 结构
  return (
    // 表单标签,绑定 onSubmit 事件,提交时触发 doLogin 处理函数
    <form onSubmit={doLogin}>
      <div>
        {/* 实时渲染受控组件的状态值 value,直观展示输入内容与 React 状态的同步效果 */}
        {value}
        
        {/* 第一个输入框:受控组件(由 React 状态管理输入值) */}
        <input
          type="text"  // 输入框类型为文本输入
          value={value}  //  React 状态 value 绑定到输入框的 value 属性让状态控制输入框的显示内容
          onChange={(e) => setValue(e.target.value)}  // 绑定输入变化事件
          // 输入变化时,获取输入框当前值(e.target.value),调用 setValue 更新 React 状态 value
          // 实现「输入变化 → 更新状态 → 更新 UI」的闭环,保持状态与输入框内容一致
        />

        {/* 第二个输入框:非受控组件(由 DOM 自身管理输入值) */}
        {/* 不绑定 value 属性和 onChange 事件,仅通过 ref 属性绑定 inputRef,用于后续获取 DOM 元素的值 */}
        <input type="text" ref={inputRef} />
      </div>
      {/* 表单提交按钮,type="submit" 点击后会触发表单的 onSubmit 事件 */}
      <button type="submit">登录</button>
    </form>
  )
}

代码效果解释:

这段代码在一个简单的登录表单中,同时实现了受控组件非受控组件两种 React 表单处理模式:

其中第一个输入框是受控组件,它通过 useState 定义响应式状态 value,以 value={value} 实现 React 状态对输入框显示值的单向绑定,又通过 onChange 事件监听输入变化,实时调用 setValue(e.target.value) 将 DOM 输入值同步到 React 状态中,输入框的所有状态完全由 React 管控,还能通过页面上渲染 {value} 实时预览输入内容。

而第二个输入框是非受控组件,它不依赖 React 状态管理,仅通过 useRef 创建的 inputRef 绑定到 DOM 元素,输入内容直接存储在 DOM 自身的 value 属性中,不在输入过程中做实时状态同步(代码中的非受控组件仅通过 ref 绑定 DOM 元素,输入内容只存储在 DOM 自身的 value 属性中,没有触发任何 React 状态更新,也不会触发组件重渲染,因此无法直接像受控组件那样实时展示输入内容。),仅在表单提交(触发 doLogin 函数)时,通过 inputRef.current.value 按需读取 DOM 中的输入值,同时表单提交时通过 e.preventDefault() 阻止了浏览器默认的页面刷新行为,清晰对比了 React 两种表单处理方式的核心差异与实现逻辑。

QQ202612-1857.gif

三、面试官会问 🤔

  1. useRef 和 useState 的核心区别是什么? 答:useRef 存储的是 “非响应式” 的持久化值,修改 current 不会触发重渲染;useState 管理的是响应式状态,调用 setter 会触发重渲染。useRef 适合存储跨渲染周期的非 UI 相关数据(如 DOM、定时器 ID),useState 适合管理影响 UI 的状态。
  2. 为什么 useRef 能跨组件渲染周期保存值? 答:因为 useRef 返回的 ref 容器对象在组件整个生命周期内引用地址不变,即使组件重新渲染,这个对象也不会被重新创建,所以 current 属性存储的值能被持久化。
  3. 受控组件和非受控组件的区别是什么?分别适合什么场景? 答:受控组件由 React 状态管理值,通过 value 和 onChange 绑定,适合实时验证、表单联动;非受控组件由 DOM 管理值,通过 ref 获取,适合一次性读取、文件上传等场景。
  4. 如何用 useRef 操作 DOM 元素? 答:先用 useRef(null) 创建 ref 对象,然后通过 ref 属性绑定到 DOM 元素,当组件挂载后,ref.current 就会指向该 DOM 元素,进而可以调用 DOM 方法(如 focus()scrollIntoView())。

四、结语 🌟

今天咱们详细学习了 useRef、useState 以及受控组件和非受控组件的知识。useRef 就像一个 “持久化的工具箱”,帮我们存储跨渲染周期的数据和操作 DOM;而受控与非受控组件则是表单处理的两种思路,各有适用场景。

其实这些知识点并不难,关键是多动手实践~ 比如用受控组件做一个带实时验证的登录表单,用 useRef 实现一个自动聚焦的搜索框,相信练习之后大家会理解得更透彻!😘

❌
❌