精讀《useEffect 完全指南》


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:

不要對 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);

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

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

    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

如果你想參與討論,請 點擊這里,每周都有新的主題,周末或周一發布。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

special Sponsors


作者:黃子毅
鏈接:https://juejin.cn/post/6844903806090608647
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM