React useRef学习笔记:掌握函数组件中的持久化引用

47 阅读13分钟

React useRef学习笔记:掌握函数组件中的持久化引用

React Hooks的引入彻底改变了函数组件的开发方式,使其具备了类组件的全部能力。在众多Hooks中,useRef是一个低调但功能强大的工具,它为函数组件提供了持久化引用的能力,而不触发组件重新渲染 。作为React官方推荐的核心Hooks之一,useRef在处理DOM元素引用、存储非响应式数据以及避免闭包陷阱等方面发挥着不可替代的作用。本文将深入探讨useRef的基本概念、工作原理、与useState的区别、三大主要应用场景以及最佳实践,帮助开发者全面掌握这一工具。

一、useRef的基本概念和作用

1.1 基本定义

useRef是React提供的一个Hook,用于创建和管理可变引用对象。它返回一个对象,该对象具有一个特殊的属性——.current,开发者可以通过这个属性直接读写值 。与useState不同,useRef不会因为其值的改变而触发组件的重新渲染,这使得它非常适合存储不需要引起UI变化的数据。

1.2 基本用法

import { useRef } from 'react';

function MyComponent() {
  const myRef = useRef(null);
  
  // 访问和修改ref的current属性
  console.log(myRef.current);
  myRef.current = 'New value';
  
  return <div ref={myRef}>This is my component</div>;
}

在上述代码中,useRef(null)创建了一个初始值为null的引用对象。myRef.current可以随时读写,且不会导致组件重新渲染 。

1.3 核心特点

特性描述
持久化引用在组件整个生命周期中保持不变
非响应式修改.current属性不会触发重新渲染
初始值设置可以设置任意类型的初始值
直接访问通过.current属性直接访问和修改值

二、useRef的工作原理和底层机制

2.1 闭包与引用对象持久化

useRef的底层实现基于JavaScript的闭包机制。当组件第一次渲染时,useRef会创建一个引用对象并将其保存在闭包中 。由于闭包的特性,这个引用对象在组件的整个生命周期中保持不变,即使组件重新渲染,引用对象的内存地址也不会改变。这种持久化特性使得useRef特别适合存储需要在多个渲染周期之间保持不变的值。

2.2 非响应式设计原理

与useState不同,useRef的设计是非响应式的。当修改ref.current的值时,React不会检测到这一变化,因此不会触发组件的重新渲染 。这种设计使得useRef成为处理副作用(如定时器ID、DOM元素引用)的理想选择,因为它不会干扰React的响应式状态管理机制。

2.3 与类组件ref的对比

在类组件中,我们可以通过React.createRef()创建ref对象,而在函数组件中,useRef提供了更简洁的实现方式 。两者的核心区别在于:类组件的ref对象需要显式传递给子组件,而函数组件可以通过forwardRefuseImperativeHandle更灵活地控制ref的传递 。

三、useRef与useState的区别和适用场景

3.1 功能对比

特性useStateuseRef
响应式是,状态变更会触发重新渲染否,修改.current不会触发重新渲染
更新方式通过函数(setState)更新直接修改.current属性
初始值可以是任意类型可以是任意类型
适用场景需要引起UI变化的状态DOM元素引用、非响应式值存储

3.2 使用场景对比

场景合适的Hook原因
表单输入管理useState输入值变化需要更新UI
需要自动聚焦的输入框useRef获取DOM元素引用
存储定时器IDuseRef避免闭包陷阱,保持引用持久化
记录上一次状态值useRef存储不需要引起UI变化的值
访问子组件DOMuseRef + forwardRef需要直接操作子组件内部元素

3.3 代码示例对比

// 使用useState管理表单输入
import { useState } from 'react';

function InputForm() {
  const [inputValue, setInputValue] = useState('');
  
  return (
    <input
      value={inputValue}
      onChange={(e) => setInputValue(e.target.value)}
    />
  );
}
// 使用useRef获取DOM元素引用
import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef(null);
  
  useEffect(() => {
    inputRef.current.focus();
  }, []); // 只在挂载时执行一次  

  return <input ref={inputRef} />;
}

四、useRef的三大主要应用场景

4.1 DOM节点引用

最常见的应用场景是获取和操作DOM元素。在函数组件中,我们可以通过useRef创建引用对象,并将其传递给需要操作的DOM元素 。这种引用在组件挂载后立即可用,且在整个生命周期中保持不变。

import { useRef, useEffect } from 'react';

function FocusableInput() {
  const inputRef = useRef(null);

  // 点击按钮时聚焦输入框
  const handleClick = () => {
    inputRef.current.focus();
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>Focus Input</button>
    </>
  );
}

4.2 存储可变值

useRef的另一个重要用途是存储不需要引起UI变化的可变值 。这些值可以在组件的多个渲染周期之间保持不变,特别适合用于保存函数、定时器ID或记录上一次的状态值。

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

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

  const start = () => {
    intervalId.current = setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalId.current);
  };

  useEffect(() => {
    // 组件卸载时清除定时器
    return () => clearInterval(intervalId.current);
  }, []);

  return (
    <>
      <div>Count: {count}</div>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  );
}

在这个例子中,如果使用useState存储定时器ID,每次组件重新渲染时,定时器ID都会被重置,导致定时器无法正确清除 。而使用useRef可以确保定时器ID在整个组件生命周期中保持不变。

4.3 跨渲染周期持久化

useRef的持久化特性使其成为跨渲染周期存储数据的理想选择。例如,在处理副作用时,我们可能需要访问上一次的状态值,而不会触发额外的渲染。

import { useRef, useEffect } from 'react';

function PreviousValueExample() {
  const [value, setValue] = useState(0);
  const previousValue = useRef(0);

  useEffect(() => {
    // 记录当前值作为下一次的前一个值
    previousValue.current = value;
  }, [value]); // 依赖value,当value变化时执行

  return (
    <>
      <div>Current Value: {value}</div>
      <div>Previous Value: {previousValue.current}</div>
      <button onClick={() => setValue(prev => prev + 1)}>
        Increment
      </button>
    </>
  );
}

在这个例子中,previousValue ref对象用于存储上一次的value值,不会引起组件的额外渲染。

五、useRef的最佳实践和注意事项

5.1 最佳实践

1. 合理使用初始值

为useRef提供合适的初始值可以避免在访问ref.current时出现undefined错误 。例如,当需要引用DOM元素时,初始值可以设置为null;当需要存储数值时,可以设置为0。

// 合理设置初始值
const inputRef = useRef(null);
const countRef = useRef(0);

2. 配合forwardRef传递DOM引用

当需要从父组件访问子组件内部的DOM元素时,可以使用forwardRef和useImperativeHandle组合 。这样可以避免直接暴露子组件内部实现细节。

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

// 子组件
const ChildComponent = forwardRef((props, ref) => {
  const inputRef = useRef(null);

  // 将内部方法暴露给父组件
  useImperativeHandle(ref, () => ({
    focusInput: () => inputRef.current.focus(),
    clearValue: () => inputRef.current.value = ''
  }));

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

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

  return (
    <>
      <ChildComponent ref={childRef} />
      <button onClick={() => childRef.current.focusInput()}>
        Focus Child Input
      </button>
      <button onClick={() => childRef.current.clearValue()}>
        Clear Child Input
      </button>
    </>
  );
}

3. 结合防抖/节流优化性能

在需要频繁触发事件的场景(如窗口大小变化、滚动事件、输入框输入等),可以使用useRef配合防抖或节流函数来优化性能 。

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

function DebounceExample() {
  const [value, setValue] = useState('');
  const [searchResult, setsearchResult] = useState(null);
  const updateRef = useRef(null);

  // 防抖函数
  const handleDebounce = () => {
    updateRef.current = value;
    // 假设这是搜索API调用
    console.log(`Searching for: ${updateRef.current}`);
  };

  // 使用防抖优化搜索
  const handleInput = _.debounce(handleDebounce, 500), []); // 空依赖数组确保函数引用不变  

  return (
    <>
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
          handleInput();
        }}
      />
      {searchResult && <div>Search Results: {searchResult}</div>}
    </>
  );
}

4. 存储上一次的值

在处理副作用时,我们经常需要访问上一次的状态值,而不会引起额外的渲染。这时可以使用useRef存储上一次的值。

import { useRef, useEffect } from 'react';

function PreviousValueExample() {
  const [value, setValue] = useState(0);
  const previousValue = useRef(0);

  useEffect(() => {
    // 记录当前值作为下一次的前一个值
    previousValue.current = value;
    console.log(`Value changed from ${previousValue.current} to ${value}`);
  }, [value]); // 依赖value,当value变化时执行

  return (
    <>
      <div>Current Value: {value}</div>
      <button onClick={() => setValue(prev => prev + 1)}>
        Increment
      </button>
    </>
  );
}

5. 存储不需要触发重新渲染的数据

当需要在组件内部存储一些临时数据或中间计算结果时,可以使用useRef,这样不会引起组件的额外渲染。

import { useRef, useEffect } from 'react';

function ExpensiveCalculationExample() {
  const [input, setInput] = useState('');
  const calculationResult = useRef(null);

  // 复杂计算
  const calculate = () => {
    // 假设这是耗时的计算
    const result = heavyCalculation(input);
    calculationResult.current = result;
  };

  // 监听输入变化并执行计算
  useEffect(() => {
    calculate();
  }, [input]);

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <div>Calculation Result: {calculationResult.current}</div>
    </div>
  );
}

5.2 注意事项

1. 不要滥用ref

ref应该只用于需要直接操作DOM或存储非响应式数据的场景。尽量使用React的声明式方式(如useState)来管理状态 ,而不是频繁操作DOM。

2. 避免在渲染过程中读写ref.current

虽然技术上可以在渲染过程中读写ref.current,但这可能导致意外的行为和难以调试的问题。最好只在事件处理函数或useEffect等副作用中读写ref.current

3. 正确处理副作用资源

当使用useRef存储资源(如定时器ID、事件监听器等)时,确保在组件卸载时正确清理这些资源 ,以防止内存泄漏。

import { useRef, useEffect } from 'react';

function TimerExample() {
  const intervalId = useRef(null);

  useEffect(() => {
    intervalId.current = setInterval(() => {
      console.log('Tick');
    }, 1000);

    // 清理函数
    return () => clearInterval(intervalId.current);
  }, []); // 空依赖数组,只在挂载时执行  

  return <div>Timer is running</div>;
}

4. 使用可选链操作符提升代码安全性

当访问ref.current时,使用可选链操作符(?.)可以避免空值导致的错误 。

useEffect(() => {
  inputRef.current?.focus(); // 安全访问
}, []);

5. 避免在useRef中存储函数

虽然可以在useRef中存储函数,但这通常不是最佳实践。如果需要记忆化函数引用以避免不必要的重新渲染,应该使用useCallback

6. 避免在类组件中使用useRef

useRef只能在函数组件中使用。如果在类组件中需要创建ref对象,应该使用React.createRef()

六、useRef的高级应用场景

6.1 实现自定义Hook中的持久化状态

useRef可以用于实现自定义Hook中的持久化状态,这些状态不会引起组件重新渲染。

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

function usePrevious(value) {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

function PreviousValueExample() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <>
      <div>Current Count: {count}</div>
      <div>Previous Count: {prevCount}</div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </>
  );
}

6.2 处理第三方库的DOM操作

当集成需要直接操作DOM的第三方库时,useRef提供了简便的方式获取DOM元素引用。

import { useRef, useEffect } from 'react';
import * as d3 from 'd3';

function D3Chart() {
  const chartRef = useRef(null);

  useEffect(() => {
    // 使用D3操作DOM元素
    constsvg = d3.select(chartRef.current)
      .append('svg')
      .attr('width', 500)
      .attr('height', 300);

    // 清理函数
    return () => {
      d3.select(chartRef.current).html('');
    };
  }, []); // 只在挂载时执行  

  return <div ref={chartRef} />;
}

6.3 实现动画和过渡效果

在实现动画和过渡效果时,useRef可以用于存储动画状态和相关参数。

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

function AnimationExample() {
  const [isExpanded, setIsExpanded] = useState(false);
  const containerRef = useRef(null);
  const animationState = useRef({ is动画ing: false });

  // 点击按钮时展开/收起
  const handleClick = () => {
    if (!animationState.current.is动画ing) {
      animationState.current.is动画ing = true;
      setIsExpanded(!isExpanded);
    }
  };

  useEffect(() => {
    // 动画完成后更新状态
    const timeout = setTimeout(() => {
      animationState.current.is动画ing = false;
    }, 500);

    return () => clearTimeout(timeout);
  }, [isExpanded]);

  return (
    <div>
      <button onClick={handleClick}>Toggle</button>
      <div
        ref={containerRef}
        style={{
          height: isExpanded ? '200px' : '50px',
          transition: 'height 0.5s ease'
        }}
      >
        Content
      </div>
    </div>
  );
}

七、useRef与性能优化

7.1 避免闭包陷阱

在函数组件中,闭包陷阱是一个常见问题。当函数引用被传递给子组件时,如果父组件重新渲染,子组件会收到新的函数引用,导致不必要的重新渲染 。使用useRef可以避免这一问题。

import { useRef, useState } from 'react';

const Parent = () => {
  const [count, setCount] = useState(0);
  const updateCount = useRef(setCount);

  // 每次渲染更新ref中的函数
  updateCount.current = setCount;

  return (
    <div>
      <Child updateCount={updateCount.current} />
      <button onClick={() => setCount(count + 1)}>
        Increment Count
      </button>
    </div>
  );
};

const Child = ({ updateCount }) => {
  // 使用useCallback优化性能
  const handleClick = () => {
    // 使用ref中的函数
    updateCount(0);
  };

  return <button onClick={handleClick}>Reset Count</button>;
};

7.2 结合React.memo优化渲染

当需要传递函数给子组件时,可以使用useRef配合React.memo来优化性能,避免不必要的重新渲染。

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

