從新 Think in Hooks

本文首發於個人我的博客html

爲何要從新來過?

我以前寫過 一篇博客,介紹了 Class 組件的各個生命週期鉤子函數在 Hooks 中對應的方案。那時 Hooks 剛剛發佈,開發者最關心的莫過於代碼的遷移問題,也就是怎麼把現有的 Class 組件改形成 Hooks 的方式。react

儘管這種方式很是的直觀有效,但很快咱們就發現,事情彷佛沒那麼簡單。單純用這個思惟來考慮問題,並不能很好地解釋 Hooks 的一些行爲,好比 useEffect 中的變量有時候沒法獲取最新的值、命令式的回調函數也不老是按照咱們的預期工做,useEffect 的依賴數組好像老是缺點什麼。git

在親自踩了 2 個多月的坑,參與了一些 React 官網的翻譯工做,拜讀了 幾篇 很是好的 博客 以後,我對「如何 Think in Hooks」有了新的認識。github

所以這篇博客,咱們來「從新 Think in Hooks」。npm

當咱們討論 Hooks 時,咱們到底在討論什麼?

要理解 Hooks,咱們得先回到 Hooks 的本質 —— 一種邏輯複用的方式。數組

Hooks 並非新的組件類型,當咱們討論 Hooks 時,咱們討論的實際上是函數組件 —— 就是那種只是根據 props 返回相應的 JSX 的渲染函數。Hooks 的出現讓函數組件能夠和 Class 組件同樣能夠擁有 state(是能夠,不是必須)。所以確切的說,咱們是在討論使用了 Hooks 的函數組件。緩存

可是「使用了 Hooks 的函數組件」這個詞太長了,而下文我又將常常提到這個詞,因此在後面的文字中,我將簡單用 Hooks 來表示這個概念。markdown

忘掉你所學

當咱們在使用 Class 組件時,每當 props 或 state 有更新,全部的修改都發生在 React 組件實例上,就像修改一個對象的屬性同樣。這個邏輯放到 Hooks 裏是行不通的,函數組件的渲染只是簡單的函數調用,不加 new 的函數調用是不存在所謂生成實例的。這也是不少問題產生的根源。閉包

因此要想真正 Think in Hooks,首先你得忘記如何 Think in Class,改成 Think in Functions。異步

爲何個人 state 不更新?

Hooks 的本質是一個渲染函數,就像是把 Class 組件的 render() 函數單獨提取出來同樣。

render() 函數在運行時會根據那一次的 props 和 state 去渲染。若是在 render() 函數運行期間 props 或是 state 再次發生變化,並不會影響這一次的執行,而是會觸發新一輪的渲染,render() 再一次被調用,而且這一次傳入的是變化後的 props 和 state。

到這裏咱們得出結論:

render() 函數中用到的 props 和 state 在函數執行的一開始就已經被肯定了。

好了,理論說得夠多了,咱們來看代碼吧。假設咱們有這樣一個組件:

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

  function onClick () {
    setTimeout(() => {
      setCount(count + 1)
    }, 2000)
  }

  return <p onClick={onClick}>You clicked {count} times</p>
}
複製代碼

等價的 Class 組件實現能夠是下面這樣:

class Counter extends Component {
  state = {
    count: 0
  }

  onClick = () => {
    setTimeout(() => {
      this.setState({
        count: this.state.count + 1
      })
    }, 2000)
  }

  render () {
    return <p onClick={this.onClick}>You clicked {this.state.count} times</p>
  }
}

複製代碼

對比一下兩段函數,若是把 Class 的語法中的全部東西所有塞到 render() 函數裏,而後把 render() 函數單獨拎出來,給變量和函數換個名字 —— 恭喜你,你獲得了一個等效的 Hooks !

開玩笑的,但這真的很像對不對。

如今考慮一個問題:若是我在 2 秒內點擊組件 3 次,那麼到第 5 秒的時候,組件會顯示什麼?

在類組件的實現中,結果是 3,由於觸發了 3 次更新,每次都在原有的基礎上加 1。

