面试题:useRef 是干什么的?ref 的工作流程是怎样的?什么叫做 ref 的失控?
先来看下AI
useRef
是 React 提供的一个 Hook,它允许你在组件中创建一个可变的 ref 对象,这个对象在组件的整个生命周期内保持不变。useRef
主要用于获取和操作 DOM 元素或者组件实例。以下是 useRef
获取 DOM 组件的基本步骤:
1. 创建 ref 对象
首先,你需要使用 useRef
创建一个 ref 对象:
const myRef = useRef(null);
在这个例子中,myRef
是一个 ref 对象,它的 current
属性被初始化为 null
。
2. 将 ref 对象附加到 DOM 元素
接下来,你需要在 JSX 中将这个 ref 对象附加到你想要引用的 DOM 元素上,通过 ref
属性:
function MyComponent() {
const myRef = useRef(null);
// 你可以在这里使用 myRef
return <div ref={myRef}>Hello, world!</div>;
}
在这个例子中,myRef
被附加到了一个 div
元素上。当这个 div
元素被渲染到 DOM 中时,myRef.current
将指向这个 div
元素。
3. 访问 DOM 元素
现在,你可以通过 myRef.current
访问这个 DOM 元素:
function MyComponent() {
const myRef = useRef(null);
// 例如,在组件挂载后聚焦输入框
useEffect(() => {
if (myRef.current) {
myRef.current.focus();
}
}, []);
return <input ref={myRef} type="text" />;
}
在这个例子中,useEffect
钩子确保在组件挂载后,如果 myRef.current
存在(即 DOM 元素已经被渲染),它将聚焦到这个输入框。
总结
- 创建一个 ref 对象:使用
useRef
创建一个初始值为null
的 ref 对象。 - 附加 ref 对象:在 JSX 中,将 ref 对象通过
ref
属性附加到 DOM 元素上。 - 访问 DOM 元素:通过
ref
对象的current
属性来访问和操作 DOM 元素。
通过这种方式,useRef
提供了一种简洁且强大的机制来直接与 DOM 元素交互,这在处理焦点、动画、媒体播放等场景中非常有用。
useRef 各个阶段
ref 是英语 reference(引用)的缩写,在 React 中,开发者可以通过 ref 保存一个对 DOM 的引用。事实上,任何需要被引用的数据,都可以保存在 ref 上。在 React 中,出现过 3 种 ref 引用模式:
- String 类型(已不推荐使用)
- 函数类型
- { current : T }
目前关于创建 ref,类组件推荐使用 createRef 方法,函数组件推荐使用 useRef
用法如下:
const refContainer = useRef(initialValue);
mount 阶段
mount 阶段调用的是 mountRef,对应的代码如下:
function mountRef(initialValue) {
// 创建 hook 对象
const hook = mountWorkInProgressHook();
const ref = { current: initialValue };
// hook 对象的 memoizedState 值为 { current: initialValue }
hook.memoizedState = ref;
return ref;
}
在 mount 阶段,首先调用 mountWorkInProgressHook 方法得到一个 hook 对象,该 hook 对象的 memoizedState 上面会缓存一个键为 current 的对象 { current: initialValue },之后向外部返回该对象。
update 阶段
update 阶段调用的是 updateRef,相关代码如下:
function updateRef(initialValue) {
// 拿到当前的 hook 对象
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
除了 useRef 以外,createRef 也会创建相同数据结构的 ref:
function createRef(){
const refObject = {
current: null
}
return refObject;
}
再看看AI
在React的更新阶段,useRef
Hook确实会复用上一次的结果,但是这并不会影响你获取更新后的最新DOM元素。这是因为useRef
创建的是一个可变的ref对象,它的current
属性被设计为可以在组件的整个生命周期内保持不变,并且可以被手动更新。
当你将useRef
创建的ref对象附加到DOM元素上时,React会在组件的每次渲染中自动更新这个ref对象的current
属性,以确保它始终指向最新的DOM元素。这意味着即使DOM元素在组件的更新过程中发生了变化(例如,由于状态变化或属性变化导致的重新渲染),你仍然可以通过ref.current
访问到更新后的最新DOM元素。
更新阶段的useRef
在组件的更新阶段,updateRef
函数会被调用,它的工作是返回当前的ref对象。这个函数不会创建一个新的ref对象,而是返回现有的ref对象,即hook.memoizedState
。这个ref对象在组件的整个生命周期内是持久的,它的current
属性会在每次渲染时更新为最新的DOM元素。
示例说明
假设你有以下组件:
function MyComponent() {
const myRef = useRef(null);
// 例如,在组件更新后打印出最新的DOM元素
useEffect(() => {
console.log(myRef.current); // 这将打印出最新的DOM元素
});
return <div ref={myRef}>Hello, world!</div>;
}
在这个组件中,即使div
元素在组件的更新过程中发生变化,myRef.current
仍然会指向最新的div
元素。React会自动处理DOM元素的更新,确保myRef.current
始终是最新的。
总结
useRef
在更新阶段会复用上一次的ref对象,但React会自动更新这个对象的current
属性,以指向最新的DOM元素。- 即使DOM元素在组件的更新过程中发生变化,你仍然可以通过
ref.current
访问到更新后的最新DOM元素。 - 这种设计允许你在组件的整个生命周期内安全地引用和操作DOM元素。
ref 的工作流程
ref 创建之后,会挂在 HostComponent 或者 ClassComponent 上面,形成 ref props,例如:
// HostComponent
<div ref={domRef}></div>
// ClassComponent
<App ref={comRef}/>
整个 ref 的工作流程分为两个阶段:
- render 阶段:标记 ref flag
- commit 阶段:根据所标记的 ref flag,执行 ref 相关的操作
上图中,markRef 表示的就是标记 ref,相关代码如下:
function markRef(current, workInProgress){
const ref = workInProgress.ref;
if((current === null && ref !== null) || (current !== null && current.ref !== ref)){
// 标记 Reg tag
workInProgress.flags != Ref;
}
}
有两种情况会标记 ref:
- mount 阶段并且 ref props 不为空
- update 阶段并且 ref props 发生了变化
标记完 ref 之后,来到了 commit 阶段,会在 mutation 子阶段执行 ref 的删除操作,删除旧的 ref:
function commitMutationOnEffectOnFiber(finishedWork, root){
// ...
if(flags & Ref){
const current = finishedWork.alternate;
if(current !== null){
// 移除旧的 ref
commitDetachRef(current);
}
}
// ...
}
上面的代码中,commitDetachRef 方法要做的事情就是移除旧的 ref,相关代码如下:
function commitDetachRef(current){
const currentRef = current.ref;
if(currentRef !== null){
if(typeof currentRef === 'function'){
// 函数类型 ref,执行并传入 null 作为参数
currentRef(null);
} else {
// { current: T } 类型的 ref,重置 current 指向
currentRef.current = null;
}
}
}
删除完成后,会在 Layout 子阶段重新赋值新的 ref,相关代码如下:
function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork, committedLanes){
// 省略代码
if(finishedWork.flags & Ref){
commitAttachRef(finishedWork);
}
}
对应的方法 commitAttachRef 就是用来重新赋值新 ref 的,相关代码如下:
function commitAttachRef(finishedWork){
const ref = finishedWork.ref;
if(ref !== null){
const instance = finishedWork.stateNode;
let instanceToUse;
switch(finishedWork.tag){
case HostComponent:
// HostComponent 需要获取对应的 DOM 元素
instanceToUse = getPublicInstance(instance);
break;
default:
// ClassComponent 使用 FiberNode.stateNode 保存实例
instanceToUse = instance;
}
if(typeof ref === 'function'){
// 函数类型,执行函数并将实例传入
let retVal;
retVal = ref(instanceToUse);
} else {
// { current: T } 类型则更新 current 指向
ref.current = instanceToUse;
}
}
}
ref 的失控
当我们使用 ref 保存对 DOM 的引用时,那么就有可能会造成 ref 的失控。
所谓 ref 的失控,开发者通过 ref 操作了 DOM,但是这一行为本来应该是由 React 接管的,两者产生了冲突,这种冲突我们就称之为 ref 的失控。
考虑下面这一段代码:
function App(){
const inputRef = useRef(null);
useEffect(()=>{
// 操作1
inputRef.current.focus();
// 操作2
inputRef.current.getBoundingClientRect();
// 操作3
inputRef.current.style.width = '500px';
}, []);
return <input ref={inputRef}/>;
}
在上面的三个操作中,第三个操作是不推荐的。
React 作为一个视图层框架,接管了大部分和视图相关的操作,这样开发者可以专注于业务上面的开发逻辑。
上面的三个操作中,前面两个并没有被 React 接管,所以当产生这样的操作时,可以百分百确定是来自于开发者的操作。但是在操作三中,并不能确定该操作究竟是 React 的行为还是开发者的行为,甚至两者会产生冲突。
例如我们再聚一个例子:
function App(){
const [isShow, setShow] = useState(true);
const ref = useRef(null);
return (
<div>
<button onClick={() => setShow(!isShow)}>React操作DOM</button>
<button onClick={() => ref.current.remove()}>开发者DOM</button>
{isShow && <p ref={ref}>Hello</p>}
</div>
);
}
上面的代码就是一个典型的 ref 失控的案例。第一个按钮通过 isShow 来控制 p 是否显示,这是 React 的行为,第二个按钮通过 ref 直接拿到了 p 的 DOM 对象,然后进行显隐操作,两者会产生冲突,上面的两个按钮,先点击任意一个,然后再点击另外一个就会报错。
ref 失控的防治
ref 失控的本质:由于开发者通过 ref 操作了 DOM,而这一行为本来应该是由 React 来进行接管的,两者之间发生了冲突而导致的。
因此我们可以从下面两个方面来进行防治:
- 防:控制 ref 失控的影响范围,使 ref 的失控更加容易被定位
- 治:从 ref 引用的数据结构入手,尽力避免可能引起的失控操作
防
在上一章我们介绍过高阶组件,在高阶组件内部是无法将 ref 直接指向 DOM 的,我们需要进行 ref 的转发。可以通过 forwardRef API 进行一个 ref 的转发,将 ref 转发的这个操作,实际上就将 ref 失控的范围控制在了单个组件内,不会出现跨越组件的 ref 失控。
因为是手动的进行 ref 的转发,所以发生 ref 失控的时候,能够更加容易的进行错误的定位
治
之前我们介绍过 useImperativeHandle 这个 Hook,它可以在使用 ref 时向父组件传递自定义的引用值:
const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus(){
realInputRef.current.focus();
}
}));
return <input {...props} ref={realInputRef} />
});
在上面的代码中,我们通过 useImperativeHandle 来定制 ref 所引用的内容,那么在外部开发者通过 ref 只能拿到:
{
focus(){
realInputRef.current.focus();
}
}
真题解答
题目:useRef 是干什么的?ref 的工作流程是怎样的?什么叫做 ref 的失控?
参考答案:
useRef 的主要作用就是用来创建 ref 保存对 DOM 元素的引用。在 React 发展过程中,出现过三种 ref 相关的引用模式:
- String 类型(已不推荐使用)
- 函数类型
- { current : T }
目前最为推荐的是在类组件中使用 createRef,函数组件中使用 useRef 来创建 Ref。
当开发者调用 useRef 来创建 ref 时,在 mount 阶段,会创建一个 hook 对象,该 hook 对象的 memoizedState 存储的是 { current: initialValue } 对象,之后向外部返回了这个对象。在 update 阶段就是从 hook 对象的 memoizedState 拿到 { current: initialValue } 对象。
ref 内部的工作流程整体上可以分为两个阶段:
- render 阶段:标记 Ref flag,对应的内部函数为 markRef
- commit 阶段:根据 Ref flag,执行 ref 相关的操作,对应的相关函数有 commitDetachRef、commitAttachRef
所谓 ref 的失控,本质是由于开发者通过 ref 操作了 DOM,而这一行为本身是应该由 React 来进行接管的,所以两者之间发生了冲突导致的。
ref 失控的防治主要体现在两个方面:
- 防:控制 ref 失控影像的范围,使 ref 失控造成的影响更容易被定位,例如使用 forwardRef
- 治:从 ref 引用的数据结构入手,尽力避免可能引起失控的操作,例如使用 useImperativeHandle
AI对话
在React中,”ref失控”(Ref Unmounting)通常指的是当组件被卸载时,ref引用的DOM元素或组件实例没有被正确清理,导致可能出现的内存泄漏或其他问题。这种情况可能发生在使用useRef
或createRef
创建的ref对象上,尤其是在类组件中使用ref
属性时。
ref失控的例子
假设我们有一个类组件,它使用ref
来引用一个DOM元素,并在componentDidMount
生命周期方法中添加了一个事件监听器:
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
// 添加事件监听器
this.myRef.current.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
// 卸载组件时,应该移除事件监听器
this.myRef.current.removeEventListener('click', this.handleClick);
}
handleClick = () => {
console.log('Clicked!');
};
render() {
return <button ref={this.myRef}>Click me</button>;
}
}
在这个例子中,如果在组件卸载时没有移除事件监听器,那么this.myRef.current
将保持对已卸载的DOM元素的引用,这就是ref失控。即使组件已经被移除,事件监听器仍然存在,如果按钮被点击,handleClick
方法仍然会被调用,这可能导致错误或内存泄漏。
避免ref失控
为了避免ref失控,你应该确保在组件卸载时清理所有通过ref设置的引用和事件监听器。在类组件中,这通常在componentWillUnmount
生命周期方法中完成。在函数式组件中,可以使用useEffect
Hook来处理清理逻辑:
function MyComponent() {
const myRef = useRef(null);
useEffect(() => {
// 添加事件监听器
if (myRef.current) {
myRef.current.addEventListener('click', handleClick);
}
// 清理函数
return () => {
if (myRef.current) {
myRef.current.removeEventListener('click', handleClick);
}
};
}, []);
const handleClick = () => {
console.log('Clicked!');
};
return <button ref={myRef}>Click me</button>;
}
在这个函数式组件的例子中,useEffect
Hook在组件挂载时添加事件监听器,并在清理函数中移除它。这样,即使组件被卸载,事件监听器也会被正确清理,避免了ref失控。
总结
- ref失控是指组件卸载后,ref引用的DOM元素或组件实例没有被正确清理。
- 避免ref失控的方法是在组件卸载时移除所有通过ref设置的引用和事件监听器。
- 在类组件中,这通常在
componentWillUnmount
中完成;在函数式组件中,可以使用useEffect
的清理函数来处理。
除了在组件卸载时未正确清理事件监听器之外,还有其他几种情况可能导致ref失控。以下是一些常见的情况及其解决方法:
1. 跨组件引用未清理
当你在父组件中使用ref引用子组件,并且在父组件卸载时未清理这些引用,可能会导致ref失控。
示例:
// 父组件
function ParentComponent() {
const childRef = useRef(null);
return (
<div>
<ChildComponent ref={childRef} />
</div>
);
}
// 子组件
function ChildComponent() {
return <div>Child</div>;
}
解决方法:
在父组件卸载时,确保清理对子组件的引用:
function ParentComponent() {
const childRef = useRef(null);
useEffect(() => {
return () => {
// 清理对子组件的引用
childRef.current = null;
};
}, []);
return (
<div>
<ChildComponent ref={childRef} />
</div>
);
}
2. 在异步操作中引用ref
在异步操作(如setTimeout、setInterval、异步API调用)中使用ref,但未在组件卸载时清理这些操作,可能导致ref失控。
示例:
function MyComponent() {
const myRef = useRef(null);
useEffect(() => {
const timer = setTimeout(() => {
if (myRef.current) {
console.log(myRef.current);
}
}, 1000);
return () => clearTimeout(timer);
}, []);
return <div ref={myRef}>Hello</div>;
}
解决方法:
在清理函数中清除异步操作:
function MyComponent() {
const myRef = useRef(null);
useEffect(() => {
const timer = setTimeout(() => {
if (myRef.current) {
console.log(myRef.current);
}
}, 1000);
return () => {
clearTimeout(timer);
// 清理ref
myRef.current = null;
};
}, []);
return <div ref={myRef}>Hello</div>;
}
3. 在闭包中引用ref
在闭包中引用ref,但未在组件卸载时清理这些闭包,可能导致ref失控。
示例:
function MyComponent() {
const myRef = useRef(null);
const handleClick = () => {
setTimeout(() => {
if (myRef.current) {
console.log(myRef.current);
}
}, 1000);
};
return <button ref={myRef} onClick={handleClick}>Click me</button>;
}
解决方法:
确保在组件卸载时清理闭包中的引用:
function MyComponent() {
const myRef = useRef(null);
useEffect(() => {
return () => {
// 清理ref
myRef.current = null;
};
}, []);
const handleClick = () => {
setTimeout(() => {
if (myRef.current) {
console.log(myRef.current);
}
}, 1000);
};
return <button ref={myRef} onClick={handleClick}>Click me</button>;
}
4. 在全局变量中引用ref
将ref存储在全局变量中,但未在组件卸载时清理这些全局变量,可能导致ref失控。
示例:
let globalRef = null;
function MyComponent() {
const myRef = useRef(null);
useEffect(() => {
globalRef = myRef;
return () => {
globalRef = null;
};
}, []);
return <div ref={myRef}>Hello</div>;
}
解决方法:
在组件卸载时清理全局变量:
let globalRef = null;
function MyComponent() {
const myRef = useRef(null);
useEffect(() => {
globalRef = myRef;
return () => {
globalRef = null;
};
}, []);
return <div ref={myRef}>Hello</div>;
}
总结
- 跨组件引用未清理:确保在父组件卸载时清理对子组件的引用。
- 在异步操作中引用ref:在清理函数中清除异步操作并清理ref。
- 在闭包中引用ref:确保在组件卸载时清理闭包中的引用。
- 在全局变量中引用ref:在组件卸载时清理全局变量。