React Hooks 深度解析:从 useRef 到受控/非受控组件,玩转表单的秘密武器!

59 阅读13分钟

好的,各位React的萌新和老鸟们,大家好呀!今天咱们要一起踏上一个超有趣的React探险之旅,从神秘的useRef钩子开始,一路闯关到 “受控组件”“非受控组件” 的奇妙世界。别看这些概念听起来有点高大上,但相信我,跟着我的节奏,咱们不仅能把它们搞得明明白白,还能在轻松愉快的氛围中,让你的React功力蹭蹭上涨!📈

这篇教程,专门为掘金的React学习者们量身定制的,保证干货满满,代码讲解细致入微,经得起推敲!准备好了吗?系好安全带,咱们这就出发!


第一站:揭开useRef的神秘面纱——DOM操作与可变数据的存储

在React的世界里,我们通常提倡“数据驱动视图”,尽量避免直接操作DOM。但总有些时候,我们就是需要“越界”一下,比如,当页面加载完成时,让某个输入框自动获得焦点;或者,我们需要在组件的整个生命周期中,保存一个不希望因为组件重新渲染而丢失,同时又不需要触发视图更新的值。这时候,useRef就闪亮登场了!

场景一:让输入框“C位出道”——自动聚焦🎯

想象一下,你打开一个登录页面,是不是希望光标能自动停留在用户名输入框里,省去你点击的麻烦?这就是一个典型的DOM操作需求。在React中,我们怎么优雅地实现它呢?

让我们来看看以下案例:

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

export default function App() {
  const [count, setCount] = useState(0);// 响应式状态
  console.log('变了~~~~~');
  const inputRef = useRef(null);// 初始值为空
  console.log(inputRef.current);
  useEffect(() => {
    console.log(inputRef.current);
    inputRef.current.focus();
  }, []);
  // 自动聚焦
  return (
    <>
      <input ref={inputRef}/>
      {count}
      <button type="button" onClickCapture={() => setCount(count + 1)}>count++</button>
    </>
  )
}

代码解析:

  1. import { useState, useRef, useEffect } from 'react';: 这里我们引入了三个React的Hooks:
    • useRef: 咱们今天的主角,用于创建可变的引用对象。
  2. const inputRef = useRef(null);: 重点来了!我们使用 useRef 创建了一个 inputRefuseRef 接收一个初始值作为参数,这里我们给它 null,表示一开始它不指向任何东西。inputRef 返回一个可变的引用对象,它的 current 属性会指向被引用的DOM元素。
  3. useEffect(() => { ... }, []);: 这是一个 useEffect 钩子,它的第二个参数是一个空数组 []。这意味着这个副作用函数只会在组件首次渲染后执行一次(类似于Vue的onMounted或类组件的componentDidMount)。
    • inputRef.current.focus();: 这就是实现自动聚焦的关键!我们通过 inputRef.current 获取到真实的DOM input 元素,然后调用它的 focus() 方法,让它获得焦点。
  4. <input ref={inputRef}/>: 在JSX中,我们将 inputRef 传递给 input 元素的 ref 属性。React会在 input 元素渲染到DOM后,将这个DOM元素的引用赋值给 inputRef.current

通过这个例子,我们成功地让输入框在页面加载时自动获得了焦点。useRef 就像一个“秘密通道”,让我们在必要时能够直接与DOM元素进行交互,而不会破坏React的声明式编程范式。

useRef vs useState:响应式✅与非响应式❌的较量

在上面的代码中,我们同时使用了 useStateuseRef。它们虽然都能存储“可变对象”,但骨子里却有着本质的区别:

  • useState 是响应式的✅:当 useState 管理的状态发生变化时,组件会重新渲染,视图也会随之更新。就像你改变了电视的频道,屏幕上的画面会立刻跟着变。
  • useRef 是非响应式的❌:useRef 存储的值发生变化时,不会触发组件的重新渲染。它更像是一个“幕后工作者”,默默地保存着数据,但不会主动通知“舞台”更新。