但在 Hooks 的實現中,結果意外地變成了 1。很奇怪對不對,明明是同樣的邏輯,爲何結果不同?(我向你保證這跟閉包沒有關係)

若是你在 onClick 函數中 console.log 一下,你會發現點擊事件確實被觸發了 3 次,可是 3 次 count 的值是同樣的。

這是爲何?

還記得咱們前面的結論嗎?「render() 函數中用到的 props 和 state 在函數執行的一開始就已經被肯定了」。爲了簡化問題,咱們能夠把 Hooks 的代碼中全部用到的 props 和 state 直接替換成那一次的取值:

// 第一次渲染
function Counter () {
  // 這裏是對 useState 的等價替換
  const count = 0 // highlight-line
  const setCount = (val) => { ... }

  function onClick () {
    setTimeout(() => {
      setCount(0 + 1) // highlight-line
    }, 2000)
  }

  return <p onClick={onClick}>You clicked 0 times</p> // highlight-line
}
複製代碼

注意到第 9 行的變化了麼?這就是爲何。在這 2 秒鐘以內,不管點擊多少次,咱們都是在給組件下達一樣的指令:2 秒鐘後把 count 設置爲 1。2 秒以後組件或許會被更新屢次,但結果都是同樣的。onClick 函數中 count 的值在一開始就已經被肯定了。

那若是我想實現 Class 版本的那種效果要怎麼辦?能夠經過給 setCount() 傳入一個回調函數來解決(若是能夠的話,我推薦在更新 state 時儘可能採用這種寫法,緣由後面會講到):

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

  function onClick () {
    setTimeout(() => {
      setCount(c => c + 1) // highlight-line
    }, 2000)
  }

  return <p onClick={onClick}>You clicked {count} times</p>
}
複製代碼

這裏表示無論 count 如今的值是多少,往上加一就行了。Class 組件中的 setState() 函數也有一樣的寫法,雖然它倆的目的並不相同。

useEffect 的依賴數組到底應該怎麼用

這多是剛接觸 Hooks 時最讓人頭疼的一個問題,相信每一個人都對「依賴數組裏的內容會決定 Effect 是否會從新執行」這一點印象深入,給人感受這就是 componentDidUpdate() 的等效實現,按照咱們對 Class 組件的認知,只要依賴數組裏的內容不變,Effect 就不會從新執行;若是某個變量不參與比對的過程,就不須要出如今依賴數組中。然而依賴數組並無咱們想象的這麼簡單。

依賴數組真正的含義,是「這個 Effect 引用了哪些外部變量」。無論它是否參與比對的過程,只要 Effect 中引用了(也就是 Effect 依賴了這個變量),就必須出如今依賴數組中。舉個例子:

在下面的代碼中,咱們想要實現:foobar 在被點擊時自身加一,其中任何一個的變化都會觸發 total 也加一,同時有一個 Effect 在每秒打印 total 的值。因爲咱們只須要在組件掛載時啓用一下計時器就好,所以咱們把依賴數組留空。

const App = (props) => {
  const [total, setTotal] = useState(0)
  const [foo, setFoo] = useState(0)
  const [bar, setBar] = useState(0)

  // highlight-range{1-5}
  useEffect(() => {
    setInterval(() => {
      console.log(total)
    }, 1000)
  }, [])

  function updateTotal () {
    setTotal(t => t + 1)
  }

  function addFoo () {
    setFoo(f => f + 1)
    updateTotal()
  }

  function addBar () {
    setBar(b => b + 1)
    updateTotal()
  }

  return <> <button onClick={addFoo}>{foo}</button> + <button onClick={addBar}>{bar}</button> = <span>{total}</span> </> } 複製代碼

這個 Effect 引用了 total 這個變量,可是 total 並無參與「是否要執行這個 Effect」的決策。按照咱們以前對於 Class 組件的理解,total 不須要出如今依賴數組中。那麼咱們來執行一下這段代碼。

