React Ref:获取dom元素及子组件实例对象

226 阅读13分钟

1 Ref的基本概念与作用

Ref(引用)是React中一种特殊的属性,用于直接访问DOM元素React组件实例。与React的状态和属性不同,Ref提供了一种绕过组件声明式渲染和数据流的逃生舱,允许开发者直接与底层DOM或组件实例交互。Ref的核心价值在于它能够在组件的整个生命周期中保持引用稳定性,即使组件重新渲染,Ref引用的对象也不会改变,除非显式修改。

Ref在React开发中有多种应用场景,主要包括以下几个方面:

  • DOM操作:当需要直接操作DOM元素时,如管理焦点、文本选择或媒体播放控制(播放/暂停)、强制动画执行等。
  • 集成第三方库:当需要与非React的第三方库(如jQuery插件、D3.js等数据可视化库)交互时,这些库通常需要直接操作DOM。
  • 组件间通信:在某些情况下,父组件需要调用子组件的特定方法,如滚动到指定位置、触发表单验证等。

1.1 Ref功能速查表

下表总结了React中Ref的主要使用场景及相应API:

表:React Ref使用场景及API参考

使用场景API选择返回值注意事项
获取DOM元素useRef(函数组件) / createRef(类组件)DOM节点对象避免过度操作DOM
获取类组件实例useRef / createRef组件实例只能访问公共方法和状态
获取函数组件内的DOM或方法useRef + forwardRef + useImperativeHandle自定义暴露对象需要显式暴露方法
回调Ref回调函数DOM节点或组件实例动态Ref管理

1.2 Ref的类型与创建方式

在React中,Ref有三种主要创建方式:

  1. 对象Ref:通过React.createRef()(类组件)或React.useRef(null)(函数组件)创建,返回一个具有current属性的对象,初始值为null,后续指向对应的DOM元素或组件实例。
  2. 回调Ref:通过传递一个函数给Ref属性,该函数接收DOM元素或组件实例作为参数,可以更灵活地控制Ref的设置和清除。
  3. 字符串Ref(已过时):早期React版本中使用字符串Ref(如ref="myRef"),这种方式存在性能问题且已被官方弃用,不推荐在新项目中使用。

在React的Fiber架构中,Ref的处理经历了一系列优化和改进。Fiber节点是React调和算法中的工作单元,每个组件和DOM元素都有一个对应的Fiber节点。Ref的存储和处理与Fiber架构密切相关——Ref对象本质上并不是直接附加到DOM元素或组件实例上,而是通过Fiber节点的ref属性进行管理和更新。

2 Ref的使用方法详解

2.1 获取React元素的Ref

2.1.1 函数组件中使用useRef

在函数组件中,我们使用useRefHook来创建Ref对象。这个Hook返回一个可变的Ref对象,其.current属性被初始化为传入的参数(初始值)。useRef在组件的整个生命周期内保持引用不变,即使组件重新渲染。

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

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  
  const onButtonClick = () => {
    // `current` 指向已挂载到 DOM 上的文本输入元素
    inputEl.current.focus();
  };
  
  useEffect(() => {
    // 组件挂载后自动聚焦
    inputEl.current.focus();
  }, []);
  
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

2.1.2 类组件中使用createRef

在类组件中,我们使用React.createRef()来创建Ref对象,通常将Ref赋值给实例属性,以便在整个组件中访问。

class CustomTextInput extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 textInput 的 DOM 元素
    this.textInput = React.createRef();
    this.focusTextInput = this.focusTextInput.bind(this);
  }
  
  focusTextInput() {
    // 直接使用原生 API 使文本输入框获得焦点
    this.textInput.current.focus();
  }
  
  componentDidMount() {
    // 组件挂载后自动聚焦
    this.focusTextInput();
  }
  
  render() {
    return (
      <div>
        <input
          type="text"
          ref={this.textInput}
        />
        <button onClick={this.focusTextInput}>
          Focus the text input
        </button>
      </div>
    );
  }
}

2.1.3 回调Ref的使用

回调Ref是另一种使用函数而不是Ref对象的方式来设置Ref。当传递一个回调函数作为Ref属性时,这个函数会在组件挂载时接收DOM元素作为参数,在卸载时接收null作为参数。

class CustomTextInput extends React.Component {
  constructor(props) {
    super(props);
    this.textInput = null;
    this.setTextInputRef = element => {
      this.textInput = element;
    };
  }
  
  focusTextInput = () => {
    // 直接使用原生 API 使文本输入框获得焦点
    if (this.textInput) {
      this.textInput.focus();
    }
  };
  
