banner
AgedCoffee

AgedCoffee

React Hooks 是一個錯誤嗎

原文地址:Were React Hooks a Mistake?

Web 開發社區最近幾週一直在談論信號,這是一種反應式編程模式,可以實現非常高效的 UI 更新。Devon Govett 寫了一個引人深思的 Twitter 線程,討論了信號和可變狀態。Ryan Carniato 回應了一篇優秀的文章,比較了信號與

討論表明的一件事是,有很多人對 React 編程模型並不適應。為什麼會這樣?

我認為問題在於人們對組件的心理模型與使用鉤子的函數組件在 React 中的工作方式不匹配。我要做一個大膽的聲明:人們喜歡信號,因為基於信號的組件更類似於類組件而不是使用鉤子的函數組件。

讓我們倒回一點。React 組件以前長成這樣

class Game extends React.Component {
  state = { count: 0, started: false }

  increment() {
    this.setState({ count: this.state.count + 1 })
  }

  start() {
    if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000)
    this.setState({ started: true })
  }

  render() {
    return (
      <button
        onClick={() => {
          this.increment()
          this.start()
        }}
      >
        {this.state.started ? 'Current score: ' + this.state.count : 'Start'}
      </button>
    )
  }
}

每個組件都是 React.Component 類的實例。狀態保存在 state 屬性中,回調只是實例上的方法。當 React 需要渲染一個組件時,它會調用 render 方法。

你仍然可以編寫像這樣的組件。語法並沒有被刪除。但是在 2015 年,React 引入了一些新東西:無狀態函數組件(stateless function components).

function CounterButton({ started, count, onClick }) {
  return <button onClick={onClick}>{started ? 'Current score: ' + count : 'Start'}</button>
}

class Game extends React.Component {
  state = { count: 0, started: false }

  increment() {
    this.setState({ count: this.state.count + 1 })
  }

  start() {
    if (!this.state.started) setTimeout(() => alert(`Your score was ${this.state.count}!`), 5000)
    this.setState({ started: true })
  }

  render() {
    return (
      <CounterButton
        started={this.state.started}
        count={this.state.count}
        onClick={() => {
          this.increment()
          this.start()
        }}
      />
    )
  }
}

當時,這些組件沒有添加狀態的方法 —— 必須將其保留在類組件中並作為 props 傳遞。想法是大多數組件都是無狀態的,由樹頂附近的一些有狀態組件提供支持。

然而,在編寫類組件時,情況有點尷尬。有狀態邏輯的構成特別棘手。比如說你需要多個不同的類來監聽窗口調整大小事件。如果不重寫每個實例方法,那該怎麼辦?如果您需要它們與組件狀態交互呢?React 試圖通過 mixin 解決此問題,但團隊很快意識到了缺點。

此外,人們真的很喜歡函數式組件!甚至還有庫可以向其中添加狀態。因此也許並不奇怪 React 提出了一个內置解決方案:hooks.

function Game() {
  const [count, setCount] = useState(0)
  const [started, setStarted] = useState(false)

  function increment() {
    setCount(count + 1)
  }

  function start() {
    if (!started) setTimeout(() => alert(`Your score was ${count}!`), 5000)
    setStarted(true)
  }

  return (
    <button
      onClick={() => {
        increment()
        start()
      }}
    >
      {started ? 'Current score: ' + count : 'Start'}
    </button>
  )
}

當我第一次嘗試使用 hooks 時,它們真的是一個啟示。它們確實使得封裝行為和重用有狀態邏輯變得容易。我毫不猶豫地跳了進去;自那以後,我寫過的唯一類組件就是錯誤邊界。

話雖如此 - 儘管乍一看這個組件與上面的類組件相同,但存在一個重要區別。也許你已經發現了:UI 中的分數將會更新,但當警報出現時,它總是會顯示 0。因為 setTimeout 只在第一次調用 start 時發生,並且關閉初始計數值,所以它永遠只能看到那個值。

你可能認為可以使用 useEffect 來解決這個問題:

function Game() {
  const [count, setCount] = useState(0)
  const [started, setStarted] = useState(false)

  function increment() {
    setCount(count + 1)
  }

  function start() {
    setStarted(true)
  }

  useEffect(() => {
    if (started) {
      const timeout = setTimeout(() => alert(`Your score is ${count}!`), 5000)
      return () => clearTimeout(timeout)
    }
  }, [count, started])

  return (
    <button
      onClick={() => {
        increment()
        start()
      }}
    >
      {started ? 'Current score: ' + count : 'Start'}
    </button>
  )
}

這個警報將顯示正確的計數。但是有一個新問題:如果您不斷點擊,遊戲永遠不會結束!為了防止效果函數閉包變得 “陳舊”,我們將 count 和 started 添加到依賴項數組中。每當它們改變時,我們都會獲得一個看到更新值的新效果函數。但是該新效果還設置了一個新的超時。每次單擊按鈕時,您都可以在警報出現之前獲得五秒鐘的新鮮時間。

在類組件中,方法始終可以訪問最新狀態,因為它們具有對類實例的穩定引用。但是,在函數組件中,每次呈現都會創建關閉其自身狀態的新回調。每次調用該函數時,它都會獲得自己的閉包。未來渲染無法更改過去渲染的狀態。

換句話說:類組件每個已掛載的組件只有一個實例,但函數組件有多個 “實例”—— 每次渲染都會創建一個。Hooks 進一步鞏固了這種約束。這是你在使用它們時遇到所有問題的根源:

  • 每次渲染都會創建自己的回調函數,這意味著任何在運行副作用之前檢查引用相等性的東西 —— 如 useEffect 及其兄弟們 —— 將過於頻繁地觸發。
  • 回調函數封閉了它們所屬渲染中的狀態和屬性,這意味著由於 useCallback、異步操作、超時等原因而持久存在於多次渲染之間的回調函數將訪問陳舊數據。

React 給你提供了一個應對這種情況的逃生口:useRef,它是一個可變對象,在渲染之間保持穩定的身份。我認為它是一種在同一已掛載組件的不同實例之間來回傳送值的方法。有了這個想法,下面是使用 hooks 的我們遊戲可能看起來像什麼:

function Game() {
  const [count, setCount] = useState(0)
  const [started, setStarted] = useState(false)
  const countRef = useRef(count)

  function increment() {
    setCount(count + 1)
    countRef.current = count + 1
  }

  function start() {
    if (!started) setTimeout(() => alert(`Your score was ${countRef.current}!`), 5000)
    setStarted(true)
  }

  return (
    <button
      onClick={() => {
        increment()
        start()
      }}
    >
      {started ? 'Current score: ' + count : 'Start'}
    </button>
  )
}

這很笨重!我們現在要在兩個不同的地方跟蹤計數,並且我們的增量函數必須同時更新它們。它能夠工作的原因是每個啟動閉包都可以訪問相同的 countRef;當我們在一個閉包中改變它時,所有其他閉包也可以看到已經改變的值。但是我們不能擺脫 useState 只依賴 useRef,因為更改引用不會導致 React 重新渲染。我們陷入了兩個不同世界之間 - 用於更新 UI 的不可變狀態和具有當前狀態的可變引用。

類組件沒有這種缺點。每個掛載組件都是類實例給了我們一種內置引用方式。Hooks 為我們提供了更好地組合有狀態邏輯所需基礎設施,但代價也隨之而來。

如果我們使用 Solid 重寫我們的小計數器遊戲,那麼它看起來會像這樣:

function Game() {
  const [count, setCount] = createSignal(0)
  const [started, setStarted] = createSignal(false)

  function increment() {
    setCount(count() + 1)
  }

  function start() {
    if (!started()) setTimeout(() => alert(`Your score was ${count()}!`), 5000)
    setStarted(true)
  }

  return (
    <button
      onClick={() => {
        increment()
        start()
      }}
    >
      {started() ? 'Current score: ' + count() : 'Start'}
    </button>
  )
}

