React useRef与DOM操作

useRef 是React中用于创建可变引用的Hook,可以访问DOM元素或存储任意可变值而不触发重新渲染。

什么是useRef?

useRef返回一个可变的ref对象,其.current属性被初始化为传入的参数。这个对象在组件的整个生命周期内保持不变。

React组件
DOM元素

基本用法

1. 访问DOM元素

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

function TextInput() {
  // 创建ref对象
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后,inputRef.current指向真实的DOM元素
    console.log('DOM元素:', inputRef.current);
    console.log('元素值:', inputRef.current.value);
  }, []);

  const handleClick = () => {
    // 访问DOM元素的属性和方法
    inputRef.current.focus();
    inputRef.current.style.backgroundColor = '#ffffcc';
  };

  return (
    <div>
      <input
        ref={inputRef}
        type="text"
        defaultValue="初始文本"
      />
      <button onClick={handleClick}>
        聚焦并高亮
      </button>
    </div>
  );
}
function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载时自动聚焦
    inputRef.current.focus();
  }, []);

  const handleClear = () => {
    inputRef.current.value = '';
    inputRef.current.focus();
  };

  return (
    <div>
      <input
        ref={inputRef}
        placeholder="输入内容..."
      />
      <button onClick={handleClear}>清空</button>
    </div>
  );
}
function MeasureElement() {
  const divRef = useRef(null);
  const [dimensions, setDimensions] = useState({});

  useEffect(() => {
    if (divRef.current) {
      const rect = divRef.current.getBoundingClientRect();
      setDimensions({
        width: rect.width,
        height: rect.height,
        top: rect.top,
        left: rect.left
      });
    }
  }, []);

  return (
    <div>
      <div
        ref={divRef}
        style={{
          padding: '20px',
          border: '1px solid #ccc',
          margin: '10px 0'
        }}
      >
        测量这个元素
      </div>
      <div>
        <p>宽度: {dimensions.width}px</p>
        <p>高度: {dimensions.height}px</p>
      </div>
    </div>
  );
}

2. 存储可变值(不触发重新渲染)

function CounterWithRef() {
  const countRef = useRef(0); // 不会触发重新渲染
  const [count, setCount] = useState(0); // 会触发重新渲染

  const incrementRef = () => {
    countRef.current += 1;
    console.log('Ref计数:', countRef.current);
  };

  const incrementState = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <div>
        <h4>useState计数: {count}</h4>
        <button onClick={incrementState}>增加State</button>
      </div>
      <div>
        <h4>useRef计数: {countRef.current}</h4>
        <button onClick={incrementRef}>增加Ref</button>
        <p className="text-muted">
          ※ Ref变化不会触发重新渲染,所以显示的值不会更新
        </p>
      </div>
    </div>
  );
}
何时使用useRef存储值:
  • 存储不需要触发重新渲染的值
  • 存储上一次的状态或props
  • 存储定时器ID、事件监听器引用等
  • 存储动画帧ID
  • 存储任何在渲染间需要持久化的值

forwardRef:传递ref给子组件

默认情况下,函数组件不能接收ref属性。使用forwardRef可以将ref传递给子组件。

import React, { forwardRef, useRef } from 'react';

// 子组件使用forwardRef包装
const FancyInput = forwardRef((props, ref) => {
  return (
    <input
      ref={ref}
      className="fancy-input"
      {...props}
    />
  );
});

// 父组件
function ParentComponent() {
  const inputRef = useRef(null);

  const handleFocus = () => {
    inputRef.current.focus();
    inputRef.current.select();
  };

  return (
    <div>
      <FancyInput
        ref={inputRef}
        placeholder="我是fancy input"
      />
      <button onClick={handleFocus}>
        聚焦并选中文本
      </button>
    </div>
  );
}

useImperativeHandle:自定义暴露的ref方法

使用useImperativeHandle可以自定义通过ref暴露给父组件的方法。

import React, {
  forwardRef,
  useRef,
  useImperativeHandle
} from 'react';

const CustomInput = forwardRef((props, ref) => {
  const inputRef = useRef(null);

  // 自定义暴露给父组件的方法
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    shake: () => {
      const input = inputRef.current;
      input.style.transform = 'translateX(10px)';
      setTimeout(() => {
        input.style.transform = 'translateX(-10px)';
        setTimeout(() => {
          input.style.transform = '';
        }, 50);
      }, 50);
    },
    getValue: () => {
      return inputRef.current.value;
    },
    setValue: (value) => {
      inputRef.current.value = value;
    }
  }));

  return <input ref={inputRef} {...props} />;
});

function ParentComponent() {
  const customInputRef = useRef(null);

  return (
    <div>
      <CustomInput
        ref={customInputRef}
        placeholder="试试特殊效果"
      />
      <div className="control-panel">
        <button onClick={() => customInputRef.current.focus()}>
          聚焦
        </button>
        <button onClick={() => customInputRef.current.shake()}>
          摇动效果
        </button>
        <button onClick={() => console.log(customInputRef.current.getValue())}>
          获取值
        </button>
      </div>
    </div>
  );
}

交互演示:useRef vs useState

常见用例

1. 管理定时器和动画

