#React 里的 "暗箱操作":useRef 与受控/非受控组件的实战避坑指南

42 阅读5分钟

在 React 的函数式组件(Functional Components)世界里,我们习惯了用 useState 来驱动一切。数据变了 -> 视图更新,这很“响应式”。

但有时候,我们并不想让每一次数据的变动都惊动视图(Re-render),或者我们仅仅想像在 Vue 里 onMounted 拿 DOM 那样简单粗暴地操作一个 input。这时候,useState 就显得有点“大材小用”甚至由于频繁渲染带来性能负担。

今天我们来聊聊 React Hooks 里的“隐形富豪”—— useRef,以及它引出的表单处理两大门派:受控组件非受控组件


一、 useRef:不仅仅是 DOM 的钩子

很多从 Vue 转 React 的同学(包括曾经的我),第一眼看到 useRef,都会下意识觉得:“哦,这就是用来拿 document.getElementById 的。”

没错,但只对了一半。

1. 它是一个“静音”的容器

useStateuseRef 都是存储数据的容器,但性格完全不同:

  • useState (高调) :它是响应式的。你改了它,它立马通知 React:“嘿!我变了,快重新渲染组件!”
  • useRef (低调) :它不是响应式的。它是可变对象的存储器。你改了它的 .current 属性,它不仅不会触发重新渲染,而且这个值在组件的多次渲染之间是持久化的。

简单总结:

如果你需要一个变量,在组件更新时不丢失,且修改它时不触发视图更新,请用 useRef

2. 实战:那个总是“捉摸不透”的定时器

我们在写定时器或倒计时时,经常遇到闭包陷阱或者变量重置的问题。看下面这个经典的例子:

JavaScript

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

export default function App() {
  // ❌ 错误示范:如果用 let intervalId = null; 
  // 每次组件 re-render(比如 count 变化时),intervalId 都会被重置为 null,
  // 导致 stop() 函数里根本找不到当初那个 id,也就停不下来。
  
  // ✅ 正确姿势:使用 useRef 创建一个“持久化”的引用
  const intervalRef = useRef(null); 
  const [count, setCount] = useState(0);

  function start() {
    // 防止重复点击导致开启多个定时器
    if (intervalRef.current) return;
    
    intervalRef.current = setInterval(() => {
      console.log('tick~~~~');
    }, 1000);
    console.log('Timer Started:', intervalRef.current);
  }

  function stop() {
    // 即使组件因为 count 更新渲染了 100 次,intervalRef.current 依然是同一个值
    clearInterval(intervalRef.current);
    intervalRef.current = null;
    console.log('Timer Stopped');
  }

  // 只是为了演示 ref 在渲染过程中的稳定性
  useEffect(() => {
    console.log('Current Interval ID:', intervalRef.current);
  }, [count]);

  return (
    <div className="card">
      <h3>Ref vs Variable</h3>
      <div style={{ gap: '10px', display: 'flex' }}>
        <button onClick={start}>开始 Ticking</button>
        <button onClick={stop}>停止</button>
        <button onClick={() => setCount(count + 1)}>
          Count ++ (当前: {count})
        </button>
      </div>
    </div>
  );
}

在这个场景下,useRef 就像一个**“默默奉献”**的后台管家,它记住了定时器的 ID,却从不因为自己的变动去干扰 UI 的渲染。


二、 表单江湖:受控 vs 非受控

理解了 useRef,我们就能更好地理解 React 处理表单的两种模式。

1. 非受控组件 (Uncontrolled Components)

核心心法useRef + ref

这更像我们以前写 jQuery 或者原生 JS 的感觉。表单数据由 DOM 元素自己接管,React 只在需要的时候(比如提交时)去“读取”一下值。

适用场景

  • 表单非常简单,不需要实时验证。
  • 文件上传(<input type="file" /> 必须是非受控的)。
  • 性能敏感场景(不想每敲一个字都导致组件重绘)。

JavaScript

import { useRef } from "react";

// 就像一个“黑盒”,我们不关心输入过程,只关心结果
export default function CommentBox() {
    const textareaRef = useRef(null);

    const handleSubmit = () => {
        // 直接操作 DOM 获取值
        const comment = textareaRef.current.value;
        
        if (!comment.trim()) return alert('请输入评论内容');
        
        console.log('提交评论:', comment);
        // 甚至可以手动清空
        textareaRef.current.value = ''; 
    }

    return (
        <div className="comment-box">
            <h4>非受控组件示例</h4>
            <textarea ref={textareaRef} placeholder="想说点什么..." rows={4} />
            <br/>
            <button onClick={handleSubmit}>发布评论</button>
        </div>
    )
}

2. 受控组件 (Controlled Components)

核心心法useState + value + onChange

这是 React 官方推荐的“正统”写法。数据驱动页面,State 是唯一的数据源(Single Source of Truth)。输入框的 value 被 state 锁死,用户的输入通过 onChange 更新 state,state 更新再重新渲染 input 的 value。

适用场景

  • 需要实时表单校验(比如密码强度检测)。
  • 条件禁用提交按钮(比如必填项未填,按钮置灰)。
  • 输入格式化(比如输入电话号码自动加空格)。

JavaScript

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} style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '300px' }}>
      <h4>受控组件示例</h4>
      <input 
        type="text" 
        placeholder="请输入用户名" 
        name="username"
        // ⭐️ Value 被状态控制
        value={form.username}
        // ⭐️ 状态随输入改变
        onChange={handleChange}
      />
      
      <input 
        type="password" 
        placeholder="请输入密码" 
        name="password"
        value={form.password}
        onChange={handleChange}
      />
      
      {/* 受控的好处:可以实时根据状态控制 UI */}
      <button type="submit" disabled={!form.username || !form.password}>
        {(!form.username || !form.password) ? '请填写完整' : '注册'}
      </button>
    </form>
  )
}

三、 总结:该怎么选?

特性受控组件 (Controlled)非受控组件 (Uncontrolled)
数据源React State (useState)DOM 自身 (useRef)
实时性高(每输入一个字都在渲染)低(提交时才读取)
代码量较多 (需要写 onChange)较少
数据流单向数据流,逻辑清晰双向/命令式,简单直接
最佳用途复杂表单、实时校验、联动逻辑简单取值、文件上传、第三方库集成

一句话建议:

如果你的表单交互复杂,或者你需要完全掌控数据,请受控;如果你只是想做一个简单的搜索框或者为了性能优化,偶尔非受控一下(用 useRef)也是极好的。

React 的世界里没有绝对的对错,只有适不适合。只要理解了 useRef 那个“可变但静音”的特性,你就能在组件渲染的洪流中,稳稳地拿捏住每一个状态。