說明#
-
本文將分析以下兩點
- useRef 是如何存儲數據的
- Refs 是如何引用到真實 dom 的
-
閱讀本文需先閱讀 React Hooks: hooks 鏈表
-
版本基於 v18.1.0
-
分析基於以下代碼
import { useRef } from "react";
function UseRef() {
const ref1 = useRef(null);
const ref2 = useRef({
a: 1,
});
const handleClick = () => {
ref2.current = {
a: 3
}
}
return (
<div id="refTest" ref={ref1} onClick = {handleClick}>123</div>
)
}
export default UseRef;
TLNR#
- useRef 的數據存在其對應的 hook 對象內(memoizedState),且修改數據不會觸發頁面更新
- Refs 與 DOM 綁定的過程發生在 commit 階段的 layout 小階段內。
useRef 解析#
mount 場景下的 useRef#
在 mount 場景下對 useRef 進行打點會進入到 mountRef 這一函數內。
function mountRef<T>(initialValue: T): {|current: T|} {
// hooks 鏈表的內容,用以創建 hook 對象並掛載到 hooks 鏈表上
const hook = mountWorkInProgressHook();
if (enableUseRefAccessWarning) {
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
} else {
// 創建一個對象存儲初始值,而初始值就是在 useRef 內傳遞的參數
const ref = {current: initialValue};
// 並將該對象存儲到 hook 對象的 memoizedState 屬性上
hook.memoizedState = ref;
return ref;
}
}
東西少的可憐,簡直沒什麼好分析的。
update 場景下的 useRef#
與其他 hooks 一樣,它也存在 update 階段,因為代碼也很簡單,就直接粘過來了。
function updateRef<T>(initialValue: T): {|current: T|} {
// updateWorkInProgressHook 會根據 current 樹上對應的 hook 對象
// 來創建一個新的 hook 對象,ref 的引用地址(存於memoizedState)同樣也會被賦值到新 hook 的 memoizedState 上
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
一目了然的代碼。當然我們對 useRef 內的值的改動不像 useState 一樣需要調用 dispatch,因此也不會調用 scheduleUpdateOnFiber,那麼這個改動也不會觸發 react 的渲染。
總結#
useRef 內傳入的值會被賦值給 ref.current 上,且 ref 會被掛載到 useRef 對應的 hook 對象上的 memoizedState 屬性上,每一次 update 階段創建新 hook 對象時都會把老 hook 對象上的 memoizedState 賦值到新 hook 對象上,因此可以保證 ref 的引用地址不變從而保證存儲的值不變(也就是說每次渲染返回的 ref 對象都是同一個對象)。且對 ref 內存儲的值的改變不會引起 react 的渲染。
Refs#
由於 useRef 的內容過少,因此我想再聊一聊例子中的 ref。
不過關於 ref 的調試思路比較複雜,由於 JSX 會被 Babel 轉譯為 React.createElement。這個函數存在於 packages/react/src/ReactElement.js
中。該函數返回的類型聲明為
{
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props,
// Record the component responsible for creating this element.
_owner: owner,
};
很顯然可以看到 ref 是被單獨分離出來的,但如果需要進行調試,則需要將目光放到根據 ReactElement 創建 fiber 的過程中,也就是 beginWork 內。不過這裡分為兩個階段 —— 1. UseRef 的 beginWork;2. div#refTest 的 beginWork。
UseRef 的 beginWork#
在進入 UseRef 的 beginWork 時,會運行 mountIndeterminateComponent
/**
* 刪除了許多不需要的代碼
*/
function mountIndeterminateComponent(...) {
...
/**
* renderWithHooks 會返回 UseRef 內 return 的結果
* 也就是該 function component 返回的 ReactElement
* */
value = renderWithHooks(...)
...
/**
* reconcileChildren 傳入 ReactElement,用以 diff 與生成 fiber
*/
reconcileChildren(null, workInProgress, value, renderLanes);
}
function renderWithHooks(...) {
...
let children = Component(props, secondArg);
...
return children;
}
reconcileChildren 這個函數已經是面試的老面孔了,diff 的入口就是它。不過這裡不講 diff,就直接看涉及到 ref 的部分(reconcileChildren -> ChildReconciler -> reconcileSingleElement)
很顯然在創建完新 fiber 節點後,會在該 fiber.ref 上賦值( coerceRef 返回的其實就是 ReactElement 上的 ref )。
div#refTest 的 beginWork#
根據斷點的記錄會進入 updateHostComponent,該函數內與 ref 相關的代碼存在於 markRef。
function markRef(current: Fiber | null, workInProgress: Fiber) {
// 此處的 ref 為例子內的 ref1
const ref = workInProgress.ref;
/**
* 當滿足以下兩個條件的任意一個時,會進入執行體
* 1. 初次更新時,ref 不為空
* 2. 非初次更新時,WIP 上的 ref 與 current 上的 ref 不相同,也就是發生了變動
*/
if (
(current === null && ref !== null) ||
(current !== null && current.ref !== ref)
) {
// Schedule a Ref effect
/**
* 通過位操作在 WIP 的 flags 上標記 Ref,以表示發生了更新
*/
workInProgress.flags |= Ref;
if (enableSuspenseLayoutEffectSemantics) {
workInProgress.flags |= RefStatic;
}
}
}
markRef 和它的名稱一樣,主要是為 fiber 打上存在 ref 的標記。
階段性總結 1#
beginWork 內與 ref 相關的邏輯其實就做了兩件事
- 在創建完 fiber 節點後,會將 ReactElement 上的 ref 屬性賦值給 fiber.ref
- 如果當前遍歷到的 fiber 節點上的 ref 不為空或者其值發生變動,就給這個 fiber 節點打上標記(workInProgress.flags |= Ref;)
refs 如何與 dom 綁定#
很顯然 refs 的邏輯到這裡還沒結束,我們完成了對 refs 的初始化,但是賦值呢?我們該怎麼把對應的 dom 綁定到 refs 上呢?
解釋這個問題我們需要參考官網關於 refs 的描述
React 會在組件掛載時給 current 屬性傳入 DOM 元素,並在組件卸載時傳入 null 值。ref 會在 componentDidMount 或 componentDidUpdate 生命週期鉤子觸發前更新。
既然描述和生命週期有關係,那只能寫一個 class 的例子看看了。
import React from "react";
class UseRef extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
console.log(this.myRef);
}
render() {
return <div ref={this.myRef} />;
}
}
export default UseRef;
對 componentDidMount 打上斷點,可以看到這樣的調用棧
可以很明顯的看到,componentDidMount 的調用發生在 commit 階段( commit 階段的入口就是 commitRootImpl ),而 commit 階段又分為三個子階段
- beforeMutation 入口為 commitBeforeMutationEffects
- mutation 入口為 commitMutationEffects
- layout 入口為 commitLayoutEffects
在這三個階段中,只有在 layout 階段可以拿到完全處理完之後的 dom 結構。那么 refs 與 dom 綁定的步驟也很適合放在此處處理。
事實上也確實如此
對於 refs 的賦值就藏在 commitLayoutEffects 的 commitLayoutEffectOnFiber 內
function commitLayoutEffectOnFiber(...) {
...
if (!enableSuspenseLayoutEffectSemantics || !offscreenSubtreeWasHidden) {
if (enableScopeAPI) {
// TODO: This is a臨時解決方案,允許我們過渡到
// React Flare on www.
/**
* finishedWork.flags & Ref
* & 操作符可表示是否包含特徵,如果不包含特徵結果則為0
* 如 0110 表示擁有的所有特徵,0010 表示某一個特定特徵
* 需要判斷 0110 是否擁有特徵 0010,則運行 0110 & 0010 得到結果為 0010
* 結果為非0,則可以判斷 0110 包含了 0010
*/
if (finishedWork.flags & Ref && finishedWork.tag !== ScopeComponent) {
commitAttachRef(finishedWork);
}
} else {
if (finishedWork.flags & Ref) {
commitAttachRef(finishedWork);
}
}
}
...
}
還記得在 beginWork 時給 fiber 打上的標記嗎?這個函數就用到了。當 flags 包含 Ref 的標記時便執行 commitAttachRef
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref; // fiber 上的 ref,此時 ref.current 為 null
if (ref !== null) {
const instance = finishedWork.stateNode; // stateNode 存著的是 dom對象
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
...
if (typeof ref === 'function') {
let retVal;
...
retVal = ref(instanceToUse); // refs 也可以是回調。。。
} else {
ref.current = instanceToUse; // 這裡就是綁定 dom 的邏輯了,賦值後可以保證 refs 的 dom 為最新。
}
}
}
階段性總結 2#
refs 與 dom 的綁定發生在 commit 的 layout 階段。