[譯] X 為啥不是 hook?

掘金翻譯計劃發表於2019-02-20

由讀者翻譯的版本:西班牙語

React Hooks 第一個 alpha 版本釋出以來, 這個問題一直被激烈討論:“為什麼 API 不是 hook?”

你要知道,只有下面這幾個算是 hooks:

但是像 React.memo()<Context.Provider>,這些 API 它們不是 Hooks。一般來說,這些 Hook 版本的 API 被認為是 非元件化反模組化 的。這篇文章將幫助你理解其中的原理。

注:這篇文章並非教你如何高效的使用 React,而是對 hooks API 饒有興趣的開發者所準備的深入分析。


以下兩個重要的屬性是我們希望 React 的 APIs 應該擁有的:

  1. 可組合Custom Hooks(自定義 Hooks)極大程度上決定了 Hooks API 為何如此好用。我們希望開發者們經常使用自定義 hooks,這樣就需要確保不同開發者所寫的 hooks 不會衝突。(撰寫乾淨並且不會相互衝突的元件實在太棒了)

  2. 可除錯:隨著應用的膨脹,我們希望 bug 很容易被發現。React 最棒的特性之一就是,當你發現某些渲染錯誤的時候,你可以順著元件樹尋找,直到找出是哪一個元件的 props 或 state 的值導致的錯誤。

有了這兩個約束,我們就知道哪些算是真正意義上的 Hook,而哪些不算。


一個真正的 Hook: useState()

可組合

多個自定義 Hooks 各自呼叫 useState() 不會衝突:

function useMyCustomHook1() {
  const [value, setValue] = useState(0);
  // 無論這裡做了什麼,它都只會作用在這裡
}

function useMyCustomHook2() {
  const [value, setValue] = useState(0);
  // 無論這裡做了什麼,它都只會作用在這裡
}

function MyComponent() {
  useMyCustomHook1();
  useMyCustomHook2();
  // ...
}
複製程式碼

無限制的呼叫一個 useState() 總是安全的。在你宣告新的狀態量時,你不用理會其他元件用到的 Hooks,也不用擔心狀態量的更新會相互干擾。

結論:useState() 不會使自定義 Hooks 變得脆弱。

可除錯

Hooks 非常好用,因為你可以在 Hooks 之間傳值:

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  // ...
  return width;
}

function useTheme(isMobile) {
  // ...
}

function Comment() {
  const width = useWindowWidth();
  const isMobile = width < MOBILE_VIEWPORT;
  const theme = useTheme(isMobile);
  return (
    <section className={theme.comment}>
      {/* ... */}
    </section>
  );
}
複製程式碼

但是如果我們的程式碼出錯了呢?我們又該怎麼除錯?

我們先假設,從 theme.comment 拿到的 CSS 的 class 是錯的。我們該怎麼除錯? 我們可以打一個斷點或者在我們的元件體內加一些 log。

我們可能會發現 theme 是錯的,但是 widthisMobile 是對的。這會提示我們問題出在 useTheme() 內部。又或許我們發現 width 本身是錯的。這可以指引我們去檢視 useWindowWidth()

簡單看一下中間值就能指導我們哪個頂層的 Hooks 有 bug。 我們不需要挨個去檢視他們所有的實現。

這樣,我們就能夠洞察 bug 所在的部分,幾次三番之後,程式問題終得其解。

如果我們的自定義 Hook 巢狀的層級加深的時候,這一點就顯得很重要了。假設一下我們有一個 3 層巢狀的自定義 Hook,每一層級的內部又用了 3 個不同的自定義 Hooks。在 3 處找bug和最多 3 + 3×3 + 3×3×3 = 39 處找 bug 的區別是巨大的。幸運的是, useState() 不會魔法般的 “影響” 其他 Hooks 或元件。與任何 useState() 所返回的變數一樣,一個可能造成 bug 的返回值也是有跡可循的。

結論:useState() 不會使你的程式碼邏輯變得模糊不清,我們可以直接沿著麵包屑找到 bug。


它不是一個 Hook: useBailout()

作為一個優化點,元件使用 Hooks 可以避免重複渲染(re-rendering)。

其中一個方法是使用 React.memo() 包裹住整個元件。如果 props 和上次渲染完之後對比淺相等(shallowly equal),就可以避免重複渲染。這和 class 模式中的PureComponent 很像。

React.memo() 接受一個元件作為引數,並返回一個元件:

function Button(props) {
  // ...
}
export default React.memo(Button);
複製程式碼

但它為什麼就不是 Hook?

不論你叫它 useShouldComponentUpdate()usePure()useSkipRender() 還是 useBailout(),它看起來都差不多長這樣:

function Button({ color }) {
  // ⚠️ 不是真正的 API
  useBailout(prevColor => prevColor !== color, color);

  return (
    <button className={'button-' + color}>  
      OK
    </button>
  )
}
複製程式碼

