React Hooks 完全指南,讀React作者博文感悟(2W字精華)

閱讀 facebook大佬:Dan Abramov 的文章頗有感悟

大佬 github地址 https://github.com/gaearon

重點總結

  1. useEffect 是同步的
  2. 狀態是捕獲的當前 propsstate
  3. 可以通過 useRef 獲取改變後的 propsstate
  4. 依賴項 [] 不能欺騙
  5. 複雜的狀態變化應該使用 useReducer
  6. 可以使用 useCallback 設置依賴
  7. 可以使用 useMemo 讓複雜對象做動態改變

但有時候當你使用 useEffect你總覺得哪兒有點不對勁。你會嘀咕你可能遺漏了什麼。它看起來像class的生命週期...但真的是這樣嗎?你發覺自己在問類似下面的這些問題:

  • 🤔 如何用 useEffect模擬 componentDidMount生命週期?
  • 🤔 如何正確地在 useEffect裏請求數據? []又是什麼?
  • 🤔 我應該把函數當做effect的依賴嗎?
  • 🤔 爲什麼有時候會出現無限重複請求的問題?
  • 🤔 爲什麼有時候在effect裏拿到的是舊的state或prop?

當我不再透過熟悉的class生命週期方法去窺視 useEffect 這個Hook的時候,我才得以融會貫通。

"忘記你已經學到的。" — Yoda

摘要

如果你打算閱讀整篇文章,你完全可以跳過這部分。我會在文章末尾帶上摘要的鏈接。

🤔 Question: 如何用 useEffect 模擬 componentDidMount 生命週期?

雖然可以使用 useEffect(fn, []),但它們並不完全相等。和 componentDidMount不一樣, useEffect捕獲 props和state。所以即便在回調函數裏,你拿到的還是初始的props和state。如果你想得到"最新"的值,你可以使用ref。不過,通常會有更簡單的實現方式,所以你並不一定要用ref。記住,effects的心智模型和 componentDidMount以及其他生命週期是不同的,試圖找到它們之間完全一致的表達反而更容易使你混淆。想要更有效,你需要"think in effects",它的心智模型更接近於實現狀態同步,而不是響應生命週期事件。

🤔 Question: 如何正確地在 useEffect 裏請求數據? [] 又是什麼?

這篇文章 是很好的入門,介紹瞭如何在 useEffect裏做數據請求。請務必讀完它!它沒有我的這篇這麼長。 []表示effect沒有使用任何React數據流裏的值,因此該effect僅被調用一次是安全的。 []同樣也是一類常見問題的來源,也即你以爲沒使用數據流裏的值但其實使用了。你需要學習一些策略(主要是 useReduceruseCallback)來移除這些effect依賴,而不是錯誤地忽略它們。

🤔 Question: 我應該把函數當做effect的依賴嗎?

一般建議把不依賴props和state的函數提到你的組件外面,並且把那些僅被effect使用的函數放到effect裏面。如果這樣做了以後,你的effect還是需要用到組件內的函數(包括通過props傳進來的函數),可以在定義它們的地方用 useCallback包一層。爲什麼要這樣做呢?因爲這些函數可以訪問到props和state,因此它們會參與到數據流中。我們官網的FAQ有更詳細的答案

🤔 Question: 爲什麼有時候會出現無限重複請求的問題?

這個通常發生於你在effect裏做數據請求並且沒有設置effect依賴參數的情況。沒有設置依賴,effect會在每次渲染後執行一次,然後在effect中更新了狀態引起渲染並再次觸發effect。無限循環的發生也可能是因爲你設置的依賴總是會改變。你可以通過一個一個移除的方式排查出哪個依賴導致了問題。但是,移除你使用的依賴(或者盲目地使用 [])通常是一種錯誤的解決方式。你應該做的是解決問題的根源。舉個例子,函數可能會導致這個問題,你可以把它們放到effect裏,或者提到組件外面,或者用 useCallback包一層。 useMemo 可以做類似的事情以避免重複生成對象。

🤔 爲什麼有時候在effect裏拿到的是舊的state或prop呢?

Effect拿到的總是定義它的那次渲染中的props和state。這能夠避免一些bugs,但在一些場景中又會有些討人嫌。對於這些場景,你可以明確地使用可變的ref保存一些值(上面文章的末尾解釋了這一點)。如果你覺得在渲染中拿到了一些舊的props和state,且不是你想要的,你很可能遺漏了一些依賴。可以嘗試使用這個lint 規則來訓練你發現這些依賴。可能沒過幾天,這種能力會變得像是你的第二天性。同樣可以看我們官網FAQ中的這個回答。

我希望這個摘要對你有所幫助!要不,我們開始正文。

每一次渲染都有它自己的 Props and State

在我們討論effects之前,我們需要先討論一下渲染(rendering)。

我們來看一個計數器組件Counter:

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 會"監聽"狀態的變化並自動更新嗎?這麼想可能是學習React的時候有用的第一直覺,但它並不是精確的心智模型

上面例子中, count 僅是一個數字而已。它不是神奇的"data binding", "watcher", "proxy",或者其他任何東西。它就是一個普通的數字像下面這個一樣:

const count = 42;

<p>You clicked {count} times </p>

我們的組件第一次渲染的時候,從 useState()拿到 count的初始值 0。當我們調用 setCount(1),React會再次渲染組件,這一次 count1。如此等等:


function Counter() {
  const count = 0;
  <p>You clicked {count} times</p>

}

function Counter() {
  const count = 1;
  <p>You clicked {count} times</p>

}

function Counter() {
  const count = 2;
  <p>You clicked {count} times</p>

}

