- React 源碼版本: v16.11.0
- 源碼註釋筆記:airingursb/react
1. useEffect 簡介#
1.1 為什麼要有 useEffect#
我們在前文中說到 React Hooks 使得 Functional Component 擁有 Class Component 的特性,其主要動機包括:
- 在組件之間復用狀態邏輯很難
- 複雜組件變得難以理解
- 難以理解的 class
對於第二點,首先,針對 Class Component 來說,我們寫 React 應用時經常要在組件的各種生命週期中編寫代碼,如在 componentDidMount
和 componentDidUpdate
中發送 HTTP 請求、事件綁定、甚至做一些額外的邏輯,使得業務邏輯扎堆在組件的生命週期函數中。在這個時候,我們的編程思路是 “在組件裝載完畢時我們需要做什麼”、“在組件更新時我們需要做什麼”,這使得 React 開發成為了面向生命週期編程,而我們在生命週期中寫的那些邏輯,則成了組件生命週期函數的副作用。
其次,面向生命週期編程會導致業務邏輯散亂在各生命週期函數裡。比如,我們在 componentDidMount
進行的事件綁定又需要在 componentDidUnmount
解绑,那事件管理的邏輯就不統一,代碼零散 review 起來會比較麻煩:
import React from 'react'
class A extends React.Componment {
componmentDidMount() {
document.getElementById('js_button')
.addEventListener('click', this.log)
}
componentDidUnmount() {
document.getElementById('js_button')
.removeEventListener('click', this.log)
}
log = () => {
console.log('log')
}
render() {
return (
<div id="js_button">button</div>
)
}
}
而 useEffect
的出現,則讓開發者的關注點從生命週期重新抽離出來聚焦在業務邏輯之上,其實 effect 的全稱就是 side effect,即副作用,useEffect 就是用來處理原本生命週期函數裡的副作用邏輯。
接下來,我們看看 useEffect 的用法。
1.2 useEffect 的用法#
上面那段代碼用 useEffect 改寫之後如下:
import React, { useEffect } from 'react'
function A() {
log() {
console.log('log')
}
useEffect(() => {
document
.getElementById('js_button')
.addEventListener('click', log)
return () => {
document
.getElementById('js_button')
.removeEventListener('click', log)
}
})
return (<div id="js_button">button</div>)
}
useEffect 接受兩個參數,第一個參數是一個 function,其實現 bind 操作並將 unbind 作為一個 thunk 函數被返回。第二個參數是一個可選的 dependencies 陣列,如果 dependencies 不存在,那麼 function 每次 render 都會執行;如果 dependencies 存在,只有當它發生了變化,function 才會執行。由此我們也可以推知,如果 dependencies 是一個空陣列,那麼當且僅當首次 render 的時候才會執行 function。
useEffect(
() => {
const subscription = props.source.subscribe();
return () => {
subscription.unsubscribe();
};
},
[props.source],
);
更多用法請閱讀 React 官網的 useEffect API 介紹: https://reactjs.org/docs/hooks-reference.html#useeffect
2. useEffect 的原理與簡單實現#
根據 useEffect 的用法,我們可以自己實現一個簡單的 useEffect:
let _deps;
function useEffect(callback, dependencies) {
const hasChanged = _deps
&& !dependencies.every((el, i) => el === _deps[i])
|| true;
// 如果 dependencies 不存在,或者 dependencies 有變化,就執行 callback
if (!dependencies || hasChanged) {
callback();
_deps = dependencies;
}
}
3. useEffect 源碼解析#
3.1 mountEffect & updateEffect#
useEffect 的入口和上一節中 useState 的一樣,都在 ReactFiberHooks.js 這個文件中,並且同 useState 一樣,在首次加載時 useEffect 實際執行的是 mountEffect,之後每次渲染執行的是 updateEffect,此處不再贅述。那我們需要重點看看 mountEffect 和 updateEffect 實際做了什麼。
對於 mountEffect:
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
對於 updateEffect:
function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
mountEffect 和 updateEffect 的入參是一個 function 和一個 array,對應的就是我們前文 useEffect 傳的 callback 和 deps。同時,我們可以發現 mountEffect 和 updateEffect 實際調用的是 mountEffectImpl 和 updateEffectImpl,它們接受的四個參數一模一樣的,後面兩個參數直接透傳的不用說,主要是前面的 UpdateEffect | PassiveEffect
、UnmountPassive | MountPassive
究竟是什麼?
閱讀代碼可知他們是從 ReactSideEffectTags
與 ReactHookEffectTags
中引入的。
import {
Update as UpdateEffect,
Passive as PassiveEffect,
} from 'shared/ReactSideEffectTags';
import {
NoEffect as NoHookEffect,
UnmountPassive,
MountPassive,
} from './ReactHookEffectTags';
看一下 ReactSideEffectTags.js 與 ReactHookEffectTags.js 中的定義:
// Don't change these two values. They're used by React Dev Tools.
export const NoEffect = /* */ 0b0000000000000;
export const PerformedWork = /* */ 0b0000000000001;
// You can change the rest (and add more).
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
export const ContentReset = /* */ 0b0000000010000;
export const Callback = /* */ 0b0000000100000;
export const DidCapture = /* */ 0b0000001000000;
export const Ref = /* */ 0b0000010000000;
export const Snapshot = /* */ 0b0000100000000;
export const Passive = /* */ 0b0001000000000;
export const Hydrating = /* */ 0b0010000000000;
export const HydratingAndUpdate = /* */ 0b0010000000100;
export const NoEffect = /* */ 0b00000000;
export const UnmountSnapshot = /* */ 0b00000010;
export const UnmountMutation = /* */ 0b00000100;
export const MountMutation = /* */ 0b00001000;
export const UnmountLayout = /* */ 0b00010000;
export const MountLayout = /* */ 0b00100000;
export const MountPassive = /* */ 0b01000000;
export const UnmountPassive = /* */ 0b10000000;
這麼設計是為了簡化類型比較與類型複合,如果項目開發的過程中有過一些複合權限系統的設計經驗,那麼可能第一眼就能反應過來,所以 UnmountPassive | MountPassive
就是 0b11000000。如果對應的位為非零,則表示 tag 實現了指定的行為。這個在未來會用到,我們這裡先不涉及,所以就先放在這裡了解即可。
3.2 mountEffectImpl & updateEffectImpl#
接著我們來看看 mountEffectImpl
與 updateEffectImpl
的具體實現。
3.2.1 mountEffectImpl#
首先是 mountEffectImpl
:
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const hook = mountWorkInProgressHook(); // 創建一個新的 Hook 並返回當前 workInProgressHook
const nextDeps = deps === undefined ? null : deps;
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}
mountWorkInProgressHook
我們在第 3 篇 4.3.3: mountWorkInProgressHook 中解析過,其就是創建一個新的 Hook 並返回當前 workInProgressHook,具體原理不再贅述。
sideEffectTag
是按位或上 fiberEffectTag
然後賦值,在 renderWithHooks
中掛載在 renderedWork.effectTag
上,並在每次渲染後重置為 0。
renderedWork.effectTag |= sideEffectTag;
sideEffectTag = 0;
具體 renderedWork.effectTag
有什麼用,我們後續會說到。
renderWithHooks 在 第 3 篇 4.3.1: renderWithHooks 中解析過,此處不再贅述。
hook.memoizedState
記錄 pushEffect
的返回結果,這個同記錄 useState 中的 newState 的原理是一致的。那么現在的重點轉移到了 pushEffect
究竟做了什麼。
3.3.2 updateEffectImpl#
接下來我們看看 updateEffectImpl
又做了些什麼工作呢?
function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const hook = updateWorkInProgressHook(); // 獲取當前正在工作中的 Hook
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(NoHookEffect, create, destroy, nextDeps);
return;
}
}
}
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}
updateWorkInProgressHook
我們在第 3 篇 4.4.3: updateWorkInProgressHook 中解析過,其就是獲取當前正在工作中的 Hook,具體原理不再贅述。
可以發現在 currentHook
為空的時候,updateEffectImpl
的邏輯與 mountEffectImpl
的邏輯是一模一樣的;當 currentHook
不為空的時候,pushEffect
的第三個參數不是 undefined,而是 destroy。並且,在這個分支存在 areHookInputsEqual(nextDeps, prevDeps)
,即如果當前 useEffect 的 deps 和上一階段的 useEffect 的 deps 相等(areHookInputsEqual
所做的事情就是遍歷比較兩個 deps 是否相等,這裡就不展開解讀了),那就執行 pushEffect(NoHookEffect, create, destroy, nextDeps);
,大膽猜測 NoHookEffect
的意思就是不執行這次的 useEffect。如此,這段代碼的邏輯就和我們之前自己實現的 useEffect 是一致的。
根據 第 3 篇 4.4.3: updateWorkInProgressHook,我們得知 currentHook
就是當前階段正在處理的 Hook,其正常邏輯下不會為空。那我們接下來需要重點關注的應該是 pushEffect
做了什麼,其第三個參數有什麼含義?
3.3 pushEffect#
function pushEffect(tag, create, destroy, deps) {
// 聲明一個新的 effect
const effect: Effect = {
tag,
create,
destroy,
deps,
// Circular
next: (null: any), // 函數組件中定義的下一個 effect 的引用
};
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue(); // 初始化 componentUpdateQueue
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
type Effect = {
tag: HookEffectTag, // 一個二進制數,它將決定 effect 的行為
create: () => (() => void) | void, // 繪製後應該運行的回調
destroy: (() => void) | void, // 用於確定是否應銷毀和重新創建 effect
deps: Array<mixed> | null, // 決定重繪製後是否執行的 deps
next: Effect, // 函數組件中定義的下一個 effect 的引用
};
這個函數首先根據入參聲明了一個新的 effect,數據結構也給出來了,它同樣也是一個循環鏈表。tag 是
接下來根據 componentUpdateQueue 是否為空走兩套邏輯,而 componentUpdateQueue 的結構其實很簡單:
export type FunctionComponentUpdateQueue = {
lastEffect: Effect | null,
};
可見,componentUpdateQueue 其實就是一個存儲 Effect 的全局變量。
-
componentUpdateQueue 為空:這種情況就是 mountEffect 時候的邏輯,它會創建一個空的 componentUpdateQueue,它其實只是
{lastEffect: null}
,之後將componentUpdateQueue.lastEffect
指向effect.next
,其實就是存了一下 effect。 -
componentUpdateQueue 不為空:這種情況就是 updateEffect 時候會走到的邏輯
-
lastEffect 為空:這種情況是新的渲染階段的第一個 useEffect,邏輯處理和 componentUpdateQueue 為空時一致。
-
lastEffect 不為空:這種情況意味著這個組件有多個 useEffect,是第二個及其之後的 useEffect 會走到的分支,將 lastEffect 指向下一個 effect。
最後 return 一個 effect。
3.4 React Fiber 流程分析#
看似源碼到這裡就結束了,但我們還存留幾個問題沒有解決:
effect.tag
的那些二進制數是什麼意思?pushEffect
之後還有什麼邏輯?componentUpdateQueue
存儲 Effect 之後會在哪裡被用到?
在 renderWithHooks
中,componentUpdateQueue
會被賦值到 renderedWork.updateQueue
上,包括我們 3.2 中的 sideEffectTag
也會賦值到 renderedWork.effectTag
上。
renderedWork.updateQueue = (componentUpdateQueue: any);
renderedWork.effectTag |= sideEffectTag;
在第 3 篇 4.3.1: renderWithHooks中,我們分析出 renderWithHooks 是在函數組件更新階段(updateFunctionComponent
)執行的函數,這裡我們要想知道上面三個問題的答案,必須要把整個 Reconciler 的流程走一遍才能解析清楚。我個人認為 Fiber 是 React 16 中最複雜的一塊邏輯了,所以在前面幾篇中我只是略微提及,並沒有展開篇幅解析。Fiber 裡面的內容很多,如果展開的話足夠寫幾篇文章了,因此這裡也盡量簡捷的走一遍流程,忽略本文不相關的細節,只梳理部分邏輯的實現,重點關注我們調用 useEffect 之後的邏輯。
注:如果對這部分不感興趣的同學可以直接跳到 3.5 繼續閱讀。
React Fiber 優秀的文章有很多,這裡再推薦閱讀幾篇文章和視頻來幫助有興趣的同學來了解
那我們開始吧!
3.4.1 ReactDOM.js#
頁面渲染的唯一入口便是 ReactDOM.render,
ReactRoot.prototype.render = ReactSyncRoot.prototype.render = function(
children: ReactNodeList,
callback: ?() => mixed,
): Work {
// ... 忽略無關代碼
updateContainer(children, root, null, work._onCommit);
return work;
};
render 的核心是調用 updateContainer
,這個函數來自於 react-reconciler 中的 ReactFiberReconciler.js。
3.4.2 ReactFiberReconciler.js#
這個文件其實也是 react-reconciler 的入口,我們先看看 updateContainer
究竟是什麼:
export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function,
): ExpirationTime {
// ... 忽略無關代碼
return updateContainerAtExpirationTime(
element,
container,
parentComponent,
expirationTime,
suspenseConfig,
callback,
);
}
忽略無關代碼發現它其實只是 updateContainerAtExpirationTime
的一層封裝,那我們看看這個是什麼:
export function updateContainerAtExpirationTime(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
expirationTime: ExpirationTime,
suspenseConfig: null | SuspenseConfig,
callback: ?Function,
) {
// ... 忽略無關代碼
return scheduleRootUpdate(
current,
element,
expirationTime,
suspenseConfig,
callback,
);
}
再次忽略一些無關代碼,發現它又是 scheduleRootUpdate
的一層封裝…… 那我們再看看 scheduleRootUpdate
是什麼:
function scheduleRootUpdate(
current: Fiber,
element: ReactNodeList,
expirationTime: ExpirationTime,
suspenseConfig: null | SuspenseConfig,
callback: ?Function,
) {
// ... 忽略無關代碼
enqueueUpdate(current, update);
scheduleWork(current, expirationTime);
return expirationTime;
}
忽略一小段無關代碼,發現它的核心是做兩件事,enqueueUpdate
我們這裡暫時先不管,重點看看任務調度 scheduleWork
,它相當於 Fiber 邏輯的入口了,在 ReactFiberWorkLoop.js 中定義。
3.4.3 ReactFiberWorkLoop.js - render#
ReactFiberWorkLoop.js 的內容非常長,有 2900 行代碼,是包含任務循環主邏輯,不過我們剛才弄清楚要從 scheduleWork
開始著手那就慢慢梳理:
export function scheduleUpdateOnFiber(
fiber: Fiber,
expirationTime: ExpirationTime,
) {
// ... 忽略無關代碼
const priorityLevel = getCurrentPriorityLevel();
if (expirationTime === Sync) {
if (
(executionContext & LegacyUnbatchedContext) !== NoContext &&
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
schedulePendingInteractions(root, expirationTime);
let callback = renderRoot(root, Sync, true);
while (callback !== null) {
callback = callback(true);
}
} else {
scheduleCallbackForRoot(root, ImmediatePriority, Sync);
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
} else {
scheduleCallbackForRoot(root, priorityLevel, expirationTime);
}
// ... 忽略特殊情況的處理
}
export const scheduleWork = scheduleUpdateOnFiber;
其實這段代碼大部分分支都會收回到 renderRoot
上,再對 renderRoot
的回調做 while 循環處理。所以我們與其說 scheduleWork
是 Fiber 邏輯的入口,不如說 renderRoot
是入口。renderRoot
就是大名鼎鼎的 Fiber 兩個階段中的 render 階段。
圖源 A Cartoon Intro to Fiber - React Conf 2017
其實 debug 一下也容易看出這兩個階段:
renderRoot
中的代碼也非常複雜,我們重點關注和本文有關的邏輯:
function renderRoot(
root: FiberRoot,
expirationTime: ExpirationTime,
isSync: boolean,
): SchedulerCallback | null {
if (isSync && root.finishedExpirationTime === expirationTime) {
// There's already a pending commit at this expiration time.
return commitRoot.bind(null, root); // 進入 commit 階段
}
// ...
do {
try {
if (isSync) {
workLoopSync();
} else {
workLoop(); // 核心邏輯
}
break;
} catch (thrownValue) {
// ...
} while (true);
// ...
}
把一些多餘的代碼略去之後,我們關注到兩個重要的點:
workLoop
是代碼的核心部分,配合循環來實現任務循環。- 在超時的情況下,會進入 commit 階段。
我們先看看 workLoop
的邏輯:
function workLoop() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
看來我們重點是需要看看 performUnitOfWork
:
function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
const current = unitOfWork.alternate;
// ... 忽略計時邏輯
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
next = beginWork(current, unitOfWork, renderExpirationTime);
} else {
next = beginWork(current, unitOfWork, renderExpirationTime);
}
// ... 忽略特殊邏輯
ReactCurrentOwner.current = null;
return next;
}
忽略計時邏輯,發現這段代碼的內容其實就是兩個 beginWork
(這裡解答了我們在第 3 篇中 4.3.1 中留下的問題)。這個 beginWork
引自 ReactFiberBeginWork.js。
3.4.4 ReactFiberBeginWork.js#
本節代碼分析同 第 3 篇 4.3.1: renderWithHooks,不再贅述。
也就是現在我們 renderedWork
上的 updateQueue
(還記得它嗎?它的內容是 Effect 鏈表) 和 effectTag
掛到了 Fiber 上,跳過這部分邏輯,我們看看 Fiber 最後怎麼處理它們。
3.4.5 ReactFiberWorkLoop.js - commit#
在剛才分析 renderRoot
的過程中,我們關注到任務超時之後會直接進入 commit 階段。我們先看看 commitRoot
的代碼:
function commitRoot(root) {
const renderPriorityLevel = getCurrentPriorityLevel();
runWithPriority(
ImmediatePriority,
commitRootImpl.bind(null, root, renderPriorityLevel),
);
return null;
}
好的,這裡發現我們應該關注 commitRootImpl
,來看看:
function commitRootImpl(root, renderPriorityLevel) {
// ...
startCommitTimer();
// Get the list of effects.
let firstEffect;
if (finishedWork.effectTag > PerformedWork) {
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
firstEffect = finishedWork.firstEffect;
}
if (firstEffect !== null) {
do {
try {
commitBeforeMutationEffects();
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitSnapshotEffectsTimer();
if (enableProfilerTimer) {
// Mark the current commit time to be shared by all Profilers in this
// batch. This enables them to be grouped later.
recordCommitTime();
}
// The next phase is the mutation phase, where we mutate the host tree.
startCommitHostEffectsTimer();
nextEffect = firstEffect;
do {
try {
commitMutationEffects(root, renderPriorityLevel);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitHostEffectsTimer();
resetAfterCommit(root.containerInfo);
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork;
// The next phase is the layout phase, where we call effects that read
// the host tree after it's been mutated. The idiomatic use case for this is
// layout, but class component lifecycles also fire here for legacy reasons.
startCommitLifeCyclesTimer();
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, expirationTime);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitLifeCyclesTimer();
nextEffect = null;
// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
if (enableSchedulerTracing) {
__interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
}
executionContext = prevExecutionContext;
} else {
// No effects.
// ...
}
stopCommitTimer();
nextEffect = firstEffect;
while (nextEffect !== null) {
const nextNextEffect = nextEffect.nextEffect;
nextEffect.nextEffect = null;
nextEffect = nextNextEffect;
}
// ...
return null;
}
commitRootImpl
的代碼是真的很長,我這裡忽略了一些和 effect 處理無關的代碼,剩下我們閱讀一下,發現當 effect 存在的時候,有三段邏輯要處理,它們的邏輯基本相同,循環 effect 鏈表傳給三個不同的函數,分別是:
- commitBeforeMutationEffects
- commitMutationEffects
- commitLayoutEffects
最後將循環 effect,將 nextEffect 賦值成 nextNextEffect。
限於篇幅問題,且第三個函數關於 useLayoutEffect,所以左右這裡這三個函數我們這裡都不一一展開解釋了,留給下篇文章中分析 useLayoutEffect 再來詳解。所以 3.4 中我們留下的問題 —— effect.tag
這個參數究竟有什麼用?目前我們僅僅知道當它是 NoHookEffect
的時候的作用是不執行 useEffect 的內容,但是其他的值我們還沒有分析到,它們分析邏輯主要在我們 3.4.5 略過的那三個函數裡。在下篇文章中,我們分析 useLayoutEffect 中會拿出來詳細分析。
大家再見。
最後附上 3.4 節分析的流程圖: