精讀《useEffect 完全指南》

hzy666666發表於2019-03-25

1. 引言

工具型文章要跳讀,而文學經典就要反覆研讀。如果說 React 0.14 版本帶來的各種生命週期可以類比到工具型文章,那麼 16.7 帶來的 Hooks 就要像文學經典一樣反覆研讀。

Hooks API 無論從簡潔程度,還是使用深度角度來看,都大大優於之前生命週期的 API,所以必須反覆理解,反覆實踐,否則只能停留在表面原地踏步。

相比 useState 或者自定義 Hooks 而言,最有理解難度的是 useEffect 這個工具,希望藉著 a-complete-guide-to-useeffect 一文,深入理解 useEffect

原文非常長,所以概述是筆者精簡後的。作者是 Dan Abramov,React 核心開發者。

2. 概述

unLearning,也就是學會忘記。你之前的學習經驗會阻礙你進一步學習。

想要理解好 useEffect 就必須先深入理解 Function Component 的渲染機制,Function Component 與 Class Component 功能上的不同在上一期精讀 精讀《Function VS Class 元件》 已經介紹,而他們還存在思維上的不同:

Function Component 是更徹底的狀態驅動抽象,甚至沒有 Class Component 生命週期的概念,只有一個狀態,而 React 負責同步到 DOM。 這是理解 Function Component 以及 useEffect 的關鍵,後面還會詳細介紹。

由於原文非常非常的長,所以筆者精簡下內容再重新整理一遍。原文非常長的另一個原因是採用了啟發式思考與逐層遞進的方式寫作,筆者最大程度保留這個思維框架。

從幾個疑問開始

假設讀者有比較豐富的前端 & React 開發經驗,並且寫過一些 Hooks。那麼你也許覺得 Function Component 很好用,但美中不足的是,總有一些疑惑縈繞在心中,比如:

  • ? 如何用 useEffect 代替 componentDidMount?
  • ? 如何用 useEffect 取數?引數 [] 代表什麼?
  • ?useEffect 的依賴可以是函式嗎?是哪些函式?
  • ? 為何有時候取數會觸發死迴圈?
  • ? 為什麼有時候在 useEffect 中拿到的 state 或 props 是舊的?

第一個問題可能已經自問自答過無數次了,但下次寫程式碼的時候還是會忘。筆者也一樣,而且在三期不同的精讀中都分別介紹過這個問題:

但第二天就忘記了,因為 用 Hooks 實現生命週期確實彆扭。 講真,如果想徹底解決這個問題,就請你忘掉 React、忘掉生命週期,重新理解一下 Function Component 的思維方式吧!

上面 5 個問題的解答就不贅述了,讀者如果有疑惑可以去 原文 TLDR 檢視。

要說清楚 useEffect,最好先從 Render 概念開始理解。

每次 Render 都有自己的 Props 與 State

可以認為每次 Render 的內容都會形成一個快照並保留下來,因此當狀態變更而 Rerender 時,就形成了 N 個 Render 狀態,而每個 Render 狀態都擁有自己固定不變的 Props 與 State。

看下面的 count

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

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

在每次點選時,count 只是一個不會變的常量,而且也不存在利用 Proxy 的雙向繫結,只是一個常量存在於每次 Render 中。

初始狀態下 count 值為 0,而隨著按鈕被點選,在每次 Render 過程中,count 的值都會被固化為 123

// During first render
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// After a click, our function is called again
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// After another click, our function is called again
function Counter() {
  const count = 2; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

其實不僅是物件,函式在每次渲染時也是獨立的。這就是 Capture Value 特性,後面遇到這種情況就不會一一展開,只描述為 “此處擁有 Capture Value 特性”。

每次 Render 都有自己的事件處理

解釋了為什麼下面的程式碼會輸出 5 而不是 3:

const App = () => {
  const [temp, setTemp] = React.useState(5);

  const log = () => {
    setTimeout(() => {
      console.log("3 秒前 temp = 5,現在 temp =", temp);
    }, 3000);
  };

  return (
    <div
      onClick={() => {
        log();
        setTemp(3);
        // 3 秒前 temp = 5,現在 temp = 5
      }}
    >
      xyz
    </div>
  );
};

log 函式執行的那個 Render 過程裡,temp 的值可以看作常量 5執行 setTemp(3) 時會交由一個全新的 Render 渲染,所以不會執行 log 函式。而 3 秒後執行的內容是由 temp5 的那個 Render 發出的,所以結果自然為 5

原因就是 templog 都擁有 Capture Value 特性。

每次 Render 都有自己的 Effects

useEffect 也一樣具有 Capture Value 的特性。

useEffect 在實際 DOM 渲染完畢後執行,那 useEffect 拿到的值也遵循 Capture Value 的特性:

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

上面的 useEffect 在每次 Render 過程中,拿到的 count 都是固化下來的常量。

如何繞過 Capture Value

利用 useRef 就可以繞過 Capture Value 的特性。可以認為 ref 在所有 Render 過程中保持著唯一引用,因此所有對 ref 的賦值或取值,拿到的都只有一個最終狀態,而不會在每個 Render 間存在隔離。

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    // Set the mutable latest value
    latestCount.current = count;
    setTimeout(() => {
      // Read the mutable latest value
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  // ...
}

也可以簡潔的認為,ref 是 Mutable 的,而 state 是 Immutable 的。

回收機制

在元件被銷燬時,通過 useEffect 註冊的監聽需要被銷燬,這一點可以通過 useEffect 的返回值做到:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

在元件被銷燬時,會執行返回值函式內回撥函式。同樣,由於 Capture Value 特性,每次 “註冊” “回收” 拿到的都是成對的固定值。

用同步取代 “生命週期”

Function Component 不存在生命週期,所以不要把 Class Component 的生命週期概念搬過來試圖對號入座。Function Component 僅描述 UI 狀態,React 會將其同步到 DOM,僅此而已。

既然是狀態同步,那麼每次渲染的狀態都會固化下來,這包括 state props useEffect 以及寫在 Function Component 中的所有函式。

然而捨棄了生命週期的同步會帶來一些效能問題,所以我們需要告訴 React 如何比對 Effect。

告訴 React 如何對比 Effects

雖然 React 在 DOM 渲染時會 diff 內容,只對改變部分進行修改,而不是整體替換,但卻做不到對 Effect 的增量修改識別。因此需要開發者通過 useEffect 的第二個引數告訴 React 用到了哪些外部變數:

useEffect(() => {
  document.title = "Hello, " + name;
}, [name]); // Our deps

直到 name 改變時的 Rerender,useEffect 才會再次執行。

然而手動維護比較麻煩而且可能遺漏,因此可以利用 eslint 外掛自動提示 + FIX:

精讀《useEffect 完全指南》

不要對 Dependencies 撒謊

如果你明明使用了某個變數,卻沒有申明在依賴中,你等於向 React 撒了謊,後果就是,當依賴的變數改變時,useEffect 也不會再次執行:

useEffect(() => {
  document.title = "Hello, " + name;
}, []); // Wrong: name is missing in dep

這看上去很蠢,但看看另一個例子呢?

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

setInterval 我們只想執行一次,所以我們自以為聰明的向 React 撒了謊,將依賴寫成 []

“元件初始化執行一次 setInterval,銷燬時執行一次 clearInterval,這樣的程式碼符合預期。” 你心裡可能這麼想。

但是你錯了,由於 useEffect 符合 Capture Value 的特性,拿到的 count 值永遠是初始化的 0相當於 setInterval 永遠在 count0 的 Scope 中執行,你後續的 setCount 操作並不會產生任何作用。

誠實的代價

筆者稍稍修改了一下標題,因為誠實是要付出代價的:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

你老實告訴 React “嘿,等 count 變化後再執行吧”,那麼你會得到一個好訊息和兩個壞訊息。

好訊息是,程式碼可以正常執行了,拿到了最新的 count

壞訊息有:

  1. 計時器不準了,因為每次 count 變化時都會銷燬並重新計時。
  2. 頻繁 生成/銷燬 定時器帶來了一定效能負擔。

怎麼既誠實又高效呢?

上述例子使用了 count,然而這樣的程式碼很彆扭,因為你在一個只想執行一次的 Effect 裡依賴了外部變數。

既然要誠實,那隻好 想辦法不依賴外部變數

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

setCount 還有一種函式回撥模式,你不需要關心當前值是什麼,只要對 “舊的值” 進行修改即可。這樣雖然程式碼永遠執行在第一次 Render 中,但總是可以訪問到最新的 state

將更新與動作解耦

你可能發現了,上面投機取巧的方式並沒有徹底解決所有場景的問題,比如同時依賴了兩個 state 的情況:

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [step]);

你會發現不得不依賴 step 這個變數,我們又回到了 “誠實的代價” 那一章。當然 Dan 一定會給我們解法的。

利用 useEffect 的兄弟 useReducer 函式,將更新與動作解耦就可以了:

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: "tick" }); // Instead of setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

這就是一個區域性 “Redux”,由於更新變成了 dispatch({ type: "tick" }) 所以不管更新時需要依賴多少變數,在呼叫更新的動作裡都不需要依賴任何變數。 具體更新操作在 reducer 函式裡寫就可以了。線上 Demo

Dan 也將 useReducer 比作 Hooks 的的金手指模式,因為這充分繞過了 Diff 機制,不過確實能解決痛點!

將 Function 挪到 Effect 裡

在 “告訴 React 如何對比 Diff” 一章介紹了依賴的重要性,以及對 React 要誠實。那麼如果函式定義不在 useEffect 函式體內,不僅可能會遺漏依賴,而且 eslint 外掛也無法幫助你自動收集依賴。

你的直覺會告訴你這樣做會帶來更多麻煩,比如如何複用函式?是的,只要不依賴 Function Component 內變數的函式都可以安全的抽出去:

// ✅ Not affected by the data flow
function getFetchUrl(query) {
  return "https://hn.algolia.com/api/v1/search?query=" + query;
}

但是依賴了變數的函式怎麼辦?

如果非要把 Function 寫在 Effect 外面呢?

如果非要這麼做,就用 useCallback 吧!

function Parent() {
  const [query, setQuery] = useState("react");

  // ✅ Preserves identity until query changes
  const fetchData = useCallback(() => {
    const url = "https://hn.algolia.com/api/v1/search?query=" + query;
    // ... Fetch data and return it ...
  }, [query]); // ✅ Callback deps are OK

  return <Child fetchData={fetchData} />;
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ Effect deps are OK

  // ...
}

由於函式也具有 Capture Value 特性,經過 useCallback 包裝過的函式可以當作普通變數作為 useEffect 的依賴。useCallback 做的事情,就是在其依賴變化時,返回一個新的函式引用,觸發 useEffect 的依賴變化,並啟用其重新執行。

useCallback 帶來的好處

在 Class Component 的程式碼裡,如果希望引數變化就重新取數,你不能直接比對取數函式的 Diff:

componentDidUpdate(prevProps) {
  // ? This condition will never be true
  if (this.props.fetchData !== prevProps.fetchData) {
    this.props.fetchData();
  }
}

反之,要比對的是取數引數是否變化:

componentDidUpdate(prevProps) {
  if (this.props.query !== prevProps.query) {
    this.props.fetchData();
  }
}

但這種程式碼不內聚,一旦取數引數發生變化,就會引發多處程式碼的維護危機。

反觀 Function Component 中利用 useCallback 封裝的取數函式,可以直接作為依賴傳入 useEffectuseEffect 只要關心取數函式是否變化,而取數引數的變化在 useCallback 時關心,再配合 eslint 外掛的掃描,能做到 依賴不丟、邏輯內聚,從而容易維護。

更更更內聚

除了函式依賴邏輯內聚之外,我們再看看取數的全過程:

一個 Class Component 的普通取數要考慮這些點:

  1. didMount 初始化發請求。
  2. didUpdate 判斷取數引數是否變化,變化就呼叫取數函式重新取數。
  3. unmount 生命週期新增 flag,在 didMount didUpdate 兩處做相容,當元件銷燬時取消取數。

