React Hook原始碼解析(二)

westwolf發表於2021-09-09

ID: 符合預期的CoyPan
CoyPan,BAT某廠符合預期的FE,正努力成為一名出色的工程師

寫在前面

在上一篇文章中,主要分析了Hook在React中是如何儲存的,以及Hook的更新過程。本文中,我們將透過下面兩個問題,繼續深入研究Hook,以彌補上文中略過的一些細節。

1、如果我連續多次呼叫setState,Hook會怎麼處理呢?

2、Hook的useEffect 是如何工作的?

連續多次setState

先看示例程式碼:

const App = () => {

    const [count, setCount] = useState(0);

    const handleClick = () => {
        setCount(count + 1);
        setCount(count + 2);
        setCount(count + 3);
    };

    return <React.Fragment>
        <span style={{ marginRight: '10px' }}>{count}</span>
        <button onClick={handleClick}>點選</button>
    </React.Fragment>
};

我們點選一次button,最終頁面上會輸出多少呢?熟悉React的朋友們,很快就會得到答案:3

在上一篇原始碼解析中,這部分內容被忽略了。本文我們來看看這裡的內部邏輯。

首先,先複習一下hook的結構:

var hook = {
    memoizedState: null, // 當前的state值
    baseState: null, 
    queue: null, // 儲存更新資訊
    baseUpdate: null,
    next: null // 指向下一個hook物件的指標
  };

我們先看一下元件掛載完成後的hook,注意queue欄位的值:

圖片描述

之前講過,呼叫setCount的時候,實際上呼叫的是dispatchAction.bind(null, currentlyRenderingFiber$1, queue)這個函式。這個函式透過閉包儲存了對應的Fiber和hook物件的queue的引用。

首次setCount的時候,hook對queue的處理如下:

// 更新資訊
var _update2 = {
    expirationTime: expirationTime, // Fiber排程相關
    suspenseConfig: suspenseConfig,
    action: action, // setCount函式接受的引數
    eagerReducer: null,
    eagerState: null,
    next: null
};

var last = queue.last;

// 首次更新時
if (last === null) {
  // This is the first update. Create a circular list.
  // 第一次更新,構建一個 環
  _update2.next = _update2;
} else {
  // 後續更新
  var first = last.next;

  if (first !== null) {
    // Still circular.
    _update2.next = first;
  }
  last.next = _update2;
}
// queue的最近一次更新指向_update2
queue.last = _update2;

第一次更新後,會構造一個環形結構:

圖片描述

第二次、第三次setCount時,會繼續構造queue鏈:

var first = last.next;

if (first !== null) {
  // Still circular.
  _update2.next = first;
}
last.next = _update2;

最終會形成下圖的結構:

圖片描述

元件重新渲染時,react會從hook的queue鏈中,找到最新的值,賦值給hook的memoizedState,我們就可以拿到最新的state了:

// 程式碼有省略

	...
  // 迴圈,直到拿到queue鏈上最新的值
	do {
    var updateExpirationTime = _update.expirationTime;

    if (updateExpirationTime < renderExpirationTime$1) {
      ...
    } else {
      ...
      if (_update.eagerReducer === reducer) {
        ...
      } else {
        var _action = _update.action;
        _newState = reducer(_newState, _action);
      }
    }
    prevUpdate = _update;
    _update = _update.next;
  } while (_update !== null && _update !== first);
      
  hook.memoizedState = _newState; // 最新的state值,本例中為3
  hook.baseUpdate = newBaseUpdate; // 最新的基礎更新資訊,action=3
  hook.baseState = newBaseState; // 最新的基礎state值,本例中為3
  queue.lastRenderedState = _newState; // 最近渲染的state值,本例中為3
  return [hook.memoizedState, dispatch];

這裡有一個注意事項,在上一篇文章中,我們提到過,setState中是支援傳入函式的。假設我們在setState中傳入的引數是一個函式,在本例中,如果我們點選按鈕後的程式碼改成:

 const handleClick = () => {
    setCount(count => count + 1);
    setCount(count => count + 2);
    setCount(count => count + 3);
 };

最終的count值就不是3了,而是6。這是因為傳入reducer的是最新的state:

... 
do {
  var action = update.action; // 這裡的action是我們傳入的回撥函式
  newState = reducer(newState, action); // newState 是最新的 state
  update = update.next; // 取hook物件queue鏈上的下一次更新
} while (update !== null); 
...

useEffect是如何工作的

首先上示例程式碼:

const fakeReq = function(input) {
    return new Promise( resolve => {
        setTimeout(() => {
            resolve(`${input} - ${Date.now()}`);
        }, 500);
    });
}

const App = () => {
    const [input, setInput] = useState('');
    const [res, setRes] = useState('');

    useEffect(() => {
        fakeReq(input).then(res => {
            setRes(res);
        });
    },[input]);

    return <React.Fragment>
        <input value={input} onChange={e => setInput(e.target.value)} />
        <div>
            返回結果為:<span>{res}</span>
        </div>
    </React.Fragment>
};

