React Diff 算法的源碼應該怎么讀
這兩個月時間密集型的輔導了我的幾個學生通過大廠面試,由于面試強度非常高,因此在準備面試時,就對原理這一塊的深度有非常大的要求,不能僅僅停留在八股文的層面去糊弄面試官,回答的東西要禁得起拷打。
于是我就專門又重新花了一點時間去讀了一下 React 19 的源碼。在讀的過程中又有了一些新的體會。
這篇文章準備給大家分享一個面試中比較容易被深入探討到的點:diff 算法。如果你的薪資訴求非常高,在 30 K 以上,那么對于這一塊的理解,就不是隨便在網上找一篇文章學一下背一下就能達到要求的了。就要求我們自己有能力去源碼中尋找答案。這篇文章主要是為了幫助大家梳理 diff 算法在源碼中的實現思路、位置,然后讓我們自己就有能力去總結出自己的獨特理解。
一、函數緩存優化
在前端開發中,有一種比較常用的優化方式。當我們執行一個函數所需要消耗的時間偏長時,第二次執行時,我們可以讀取上一次的執行結果,從而跳過運算過程,直接輸出結果。
思路大概如下,首先我們定義一個用于緩存的對象。
const cache = {
preA: null,
preB: null,
preResult: null
}
這里我們假設 expensive(a, b) 需要花費很長時間,我們要盡量避免重復運算它。
function cul(a, b) {
return expensive(a, b)
}
于是,我們在第二次執行 cal 時,就需要提前判斷入參。如果我們發現,兩次的執行入參是一樣的,那么我們就可以不必重新運算,而是直接返回上一次的運算結果
代碼調整如下:
function cul(a, b) {
// 對比之后選擇復用緩存的結果
if (cache.preA === a && cache.preB === b) {
return cache.preResult
}
// 緩存參數與結果
cache.preA = a
cache.preB = b
const result = expensive(a, b)
cache.preResult = result
return result
}
那么,當我們多次執行如下代碼的時候,耗時運算 expensive() 僅會執行一次。
cul(1000, 999)
cul(1000, 999)
cul(1000, 999)
cul(1000, 999)
cul(1000, 999)
React 的底層更新機制與這個簡化版的優化策略一模一樣。只不過 React 遇到的需要緩存的內容稍微復雜一點,他的數據結果從一個普通的 cache 對象,變成了一個完整的 Fiber 鏈表。
二、Fiber 鏈表結構
我們常說的 Fiber 對象就是 React 中,用以存儲入參和返回結果的緩存對象。這里需要注意的是,和虛擬 DOM 不同,Fiber 是一種運行時上下文,他的字段中記錄了大量節點在運行過程中的狀態。
function FiberNode(tag: WorkTag, pendingProps: mixed, key: null | string, mode: TypeOfMode) {
// 靜態數據結構
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null; // 指向真實 DOM 對象
// 構建 Fiber 樹的指針
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
// 存儲更新與狀態
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 存儲副作用回調
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 優先級
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 復用節點
this.alternate = null;
}
其中,如下幾個字段,是構成 Fiber 鏈表的重要字段。
// 構建 Fiber 樹的指針
this.return = null;
this.child = null;
this.sibling = null;
其中,this.return 指向父節點。
this.child 指向子元素的第一個節點。
this.sibling 指向兄弟節點。
三、深度有限遍歷
在代碼中,我們的完整應用整合起來大概長這樣。
<App>
<Header />
<Sider />
<Content />
<Footer />
</App>
當然,這只是語法糖,實際上他的代碼是這樣運行的。
function App() {
return (
<>
{Header()}
{Sider()}
{Content()}
{Footer()}
</>
)
}
因此,節點的執行過程,實際上就是一個函數的正常執行過程。他需要滿足函數調用棧的執行順序。也就是深度優先
四、更新機制
React 的每一次更新,都是全量更新。因此,他的執行,都是從最頂層的根節點開始往下執行。這也是 React 被普遍認為性能差的核心原因。
?
但是實際上,充分利用好 React 的 diff 規則,是可以寫出元素級別的細粒度更新的高性能代碼的。只是這對開發者的要求非常高,很少有開發者能夠充分理解并運用 diff 規則。
因此,當我們明白了這種更新機制之后,在源碼中,就很容易找到每一次更新的起點位置。
五、diff 起點
每一次的更新,都是從根節點開始,該位置在 ReactFiberWorkLoop.js 中。
方法名為 performWorkOnRoot。
?
在之前的版本中,并發更新的方法名為 performConcurrentWorkOnRoot,同步更新的方法名為 performSyncWorkOnRoot。
export function performWorkOnRoot(
root: FiberRoot,
lanes: Lanes,
forceSync: boolean,
): void {
const shouldTimeSlice =
!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
...
ensureRootIsScheduled(root);
}
其中,renderRootConcurrent 會啟動 workLoopConcurrent 循環。
renderRootSync,該方法會啟動 workLoopSync 循環。
// The fiber we're working on
let workInProgress: Fiber | null = null;
// workInProgress 起始值為根節點
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
performUnitOfWork(workInProgress);
}
}
workLoopSync 的邏輯非常簡單,就是開始對 Fiber 節點進行遍歷。
// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
他們的差別就是是否支持在循環過程中,被 shouldYield() 中斷循環的執行。最終他們都會執行 performUnitOfWork。
這里很核心的一個知識點就是,workInProgress 是一個全局上下文變量,他的值會在執行的過程中不斷發生變化。許多人會因為許多文章中提到的雙緩存機制對該變量產生誤解。實際上,他指的是當前正在被比較的節點。而不僅僅只是 Fiber 樹的起點。我們會在后續的分析中,看到他不停的被改變,然后執行下一個節點。
從邏輯中我們可以得出結論,當最終沒有節點時,workInProgress = null,循環才會結束。
我們注意關注接下來的 performUnitOfWork 方法。
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
setCurrentDebugFiberInDEV(unitOfWork);
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, subtreeRenderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, subtreeRenderLanes);
}
resetCurrentDebugFiberInDEV();
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}
我們要把 current 與 workInProgress 理解成為一個一直會移動的指針,他們總是指向當前正在執行的 Fiber 節點。當前節點執行完之后,我們就會在修改 workInProgress 的值。
核心的代碼是下面這兩句。
next = beginWork(current, unitOfWork, subtreeRenderLanes);
workInProgress = next;
其中,current 表示已經上一次緩存的 Fiber 節點,workInProgress 表示當前構建的 Fiber 節點。
了解這個循環過程,是關鍵。希望我這樣解釋之后,大家都能夠完整的理解到我想要傳達的含義。
六、beginWork 的作用
這里需要特別注意的是,beginWork 是利用當前節點,去計算下一個節點。因此我們要特別關注他的入參和返回值。才能夠更加準確的理解 diff 的原理。
next = beginWork(current, unitOfWork, subtreeRenderLanes);
在 beginWork 的執行中,會優先比較當前節點的 props 與 context,來決定是否需要復用下一個節點。注意理解這句話,可能跟我們常規的理念很不一樣。這也是準確理解 React diff 的關鍵。
我們會在后續的代碼中觀察這一邏輯。現在先來看一下 beginWork 中的代碼和比較邏輯。
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
// Force a re-render if the implementation changed due to hot reload:
(__DEV__ ? workInProgress.type !== current.type : false)
) {
// If props or context changed, mark the fiber as having performed work.
// This may be unset if the props are determined to be equal later (memo).
didReceiveUpdate = true;
} else {
// Neither props nor legacy context changes. Check if there's a pending
// update or context change.
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes,
);
if (
!hasScheduledUpdateOrContext &&
// If this is the second pass of an error or suspense boundary, there
// may not be work scheduled on `current`, so we check for this flag.
(workInProgress.flags & DidCapture) === NoFlags
) {
// No pending updates or context. Bail out now.
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
}
...
這里的關鍵是一個名為 didReceiveUpdate 的全局上下文變量。該變量用于標記當前 fiber 節點是否需要復用其子 fiber 節點。
其中 props 與 context 的比較代碼如下:
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
// Force a re-render if the implementation changed due to hot reload:
(__DEV__ ? workInProgress.type !== current.type : false)
) {
...
}
然后這里還有一個關鍵比較函數 checkScheduledUpdateOrContext,該函數用于比較是否存在 update 與 context 的變化。
function checkScheduledUpdateOrContext(
current: Fiber,
renderLanes: Lanes,
): boolean {
// Before performing an early bailout, we must check if there are pending
// updates or context.
const updateLanes = current.lanes;
if (includesSomeLane(updateLanes, renderLanes)) {
return true;
}
// No pending update, but because context is propagated lazily, we need
// to check for a context change before we bail out.
if (enableLazyContextPropagation) {
const dependencies = current.dependencies;
if (dependencies !== null && checkIfContextChanged(dependencies)) {
return true;
}
}
return false;
}
這里很難理解的地方在于 state 的比較是如何發生的。簡單說一下,當我們在剛開始調用 dispatchReducerAction
等函數觸發更新時,都會提前給被影響的 fiber 節點標記更新優先級。然后再通過 scheduleUpdateOnFiber 進入后續的調度更新流程。
例如這樣:
function dispatchReducerAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
): void {
...
const lane = requestUpdateLane(fiber);
...
scheduleUpdateOnFiber(root, fiber, lane);
因此,我們可以通過 includesSomeLane 方法來比較前后兩次 fiber 節點的優先級是否發生了變化來判斷是否存在更新。
didReceiveUpdate 的值的變化非常重要,除了在 beginWork 執行的時候,我們比較了 props 和 context 之外,在前置的邏輯中,還設定一個了一個方法用于設置他的值為 true。
export function markWorkInProgressReceivedUpdate() {
didReceiveUpdate = true;
}
該方法被運用在 state 的比較結果中。
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook();
return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}
function updateReducerImpl<S, A>(
hook: Hook,
current: Hook,
reducer: (S, A) => S,
): [S, Dispatch<A>] {
const queue = hook.queue;
...
// Mark that the fiber performed work, but only if the new state is
// different from the current state.
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
...
}
當 state、props、context 的比較結果都沒有發生變化時,表示此時沒有更新發生,因此會直接進入 bailout。
// No pending updates or context. Bail out now.
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
后續調用的方法為 bailoutOnAlreadyFinishedWork,會返回當前節點的子節點進行復用,重點關注下面的代碼。
function bailoutOnAlreadyFinishedWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
// This fiber doesn't have work, but its subtree does. Clone the child
// fibers and continue.
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
如果比較結果是無法復用,那么就會根據不同的 tag 執行不同的創建函數。
switch (workInProgress.tag) {
case LazyComponent: {
const elementType = workInProgress.elementType;
return mountLazyComponent(
current,
workInProgress,
elementType,
renderLanes,
);
}
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
disableDefaultPropsExceptForClasses ||
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultPropsOnNonClassComponent(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps = resolveClassComponentProps(
Component,
unresolvedProps,
workInProgress.elementType === Component,
);
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
...
}
我們重點關注 updateFunctionComponent,并重點關注如下幾行代碼。
function updateFunctionComponent(
current: null | Fiber,
workInProgress: Fiber,
Component: any,
nextProps: any,
renderLanes: Lanes,
) {
...
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
reconcileChildren 中會調用 reconcileChildFibers 方法,該方法則可以被稱為是子節點 diff 的入口函數。他會根據 newChild 的不同類型做不同的處理。
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const isUnkeyedTopLevelFragment =
typeof newChild === 'object' &&
newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null;
if (isUnkeyedTopLevelFragment) {
newChild = newChild.props.children;
}
// Handle object types
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_PORTAL_TYPE:
return placeSingleChild(
reconcileSinglePortal(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_LAZY_TYPE:
const payload = newChild._payload;
const init = newChild._init;
// TODO: This function is supposed to be non-recursive.
return reconcileChildFibers(
returnFiber,
currentFirstChild,
init(payload),
lanes,
);
}
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
if (getIteratorFn(newChild)) {
return reconcileChildrenIterator(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
if (__DEV__) {
if (typeof newChild === 'function') {
warnOnFunctionType(returnFiber);
}
}
// Remaining cases are all treated as empty.
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
return reconcileChildFibers;
}
子節點的類型非常多,每一種類型如何處理我們都要單獨去判斷。我們這里以其中一個單元素節點為例 reconcileSingleElement 來繼續分析。他的代碼如下:
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
if (__DEV__) {
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
} else {
if (
child.elementType === elementType ||
// Keep this check inline so it only runs on the false path:
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false) ||
// Lazy types should reconcile their resolved type.
// We need to do this after the Hot Reloading check above,
// because hot reloading has different semantics than prod because
// it doesn't resuspend. So we can't let the call below suspend.
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
if (__DEV__) {
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
}
// Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
lanes,
element.key,
);
created.return = returnFiber;
return created;
} else {
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
在代碼中我們可以看到,這里會以此比較 key、type、tag 的值。如果都相同,則選擇復用,返回已經存在的節點。
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
return existing;
到這里,diff 的整個流程都已經比較清楚了。在理解 diff 時,我們對 Fiber 的鏈表結構足夠清晰,明確兩個指針 current 與 workInProgress 的含義與移動方向,理解起來就會非常容易。
七、總結
由于時間有限,文字的表達能力有限,本文并沒有完全的從結論的角度為大家介紹 diff 算法是如何如何。而是從源碼的角度引導大家應該如何在代碼中去自己提煉相關的觀點。因此,主要的表訴方式是以大概的實現思路加上源碼的函數調用路徑來跟大家分享。