const Parent = () => {
  const [count, setCount] = useState(0);
  const updateCount = useRef(setCount);

  // 每次渲染更新ref中的函数
  updateCount.current = setCount;

  return (
    <div>
      <Child updateCount={updateCount.current} />
      <button onClick={() => setCount(count + 1)}>
        Increment Count
      </button>
    </div>
  );
};

const Child = React.memo(({ updateCount }) => {
  // 使用useRef中的函数不会导致不必要的重新渲染
  return <button onClick={() => updateCount(0)}>Reset Count</button>;
});

7.3 与useCallback的协同使用

useRef和useCallback可以协同工作,提供更高效的性能优化方案 。当需要传递函数给子组件时,可以使用useRef存储函数引用,然后使用useCallback返回记忆化的函数。

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

const Parent = () => {
  const [count, setCount] = useState(0);
  const updateCount = useRef(setCount);

  // 每次渲染更新ref中的函数
  updateCount.current = setCount;

  // 返回记忆化的函数
  const memoizedUpdate = React.useCallback(() => {
    updateCount.current(0);
  }, []);

  return (
    <div>
      <Child updateCount={memoizedUpdate} />
      <button onClick={() => setCount(count + 1)}>
        Increment Count
      </button>
    </div>
  );
};

const Child = React.memo(({ updateCount }) => {
  // 使用记忆化的函数不会导致不必要的重新渲染
  return <button onClick={updateCount}>Reset Count</button>;
});

八、总结与展望

useRef作为React Hooks生态系统中的重要成员,提供了持久化引用的能力,弥补了函数组件在直接操作DOM和存储非响应式数据方面的不足 。它与useState、useEffect等其他Hooks共同构成了React函数组件的完整功能集合。

随着React 18并发特性的引入,useRef的应用场景将进一步扩展。在并发模式下,useRef可以用于管理更复杂的副作用和状态,为构建高性能、响应式的应用提供更强大的工具支持。

掌握useRef的正确使用方法,不仅能解决开发中的实际问题,还能提升代码质量和性能。在实际项目中,应该根据具体需求选择合适的Hook,避免滥用ref导致代码复杂度增加 。通过合理使用useRef,可以构建出更加灵活、高效的React应用。

九、常见问题解答

9.1 为什么useRef的值改变不会触发重新渲染?

useRef的设计是非响应式的。React不会跟踪ref.current的变化,因此即使修改了ref.current的值,也不会触发组件的重新渲染 。这种设计使得useRef适合存储不需要引起UI变化的数据。

9.2 什么时候应该使用useRef而不是useState?

当需要存储不需要引起UI变化的数据,或者需要直接操作DOM元素时,应该使用useRef 。例如,存储定时器ID、记录上一次的状态值、获取DOM元素引用等场景。

9.3 为什么在useEffect中访问ref.current有时会得到undefined?

这可能是因为ref对象尚未初始化,或者DOM元素尚未挂载。确保在组件挂载后才访问ref.current,例如在useEffect中,并且设置合适的初始值 。

9.4 如何在函数组件中访问子组件的DOM元素?

可以使用forwardRef和useImperativeHandle组合,将子组件的DOM引用传递给父组件 。

9.5为什么在类组件中不能使用useRef?

useRef只能在函数组件中使用。如果在类组件中需要创建ref对象,应该使用React.createRef()

十、实践建议

1. 先尝试useState,再考虑useRef

在大多数情况下,useState是管理状态的首选。只有当需要直接操作DOM或存储非响应式数据时,才考虑使用useRef 。

2. 合理设置ref的初始值

为useRef提供合适的初始值可以避免在访问ref.current时出现undefined错误 。例如,当需要引用DOM元素时,初始值可以设置为null。

3. 使用可选链操作符提升代码安全性

当访问ref.current时,使用可选链操作符(?.)可以避免空值导致的错误 。

4. 避免在渲染过程中读写ref.current

虽然技术上可以在渲染过程中读写ref.current,但这可能导致意外的行为和难以调试的问题。最好只在事件处理函数或useEffect等副作用中读写ref.current

5. 正确处理副作用资源

当使用useRef存储资源(如定时器ID、事件监听器等)时,确保在组件卸载时正确清理这些资源 ,以防止内存泄漏。

6. 结合其他Hooks实现复杂功能

useRef通常与其他Hooks(如useEffect、useCallback等)协同工作,实现更复杂的组件功能 。例如,在useEffect中访问ref.current,或者在useCallback中使用ref.current。

通过掌握useRef的正确使用方法,开发者可以构建出更加灵活、高效的React应用,充分利用函数组件的优势。在实际项目中,应该根据具体需求选择合适的Hook,避免滥用ref导致代码复杂度增加 。随着React生态的不断演进,useRef的应用场景也将继续扩展,为开发者提供更多可能性。