上面的程式碼中,我們在輸入框進入輸入的同時,會發起一個請求,並且將返回的結果顯示在頁面上。首先,我們來看看React是怎麼儲存useEffect的。

在程式碼中,呼叫useEffect後,同樣會生成一個hook物件,只是這個hook物件的memoizedState欄位不太一樣:

...
// fiberEffectTag 和 hookEffectTag 是兩個標識
// create、deps是我們傳入useEffect的兩個引數
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
  var hook = mountWorkInProgressHook();
  var nextDeps = deps === undefined ? null : deps;
  sideEffectTag |= fiberEffectTag;
  // useEffect生成的hook物件的memoizedState是一個特殊的物件
  hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}
...

我們來看看pushEffect幹了什麼:

function pushEffect(tag, create, destroy, deps) {
	
	// effect物件
  var effect = {
    tag: tag,
    create: create,
    destroy: destroy,
    deps: deps,
    // Circular
    next: null
  };

  // componentUpdateQueue是一個全域性變數,用來儲存元件的最新的副作用
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    var lastEffect = componentUpdateQueue.lastEffect;

    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      var firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }

  return effect;
}

構造一個帶環的鏈:

圖片描述

在本例中,初始化完成後,最終Fiber物件的hook鏈為:

圖片描述

當我們在輸入框進行輸入時,來看看useEffect是如何起作用的。

輸入時,會觸發元件的重新渲染,假設我們輸入了3,此時傳入useEffect的依賴變成了:

useEffect(() => {
    fakeReq(input).then(res => {
        setRes(res);
    });
},['3']);

useEffect的更新程式碼為:

function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
  // 當前處理的hook
  var hook = updateWorkInProgressHook();
  // 最新傳入的依賴
  var nextDeps = deps === undefined ? null : deps;
  var destroy = undefined;

  if (currentHook !== null) {
    // 上一次的effect
    var prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;

    if (nextDeps !== null) {
      var prevDeps = prevEffect.deps;
			// 對比兩次依賴是否相同。如果相同,則在componentUpdateQueue上增加一個 tag = NoEffect$1 的 effect。這裡的 NoEffect$1 是一個常量, 值為 0。 這裡很重要
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(NoEffect$1, create, destroy, nextDeps);
        return;
      }
    }
  }
	
  sideEffectTag |= fiberEffectTag;
  // 如果兩次依賴不同,在 componentUpdateQueue 上增加一個 effect,並且更新hook的memorizedState
  hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}

經過React的排程,會在 commitHookEffectList 這個函式中,判斷是否需要執行 useEffect 中傳入的函式:

function commitHookEffectList(unmountTag, mountTag, finishedWork) {

  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      if ((effect.tag & unmountTag) !== NoEffect$1) {
        // Unmount
        var destroy = effect.destroy;
        effect.destroy = undefined;

        if (destroy !== undefined) {
          destroy();
        }
      }
			
      if ((effect.tag & mountTag) !== NoEffect$1) {
        // Mount
        var create = effect.create;
        effect.destroy = create();

        {
          var _destroy = effect.destroy;

          if (_destroy !== undefined && typeof _destroy !== 'function') {
            var addendum = void 0;

            if (_destroy === null) {
              addendum = ' You returned null. If your effect does not require clean ' + 'up, return undefined (or nothing).';
            } else if (typeof _destroy.then === 'function') {
              ...
            } else {
              addendum = ' You returned: ' + _destroy;
            }
            ...
          }
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

NoEffect$1是一個等於0的全域性常量,從上面程式碼的do...while...部分可以看到,當一個 effect 的 tag 為 0時,和任何變數做與運算,值都為0,不會進行任何操作。而上面的分析也提到了,useEffect的dep沒有變時,會宣告一個 tag = NoEffect$1 的effect。因此,useEffect的dep沒有變化時,useEffect的函式不會被執行。

我們再來看看,react是怎麼比較兩次的deps是否相同的:

// useEffect中傳入的Deps是否相同
function areHookInputsEqual(nextDeps, prevDeps) {
 
	...
  for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // 這裡的 is$1 ,就是 Object.is 這個方法
    if (is$1(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

總結

回到本文開頭的兩個問題:

1、如果我連續多次呼叫setState,Hook會怎麼處理呢?

2、Hook的useEffect 是如何工作的?

對於每一個hook,react會在hook物件的queue欄位上,以有環鏈的形式,儲存更新資訊。連續多次更新,會沿著queue鏈計算出最新該hook最新的值。

使用useEffect,也會生成一個hook物件。只是該hook物件與useState生成的hook物件有區別。元件重新渲染時,會判斷傳入useEffect的dep依賴是否與上一次相同,相同的話,則會為此次更新打上特殊的tag,保證不會執行useEffect中傳入的函式。

寫在後面

本文在前一篇文章的基礎上,進一步分析了hook中state的更新機制。另外,大致分析了useEffect是如何儲存,如何工作的。由於本文不涉及react的排程更新過程,看起來不太連貫,請多包涵。關於react hook的更多解析,請關注我後續的文章。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4662/viewspace-2825142/,如需轉載,請註明出處,否則將追究法律責任。

相關文章