  componentDidMount() {
    // 组件挂载后自动聚焦
    this.focusTextInput();
  }
  
  render() {
    return (
      <div>
        <input
          type="text"
          ref={this.setTextInputRef}
        />
        <button onClick={this.focusTextInput}>
          Focus the text input
        </button>
      </div>
    );
  }
}

回调Ref的优势在于可以更精细地控制Ref的设置和清除过程,特别适用于动态Ref管理或需要在一个Ref中存储多个引用的情况。

2.2 获取Class组件实例

当Ref属性用于自定义Class组件时,Ref对象将接收组件挂载后的实例作为其current属性。这使得父组件可以直接访问子组件的实例方法和状态。

// 子组件
class AutoFocusTextInput extends React.Component {
  constructor(props) {
    super(props);
    this.textInput = React.createRef();
  }
  
  focus() {
    this.textInput.current.focus();
  }
  
  render() {
    return <CustomTextInput ref={this.textInput} />;
  }
}

// 父组件
class ParentComponent extends React.Component {
  constructor(props) {
    super(props);
    this.autoFocusInput = React.createRef();
  }
  
  componentDidMount() {
    // 子组件实例的focus方法
    this.autoFocusInput.current.focus();
  }
  
  render() {
    return <AutoFocusTextInput ref={this.autoFocusInput} />;
  }
}

需要注意的是,这种方法只适用于Class组件,不适用于函数组件(因为函数组件没有实例)。此外,过度使用Ref进行组件间通信可能会使代码变得难以维护,因此应当谨慎使用,优先考虑使用状态提升和属性传递等React声明式数据流。

2.3 获取函数组件中的DOM或方法

默认情况下,不能在函数组件上使用Ref属性,因为它们没有实例。如果希望在函数组件中使用Ref,可以使用React.forwardRef API来将Ref自动通过组件传递到其子组件。

2.3.1 使用forwardRef转发Ref

forwardRef接受一个渲染函数,该函数接收props和ref两个参数,并返回一个React节点。

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="fancy-button">
    {props.children}
  </button>
));

// 父组件中使用
const App = () => {
  const buttonRef = useRef(null);
  
  useEffect(() => {
    if (buttonRef.current) {
      buttonRef.current.focus();
    }
  }, []);
  
  return (
    <FancyButton ref={buttonRef}>
      Click me!
    </FancyButton>
  );
};

2.3.2 使用useImperativeHandle自定义暴露值

在某些情况下,可能希望向父组件暴露一个自定义的实例值而不是直接暴露DOM节点。可以使用useImperativeHandle Hook与forwardRef一起实现这一需求。

const FancyInput = React.forwardRef((props, ref) => {
  const inputRef = useRef();
  
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    shake: () => {
      // 自定义方法
      alert('Shaking animation!');
    },
    value: inputRef.current?.value
  }), []);
  
  return <input {...props} ref={inputRef} />;
});

// 父组件中使用
const App = () => {
  const fancyInputRef = useRef(null);
  
  const handleClick = () => {
    if (fancyInputRef.current) {
      fancyInputRef.current.focus();
      fancyInputRef.current.shake();
      console.log(fancyInputRef.current.value);
    }
  };
  
  return (
    <div>
      <FancyInput ref={fancyInputRef} />
      <button onClick={handleClick}>操作输入框</button>
    </div>
  );
};

useImperativeHandle允许我们控制要暴露给父组件的实例值,使我们可以只暴露必要的方法而不是整个组件实例或DOM节点,这有助于保持组件的封装性。

3 Ref的原理与源码分析

3.1 React元素Ref的原理

3.1.1 Fiber架构与Ref处理

在React的Fiber架构中,每个组件和DOM元素都有一个对应的Fiber节点,它是React调和算法中的工作单元。Ref的存储和处理与Fiber架构密切相关。

当React元素创建时,Ref信息会被存储在Fiber节点的ref属性上。对于对象Ref(由createRefuseRef创建),Fiber节点的ref属性会指向这个Ref对象;对于回调Ref,Fiber节点会存储回调函数的引用。

协调阶段(reconciliation),React会为带有Ref属性的元素创建或更新Fiber节点。当组件挂载或更新时,React会处理Ref的附加和分离操作。

3.1.2 Commit阶段的Ref处理

Ref的实际附加操作发生在Commit阶段,这是React将更改应用到DOM的实际阶段。React会在DOM更新后设置Ref的current值,并在适当的时候清理Ref。

在Commit阶段,React会执行以下Ref相关操作:

  1. BeforeMutation阶段:准备Ref处理所需的信息
  2. Mutation阶段:实际处理DOM的增删改操作
  3. Layout阶段:设置Ref的current值,执行副作用