你會覺得程式碼跳來跳去的,不僅同時關心取數函式與取數引數,還要在不同生命週期裡維護多套邏輯。那麼換成 Function Component 的思維是怎樣的呢?

筆者利用 useCallback 對原 Demo 進行了改造。

function Article({ id }) {
  const [article, setArticle] = useState(null);

  // 取數函式:只關心依賴的 id
  const fetchArticle = useCallback(async () => {
    const article = await API.fetchArticle(id);
    if (!didCancel) {
      setArticle(article);
    }
  }, [id]);

  // 副作用,只關心依賴了取數函式
  useEffect(() => {
    // didCancel 賦值與變化的位置更內聚
    let didCancel = false;
    fetchArticle(didCancel);

    return () => {
      didCancel = true;
    };
  }, [fetchArticle]);

  // ...
}

當你真的理解了 Function Component 理念後,就可以理解 Dan 的這句話:雖然 useEffect 前期學習成本更高,但一旦你正確使用了它,就能比 Class Component 更好的處理邊緣情況。

useEffect 只是底層 API,未來業務接觸到的是更多封裝後的上層 API,比如 useFetch 或者 useTheme,它們會更好用。

3. 精讀

原文有 9000+ 單詞,非常長。但同時也配合一些 GIF 動圖生動解釋了 Render 執行原理,如果你想用好 Function Component 或者 Hooks,這篇文章幾乎是必讀的,因為沒有人能猜到什麼是 Capture Value,然而不能理解這個概念,Function Component 也不能用的順手。

重新捋一下這篇文章的思路:

  1. 從介紹 Render 引出 Capture Value 的特性。
  2. 擴充到 Function Component 一切均可 Capture,除了 Ref。
  3. 從 Capture Value 角度介紹 useEffect 的 API。
  4. 介紹了 Function Component 只關注渲染狀態的事實。
  5. 引發瞭如何提高 useEffect 效能的思考。
  6. 介紹了不要對 Dependencies 撒謊的基本原則。
  7. 從不得不撒謊的特例中介紹瞭如何用 Function Component 思維解決這些問題。
  8. 當你學會用 Function Component 理念思考時,你逐漸發現它的一些優勢。
  9. 最後點出了邏輯內聚,高階封裝這兩大特點,讓你同時領悟到 Hooks 的強大與優雅。

可以看到,比寫框架更高的境界是發現程式碼的美感,比如 Hooks 本是為增強 Function Component 能力而創造,但在丟擲問題-解決問題的過程中,可以不斷看到規則限制,換一個角度打破它,最後體會到整體的邏輯之美。

從這篇文章中也可以讀到如何增強學習能力。作者告訴我們,學會忘記可以更好的理解。我們不要拿生命週期的固化思維往 Hooks 上套,因為那會阻礙我們理解 Hooks 的理念。

另補充一些零碎的內容。

useEffect 還有什麼優勢

useEffect 在渲染結束時執行,所以不會阻塞瀏覽器渲染程式,所以使用 Function Component 寫的專案一般都有用更好的效能。

自然符合 React Fiber 的理念,因為 Fiber 會根據情況暫停或插隊執行不同元件的 Render,如果程式碼遵循了 Capture Value 的特性,在 Fiber 環境下會保證值的安全訪問,同時弱化生命週期也能解決中斷執行時帶來的問題。

useEffect 不會在服務端渲染時執行。

由於在 DOM 執行完畢後才執行,所以能保證拿到狀態生效後的 DOM 屬性。

4. 總結

最後,提兩個最重要的點,來檢驗你有沒有讀懂這篇文章:

  1. Capture Value 特性。
  2. 一致性。將注意放在依賴上(useEffect 的第二個引數 []),而不是關注何時觸發。

你對 “一致性” 有哪些更深的解讀呢?歡迎留言回覆。

討論地址是:精讀《useEffect 完全指南》 · Issue #138 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《useEffect 完全指南》

special Sponsors

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章