当你点击 count++ 按钮时:

  1. setCount(count + 1) 会更新 count 状态。
  2. count 状态的改变会触发组件重新渲染。
  3. 你会看到 console.log('变了~~~~~'); 被打印出来,{count} 显示的值也会更新。
  4. 但是,inputRef.current 的值(它指向的DOM元素)并没有改变,而且 useRef 本身也不会因为其 current 属性的改变而触发组件重新渲染。

所以,总结一下:

  • 如果你需要一个值在变化时能触发视图更新,那就用 useState✅。
  • 如果你需要一个值在组件的整个生命周期中保持不变(或者变化时不触发视图更新),并且你希望它能引用DOM元素或者其他可变对象,那就用 useRef❌。

场景二:保存“不希望丢失”的可变数据——计时器ID⏱️

除了DOM引用,useRef 还有一个非常重要的应用场景:存储在组件多次渲染之间需要保持不变,但又不需要触发重新渲染的任意可变值。最经典的例子就是计时器的ID。

让我们看看以下案例:

import {
    useEffect,
    useRef, // 默默奉献存储能力
    useState // 响应
} from 'react';

export default function App() {
    let intervalId = useRef(null); // 使用 useRef 存储 intervalId
    const [count, setCount] = useState(0);

    function start() {
        intervalId.current = setInterval(() => {
            console.log('tick~~~~');
        },1000);
        console.log(intervalId); // 打印 useRef 对象
    }

    function stop() {
        clearInterval(intervalId.current); // 清除计时器
    }

    useEffect(() => {
        console.log(intervalId.current); // 在 useEffect 中访问
    }, [count]); // 依赖 count,当 count 变化时执行

    return (
        <>
        <button onClick={start}>开始</button>
        <button onClick={stop}>暂停</button>
        {count}
        <button type="button" onClickCapture={() => setCount(count + 1)}>count++</button>
        </>  
    )
}

代码解析:

  1. let intervalId = useRef(null);: 这里我们再次请出 useRef,这次它不是用来引用DOM,而是用来存储一个普通的JavaScript值——计时器的ID。初始值同样是 null
  2. function start() { ... }:
    • intervalId.current = setInterval(() => { console.log('tick~~~~'); },1000);: 当点击“开始”按钮时,我们启动一个 setInterval 计时器,每秒打印一次“tick~~~~”。setInterval 会返回一个唯一的ID,我们把这个ID赋值给 intervalId.current。注意,这里我们是通过 intervalId.current 来访问和修改 useRef 存储的值。
    • console.log(intervalId);: 打印的是 useRef 对象本身,它会包含一个 current 属性。
  3. function stop() { clearInterval(intervalId.current); }: 当点击“暂停”按钮时,我们通过 clearInterval 清除之前启动的计时器。同样,我们通过 intervalId.current 获取到计时器的ID。
  4. useEffect(() => { console.log(intervalId.current); }, [count]);: 这个 useEffect 依赖于 count。当 count 变化时,它会执行。你会发现,即使 count 变化导致组件重新渲染,intervalId.current 存储的计时器ID也不会丢失,它依然是之前 setInterval 返回的那个ID。

🧠为什么这里不能用普通变量 let intervalId = null; 呢?

如果你尝试用一个普通的 let intervalId = null; 来代替 useRef,你会发现,每当 count 状态改变,组件重新渲染时,这个 intervalId 变量都会被重新初始化为 null。这意味着,你启动的计时器ID会丢失,stop 函数就无法清除正确的计时器了。

useRef 就像一个“记忆盒子”,它创建的引用对象在组件的整个生命周期中都是同一个。即使组件重新渲染,intervalId 这个 useRef 对象本身不会变,它内部的 current 属性也能够持久地保存你赋予它的值,而不会被重置。这就是 useRef 在存储可变但不需要响应式更新的数据时的强大之处!


第二站:驾驭表单——受控组件与非受控组件的艺术📝

在Web开发中,表单是与用户交互的重中之重。在React中处理表单输入,我们有两种主要策略:“受控组件”和“非受控组件”。这两种方式各有千秋,理解它们的区别和适用场景,能让你在表单处理上游刃有余。

  • 受控组件:表单元素的值由React状态(state)控制。数据流是单向的,状态驱动视图。
  • 非受控组件:表单元素的值由DOM自身管理。通过 ref 来获取表单的当前值。