// 简化版的Ref处理逻辑
function commitMutationEffects(root, renderPriority) {
  // 处理含有Ref的节点
  if (effectTag.includes(Ref)) {
    // 处理Ref的分离
    safelyDetachRef(current);
  }
  
  // 处理DOM变更
  commitDOMOperations();
  
  // 处理新Ref的附加
  if (effectTag.includes(Ref)) {
    safelyAttachRef(finishedWork);
  }
}

3.2 源码中的Ref更新机制

3.2.1 Ref的创建与更新

在React源码中,createRef的实现非常简单,只是创建一个带有current属性的对象:

export function createRef(): RefObject {
  const refObject = {
    current: null,
  };
  return refObject;
}

useRef的实现则基于Hooks系统,它与createRef类似,但具有Hooks的特性(如依赖跟踪和更新触发):

function mountRef<T>(initialValue: T): {current: T} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

3.2.2 Ref的附加与分离

当组件挂载时,React会通过commitAttachRef函数将Ref附加到对应的DOM元素或组件实例上:

function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    const instance = finishedWork.stateNode;
    
    // 获取实例(DOM元素或组件实例)
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostComponent: // DOM元素
        instanceToUse = getPublicInstance(instance);
        break;
      case ClassComponent: // 类组件实例
        instanceToUse = instance;
        break;
      case ForwardRef: // ForwardRef组件
        instanceToUse = instance;
        break;
      default:
        instanceToUse = null;
    }
    
    // 设置Ref的current值
    if (typeof ref === 'function') {
      // 回调Ref
      ref(instanceToUse);
    } else {
      // 对象Ref
      ref.current = instanceToUse;
    }
  }
}

当组件卸载时,React会通过safelyDetachRef函数分离Ref,避免内存泄漏:

function safelyDetachRef(current: Fiber) {
  const ref = current.ref;
  if (ref !== null) {
    if (typeof ref === 'function') {
      // 回调Ref传入null
      ref(null);
    } else {
      // 对象Ref设置为null
      ref.current = null;
    }
  }
}

3.3 更新流程的源码分析

3.3.1 协调阶段处理Ref

在协调阶段(reconciliation),React会为带有Ref属性的元素创建或更新Fiber节点。这个过程发生在beginWork函数中。

当处理带有Ref的元素时,React会为Fiber节点添加相应的EffectTag,标记这个节点需要处理Ref:

function updateHostComponent(current, workInProgress, renderLanes) {
  // ...其他逻辑
  
  // 处理Ref
  if (current !== null && current.ref !== workInProgress.ref) {
    // Ref发生变化,标记需要更新Ref
    workInProgress.effectTag |= Ref;
  } else if (workInProgress.ref !== null) {
    // 有新Ref,标记需要设置Ref
    workInProgress.effectTag |= Ref;
  }
  
  // ...其他逻辑
}

3.3.2 Commit阶段处理Ref

在Commit阶段,React会批量处理所有需要更新Ref的节点。这个过程主要在commitMutationEffectscommitLayoutEffects中完成。

在Mutation阶段,React会处理Ref的分离:

function commitMutationEffects(root, renderPriority) {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    
    // 处理Ref分离
    if (effectTag & Ref) {
      const current = nextEffect.alternate;
      if (current !== null) {
        // 分离旧的Ref
        safelyDetachRef(current);
      }
    }
    
    nextEffect = nextEffect.nextEffect;
  }
}

在Layout阶段,React会处理Ref的附加:

function commitLayoutEffects(root, lanes) {
  while (nextEffect !== null) {
    const effectTag = nextEffect.effectTag;
    
    // 处理Ref附加
    if (effectTag & Ref) {
      // 附加新的Ref
      commitAttachRef(nextEffect);
    }
    
    nextEffect = nextEffect.nextEffect;
  }
}

这种分阶段处理Ref的机制确保了Ref的正确性和一致性,即使在复杂的更新场景下也能保持正确的行为。

4 Ref的高级用法与性能优化

4.1 动态Ref与回调Ref的高级用法

在某些高级场景中,可能需要动态管理Ref,或者需要在一个Ref中存储多个引用。这时可以使用回调Ref的增强模式来实现。

4.1.1 动态Ref管理