當我們更新狀態的時候,React會重新渲染組件。每一次渲染都能拿到獨立的 count 狀態,這個狀態值是函數中的一個常量。

所以下面的這行代碼沒有做任何特殊的數據綁定:

<p>You clicked {count} times</p>

它僅僅只是在渲染輸出中插入了count這個數字。這個數字由React提供。當 setCount的時候,React會帶着一個不同的 count值再次調用組件。然後,React會更新DOM以保持和渲染輸出一致。

這裏關鍵的點在於任意一次渲染中的 count常量都不會隨着時間改變。渲染輸出會變是因爲我們的組件被一次次調用,而每一次調用引起的渲染中,它包含的 count值獨立於其他渲染。

(關於這個過程更深入的探討可以查看我的另一篇文章React as a UI Runtime 。)

每一次渲染都有它自己的事件處理函數

到目前爲止一切都還好。那麼事件處理函數呢?

看下面的這個例子。它在三秒後會alert點擊次數 count

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

  function handleAlertClick() {    setTimeout(() => {      alert('You clicked on: ' + count);    }, 3000);  }
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
              Click me
      </button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

如果我按照下面的步驟去操作:

  • 點擊增加counter到3
  • 點擊一下 "Show alert"
  • 點擊增加 counter到5並且在定時器回調觸發前完成

來自己 試試吧!

這篇文章深入探索了箇中緣由。正確的答案就是 3 。alert會"捕獲"我點擊按鈕時候的狀態。

(雖然有其他辦法可以實現不同的行爲,但現在我會專注於這個默認的場景。當我們在構建一種心智模型的時候,在可選的策略中分辨出"最小阻力路徑"是非常重要的。)

但它究竟是如何工作的呢?

我們發現 count在每一次函數調用中都是一個常量值。值得強調的是 — 我們的組件函數每次渲染都會被調用,但是每一次調用中 count 值都是常量,並且它被賦予了當前渲染中的狀態值。

這並不是React特有的,普通的函數也有類似的行爲:

function sayHi(person) {
  const name = person.name;  setTimeout(() => {
    alert('Hello, ' + name);
  }, 3000);
}

let someone = {name: 'Dan'};
sayHi(someone);

someone = {name: 'Yuzhi'};
sayHi(someone);

someone = {name: 'Dominic'};
sayHi(someone);

這個例子中, 外層的 someone會被賦值很多次(就像在React中, _當前_的組件狀態會改變一樣)。 然後,在 sayHi 函數中,局部常量 name 會和某次調用中的 person 關聯。因爲這個常量是局部的,所以每一次調用都是相互獨立的。結果就是,當定時器回調觸發的時候,每一個alert都會彈出它擁有的 name

這就解釋了我們的事件處理函數如何捕獲了點擊時候的 count值。如果我們應用相同的替換原理,每一次渲染"看到"的是它自己的 count


function Counter() {
  const count = 0;
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

}

function Counter() {
  const count = 1;
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

}

function Counter() {
  const count = 2;
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

}

所以實際上,每一次渲染都有一個"新版本"的 handleAlertClick。每一個版本的 handleAlertClick"記住" 了它自己的 count


function Counter() {

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 0);    }, 3000);
  }

  <button onClick={handleAlertClick} />
}

function Counter() {

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 1);    }, 3000);
  }

  <button onClick={handleAlertClick} />
}

function Counter() {

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + 2);    }, 3000);
  }

  <button onClick={handleAlertClick} />
}

這就是爲什麼在這個demo中中,事件處理函數"屬於"某一次特定的渲染,當你點擊的時候,它會使用那次渲染中 counter的狀態值。

在任意一次渲染中,props和state是始終保持不變的。如果props和state在不同的渲染中是相互獨立的,那麼使用到它們的任何值也是獨立的(包括事件處理函數)。它們都"屬於"一次特定的渲染。即便是事件處理中的異步函數調用"看到"的也是這次渲染中的 count值。

備註:上面我將具體的 count 值直接內聯到了 handleAlertClick 函數中。這種心智上的替換是安全的因爲 count 值在某次特定渲染中不可能被改變。它被聲明成了一個常量並且是一個數字。這樣去思考其他類型的值比如對象也同樣是安全的,當然需要在我們都同意應該避免直接修改state這個前提下。通過調用 setSomething(newObj) 的方式去生成一個新的對象而不是直接修改它是更好的選擇,因爲這樣能保證之前渲染中的state不會被污染。

每次渲染都有它自己的Effects

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>
  );
}

拋一個問題給你:effect是如何讀取到最新的 count 狀態值的呢?

也許,是某種"data binding"或"watching"機制使得 count能夠在effect函數內更新?也或許 count是一個可變的值,React會在我們組件內部修改它以使我們的effect函數總能拿到最新的值?

都不是。

我們已經知道 count是某個特定渲染中的常量。事件處理函數"看到"的是屬於它那次特定渲染中的 count狀態值。對於effects也同樣如此:

並不是 count 的值在"不變"的effect中發生了改變,而是 effect 函數本身 在每一次渲染中都不相同。

每一個effect版本"看到"的 count值都來自於它屬於的那次渲染:


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

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

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

React會記住你提供的effect函數,並且會在每次更改作用於DOM並讓瀏覽器繪製屏幕後去調用它。

所以雖然我們說的是一個 effect(這裏指更新document的title),但其實每次渲染都是一個 不同的函數 — 並且每個effect函數"看到"的props和state都來自於它屬於的那次特定渲染。

概念上,你可以想象effects是渲染結果的一部分。

嚴格地說,它們並不是(爲了允許Hook的組合並且不引入笨拙的語法或者運行時)。但是在我們構建的心智模型上,effect函數 _屬於_某個特定的渲染,就像事件處理函數一樣。

爲了確保我們已經有了紮實的理解,我們再回顧一下第一次的渲染過程:

  • React: 給我狀態爲 0時候的UI。
  • 你的組件:
    • 給你需要渲染的內容: <span> You clicked 0 times</span>
    • 記得在渲染完了之後調用這個effect: () => { document.title = 'You clicked 0 times' }
  • React: 沒問題。開始更新UI,喂瀏覽器,我要給DOM添加一些東西。
  • 瀏覽器: 酷,我已經把它繪製到屏幕上了。
  • React: 好的, 我現在開始運行給我的effect
    • 運行 () => { document.title = 'You clicked 0 times' }

現在我們回顧一下我們點擊之後發生了什麼:

  • 你的組件: 喂 React, 把我的狀態設置爲 1
  • React: 給我狀態爲 1時候的UI。
  • 你的組件:
    • 給你需要渲染的內容: <span> You clicked 1 times</span>
    • 記得在渲染完了之後調用這個effect: () => { document.title = 'You clicked 1 times' }
  • React: 沒問題。開始更新UI,喂瀏覽器,我修改了DOM。
  • Browser: 酷,我已經將更改繪製到屏幕上了。
  • React: 好的, 我現在開始運行屬於這次渲染的effect
    • 運行 () => { document.title = 'You clicked 1 times' }

每一次渲染都有它自己的...所有

我們現在知道effects會在每次渲染後運行,並且概念上它是組件輸出的一部分,可以"看到"屬於某次特定渲染的props和state。

我們來做一個思想實驗,思考下面的代碼:

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

  useEffect(() => {    setTimeout(() => {      console.log(`You clicked ${count} times`);    }, 3000);  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
              Click me
      </button>
    </div>
  );
}

如果我點擊了很多次並且在effect裏設置了延時,打印出來的結果會是什麼呢?

你可能會認爲這是一個很繞的題並且結果是反直覺的。完全錯了!我們看到的就是順序的打印輸出 — 每一個都屬於某次特定的渲染,因此有它該有的 count值。你可以自己試一試

不過,class中的 this.state並不是這樣運作的。你可能會想當然以爲下面的class 實現和上面是相等的:

this.state.count總是指向 _最新_的count值,而不是屬於某次特定渲染的值。所以你會看到每次打印輸出都是 5

我覺得Hooks這麼依賴Javascript閉包是挺諷刺的一件事。有時候組件的class實現方式會受閉包相關的苦(the canonical wrong-value-in-a-timeout confusion),但其實這個例子中真正的混亂來源是可變數據(React 修改了class中的 this.state使其指向最新狀態),並不是閉包本身的錯。

當封閉的值始終不會變的情況下閉包是非常棒的。這使它們非常容易思考因爲你本質上在引用常量。正如我們所討論的,props和state在某個特定渲染中是不會改變的。順便說一下,我們可以使用閉包修復上面的class版本...

逆潮而動

到目前爲止,我們可以明確地喊出下面重要的事實: 每一個組件內的函數(包括事件處理函數,effects,定時器或者API調用等等)會捕獲某次渲染中定義的props和state。

所以下面的兩個例子是相等的:

function Example(props) {
  useEffect(() => {
    setTimeout(() => {
      console.log(props.counter);
    }, 1000);
  });

}
function Example(props) {
  const counter = props.counter; 
  useEffect(() => {
    setTimeout(() => {
      console.log(counter);
    }, 1000);
  });

}

在組件內什麼時候去讀取props或者state是無關緊要的。因爲它們不會改變。在單次渲染的範圍內,props和state始終保持不變。(解構賦值的props使得這一點更明顯。)

當然,有時候你可能想在effect的回調函數裏讀取最新的值而不是捕獲的值。最簡單的實現方法是使用refs,這篇文章的最後一部分介紹了相關內容。

需要注意的是當你想要從 過去 渲染中的函數裏讀取 未來 的props和state,你是在逆潮而動。雖然它並沒有 錯(有時候可能也需要這樣做),但它因爲打破了默認範式會使代碼顯得不夠"乾淨"。這是我們有意爲之的,因爲它能幫助突出哪些代碼是脆弱的,是需要依賴時間次序的。在class中,如果發生這種情況就沒那麼顯而易見了。

下面這個計數器版本 模擬了class中的行爲:

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);
  useEffect(() => {
    latestCount.current = count;
    setTimeout(() => {
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
}

在React中去直接修改值看上去有點怪異。然而,在class組件中React正是這樣去修改 this.state的。不像捕獲的props和state,你沒法保證在任意一個回調函數中讀取的 latestCount.current是不變的。根據定義,你可以隨時修改它。這就是爲什麼它不是默認行爲,而是需要你主動選擇這樣做。

那Effect中的清理又是怎樣的呢?

文檔中解釋的, 有些 effects 可能需要有一個清理步驟。本質上,它的目的是消除副作用(effect),比如取消訂閱。

思考下面的代碼:

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

假設第一次渲染的時候 props{id: 10},第二次渲染的時候是 {id: 20}

React只會在瀏覽器繪製後運行effects。這使得你的應用更流暢因爲大多數effects並不會阻塞屏幕的更新。Effect的清除同樣被延遲了。 上一次的effect會在重新渲染後被清除:

  • React 渲染 {id: 20} 的UI。
  • 瀏覽器繪製。我們在屏幕上看到 {id: 20}的UI。
  • React 清除 {id: 10} 的effect。
  • React 運行 {id: 20}的effect。

你可能會好奇:如果清除上一次的effect發生在props變成 {id: 20}之後,那它爲什麼還能"看到"舊的 {id: 10}

引用上半部分得到的結論:

組件內的每一個函數(包括事件處理函數,effects,定時器或者API調用等等)會捕獲定義它們的那次渲染中的props和state。

現在答案顯而易見。effect的清除並不會讀取"最新"的props。它只能讀取到定義它的那次渲染中的props值:

同步, 而非生命週期

我最喜歡React的一點是它統一描述了初始渲染和之後的更新。這降低了你程序的

比如我有個組件像下面這樣:

function Greeting({ name }) {
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

我先渲染 <greeting name="Dan"></greeting>然後渲染 <greeting name="Yuzhi"></greeting>,和我直接渲染 <greeting name="Yuzhi"></greeting>並沒有什麼區別。在這兩種情況中,我最後看到的都是"Hello, Yuzhi"。

人們總是說:"重要的是旅行過程,而不是目的地"。在React世界中,恰好相反。 重要的是目的,而不是過程。這就是JQuery代碼中 $.addClass$.removeClass這樣的調用(過程)和React代碼中聲明CSS類名 應該是什麼(目的)之間的區別。

React會根據我們當前的props和state同步到DOM。"mount"和"update"之於渲染並沒有什麼區別。

你應該以相同的方式去思考effects。 ** useEffect 使你能夠根據props和state 同步 React tree之外的東西。**

function Greeting({ name }) {
  useEffect(() => {    document.title = 'Hello, ' + name;  });  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

這就是和大家熟知的 _mount/update/unmount_心智模型之間細微的區別。理解和內化這種區別是非常重要的。 如果你試圖寫一個effect會根據是否第一次渲染而表現不一致,你正在逆潮而動。如果我們的結果依賴於過程而不是目的,我們會在同步中犯錯。

先渲染屬性A,B再渲染C,和立即渲染C並沒有什麼區別。雖然他們可能短暫地會有點不同(比如請求數據時),但最終的結果是一樣的。

不過話說回來,在 _每一次_渲染後都去運行所有的effects可能並不高效。(並且在某些場景下,它可能會導致無限循環。)

所以我們該怎麼解決這個問題?

告訴React去比對你的Effects

其實我們已經從React處理DOM的方式中學習到了解決辦法。React只會更新DOM真正發生改變的部分,而不是每次渲染都大動干戈。

當你把

<h1 className="Greeting">
  Hello, Dan
</h1>

更新到

<h1 className="Greeting">
  Hello, Yuzhi
</h1>

React 能夠看到兩個對象:

const oldProps = {className: 'Greeting', children: 'Hello, Dan'};
const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};

它會檢測每一個props,並且發現 children發生改變需要更新DOM,但 className並沒有。所以它只需要這樣做:

domNode.innerText = 'Hello, Yuzhi';

我們也可以用類似的方式處理effects嗎?如果能夠在不需要的時候避免調用effect就太好了。

舉個例子,我們的組件可能因爲狀態變更而重新渲染:

function Greeting({ name }) {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    document.title = 'Hello, ' + name;
  });

  return (
    <h1 className="Greeting">
      Hello, {name}
      <button onClick={() => setCounter(counter + 1)}>Increment</button>
    </h1>
  );
}

但是我們的effect並沒有使用 counter這個狀態。 我們的effect只會同步 name 屬性給 document.title ,但 name 並沒有變。在每一次counter改變後重新給 document.title賦值並不是理想的做法。

好了,那React可以...區分effects的不同嗎?

let oldEffect = () => { document.title = 'Hello, Dan'; };
let newEffect = () => { document.title = 'Hello, Dan'; };

並不能。React並不能猜測到函數做了什麼如果不先調用的話。(源碼中並沒有包含特殊的值,它僅僅是引用了 name屬性。)

這是爲什麼你如果想要避免effects不必要的重複調用,你可以提供給 useEffect一個依賴數組參數(deps):

  useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

這好比你告訴React:"Hey,我知道你看不到這個函數裏的東西,但我可以保證只使用了渲染中的 name ,別無其他。"

如果當前渲染中的這些依賴項和上一次運行這個effect的時候值一樣,因爲沒有什麼需要同步React會自動跳過這次effect:

const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan'];

const newEffect = () => { document.title = 'Hello, Dan'; };
const newDeps = ['Dan'];

即使依賴數組中只有一個值在兩次渲染中不一樣,我們也不能跳過effect的運行。要同步所有!

關於依賴項不要對React撒謊

關於依賴項對React撒謊會有不好的結果。直覺上,這很好理解,但我曾看到幾乎所有依賴class心智模型使用 useEffect的人都試圖違反這個規則。(我剛開始也這麼幹了!)

function SearchResults() {
  async function fetchData() {

  }

  useEffect(() => {
    fetchData();
  }, []);

}

(官網的Hooks FAQ 解釋了應該怎麼做。 我們在下面 會重新回顧這個例子。)

"但我只是想在掛載的時候運行它!",你可能會說。現在只需要記住:如果你設置了依賴項, effect中用到的所有組件內的值都要包含在依賴中。這包括props,state,函數 — 組件內的任何東西。

有時候你是這樣做了,但可能會引起一個問題。比如,你可能會遇到無限請求的問題,或者socket被頻繁創建的問題。 解決問題的方法不是移除依賴項。我們會很快了解具體的解決方案。

不過在我們深入解決方案之前,我們先嚐試更好地理解問題。

如果設置了錯誤的依賴會怎麼樣呢?

如果依賴項包含了所有effect中使用到的值,React就能知道何時需要運行它:

  useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

(依賴發生了變更,所以會重新運行effect。)

但是如果我們將 []設爲effect的依賴,新的effect函數不會運行:

  useEffect(() => {
    document.title = 'Hello, ' + name;
  }, []);

(依賴沒有變,所以不會再次運行effect。)

在這個例子中,問題看起來顯而易見。但在某些情況下如果你腦子裏"跳出"class組件的解決辦法,你的直覺很可能會欺騙你。

舉個例子,我們來寫一個每秒遞增的計數器。在Class組件中,我們的直覺是:"開啓一次定時器,清除也是一次"。這裏有一個例子說明怎麼實現它。當我們理所當然地把它用 useEffect的方式翻譯,直覺上我們會設置依賴爲 []。"我只想運行一次effect",對嗎?

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

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

然而,這個例子只會遞增一次天了嚕。

如果你的心智模型是"只有當我想重新觸發effect的時候才需要去設置依賴",這個例子可能會讓你產生存在危機。你想要觸發一次因爲它是定時器 — 但爲什麼會有問題?

如果你知道依賴是我們給React的暗示,告訴它effect所有需要使用的渲染中的值,你就不會吃驚了。effect中使用了 count但我們撒謊說它沒有依賴。如果我們這樣做遲早會出幺蛾子。

在第一次渲染中, count0。因此, setCount(count + 1)在第一次渲染中等價於 setCount(0 + 1)既然我們設置了 [] 依賴,effect不會再重新運行,它後面每一秒都會調用 setCount(0 + 1)


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

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

}

我們對React撒謊說我們的effect不依賴組件內的任何值,可實際上我們的effect有依賴!

我們的effect依賴 count - 它是組件內的值(不過在effect外面定義):

  const count = //...

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

因此,設置 []爲依賴會引入一個bug。React會對比依賴,並且跳過後面的effect:

(依賴沒有變,所以不會再次運行effect。)

類似於這樣的問題是很難被想到的。因此,我鼓勵你將誠實地告知effect依賴作爲一條硬性規則,並且要列出所以依賴。(我們提供了一個lint規則如果你想在你的團隊內做硬性規定。)

兩種誠實告知依賴的方法

有兩種誠實告知依賴的策略。你應該從第一種開始,然後在需要的時候應用第二種。

第一種策略是在依賴中包含所有effect中用到的組件內的值。讓我們在依賴中包含 count

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

現在依賴數組正確了。雖然它可能不是 _太理想_但確實解決了上面的問題。現在,每次 count修改都會重新運行effect,並且定時器中的 setCount(count + 1)會正確引用某次渲染中的 count值:


function Counter() {

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

}

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

}