點擊按鈕,foobar 如咱們預期的那樣自增了,頁面上 total 也顯示了最新的值。然而控制檯打印出來的 total 卻始終爲 0。

爲何會這樣?

如咱們上一節所說的,「render() 函數中用到的 props 和 state 在函數執行的一開始就已經被肯定了」,Effect 也是 render 函數的一部分,所以一樣適用這條規則,那麼咱們帶入變量值看一下:

// 初始化時
useEffect(() => {
  setInterval(() => {
    console.log(0)
  }, 1000)
}, [])
複製代碼
// 點擊 foo
useEffect(() => {
  setInterval(() => {
    console.log(0)
  }, 1000)
}, [])
複製代碼
// 再點擊 bar
useEffect(() => {
  setInterval(() => {
    console.log(0)
  }, 1000)
}, [])
複製代碼

因爲 total 並無在依賴數組中申明,所以 total 的更新不會觸發 Effect 從新執行,也就不會去獲取它的最新值,每次執行都引用了第一次執行時候的值。

要解決這個問題,咱們能夠把 total 加入依賴數組,告訴 Effect 當 total 更新時從新執行 Effect,這樣依賴 Effect 就能在從新執行時獲取到 total 的最新值了。同時注意,因爲每次 total 改變會引發 Effect 的從新執行,所以 setInterval() 也會重複執行,建立多個計時器,要解決這個問題,只要讓 Effect 返回一個清理函數,結束掉上一個計時器便可:

useEffect(() => {
  const id = setInterval(() => {
    console.log(total)
  }, 1000)

  return () => {
    clearInterval(id)
  }
}, [total])
複製代碼

這麼一來,程序就正常了。

如今新版的 React 已經自帶了對 Hooks 規則的一些檢查,當它發現一些不合規的寫法(好比 Effect 中引用了外部變量,但沒有在依賴數組中進行申明),就會給出提示。只要保持使用最新版的 React,理論上就能夠避免這一類的錯誤。若是你出於某些緣由不方便升級,也能夠手動安裝 eslint-plugin-react-hooks 來進行檢查。

總的來講,對於 useEffect() 的依賴數組,必定要牢記:

只要是 useEffect() 中用到的,都要在依賴數組中申明。

那若是 useEffect() 中引用了一些不參與「是否執行 Effect」的決策的變量,咱們要怎麼處理這些尷尬的變量呢?別擔憂,方法有不少:

  1. 用回調函數的方式來設置 state,以解除對某些 state 變量的引用。
  2. 若是組件內部的函數僅用於某個 Effect,能夠把這個函數的定義移到 useEffect() 內部,以解除對某些函數的引用。
  3. 若是一些變量的存在是爲了決定另外一些變量(好比 url 查詢參數),能夠把相關邏輯抽取爲獨立的函數,用 useCallback() 進行優化,而後咱們就能夠把這部分變量提取到 Effect 以外去,以精簡依賴數組。
  4. 實在無法優化了,還有個最簡單粗暴的方法。在 useEffect() 中對全部參與決策的變量進行比對,判斷是否發生變化,以決定是繼續執行仍是就此返回。

不要擔憂重複定義函數

從工程學的角度,咱們習慣經過緩存來避免頻繁的銷燬和重建一樣的內容。在 Class 組件中,經過函數綁定,咱們能夠很輕易的作到這一點。但在 Hooks 中,咱們或許須要改變一下習慣,試着接受這一類的開銷。

因爲函數組件的特性,它不像類組件的實例那樣,存在生命週期的概念。函數組件的核心就只有一個渲染函數,即使 Hooks 引入了 state,函數組件的更新也仍是從新執行整個函數,而不是在某個實例上小修小改。這樣的特定就決定了函數組件內定義的函數,會在組件每次從新渲染時被銷燬而後重建,即使函數自己並無改變,只是傳入的參數發生了改變。

好在,只要不是很是高頻的更新,這種程度的開銷並不會對咱們的應用形成明顯的負面影響。所以咱們能夠容許這種反模式的存在。

如何在 Hooks 中發起 HTTP 請求

在 Class 組件中,咱們常見的作法是定義一個獲取數據的函數,在其中讀取 props 和 state,拼接出要傳遞的參數,好一點的作法或許還要判斷一下 loading 狀態以免重複操做和異步衝突,而後發起其請求,等 Promise 被 resolve 後,處理返回的結果,更新一些 state。

但當咱們嘗試在 Hooks 中重現這一套路時,咱們遇到了問題。要想讀取最新的 props 和 state,咱們就必須把發起請求的函數寫到一個 Effect 中,而且全部引用到的變量都必須放進依賴數組中。這就致使咱們必須很是當心地處理每個依賴的變化,一不當心就會陷入死循環。

具體的操做,展開來篇幅太長了,這裏就不展開了,推薦一篇很是全面的文章,須要的能夠看一下。這篇文章國內有很多人作了翻譯,這篇是一個不錯的譯本,英文有壓力的同窗能夠看看。

如何使用 setInterval()

還有一個很是常見的命令式操做,就是設置定時器。

好比一個短信驗證碼的倒計時,在 Class 組件中,咱們一般會這麼作:

state = {
  countdown: 0
}

timer = null

startCountdown = (duration) => {
  this.setState({ countdown: duration }, () => {
    this.timer = setInterval(() => {
      this.setState({ countdown: this.state.countdown - 1 }, () => {
        if (!this.state.countdown) {
          clearInterval(this.timer)
        }
      })
    }, 1000)
  })
}
複製代碼

如今咱們嘗試改用 Hooks 來實現。

設置一個 state 用於存儲當前剩餘秒數,而後在 setInterval() 的回調函數中更新這個值(經過回調函數的寫法,咱們不須要引用這個 state 也能正確更新它)。很好,倒計時開始了,頁面上也能獲取到更新了,目前爲止一切順利。

const [countdown, setCountdown] = useState(0)
const timer = useRef()

function startCountdown (duration) {
  setCountdown(duration)
  timer = setInterval(() => {
    setCountdown(c => c - 1)
  }, 1000)
}
複製代碼

三、二、一、0、-1?問題出現了。咱們但願數到 0 的時候結束倒計時,爲此咱們須要判斷 countdown 是否爲 0 以決定是否要 clearInterval(),然而如今咱們沒法直接讀取 countdown 的最新值。爲了能讀到 countdown 的最新值,咱們須要把這個邏輯放到一個 Effect 裏,並把 countdown 放進依賴數組中。

const [countdown, setCountdown] = useState(0)

function startCountdown (duration) {
  setCountdown(duration)
}

useEffect(() => {
  if (!countdown) return
  timer = setInterval(() => {
    setCountdown(c => c - 1)
    if (!countdown) {
      clearInterval(timer)
    }
  }, 1000)
}, [countdown])
複製代碼

然而事情尚未結束,仔細看一下代碼,不難發現每次 countdown 更新都會觸發一次新的 setInterval(),這並非咱們想要的。而且咱們無法提早結束這個計時器。

哎~明明在 Class 組件中很簡單的事情,怎麼到了 Hooks 中這麼複雜。

解決方案看這裏,你會驚訝的。

小結

第一次看到官方文檔中的「It takes a bit of a mindshift to start 「thinking in Hooks」」這句話的時候,我並無太當回事,以爲無非就是有同樣新東西要學而已。時隔幾個月再看,這句話份量仍是挺重的。從 Class 到 Hooks 的變化真的很大,不少思惟模式都變了,咱們甚至須要接受一些曾經極力避免的反模式。

React 從一開始就推崇聲明式的設計,萬物皆組件,最大的感覺就是路由的設計。Hooks 相比 Class 更加符合聲明式的設計,今後 React 進入「萬物皆函數」的時代。

若是你以爲 Hooks 是一顆重磅炸彈,我建議你瞭解一下 Concurrent Mode。而後你會發現,Hooks 只是一道前菜,是爲後面真正的主菜作鋪墊用的。

相關文章
相關標籤/搜索