還有一些其他的變種 (比如:一個簡單的 usePure()) 但是大體上來說,他們都有一些相同的缺陷。

可組合

我們來試試把 useBailout() 放在 2 個自定義 Hooks 中:

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ⚠️ 不是真正的 API
  useBailout(prevIsOnline => prevIsOnline !== isOnline, isOnline);

  useEffect(() => {
    const handleStatusChange = status => setIsOnline(status.isOnline);
    ChatAPI.subscribe(friendID, handleStatusChange);
    return () => ChatAPI.unsubscribe(friendID, handleStatusChange);
  });

  return isOnline;
}

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  
  // ⚠️ 不是真正的 API
  useBailout(prevWidth => prevWidth !== width, width);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  });

  return width;
}
複製程式碼

譯註:使用了 useBailout 後,useFriendStatus 只會在 isOnline 狀態變化時才允許 re-render,useWindowWidth 只會在 width 變化時才允許 re-render。

現在如果你在同一個元件中同時用到他們會怎麼樣呢?

function ChatThread({ friendID, isTyping }) {
  const width = useWindowWidth();
  const isOnline = useFriendStatus(friendID);
  return (
    <ChatLayout width={width}>
      <FriendStatus isOnline={isOnline} />
      {isTyping && 'Typing...'}
    </ChatLayout>
  );
}
複製程式碼

什麼時候會 re-render 呢?

如果每一個 useBailout() 的呼叫都有能力跳過這次更新,如果 useFriendStatus() 阻止了 re-render,那麼 useWindowWidth 就無法獲得更新,反之亦然。這些 Hooks 會相互阻塞。

然而,在元件內部,倘若只有所有呼叫了 useBailout() 都同意不 re-render 元件才不會更新,那麼當 props 中的 isTyping 改變時,由於內部所有 useBailout() 呼叫都沒有同意更新,導致 ChatThread 也無法更新。

基於這種假設,將導致更糟糕的局面,任何新置入元件的 Hooks 都需要去呼叫 useBailout(),不這樣做的話,它們就無法投出“反對票”來讓自己獲得更新。

結論: ? useBailout() 破壞了可組合性。新增一個 Hook 會破壞其他 Hooks 的狀態更新。我們希望這些 APIs 是穩定的,但是這個特性顯然是與之相反了。

Debugging

useBailout() 對除錯有什麼影響呢?

我們用相同的例子:

function ChatThread({ friendID, isTyping }) {
  const width = useWindowWidth();
  const isOnline = useFriendStatus(friendID);
  return (
    <ChatLayout width={width}>
      <FriendStatus isOnline={isOnline} />
      {isTyping && 'Typing...'}
    </ChatLayout>
  );
}
複製程式碼

事實上即使 prop 上層的某處改變了,Typing... 這個 label 也不會像我們期望的那樣出現。那麼我們怎麼除錯呢?

一般來說, 在 React 中你可以通過向尋找的辦法,自信的回答這個問題。 如果 ChatThread 沒有得到新的 isTyping 的值, 我們可以開啟那個渲染 <ChatThread isTyping={myVar} /> 的元件,檢查 myVar,諸如此類。 在其中的某一層, 我們會發現要麼是容易出錯的 shouldComponentUpdate() 跳過了渲染, 要麼是一個錯誤的 isTyping 的值被傳遞了下來。通常來說檢視這條鏈路上的每個元件,已經足夠定位到問題的來源了。

然而, 假如這個 useBailout() 真是個 Hook,如果你不檢查我們在 ChatThread 中用到的每一個自定義 Hook (深入地) 和在各自鏈路上的所有元件,你永遠都不會知道跳過這次更新的原因。更因為任何父元件可能會用到自定義 Hooks, 這個規模很恐怖。

這就像你要在抽屜裡找一把螺絲刀,而每一層抽屜裡都包含一堆小抽屜,你無法想象愛麗絲仙境中的兔子洞有多深。

結論:? useBailout() 不僅破壞了可組合性,也極大的增加了除錯的步驟和找 bug 過程的認知負擔 — 某些時候,是指數級的。


全文我們探討了一個真正的 Hook,useState(),和一個不太算是 Hook 的 useBailout(),並從可組合性及可除錯性兩個方面說明了為什麼一個是 Hook,而一個不算是 Hook。

儘管現在沒有 “Hook 版本的 memo()shouldComponentUpdate(),但 React 確實提供了一個名叫 useMemo() 的 Hook。它有類似的作用,但是他的語義不會迷惑使用它的人。

useBailout() 這個例子,描述了控制元件是否 re-render 並不適合做成一個 hook。這裡還有一些其他的例子 - 例如,useProvider()useCatch()useSuspense()

現在你知道為什麼某些 API 不算是 Hook 了嗎?

(當你開始迷惑時,就提醒自己:可組合... 可除錯)

Discuss on TwitterEdit on GitHub

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章