這能解決問題但是我們的定時器會在每一次 count改變後清除和重新設定。這應該不是我們想要的結果:

(依賴發生了變更,所以會重新運行effect。)

第二種策略是修改effect內部的代碼以確保它包含的值只會在需要的時候發生變更。我們不想告知錯誤的依賴 - 我們只是修改effect使得依賴更少。

讓我們來看一些移除依賴的常用技巧。

我們想去掉effect的 count依賴。

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

爲了實現這個目的,我們需要問自己一個問題: 我們爲什麼要用 count可以看到我們只在 setCount調用中用到了 count。在這個場景中,我們其實並不需要在effect中使用 count。當我們想要根據前一個狀態更新狀態的時候,我們可以使用 setState函數形式

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

我喜歡把類似這種情況稱爲"錯誤的依賴"。是的,因爲我們在effect中寫了 setCount(count + 1)所以 count是一個必需的依賴。但是,我們真正想要的是把 count轉換爲 count+1,然後返回給React。可是React其實已經知道當前的 count我們需要告知React的僅僅是去遞增狀態 - 不管它現在具體是什麼值。

這正是 setCount(c => c + 1)做的事情。你可以認爲它是在給React"發送指令"告知如何更新狀態。這種"更新形式"在其他情況下也有幫助,比如你需要 批量更新。

注意我們做到了移除依賴,並且沒有撒謊。我們的effect不再讀取渲染中的 count 值。

(依賴沒有變,所以不會再次運行effect。)

你可以自己 試試

儘管effect只運行了一次,第一次渲染中的定時器回調函數可以完美地在每次觸發的時候給React發送 c => c + 1更新指令。它不再需要知道當前的 count值。因爲React已經知道了。

函數式更新 和 Google Docs

還記得我們說過同步纔是理解effects的心智模型嗎?同步的一個有趣地方在於你通常想要把同步的"信息"和狀態解耦。舉個例子,當你在Google Docs編輯文檔的時候,Google並不會把整篇文章發送給服務器。那樣做會非常低效。相反的,它只是把你的修改以一種形式發送給服務端。

雖然我們effect的情況不盡相同,但可以應用類似的思想。 只在effects中傳遞最小的信息會很有幫助。 類似於 setCount(c => c + 1)這樣的更新形式比 setCount(count + 1)傳遞了更少的信息,因爲它不再被當前的count值"污染"。它只是表達了一種行爲("遞增")。"Thinking in React"也討論了如何找到最小狀態。原則是類似的,只不過現在關注的是如何更新。

表達 意圖(而不是結果)和Google Docs 如何處理共同編輯異曲同工。雖然這個類比略微延伸了一點,函數式更新在React中扮演了類似的角色。它們確保能以批量地和可預測的方式來處理各種源頭(事件處理函數,effect中的訂閱,等等)的狀態更新。

然而,即使是 setCount(c => c + 1) 也並不完美。 它看起來有點怪,並且非常受限於它能做的事。舉個例子,如果我們有兩個互相依賴的狀態,或者我們想基於一個prop來計算下一次的state,它並不能做到。幸運的是, setCount(c => c + 1)有一個更強大的姐妹模式,它的名字叫 useReducer