它看起來幾乎與第一個 hooks 版本相同!唯一可見的區別是我們調用 createSignal 而不是 useState,並且每當我們想要訪問值時,count 和 started 都是我們調用的函數。然而,就像類組件和函數組件一樣,外觀上的相似性掩蓋了一個重要的區別。

Solid 和其他基於信號的框架的關鍵在於組件只運行一次,並且框架設置了一個數據結構,在信號更改時自動更新 DOM。僅運行組件一次意味著我們只有一個閉包。僅有一個閉包再次為已安裝的每個組件提供穩定實例,因為閉包等效於類。

什麼?

這是真的!從根本上講,它們都只是數據和行為的捆綁體。閉包主要是行為(函數調用),帶有相關數據(封閉變量),而類主要是數據(實例屬性)與相關行為(方法)。如果你真的想,你可以用其中一個來編寫另一個。

想一想。使用類組件...

  • 構造函數設置了組件渲染所需的所有內容(設置初始狀態、綁定實例方法等)。
  • 當您更新狀態時,React 會改變類實例,調用 render 方法並對 DOM 進行任何必要的更改。
  • 所有函數都可以訪問存儲在類實例上的最新狀態。

而使用信號組件...

  • 函數體設置了組件渲染所需的所有內容(設置數據流、創建 DOM 節點等)。
  • 當您更新信號時,框架會改變存儲的值,運行任何依賴信號,並對 DOM 進行任何必要的更改。
  • 所有函數都可以訪問存儲在函數閉包中的最新狀態。

從這個角度來看,更容易看到權衡。與類一樣,信號是可變的。這可能有點奇怪。畢竟,Solid 組件沒有分配任何東西 —— 它調用了 setCount,就像 React 一樣!但請記住,count 不是一個值本身 —— 它是一個返回信號當前狀態的函數。當調用 setCount 時,它會改變信號,並且進一步對 count () 的調用將返回新值。

儘管 Solid 的 createSignal 看起來像 React 的 useState,但信號實際上更像引用:對可變對象的穩定引用。區別在於,在圍繞不可變性構建的 React 中,引用是一個逃生口子,在渲染上沒有影響。但是像 Solid 這樣的框架將信號放在首位。框架不會忽略它們,在其改變時做出反應,並僅更新使用其值的 DOM 特定部分。

這種情況帶來的重大後果是 UI 不再是狀態純函數。這就是為什麼 React 擁抱不可變性:它保證狀態和 UI 一致。當引入突變時,您還需要一種方法使 UI 保持同步。信號承諾成為實現此目標可靠方式,並且他們成功與否取決於他們履行該承諾能力如何表現好壞

簡要概述:

  • 首先,我們有類組件,在渲染之間共享單個實例中保留狀態。
  • 然後,我們有帶有鉤子的函數組件,在其中每個渲染都具有其自己隔離的實例和狀態。
  • 現在,我們正在轉向信號,再次將狀態保留在單個實例中。

那麼 React hooks 是一個錯誤嗎?它們確實使得分解組件和重用有狀態邏輯變得更容易。即使我打這些字的時候,如果你問我是否會放棄 hooks 並返回到類組件,我會告訴你不會。

同時,我也意識到信號的吸引力在於重新獲得我們以前使用類組件時所具有的功能。React 對不可變性進行了大膽嘗試,但人們一直在尋找既能保持數據不可變又方便操作的方法。這就是為什麼存在像 immer 和 MobX 這樣的庫:事實證明,使用可變數據的人機交互體驗非常方便。

信號比鉤子更好嗎?我認為這不是正確的問題。每件事都有權衡,我們對信號所做的權衡相當確定:它們放棄了狀態的不可變性和 UI 作為純函數,換取更好的更新性能和每個已安裝組件的穩定、可變實例。

時間會告訴我們信號是否會帶回 React 創建以解決的問題。但現在,框架似乎正在嘗試在鉤子的組合性和類別穩定性之間尋求一個舒適點。至少,這是值得探索的選項。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。