為什麼我的 useReducer 會被執行兩次?在 React hooks 官方文件沒有清楚告訴你的 useReducer 的 bail out 機制 - Jason's Web Memo

為什麼我的 useReducer 會被執行兩次?在 React hooks 官方文件沒有清楚告訴你的 useReducer 的 bail out 機制

在先前的文章裡面 如何錯誤地使用 React hooks useCallback 來保存相同的 function instance 有談到 useReducer 是一個很好的讓各處 Component 避免因為 function instance 不同 rerender 的好方法,最近研究 useReducer 之後發現這 hooks 真的有點難

先從一個簡單的 useReducer 例子說起

import React, { useReducer, memo } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = memo(({ dispatch }) => {
return <button onClick={() => dispatch("CLICK")}>click</button>;
});

function App() {
const [state, dispatch] = useReducer((state, action) => {
switch (action) {
case "CLICK":
return !state;
default:
return state;
}
}, false);

return (
<div className="App">
<div className="container">
<span className={state ? "right" : "left"}>
{state ? "right" : "left"}
</span>
</div>
<Button dispatch={dispatch} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

點擊按鈕會有一個 transition 的動畫,left 會變成 right 並移動到右邊,再按一次則會反向移動回來。但是如果連續點擊的時候,你就會看到文字還沒跑道中間的時候開始折返

那我們要如何避免連續點擊的狀況,使得在動畫結束前的點擊沒有作用?也許你一開始會想到,可以用 state 來控制事件的綁定,動畫結束時才把 onClick 綁上去,但是一旦用到了 state 去控制事件的綁定也會造成 rerender,有沒有可以不需要 rerender 的方法呢?也許可以用 useRef 來暫存 flag 變數,利用 hooks 的特性可以讓 reducer 宣告在 Component 內這樣我們就可以透過 closure 去取得 ref 的值,因此可以改寫如下

import React, { useReducer, useRef, memo } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

const Button = memo(({ dispatch }) => {
return <button onClick={() => dispatch("CLICK")}>click</button>;
});

function App() {
const clickableRef = useRef(true);
const [state, dispatch] = useReducer((state, action) => {
console.log("run reducer and clickableRef is", clickableRef.current);
if (!clickableRef.current) {
return state;
}
switch (action) {
case "CLICK":
clickableRef.current = false;
return !state;
default:
return state;
}
}, false);

return (
<div className="App">
<div
className="container"
onTransitionEnd={() => {
clickableRef.current = true;
}}
>
<span className={state ? "right" : "left"}>
{state ? "right" : "left"}
</span>
</div>
<Button dispatch={dispatch} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

我們在 reducer 內觸發 click 的時候就把 clickable 的 ref 設成 false,等下一次 click 進來 reducer 的時候,會檢查 clickable 的 ref 如果不允許點擊則會 return 原本的 state,等動畫結束的時候再 clickable 的 ref 設成 true,useReducer 的文件有說明如果 state 沒有變的話,是不會 render children 跟觸發 effect

Bailing out of a dispatch

If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the [Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is#Description) comparison algorithm.)

於是我們來看看結果吧

Click to load CodeSandbox demo👆

結果居然點了一點反應都沒有…..

代誌果然不是憨人想的那麼甘單,於是我插了一個 console.log 在 reducer 裡面,就可以看到 useReducer 的的確確跑了兩次,whyyyyyyyyyy

從 source code 來看 useReducer 的 bail out  機制

Bail out 這個片語的意思有點像是終止跳脫的意思,意思是指我不會進行接下來的動作

我們把 dispatch 後的發生的事情拆成兩個部分來觀察,dispatch 階段跟 render 階段

Dispatch 階段 dispatchAction

執行 dispatch 的時候,跑的是 dispatchAction 這個 function,觀察下面的程式

在滿足 fiber queue 是空的狀況下,dispatch 就可以直接拿 reducer 算出新的 state 來決定要不要 bail out,如果沒有辦法 bail out 則會進行重新渲染。

那為什麼既然在 dispatch 的時候都算完了 reducer 的 state 的結果,在 rendering 的階段又要重新執行一次 reducer 呢,讓我們繼續看下去。

Render 階段 updateReducer

在 update reducer 時,會檢查這次 useReducer 裡頭的 reducer 跟上一次的 reducer instance 相不相同,如果不相同,則會用新的 reducer 重新計算結果,而我們在 Component 裡頭宣吿 reducer ,等同於每一次 reducer 的 instance 都不一樣,所以造成會算兩次的結果。

useReducer((state, action) => { ... })
// reducer always be new instance in every render

所有在 function Component 裡面宣告的 function 都用 closure 保持並取得屬於他們那次 render 的外部變數

那為什麼不同的 reducer instance 就要重新計算呢,因為 reducer 會用 closure 取得屬於她那次 render 的外部變數,包含 props,所以要是 props 改變了,舊 reducer 依舊會用舊 props 去重新計算,

React Github 上有一個類似的 issue Dan 的解釋如下

另外 Prettier 的原作者 James Long 跟 Dan 在 Twitter 上也有類似的討論

James Long
@jlongster
Another hooks question: setting a ref inside a reducer is kosher, right? When is the reducer actually run, and can it be run multiple times?
02:32 PM, Mar 27, 2019
0 Retweets 0 Quote Tweets 8 Likes 3 Replys

stackoverflow 也有類似的問題

解法其中之一:變動 ref 的動作應該是 side effect

回到原本的例子應該要怎麼處理 ref 呢,其實去變動 ref 的動作是一個 render 以後的 side effect,只要把這個動作放到 effect 去做,就不用怕 reducer 多跑幾遍了

import React, { useReducer, useEffect, useRef, memo } from "react";
import ReactDOM from "react-dom";
import useUpdateEffect from "react-use/lib/useUpdateEffect";
import "./styles.css";

const Button = memo(({ dispatch }) => {
return <button onClick={() => dispatch("CLICK")}>click</button>;
});

function App() {
const clickableRef = useRef(true);
const [state, dispatch] = useReducer((state, action) => {
if (!clickableRef.current) {
return state;
}
switch (action) {
case "CLICK":
return !state;
default:
return state;
}
}, false);

useUpdateEffect(() => {
clickableRef.current = false;
}, [state]);

return (
<div className="App">
<div
className="container"
onTransitionEnd={() => {
clickableRef.current = true;
}}
>
<span className={state ? "right" : "left"}>
{state ? "right" : "left"}
</span>
</div>
<Button dispatch={dispatch} />
</div>
);
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Click to load CodeSandbox demo👆

這樣結果就正確了

總結

這個例子用 useReducer 可能有點殺雞用牛刀,我們也可以用其他變通的方法去實作這樣的功能,這篇的文章的目的是想透過這個例子去解釋 useReducer 發生的現象。

所以我們應該要避免在 Component 裡面宣告 reducer 嗎?

效能上我覺得問題不大,我的看法是 reducer 如果對外部沒有任何依賴,就不需要宣告在 Component 內部,如果需要取用外部 props 再放到 component 裡面,重點是不要讓 reducer 的執行會有任何的 side effect,譬如對 ref 的修改,因為 bail out 的機制可能會讓 reducer 不會只執行一次而已。

也許你會覺得這篇文章依舊遺留下很多問題,fiber queue 為空的時機是什麼時候?為什麼在 fiber queue 為空的時候才會進行 dispatch 的 bail out?如果 reducer 是新的 instance 就要重新計算,那我們拿舊的 reducer 的結果進行 bail out 精準嗎?如果精準的話,那又為什麼在 Render 階段要重新計算呢?

這些種種的問題,其實我本人也還沒有想通 XD

只能期待哪天 overreacted.io 會有篇詳細的文章講解 concurrent rendering 跟 hooks 之間的機制了

Webmention 社群迴響 0

喜歡 0
    轉推 0
      引用或評論 0

        用 Webmentions 參與社群迴響

        如果你的 blog 文章想要引用本文,歡迎透過下方表單用將你的 blog 文章網址傳送給我,若你的 blog 文章含有正確的本文網址連結,並且 blog 文章本身支持 microformat,之後你的引用資訊會更新在上面社群迴響的引用評論列表。

        社群迴響將不定期更新,不保證同步,同時有資料缺漏的可能性。

        Jason Chen - Yahoo Taiwan EC Web frontend engineer currently. Write something about web and React.js here

        Jason Chen

        Yahoo Taiwan Sr. Frontend Engineer. Write something about web and React.js here.

        訂閱 blog 更新 開啟小鈴鐺

        複製 RSS xml 網址

        訂閱 Google Groups 電子報

        追蹤我的 Medium

        --

        Other Posts