解耦來自Actions的更新

我們來修改上面的例子讓它包含兩個狀態: countstep。我們的定時器會每次在count上增加一個 step值:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

(這裏是demo.)

注意 我們沒有撒謊。既然我們在effect裏使用了 step,我們就把它加到依賴裏。所以這也是爲什麼代碼能運行正確。

這個例子目前的行爲是修改 step會重啓定時器 - 因爲它是依賴項之一。在大多數場景下,這正是你所需要的。清除上一次的effect然後重新運行新的effect並沒有任何錯。除非我們有很好的理由,我們不應該改變這個默認行爲。

不過,假如我們不想在 step改變後重啓定時器,我們該如何從effect中移除對 step的依賴呢?

當你想更新一個狀態,並且這個狀態更新依賴於另一個狀態的值時,你可能需要用 useReducer 去替換它們。

當你寫類似 setSomething(something => ...)這種代碼的時候,也許就是考慮使用reducer的契機。reducer可以讓你 把組件內發生了什麼(actions)和狀態如何響應並更新分開表述。

我們用一個 dispatch依賴去替換effect的 step依賴:

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' });
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

(查看 demo。)

你可能會問:"這怎麼就更好了?"答案是 React會保證 dispatch 在組件的聲明週期內保持不變。所以上面例子中不再需要重新訂閱定時器。

我們解決了問題!

(你可以從依賴中去除 dispatch, setState, 和 useRef 包裹的值因爲React會確保它們是靜態的。不過你設置了它們作爲依賴也沒什麼問題。)

相比於直接在effect裏面讀取狀態,它dispatch了一個 _action_來描述發生了什麼。這使得我們的effect和 step狀態解耦。我們的effect不再關心怎麼更新狀態,它只負責告訴我們發生了什麼。更新的邏輯全都交由reducer去統一處理:

(這裏是demo 如果你之前錯過了。)

爲什麼useReducer是Hooks的作弊模式

我們已經學習到如何移除effect的依賴,不管狀態更新是依賴上一個狀態還是依賴另一個狀態。 但假如我們需要依賴 props 去計算下一個狀態呢?舉個例子,也許我們的API是 <counter step="{1}"></counter>。確定的是,在這種情況下,我們沒法避免依賴 props.step 。是嗎?

實際上, 我們可以避免!我們可以把 _reducer_函數放到組件內去讀取props:

function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

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

這種模式會使一些優化失效,所以你應該避免濫用它,不過如果你需要你完全可以在reducer裏面訪問props。(這裏是demo。)

即使是在這個例子中,React也保證 dispatch 在每次渲染中都是一樣的。 所以你可以在依賴中去掉它。它不會引起effect不必要的重複執行。

你可能會疑惑:這怎麼可能?在之前渲染中調用的reducer怎麼"知道"新的props?答案是當你 dispatch的時候,React只是記住了action - 它會在下一次渲染中再次調用reducer。在那個時候,新的props就可以被訪問到,而且reducer調用也不是在effect裏。

這就是爲什麼我傾向認爲 useReducer 是Hooks的"作弊模式"。它可以把更新邏輯和描述發生了什麼分開。結果是,這可以幫助我移除不必需的依賴,避免不必要的effect調用。

把函數移到Effects裏

一個典型的誤解是認爲函數不應該成爲依賴。舉個例子,下面的代碼看上去可以運行正常:

function SearchResults() {
  const [data, setData] = useState({ hits: [] });

  async function fetchData() {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

(這個例子 改編自Robin Wieruch這篇很棒的文章 —點擊查看 !)

需要明確的是,上面的代碼可以正常工作。 但這樣做在組件日漸複雜的迭代過程中我們很難確保它在各種情況下還能正常運行。

想象一下我們的代碼做下面這樣的分離,並且每一個函數的體量是現在的五倍,然後我們在某些函數內使用了某些state或者prop:

function SearchResults() {
  const [query, setQuery] = useState('react');

  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;  }

  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

}

如果我們忘記去更新使用這些函數(很可能通過其他函數調用)的effects的依賴,我們的effects就不會同步props和state帶來的變更。這當然不是我們想要的。

幸運的是,對於這個問題有一個簡單的解決方案。 如果某些函數僅在effect中調用,你可以把它們的定義移到effect中:

function SearchResults() {

  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=react';
    }
    async function fetchData() 
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    fetchData();
  }, []);

}

(這裏是demo.)

這麼做有什麼好處呢?我們不再需要去考慮這些"間接依賴"。我們的依賴數組也不再撒謊: 在我們的effect中確實沒有再使用組件範圍內的任何東西。

如果我們後面修改 getFetchUrl去使用 query狀態,我們更可能會意識到我們正在effect裏面編輯它 - 因此,我們需要把 query添加到effect的依賴裏:

function SearchResults() {
  const [query, setQuery] = useState('react');

  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }

    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, [query]);

}

(這裏是demo.)

添加這個依賴,我們不僅僅是在"取悅React"。在query改變後去重新請求數據是合理的。 useEffect的設計意圖就是要強迫你關注數據流的改變,然後決定我們的effects該如何和它同步 - 而不是忽視它直到我們的用戶遇到了bug。

感謝 eslint-plugin-react-hooks 插件的 exhaustive-depslint規則,它會在你編碼的時候就分析effects並且提供可能遺漏依賴的建議。換句話說,機器會告訴你組件中哪些數據流變更沒有被正確地處理。

非常棒。

但我不能把這個函數放到Effect裏

有時候你可能不想把函數移入effect裏。比如,組件內有幾個effect使用了相同的函數,你不想在每個effect裏複製黏貼一遍這個邏輯。也或許這個函數是一個prop。

在這種情況下你應該忽略對函數的依賴嗎?我不這麼認爲。再次強調, effects不應該對它的依賴撒謊。通常我們還有更好的解決辦法。一個常見的誤解是,"函數從來不會改變"。但是這篇文章你讀到現在,你知道這顯然不是事實。實際上,在組件內定義的函數每一次渲染都在變。

函數每次渲染都會改變這個事實本身就是個問題。 比如有兩個effects會調用 getFetchUrl:

function SearchResults() {
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
  }, []);

  useEffect(() => {
    const url = getFetchUrl('redux');
  }, []);

}

在這個例子中,你可能不想把 getFetchUrl 移到effects中,因爲你想複用邏輯。

另一方面,如果你對依賴很"誠實",你可能會掉到陷阱裏。我們的兩個effects都依賴 getFetchUrl而它每次渲染都不同,所以我們的依賴數組會變得無用:

function SearchResults() {
  function getFetchUrl(query) {    return 'https://hn.algolia.com/api/v1/search?query=' + query;  }
  useEffect(() => {
    const url = getFetchUrl('react');

  }, [getFetchUrl]);

  useEffect(() => {
    const url = getFetchUrl('redux');

  }, [getFetchUrl]);

}

一個可能的解決辦法是把 getFetchUrl從依賴中去掉。但是,我不認爲這是好的解決方式。這會使我們後面對數據流的改變很難被發現從而忘記去處理。這會導致類似於上面"定時器不更新值"的問題。

相反的,我們有兩個更簡單的解決辦法。

第一個, 如果一個函數沒有使用組件內的任何值,你應該把它提到組件外面去定義,然後就可以自由地在effects中使用:

function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
  }, []);

  useEffect(() => {
    const url = getFetchUrl('redux');
  }, []);

}

你不再需要把它設爲依賴,因爲它們不在渲染範圍內,因此不會被數據流影響。它不可能突然意外地依賴於props或state。

或者, 你也可以把它包裝成 useCallback Hook:

function SearchResults() {
  const getFetchUrl = useCallback((query) => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []);

  useEffect(() => {
    const url = getFetchUrl('react');
  }, [getFetchUrl]);

  useEffect(() => {
    const url = getFetchUrl('redux');
  }, [getFetchUrl]);

}

useCallback本質上是添加了一層依賴檢查。它以另一種方式解決了問題 - 我們使函數本身只在需要的時候才改變,而不是去掉對函數的依賴。

我們來看看爲什麼這種方式是有用的。之前,我們的例子中展示了兩種搜索結果(查詢條件分別爲 'react''redux')。但如果我們想添加一個輸入框允許你輸入任意的查詢條件(query)。不同於傳遞 query參數的方式,現在 getFetchUrl會從狀態中讀取。

我們很快發現它遺漏了 query依賴:

function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []);
}

如果我把 query添加到 useCallback 的依賴中,任何調用了 getFetchUrl的effect在 query改變後都會重新運行:

function SearchResults() {
  const [query, setQuery] = useState('react');

  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);

  useEffect(() => {
    const url = getFetchUrl();
  }, [getFetchUrl]);

}

我們要感謝 useCallback,因爲如果 query 保持不變, getFetchUrl也會保持不變,我們的effect也不會重新運行。但是如果 query修改了, getFetchUrl也會隨之改變,因此會重新請求數據。這就像你在Excel裏修改了一個單元格的值,另一個使用它的單元格會自動重新計算一樣。

這正是擁抱數據流和同步思維的結果。 對於通過屬性從父組件傳入的函數這個方法也適用:

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

  const fetchData = useCallback(() => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);
  return <Child fetchData={fetchData} />
}

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

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]);

}

因爲 fetchData只有在 Parentquery狀態變更時纔會改變,所以我們的 Child只會在需要的時候纔去重新請求數據。

函數是數據流的一部分嗎?

有趣的是,這種模式在class組件中行不通,並且這種行不通恰到好處地揭示了effect和生命週期範式之間的區別。考慮下面的轉換:

class Parent extends Component {
  state = {
    query: 'react'
  };

  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
  };

  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  } 
  render() {
  }
}

你可能會想:"少來了Dan,我們都知道 useEffect 就像 componentDidMountcomponentDidUpdate的結合,你不能老是破壞這一條!" 好吧,就算加了 componentDidUpdate 照樣無用:

class Child extends Component {
  state = {
    data: null
  };

  componentDidMount() {
    this.props.fetchData();
  }

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

當然如此, fetchData是一個class方法!(或者你也可以說是class屬性 - 但這不能改變什麼。)它不會因爲狀態的改變而不同,所以 this.props.fetchDataprevProps.fetchData始終相等,因此不會重新請求。那我們刪掉條件判斷怎麼樣?

  componentDidUpdate(prevProps) {
    this.props.fetchData();
  }

等等,這樣會在每次渲染後都去請求。(添加一個加載動畫可能是一種有趣的發現這種情況的方式。)也許我們可以綁定一個特定的query?

  render() {
    return <Child fetchData={this.fetchData.bind(this, this.state.query)} />;
  }

但這樣一來, this.props.fetchData !== prevProps.fetchData 表達式永遠是 true,即使 query並未改變。這會導致我們總是去請求。

想要解決這個class組件中的難題,唯一現實可行的辦法是硬着頭皮把 query本身傳入 Child 組件。 Child 雖然實際並沒有直接 _使用_這個 query的值,但能在它改變的時候觸發一次重新請求:

class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
  };
  render() {
    return <Child fetchData={this.fetchData} query={this.state.query} />;  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
  }
  render() {
  }
}

