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有三种主要创建方式:
- 对象Ref:通过
React.createRef()(类组件)或React.useRef(null)(函数组件)创建,返回一个具有current属性的对象,初始值为null,后续指向对应的DOM元素或组件实例。 - 回调Ref:通过传递一个函数给Ref属性,该函数接收DOM元素或组件实例作为参数,可以更灵活地控制Ref的设置和清除。
- 字符串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(由createRef或useRef创建),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相关操作:
- BeforeMutation阶段:准备Ref处理所需的信息
- Mutation阶段:实际处理DOM的增删改操作
- 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的节点。这个过程主要在commitMutationEffects和commitLayoutEffects中完成。
在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可能导致内存泄漏或其他安全问题。以下是一些最佳实践:
- 及时清理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;
}
- 避免循环依赖:当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 />;
}
- 条件性地处理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的最佳实践:
-
谨慎使用Ref:Ref是React声明式编程模型的逃生舱,大多数情况下应优先使用状态和属性来实现功能。只有在真正需要直接操作DOM或组件实例时才使用Ref。
-
选择合适的Ref类型:
- 使用
useRef用于函数组件 - 使用
createRef用于类组件 - 需要动态管理Ref时使用回调Ref
- 使用
-
及时清理Ref相关资源:在组件卸载时,确保清理与Ref相关的任何订阅或事件监听器,防止内存泄漏。
-
条件性访问Ref:总是检查Ref的current属性是否存在 before使用,避免运行时错误。
-
测试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的适用场景及其可能的替代方案:
-
管理焦点、文本选择或媒体播放:这是Ref的典型使用场景,通常没有更好的声明式替代方案。
-
触发强制动画:对于需要精细控制的动画,Ref可能是合适的,但考虑使用React Spring、Framer Motion等动画库。
-
集成第三方DOM库:Ref适合用于集成jQuery插件等第三方库,但考虑寻找或构建专门的React组件。
-
调用组件方法:有时使用Ref调用子组件方法是必要的,但优先考虑使用状态提升、属性回调或Context API实现组件间通信。
-
测量DOM元素:Ref可用于获取元素尺寸和位置,但考虑使用ResizeObserver或专用Hook库。
通过遵循这些最佳实践和原则,可以确保Ref代码的可维护性和性能,同时保持React应用的声明式特性。记住,Ref是强大但应该谨慎使用的工具,在大多数情况下,React的声明式数据流应该是首选解决方案。