场景三:深入理解受控与非受控——一个表单,两种玩法

让我们通过下面这个例子,来直观感受一下这两种组件的差异:

  const [value, setValue] = useState(''); // 受控组件的状态
  const inputRef = useRef(null); // 非受控组件的引用

  const doLogin = (e) => {
    e.preventDefault(); // 阻止表单默认提交行为
    console.log('受控组件的值:', value);
    console.log('非受控组件的值:', inputRef.current.value);
  }

  return (
    <form onSubmit={doLogin}>
    
      <p>受控组件的值: {value}</p>
      <input 
      type="text"
      value={value} // 绑定状态
      onChange={(e) => setValue(e.target.value)} // 监听变化更新状态
      />
      
      <p>非受控组件的值:</p>
      <input type="text" ref={inputRef} /> {/* 绑定 ref */}
      <button type="submit">登录</button>
      
    </form>
  )

代码解析:

  1. const doLogin = (e) => { ... }: 表单提交时的处理函数。

    • e.preventDefault();: 阻止浏览器默认的表单提交行为(页面刷新)。
  2. 受控组件的 <input>:

    • value={value}: 这是受控组件的核心!输入框的 value 属性被直接绑定到React的 value 状态。这意味着,输入框显示的内容完全由 value 状态决定。
    • onChange={(e) => setValue(e.target.value)}: 当用户在输入框中输入内容时,onChange 事件会被触发。我们通过 e.target.value 获取到输入框最新的值,然后调用 setValue 来更新 value 状态。
    • 思考🧠:如果只设置 value={value} 而不设置 onChange 会怎样? 你会发现,输入框变成了一个“只读”的输入框!你无法在里面输入任何内容。这是因为 value 属性被 value 状态牢牢控制着,而 value 状态又没有机会被更新。所以,对于受控组件,valueonChange 总是形影不离的“好搭档”。
  3. 非受控组件的 <input>:

    • <input type="text" ref={inputRef} />: 这个输入框没有 value 属性,也没有 onChange 事件处理器。它的值完全由DOM自身管理。
    • 当用户在这个输入框中输入内容时,输入框会像普通的HTML输入框一样,自行更新其内部的 value
    • 如果我们需要获取它的值,就必须通过 inputRef.current.value 来直接访问DOM。

受控组件的优点:

  • 单向数据流,易于理解和调试:数据从状态流向表单,表单的变化再更新状态。整个过程清晰明了。
  • 实时验证和反馈:你可以在 onChange 事件中立即对输入进行验证,并向用户提供实时反馈(比如密码强度提示)。
  • 方便的数据操作:你可以轻松地对表单数据进行格式化、过滤或与其他状态进行联动。
  • 表单重置和预填充:由于值由状态控制,你可以随时通过改变状态来重置表单或预填充数据。

非受控组件的优点:

  • 简单直接:对于简单的表单,不需要编写 onChange 事件处理器,代码量更少。
  • 性能优化:由于不需要每次输入都更新React状态并触发重新渲染,对于某些性能敏感的场景(如文件上传),非受控组件可能更具优势。
  • 与传统HTML表单行为一致:如果你习惯了传统的HTML表单处理方式,非受控组件会让你感觉更熟悉。

场景四:混合使用——评论区与登录框的智慧选择

在实际项目中,我们往往会根据具体需求,灵活选择受控组件或非受控组件,甚至混合使用。让我们看看下面这个实战案例,它引入了 CommentBoxLoginFrom 两个组件:

import CommentBox from './Components/CommentBox';
import LoginFrom from './Components/LoginForm'; 
export default function  App() {
    return (
        <>
        <CommentBox />
        <LoginFrom />
        </>
    )
}

实现策略:

  • CommentBox (评论输入框)

    • 常见选择:非受控组件
    • 理由:评论输入通常是用户输入一大段文字,然后一次性提交。我们可能不需要在用户输入每个字符时都进行实时验证或复杂的联动。使用 ref 在提交时获取评论内容,可以减少不必要的组件渲染,提高性能。当然,如果需要实时字数统计、敏感词过滤等功能,也可以选择受控组件。
  • LoginForm (登录表单)

    • 常见选择:受控组件
    • 理由:登录表单通常包含用户名、密码等字段,这些字段往往需要:
      • 实时验证:比如用户名是否符合格式、密码强度是否足够。
      • 禁用提交按钮:在表单内容不合法时禁用提交按钮。
      • 错误提示:根据输入内容实时显示错误信息。
      • 数据预处理:比如自动去除输入首尾空格。
    • 所有这些功能,受控组件都能非常优雅地实现,因为表单的值始终在React状态的掌控之中。

LoginForm 是一个受控组件,并且它的状态是一个对象,就像这样:

import { useState } from 'react';
export default function LoginForm() {
    const [form, setForm] = useState({
        username: '',
        password: '',
    });
    const handleChange = (e) => {
        // 返回一个新的对象
        setForm({
            ...form,
            [e.target.name]: e.target.value
        })
    }
    const handleSubmit = (e) => {
        e.preventDefault();
        console.log(form);
    }
    return (
        <form onSubmit={handleSubmit}>
            <input 
                type="text" 
                placeholder="请输入用户名"
                name="username"
                onChange={handleChange}
                value={form.username}
             />
             <input 
                type="password" 
                placeholder="请输入密码"
                name="password"
                onChange={handleChange}
                value={form.password}
             />
             <button type="submit">注册</button>
        </form>
    )
}

useState 赋值为对象:

在这个 LoginForm 示例中,我们看到 useState 不仅可以存储基本类型(如字符串、数字),还可以存储对象:

const [form, setForm] = useState({
        username: '',
        password: ''
});

当我们需要管理多个相关的表单字段时,将它们组织成一个对象是非常常见的做法。这样,我们只需要一个 form 状态来代表整个表单的数据。

handleChange 函数中,我们使用了对象展开运算符 (...form) 来更新状态。这是React中更新对象状态的推荐方式,因为它确保了状态的不可变性(即每次都返回一个新的对象,而不是直接修改旧对象),这对于React的性能优化和状态管理至关重要。

const handleChange = (e) => {
        // 返回一个新的对象
        setForm({
            ...form,// 原本的值
            [e.target.name]: e.target.value // 重新给修改的值赋值覆盖之前的值
        })
    }

这里的 [e.target.name]: value 获得修改了的值的DOM元素的name,这个名字与form这个state的name属性一一对应。这样,一个 handleChange 函数就可以通用地处理 usernamepassword 两个输入框的变化了。


总结与展望:React表单处理的“双剑合璧” ⚔️

恭喜你,一路披荆斩棘,我们已经成功地探索了 useRef 的奥秘,并掌握了受控组件和非受控组件的精髓!

回顾一下我们的收获:

  • useRef
    • 可以储存DOM元素,是你在React中进行DOM编程的利器,比如实现自动聚焦。
    • 是存储可变但不需要触发重新渲染的数据的绝佳选择,比如计时器ID。
    • useState 最大的区别在于其非响应式的特性,它不会引起组件的重新渲染。
  • 受控组件
    • 表单元素的值完全由React状态控制。
    • 需要 value 属性和 onChange 事件处理器协同工作
    • 适用于需要实时验证、数据联动、复杂逻辑的表单。
  • 非受控组件
    • 表单元素的值由DOM自身管理。
    • 通过 ref 在需要时直接访问DOM来获取值。
    • 适用于简单表单、一次性读取、性能敏感的场景(如文件上传)。
  • useState 存储对象
    • 管理多个相关表单字段的优雅方式。
    • 更新对象状态时,务必注意不可变性,使用展开运算符 (...) 来创建新对象。

在实际开发中,没有绝对的好坏,只有最适合的方案。理解这两种表单处理方式的优缺点,并根据具体需求灵活选择,是每个React开发者必备的技能。就像武林高手,既要会使“内功”(数据驱动),也要会使“外功”(DOM操作),才能在江湖中立于不败之地!

希望这篇教程能让你对React的 useRef 和表单处理有更深刻的理解。现在,是时候打开你的IDE,动手实践一下这些知识了!在代码的世界里,实践才是检验真理的唯一标准!

如果你有任何疑问或者想分享你的学习心得,欢迎在评论区留言,我们一起交流进步!下次再见啦,Reacter们!