function TimerComponent() {
  const [time, setTime] = useState(0);
  const timerRef = useRef(null);

  const startTimer = () => {
    if (!timerRef.current) {
      timerRef.current = setInterval(() => {
        setTime(prev => prev + 1);
      }, 1000);
    }
  };

  const stopTimer = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  };

  const resetTimer = () => {
    stopTimer();
    setTime(0);
  };

  useEffect(() => {
    // 组件卸载时清理定时器
    return () => stopTimer();
  }, []);

  return (
    <div>
      <h3>计时器: {time}秒</h3>
      <button onClick={startTimer}>开始</button>
      <button onClick={stopTimer}>停止</button>
      <button onClick={resetTimer}>重置</button>
    </div>
  );
}

2. 存储上一次的值

function UsePreviousValue() {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef();

  useEffect(() => {
    // 更新ref为当前值
    prevCountRef.current = count;
  });

  const prevCount = prevCountRef.current;

  return (
    <div>
      <p>当前计数: {count}</p>
      <p>上一次计数: {prevCount !== undefined ? prevCount : '无'}</p>
      <button onClick={() => setCount(count + 1)}>
        增加
      </button>
    </div>
  );
}

3. 视频播放控制

function VideoPlayer() {
  const videoRef = useRef(null);
  const [isPlaying, setIsPlaying] = useState(false);

  const togglePlay = () => {
    if (videoRef.current) {
      if (isPlaying) {
        videoRef.current.pause();
      } else {
        videoRef.current.play();
      }
      setIsPlaying(!isPlaying);
    }
  };

  const skip = (seconds) => {
    if (videoRef.current) {
      videoRef.current.currentTime += seconds;
    }
  };

  return (
    <div>
      <video
        ref={videoRef}
        width="400"
        src="/path/to/video.mp4"
        onPlay={() => setIsPlaying(true)}
        onPause={() => setIsPlaying(false)}
      />
      <div className="control-panel">
        <button onClick={togglePlay}>
          {isPlaying ? '暂停' : '播放'}
        </button>
        <button onClick={() => skip(-10)}>
          -10秒
        </button>
        <button onClick={() => skip(10)}>
          +10秒
        </button>
        <button onClick={() => videoRef.current.volume = 0}>
          静音
        </button>
      </div>
    </div>
  );
}

Ref回调模式

function RefCallbackExample() {
  const [height, setHeight] = useState(0);

  // Ref回调函数
  const measureRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <div>
      <div
        ref={measureRef}
        style={{ padding: '20px', background: '#e3f2fd' }}
      >
        这个元素的高度会被测量
      </div>
      <p>元素高度: {height}px</p>
    </div>
  );
}
注意事项:
  • 避免在渲染期间修改ref.current(在useEffect或事件处理函数中修改)
  • ref.current在初始渲染期间为null
  • ref不会自动通知组件重新渲染
  • 过度使用ref可能违反React的数据流原则
  • 在函数组件中使用ref,不能使用字符串ref(如ref="myRef")

useRef vs createRef

特性 useRef createRef
使用场景 函数组件 类组件
生命周期 在整个组件生命周期中保持不变 每次渲染创建新引用
性能 更高效,避免重复创建 每次渲染都会创建新对象
存储值 可以存储任意值并保持更新 主要用于DOM引用
重新渲染 变化不会触发重新渲染 每次渲染创建新引用

最佳实践

  1. 只在必要时使用ref:优先考虑使用state和props
  2. 清理资源:在useEffect返回函数中清理定时器、监听器等
  3. 类型安全:使用TypeScript时定义ref的类型
    const inputRef = useRef<HTMLInputElement>(null);
  4. 避免过度暴露:使用useImperativeHandle仅暴露必要的方法
  5. 处理null值:始终检查ref.current是否存在
  6. 性能优化:对复杂组件使用forwardRef避免不必要的包装
完整示例:可编辑列表

这个示例展示了如何结合使用useRef、useState和DOM操作:

function EditableList() {
  const [items, setItems] = useState(['苹果', '香蕉', '橙子']);
  const inputRef = useRef(null);
  const listRef = useRef(null);

  const addItem = () => {
    const value = inputRef.current.value.trim();
    if (value) {
      setItems([...items, value]);
      inputRef.current.value = '';
      inputRef.current.focus();
    }
  };

  const removeItem = (index) => {
    const newItems = items.filter((_, i) => i !== index);
    setItems(newItems);
  };

  useEffect(() => {
    // 每次列表更新后滚动到底部
    if (listRef.current) {
      listRef.current.scrollTop = listRef.current.scrollHeight;
    }
  }, [items]);

  return (
    <div>
      <div>
        <input ref={inputRef} placeholder="输入新项目..." />
        <button onClick={addItem}>添加</button>
      </div>
      <ul
        ref={listRef}
        style={{
          maxHeight: '200px',
          overflowY: 'auto',
          padding: '10px',
          border: '1px solid #ddd'
        }}
      >
        {items.map((item, index) => (
          <li key={index} style={{ margin: '5px 0' }}>
            {item}
            <button
              onClick={() => removeItem(index)}
              style={{ marginLeft: '10px' }}
            >
              删除
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}