function DynamicRefs() {
  const [count, setCount] = useState(3);
  const refs = useRef([]);
  
  // 动态创建Refs
  useEffect(() => {
    refs.current = refs.current.slice(0, count);
  }, [count]);
  
  const addRef = () => setCount(c => c + 1);
  const removeRef = () => setCount(c => Math.max(0, c - 1));
  
  return (
    <div>
      <button onClick={addRef}>添加输入框</button>
      <button onClick={removeRef}>移除输入框</button>
      
      {Array.from({ length: count }).map((_, i) => (
        <input
          key={i}
          ref={el => refs.current[i] = el}
          placeholder={`输入框 #${i + 1}`}
        />
      ))}
      
      <button onClick={() => {
        refs.current.forEach(input => {
          if (input) input.focus();
        });
      }}>
        聚焦所有输入框
      </button>
    </div>
  );
}

4.1.2 自定义Hook封装Ref逻辑

可以将复杂的Ref逻辑封装成自定义Hook,提高代码的可复用性和可读性:

function useMultiRef(initialCount = 0) {
  const [count, setCount] = useState(initialCount);
  const refs = useRef([]);
  
  // 更新Refs数组大小
  useEffect(() => {
    refs.current = refs.current.slice(0, count);
  }, [count]);
  
  const getRef = index => el => {
    refs.current[index] = el;
  };
  
  const addRef = () => setCount(c => c + 1);
  const removeRef = () => setCount(c => Math.max(0, c - 1));
  
  return {
    refs: refs.current,
    getRef,
    addRef,
    removeRef,
    count
  };
}

// 使用自定义Hook
function MultiInputForm() {
  const { refs, getRef, addRef, removeRef, count } = useMultiRef(2);
  
  const validateAll = () => {
    const allValid = refs.every(ref => ref?.value.trim() !== '');
    if (allValid) {
      console.log('所有字段填写正确');
    } else {
      console.log('请填写所有字段');
      // 聚焦第一个空字段
      const firstEmpty = refs.findIndex(ref => ref?.value.trim() === '');
      if (firstEmpty !== -1 && refs[firstEmpty]) {
        refs[firstEmpty].focus();
      }
    }
  };
  
  return (
    <div>
      {Array.from({ length: count }).map((_, i) => (
        <input
          key={i}
          ref={getRef(i)}
          placeholder={`字段 #${i + 1}`}
        />
      ))}
      
      <button onClick={addRef}>添加字段</button>
      <button onClick={removeRef}>移除字段</button>
      <button onClick={validateAll}>验证所有</button>
    </div>
  );
}

4.2 Ref转发的高级场景

Ref转发不仅限于直接转发到DOM元素,还可以用于更复杂的场景,如高阶组件中的Ref转发跨多层组件传递Ref

4.2.1 高阶组件中的Ref转发

当使用高阶组件包装组件时,通常需要将Ref转发到被包装的组件:

function withTheme(Component) {
  class WithTheme extends React.Component {
    // ...高阶组件的逻辑
    
    render() {
      const { forwardedRef, ...rest } = this.props;
      return (
        <ThemeContext.Consumer>
          {theme => (
            <Component
              ref={forwardedRef}
              theme={theme}
              {...rest}
            />
          )}
        </ThemeContext.Consumer>
      );
    }
  }
  
  // 使用forwardRef转发Ref
  return React.forwardRef((props, ref) => {
    return <WithTheme {...props} forwardedRef={ref} />;
  });
}

// 使用高阶组件
const ThemedButton = withTheme(Button);

// 在父组件中,Ref会被转发到Button组件
function App() {
  const buttonRef = useRef(null);
  
  useEffect(() => {
    if (buttonRef.current) {
      // 可以访问被包装的Button组件的实例
      console.log(buttonRef.current);
    }
  }, []);
  
  return <ThemedButton ref={buttonRef}>按钮</ThemedButton>;
}

4.2.2 跨多层组件传递Ref

在某些情况下,可能需要将Ref通过多个组件层级传递到目标组件。这时可以结合使用forwardRef和Context API:

const RefForwardContext = React.createContext();

function DeepNestedComponent({ forwardRef }) {
  const localRef = useRef();
  
  useImperativeHandle(forwardRef, () => ({
    focus: () => {
      localRef.current.focus();
    },
    scrollIntoView: () => {
      localRef.current.scrollIntoView();
    }
  }));
  
  return <input ref={localRef} />;
}

// 中间组件
function MiddleComponent(props) {
  return (
    <div>
      <DeepNestedComponent {...props} />
    </div>
  );
}

// 顶层组件
function TopLevelComponent({ ref }) {
  return (
    <RefForwardContext.Provider value={ref}>
      <MiddleComponent />
    </RefForwardContext.Provider>
  );
}

export default React.forwardRef((props, ref) => (
  <TopLevelComponent {...props} ref={ref} />
));

4.3 性能优化与安全使用

4.3.1 避免不必要的Ref重建

由于Ref的变化会触发React的更新和副作用执行,因此应该避免不必要的Ref重建:

