useRef返回一个current属性的对象,current属性存储的值不会随着重新渲染而重置,且修改该对象不会引起组件的重新渲染。我们可以选择用ref存储一个值,或者存储dom。
假设存在以下示例
import { useState, useRef, useEffect } from "react";
export default function App() {
const [state, setState] = useState(0);
const cbRef = useRef(() => {});
const btnRef = useRef(null);
const cb = () => console.log(state);
cbRef.current = cb;
function handleCb() {
console.log("cb():");
cb();
}
function handleCbRef() {
console.log("cbRef.current():");
cbRef.current();
}
useEffect(() => {
btnRef.current.addEventListener("click", handleCbRef);
btnRef.current.addEventListener("click", handleCb);
return () => {
btnRef.current.removeEventListener("click", handleCbRef);
btnRef.current.removeEventListener("click", handleCb);
};
}, []);
return (
<>
<button ref={btnRef} onClick={() => setState(state + 1)}>
state+1
</button>
</>
);
}
点击三次按钮,打印台输出如下。
这个例子印证了ref在多次渲染中保持着唯一实例,而普通的变量每次渲染都会新增一个实例。
使用ref引用一个值
mount阶段
和之前分析useEffect一样,当我们通过renderWithHooks调用到Component方法时,我们就会执行StopWatch函数,由此调用到useRef。在mount阶段我们使用mount hook,所以就调用到了mountRef。
export function useRef<T>(initialValue: T): {|current: T|} {
const dispatcher = resolveDispatcher();
return dispatcher.useRef(initialValue);
}
function mountRef<T>(initialValue: T): {|current: T|} {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
mountWorkInProgressHook我们在学习useEffect也分析过,就是形成hook的单向链表。
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
所以如果我们的代码中只含有useRef这一种Hook,那么形成的结构就应该如下。
由于useRef会返回ref对象,所以我们可以直接通过ref.current读取/修改存储值。
要保证ref.current在多次渲染中保持唯一实例,我们就需要查看更新阶段react做了什么处理。
update阶段
function updateRef<T>(initialValue: T): {|current: T|} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
function updateWorkInProgressHook(): Hook {
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}
let nextWorkInProgressHook: null | Hook;
if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}
if (nextWorkInProgressHook !== null) {
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
} else {
workInProgressHook = workInProgressHook.next = newHook;
}
}
return workInProgressHook;
}
update阶段所做的操作其实很简单,就是拷贝hook,复用ref。我们在多次渲染期间对于一个useRef,hook实例会有多个,但是ref实例是唯一的,所以当我们修改ref.current时,无论是在哪次渲染绑定的回调函数,ref.current的值都会被同步修改。
我们每重新渲染一次,都会形成一个新的cb,复用旧的Ref并修改这个Ref的current指针指向最新的cb,只有在新的cb中才能获取到最新的state。
然而我们只在初始化时绑定了事件监听器,所以调用到的handleCb和handleCbRef都是初始化时形成的。初始化时的handleCb直接调用cb函数,自然调用到的就是初始化时形成的cb,打印出的值就是初始化时的state。初始化时的handleCbRef调用ref.current,调用的也是初始化时形成的ref,但是ref.current已经被修改指向最新的cb,所以打印出的值就是最新的state。
使用ref操作dom
当我们在tsx标签上写入ref属性时,在renderWithHooks中调用Component方法返回的element元素的button子节点元素上ref指针就会指向{current: null},于是在diff算法阶段就会将相应的button fiber的ref指针赋值为{current: null}并给fiber.flag 打上Ref标签。
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
// ...
} else {
if (
child.elementType === elementType
) {
// ...
existing.ref = coerceRef(returnFiber, child, element);
// ...
return existing;
}
}
// ...
}
}
if (element.type === REACT_FRAGMENT_TYPE) {
// ...
} else {
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
return created;
}
}
function coerceRef(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
) {
const mixedRef = element.ref;
// ...
return mixedRef;
}
function updateHostComponent(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// ...
markRef(current, workInProgress);
// ...
}
function markRef(current: Fiber | null, workInProgress: Fiber) {
const ref = workInProgress.ref;
if (
(current === null && ref !== null) ||
(current !== null && current.ref !== ref)
) {
// Schedule a Ref effect
workInProgress.flags |= Ref;
if (enableSuspenseLayoutEffectSemantics) {
workInProgress.flags |= RefStatic;
}
}
}
那么ref.current什么时候被修改为真实的dom节点的呢?其实是在commit阶段执行的。在commiyRootImpl中,我们会调用到commitLayoutEffects,执行跟Layout相关的副作用,最终在commitLayoutEffectOnFiber中调用commitAttachRef方法。
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
// ...
if (!enableSuspenseLayoutEffectSemantics || !offscreenSubtreeWasHidden) {
if (finishedWork.flags & Ref) {
commitAttachRef(finishedWork);
}
}
}
ref === function就对应以下情况,相当于把dom节点手动收集在外部变量中。
<button ref={c => this.btn = c} />
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
instanceToUse = instance;
if (typeof ref === 'function') {
let retVal;
retVal = ref(instanceToUse);
} else {
ref.current = instanceToUse;
}
}
}