在使用React的class組件這麼多年後,我已經如此習慣於把不必要的props傳遞下去並且破壞父組件的封裝以至於我在一週之前才意識到我爲什麼一定要這樣做。

在class組件中,函數屬性本身並不是數據流的一部分。組件的方法中包含了可變的 this變量導致我們不能確定無疑地認爲它是不變的。因此,即使我們只需要一個函數,我們也必須把一堆數據傳遞下去僅僅是爲了做"diff"。我們無法知道傳入的 this.props.fetchData 是否依賴狀態,並且不知道它依賴的狀態是否改變了。

使用 useCallback ,函數完全可以參與到數據流中。我們可以說如果一個函數的輸入改變了,這個函數就改變了。如果沒有,函數也不會改變。感謝周到的 useCallback,屬性比如 props.fetchData的改變也會自動傳遞下去。

類似的,useMemo可以讓我們對複雜對象做類似的事情。

function ColorPicker() {

  const [color, setColor] = useState('pink');
  const style = useMemo(() => ({ color }), [color]);
  return <Child style={style} />;
}

我想強調的是,到處使用 useCallback 是件挺笨拙的事。當我們需要將函數傳遞下去並且函數會在子組件的effect中被調用的時候, useCallback 是很好的技巧且非常有用。或者你想試圖減少對子組件的記憶負擔,也不妨一試。但總的來說Hooks本身能更好地避免傳遞迴調函數

在上面的例子中,我更傾向於把 fetchData放在我的effect裏(它可以抽離成一個自定義Hook)或者是從頂層引入。我想讓effects保持簡單,而在裏面調用回調會讓事情變得複雜。("如果某個 props.onComplete回調改變了而請求還在進行中會怎麼樣?")你可以模擬class的行爲但那樣並不能解決競態的問題。

說說競態

下面是一個典型的在class組件裏發請求的例子:

class Article extends Component {
  state = {
    article: null
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }

}

你很可能已經知道,上面的代碼埋伏了一些問題。它並沒有處理更新的情況。所以第二個你能夠在網上找到的經典例子是下面這樣的:

class Article extends Component {
  state = {
    article: null
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);    
    }  
  }  
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }

}

這顯然好多了!但依舊有問題。有問題的原因是請求結果返回的順序不能保證一致。比如我先請求 {id: 10},然後更新到 {id: 20},但 {id: 20}的請求更先返回。請求更早但返回更晚的情況會錯誤地覆蓋狀態值。

這被叫做競態,這在混合了 async / await(假設在等待結果返回)和自頂向下數據流的代碼中非常典型(props和state可能會在async函數調用過程中發生改變)。

Effects並沒有神奇地解決這個問題,儘管它會警告你如果你直接傳了一個 async 函數給effect。(我們會改善這個警告來更好地解釋你可能會遇到的這些問題。)

如果你使用的異步方式支持取消,那太棒了。你可以直接在清除函數中取消異步請求。

或者,最簡單的權宜之計是用一個布爾值來跟蹤它:

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

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

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

}

這篇文章討論了更多關於如何處理錯誤和加載狀態,以及抽離邏輯到自定義的Hook。我推薦你認真閱讀一下如果你想學習更多關於如何在Hooks裏請求數據的內容。

提高水準

在class組件生命週期的思維模型中,副作用的行爲和渲染輸出是不同的。UI渲染是被props和state驅動的,並且能確保步調一致,但副作用並不是這樣。這是一類常見問題的來源。

而在 useEffect的思維模型中,默認都是同步的。副作用變成了React數據流的一部分。對於每一個 useEffect調用,一旦你處理正確,你的組件能夠更好地處理邊緣情況。

然而,用好 useEffect的前期學習成本更高。這可能讓人氣惱。用同步的代碼去處理邊緣情況天然就比觸發一次不用和渲染結果步調一致的副作用更難。

這難免讓人擔憂如果 useEffect是你現在使用最多的工具。不過,目前大抵還處理低水平使用階段。因爲Hooks太新了所以大家都還在低水平地使用它,尤其是在一些教程示例中。但在實踐中,社區很可能即將開始高水平地使用Hooks,因爲好的API會有更好的動量和衝勁。

我看到不同的應用在創造他們自己的Hooks,比如封裝了應用鑑權邏輯的 useFetch或者使用theme context的 useTheme 。你一旦有了包含這些的工具箱,你就不會那麼頻繁地直接使用 useEffect。但每一個基於它的Hook都能從它的適應能力中得到益處。

目前爲止, useEffect主要用於數據請求。但是數據請求準確說並不是一個同步問題。因爲我們的依賴經常是 []所以這一點尤其明顯。那我們究竟在同步什麼?

長遠來看, Suspense用於數據請求 會允許第三方庫通過第一等的途徑告訴React暫停渲染直到某些異步事物(任何東西:代碼,數據,圖片)已經準備就緒。

當Suspense逐漸地覆蓋到更多的數據請求使用場景,我預料 useEffect 會退居幕後作爲一個強大的工具,用於同步props和state到某些副作用。不像數據請求,它可以很好地處理這些場景因爲它就是爲此而設計的。不過在那之前,自定義的Hooks比如這兒提到的是複用數據請求邏輯很好的方式。

在結束前

現在你差不多知道了我關於如何使用effects的所有知識,可以檢查一下開頭的TLDR。你現在覺得它說得有道理嗎?我有遺漏什麼嗎?(我的紙還沒有寫完!)

最後

譯者寫了一個 React + Hooks 的 UI 庫,方便大家學習和使用,

React + Hooks 項目實戰

歡迎關注公衆號「前端進階課」認真學前端,一起進階。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章