// 不推荐:每次渲染都创建新的Ref回调
function Component() {
  return <div ref={el => console.log(el)} />;
}

// 推荐:使用useCallback缓存Ref回调
function Component() {
  const refCallback = useCallback(el => {
    console.log(el);
  }, []);
  
  return <div ref={refCallback} />;
}

// 或者使用useRef
function Component() {
  const ref = useRef();
  
  useEffect(() => {
    console.log(ref.current);
  }, []);
  
  return <div ref={ref} />;
}

4.3.2 Ref的内存管理与安全使用

不当使用Ref可能导致内存泄漏或其他安全问题。以下是一些最佳实践:

  1. 及时清理Ref:在组件卸载时,React会自动清理Ref,但在某些自定义场景中可能需要手动清理:
function useSafeRef(initialValue) {
  const isMounted = useRef(true);
  const ref = useRef(initialValue);
  
  useEffect(() => {
    return () => {
      isMounted.current = false;
      // 执行必要的清理操作
      if (ref.current) {
        // 清理Ref相关的资源
        ref.current = null;
      }
    };
  }, []);
  
  return ref;
}
  1. 避免循环依赖:当Ref指向的对象包含对组件或其他对象的引用时,可能产生循环依赖,阻止垃圾回收:
// 不推荐:可能创建循环引用
function Component() {
  const [data] = useState({});
  const ref = useRef();
  
  useEffect(() => {
    // 创建循环引用:data.ref → ref, ref.current → data
    data.ref = ref;
    ref.current = data;
  }, [data]);
  
  return <div />;
}
  1. 条件性地处理Ref:在使用Ref前总是检查其是否存在,避免运行时错误:
function SafeComponent() {
  const ref = useRef();
  
  const handleClick = () => {
    // 安全使用:检查Ref是否存在
    if (ref.current) {
      ref.current.focus();
    } else {
      console.log('Ref not available');
    }
  };
  
  return (
    <div>
      <input ref={ref} />
      <button onClick={handleClick}>聚焦</button>
    </div>
  );
}

5 总结与最佳实践

5.1 Ref使用的最佳实践

根据React官方推荐和实际开发经验,以下是使用Ref的最佳实践:

  1. 谨慎使用Ref:Ref是React声明式编程模型的逃生舱,大多数情况下应优先使用状态和属性来实现功能。只有在真正需要直接操作DOM或组件实例时才使用Ref。

  2. 选择合适的Ref类型

    • 使用useRef用于函数组件
    • 使用createRef用于类组件
    • 需要动态管理Ref时使用回调Ref
  3. 及时清理Ref相关资源:在组件卸载时,确保清理与Ref相关的任何订阅或事件监听器,防止内存泄漏。

  4. 条件性访问Ref:总是检查Ref的current属性是否存在 before使用,避免运行时错误。

  5. 测试Ref相关逻辑:为确保Ref相关代码的可靠性,应编写适当的测试用例,尤其是对于复杂的Ref逻辑。

5.2 常见问题与解决方案

表:Ref使用中的常见问题及解决方案

问题现象可能原因解决方案
Ref.current为null组件未挂载或已卸载确保在组件挂载后访问Ref,如useEffect中访问
Ref更新不及时异步更新导致Ref滞后使用useEffect监听Ref变化
内存泄漏未清理Ref相关资源在清理效果中释放资源
函数组件Ref不工作未使用forwardRef转发使用forwardRef包装函数组件
过度重新渲染Ref回调函数每次重新创建使用useCallback缓存Ref回调

5.3 Ref的适用场景与替代方案

虽然Ref功能强大,但并非所有场景都适用。以下是一些Ref的适用场景及其可能的替代方案:

  1. 管理焦点、文本选择或媒体播放:这是Ref的典型使用场景,通常没有更好的声明式替代方案。

  2. 触发强制动画:对于需要精细控制的动画,Ref可能是合适的,但考虑使用React Spring、Framer Motion等动画库。

  3. 集成第三方DOM库:Ref适合用于集成jQuery插件等第三方库,但考虑寻找或构建专门的React组件。

  4. 调用组件方法:有时使用Ref调用子组件方法是必要的,但优先考虑使用状态提升、属性回调或Context API实现组件间通信。

  5. 测量DOM元素:Ref可用于获取元素尺寸和位置,但考虑使用ResizeObserver或专用Hook库。

通过遵循这些最佳实践和原则,可以确保Ref代码的可维护性和性能,同时保持React应用的声明式特性。记住,Ref是强大但应该谨慎使用的工具,在大多数情况下,React的声明式数据流应该是首选解决方案。