看完這篇,你也能把 React Hooks 玩出花

  • 2019 年 10 月 10 日
  • 筆記

本文首發於政采雲前端團隊部落格:看完這篇,你也能把 React Hooks 玩出花 https://www.zoo.team/article/react-hooks

本文中出現的部分名稱映射:

函數式組件 => Function Component

類組件 => Class Component

工具函數 => Util Function

鉤子 => React Hook

初始值 => initialValue

先講概念

React v16.7.0-alpha 中第一次引入了 Hooks 的概念,在 v16.8.0 版本被正式發布。React Hooks 在 React 中只是對 React Hook 的概念性的描述,在開發中我們用到的實際功能都應該叫做 React hook

React Hook 是一種特殊的函數,其本質可以是函數式組件(返回 Dom 或 Dom 及 State ),也可以只是一個工具函數(傳入配置項返回封裝後的數據處理邏輯)。

再總結

React Hooks 的出現使函數式組件變得煥然一新,其帶來的最大的變化在於給予了函數式組件類似於類組件生命周期的概念,擴大了函數式組件的應用範圍。

目前函數式組件基本用於純展示組件,一旦函數式組件耦合有業務邏輯,就需要通過 Props 的傳遞,通過子組件觸發父組件方法的方式來實現業務邏輯的傳遞,Hooks 的出現使得函數組件也有了自己的狀態與業務邏輯,簡單邏輯在自己內部處理即可,不再需要通過 Props 的傳遞,使簡單邏輯組件抽離更加方便,也使使用者無需關心組件內部的邏輯,只關心 Hooks 組件返回的結果即可。

在我看來,Hooks 組件的目標並不是取代類組件,而是增加函數式組件的使用率,明確通用工具函數與業務工具函數的邊界,鼓勵開發者將業務通用的邏輯封裝成 React Hooks 而不是工具函數

之所以把總結放在前面,是想讓大家在看後面的內容時有一個整體的概念去引導大家去思考 React Hooks 具體給函數式組件帶來了什麼變化。

Hooks 初識

官方提供的鉤子

目前官方提供的鉤子共分為兩種,分為基本鉤子以及拓展鉤子

基本鉤子共有:

useState 、useEffect 、 useContext

額外的鉤子有:

useRef 、useCallback 、useMemo 、

useReducer 、useLayoutEffect 、

useImperativeHandle 、useDebugValue

不同鉤子用法

useState

該鉤子用於創建一個新的狀態,參數為一個固定的值或者一個有返回值的方法。鉤子執行後的結果為一個數組,分別為生成的狀態以及改變該狀態的方法,通過解構賦值的方法拿到對應的值與方法。

使用方法如下:

export default function HookDemo() {    const [count, changeCount] = useState(0);      return (      <div>        {count}        <button onClick={() => { changeCount(Math.ceil(Math.random() * 1000)); }}>          改變count        </button>      </div>    );  }
useEffect

顧名思義,執行副作用鉤子。主要用於以下兩種情況:

  1. 函數式組件中不存在傳統類組件生命周期的概念,如果我們需要在一些特定的生命周期或者值變化後做一些操作的話,必須藉助 useEffect 的一些特性去實現。
  2. useState 產生的 changeState 方法並沒有提供類似於 setState 的第二個參數一樣的功能,因此如果需要在 State 改變後執行一些方法,必須通過 useEffect 實現。

該鉤子接受兩個參數,第一個參數為副作用需要執行的回調,生成的回調方法可以返回一個函數(將在組件卸載時運行);第二個為該副作用監聽的狀態數組,當對應狀態發生變動時會執行副作用,如果第二個參數為空,那麼在每一個 State 變化時都會執行該副作用。

使用方法如下:

const [count, changeCount] = useState(0);    // 將在count變化時列印最新的count數據  useEffect(() => {    message.info(`count發生變動,最新值為${count}`);  }, [count])

在上面程式碼中我們實現了在 useEffect 這個鉤子適用情況中的第二種情況,那麼如何使用該鉤子才能實現類似於類組件中生命周期的功能呢?既然第一個參數是副作用執行的回調,那麼實現我們所要功能的重點就應該在第二個參數上了。

componentDidMount && componentWillUnmout:這兩個生命周期只在頁面掛載/卸載後執行一次。前面講過,所有的副作用在組件掛載完成後會執行一次 ,如果副作用存在返回函數,那麼返回的函數將在卸載時運行。藉助這樣的特性,我們要做的就是讓目標副作用在初始化執行一次後再也不會被調用,於是只要讓與該副作用相關聯的狀態為空,不管其他狀態如何變動,該副作用都不會再次執行,即實現了 componentDidMountcomponentWillUnmout

import React, { useState, useEffect } from 'react';  import { message } from 'antd';    function Child({ visible }) {    useEffect(() => {      message.info('我只在頁面掛載時列印');      return () => {        message.info('我只在頁面卸載時列印');      };    }, []);      return visible ? 'true' : 'false';  }    export default function HookDemo() {    const [visible, changeVisible] = useState(true);        return (      <div>        {          visible && <Child visible={visible} />        }        <button onClick={() => { changeVisible(!visible); }}>          改變visible        </button>      </div>    );  }

componentDidUpdate:該生命周期在每次頁面更新後都會被調用。那麼按照之前的邏輯,就應該把所有的狀態全部放在第二個狀態中,但是這樣的話新增/刪除一個狀態都需要改變第二參數。其實,如果第二個參數為空,那麼在每一個 State 變化時都會執行該副作用,那麼如果要實現 componentDidUpdate 就非常簡單了。

useEffect(() => {    // ...副作用邏輯  }) // 注意上面說的關聯狀態為空不是說不傳遞第二個參數,而是第二個參數應該為一個空數組

在類組件中,如果在 componentDidMount 中多次調用 setState 設置一個值(當然不推薦這樣做),並在成功的回調中列印該值,那麼最後的結果很可能會列印很多個相同的最後一次設置的值。是因為類的 setState 是一個類非同步的結果,他們會將所有變動的內容進行收集然後在合適的時間去統一賦值。 而在 useEffect 中,所有的變數的值都會保留在該副作用執行的時刻,類似於 for 循環中的 let 或者 閉包,所有的變數都維持在副作用執行時的狀態,也有人稱這個為 Capture Value。

useCallback

生成 Callback 的鉤子。用於對不同 useEffect 中存在的相同邏輯的封裝,減少程式碼冗餘,配合 useEffect 使用。

該鉤子先看例子會比較好理解一下:

const [count1, changeCount1] = useState(0);  const [count2, changeCount2] = useState(10);    const calculateCount = useCallback(() => {    if (count1 && count2) {      return count1 * count2;    }    return count1 + count2;  }, [count1, count2])    useEffect(() => {      const result = calculateCount(count, count2);      message.info(`執行副作用,最新值為${result}`);  }, [calculateCount])

在上面的例子中我們通過 useCallback 的使用生成了一個回調,useCallback 的使用方法和 useEffect 一致,第一個參數為生成的回調方法,第二個參數為該方法關聯的狀態,任一狀態發生變動都會重新生成新的回調

通過上面程式碼的使用,我們將 count1 / count2 的值與一個叫做 calculateCount 的方法關聯了起來,如果組件的副作用中用到計算 count1 和 count2 的值的地方,直接調用該方法即可。

其中和直接使用 useEffect 不同的地方在於使用 useCallback 生成計算的回調後,在使用該回調的副作用中,第二個參數應該是生成的回調。其實這個問題是很好理解的,我們使用 useCallback 生成了一個與 count1 / count2 相關聯的回調方法,那麼當關聯的狀態發生變化時會重新生成新的回調,副作用監聽到了回調的變化就會去重新執行副作用,此時 useCallbackuseEffect 是按順序執行的, 這樣就實現了副作用邏輯的抽離。

useRef

useRef 接受一個參數,為 ref 的初始值。類似於類組件中的 createRef 方法 ,該鉤子會返回一個對象,對象中的 current 欄位為我們 指向的實例 / 保存的變數,可以實現獲得目標節點實例或保存狀態的功能。

useRef 保存的變數不會隨著每次數據的變化重新生成,而是保持在我們最後一次賦值時的狀態,依靠這種特性,再配合 useCabllbackuseEffect 我們可以實現 preProps/preState 的功能。

const [count, changeCount] = useState(0);  const [count1, changeCount1] = useState(0);  // 創建初始值為空對象的prestate  const preState = useRef({});  // 依賴preState進行判斷時可以先判斷,最後保存最新的state數據  useEffect(() => {    const { ... } = preState.current;    if (// 條件判斷) {      // 邏輯    }    // 保存最新的state    preState.current = {      count,      count1,    }  });

另外,當我們將使用 useState 創建的狀態賦值給 useRef 用作初始化時,手動更改 Ref 的值並不會引起關聯狀態的變動。從該現象來看,useRef 似乎只是在記憶體空間中開闢了一個堆空間將初始化的值存儲起來,該值與初始化的值存儲在不同的記憶體空間,修改 Ref 的值不會引起視圖的變化。

export default function HookDemo() {    const [count] = useState({ count: 1 });      const countRef = useRef(count);      return (      <div>        {count.count}        <button onClick={() => { countRef.current.count = 10; }}>          改變ref        </button>      </div>    );  }

useRef正常

useMemo

Memo 為 Memory 簡寫,useMemo 即使用記憶的內容。該鉤子主要用於做性能的優化。

前面我們說過了當狀態發生變化時,沒有設置關聯狀態的 useEffect 會全部執行。同樣的,通過計算出來的值或者引入的組件也會重新計算/掛載一遍,即使與其關聯的狀態沒有發生任何變化

在類組件中我們有

shouldComponetUpdate 以及 React.memo

幫助我們去做性能優化,如果在函數組件中沒有類似的功能顯示是違背了官方的初衷的,於是就有了 useMemo 這個鉤子。

在業務中,我們可以用 useMemo 來處理計算結果的快取或引入組件的防止重複掛載優化。其接受兩個參數,第一個參數為一個 Getter 方法,返回值為要快取的數據或組件,第二個參數為該返回值相關聯的狀態,當其中任何一個狀態發生變化時就會重新調用 Getter 方法生成新的返回值。

具體程式碼如下:

import React, { useState, useMemo } from 'react';  import { message } from 'antd';    export default function HookDemo() {    const [count1, changeCount1] = useState(0);    const [count2, changeCount2] = useState(10);      const calculateCount = useMemo(() => {      message.info('重新生成計算結果');      return count1 * 10;    }, [count1]);    return (      <div>        {calculateCount}        <button onClick={() => { changeCount1(count1 + 1); }}>改變count1</button>        <button onClick={() => { changeCount2(count2 + 1); }}>改變count2</button>      </div>    );  }

初次接受 useMemo 時可能我們會覺得該鉤子只是用來做計算結果的快取,返回值只能是一個數字或字元串。其實 useMemo 並不關心我們的返回值類型是什麼,它只是在關聯狀態發生變動時重新調用我們傳遞的 Getter 方法 生成新的返回值,也就是說 useMemo 生成的是 Getter 方法與依賴數組的關聯關係。因此,如果我們將函數的返回值替換為一個組件,那麼就可以實現對組件掛載/重新掛載的性能優化

程式碼如下:

import React, { useState, useMemo } from 'react';  import { message } from 'antd';    function Child({ count }) {    return <p>當前傳遞的count為:{count}</p>;  }    export default function HookDemo() {    const [count1, changeCount1] = useState(0);    const [count2, changeCount2] = useState(10);      const child = useMemo(() => {      message.info('重新生成Child組件');      return <Child count={count1} />;    }, [count1]);    return (      <div>        {child}        <button onClick={() => { changeCount1(count1 + 1); }}>改變count1</button>        <button onClick={() => { changeCount2(count2 + 1); }}>改變count2</button>      </div>    );  }
其他鉤子

今天主要講了組件中常用的幾個鉤子,剩下的未講解的鉤子中,如

useLayoutEffect useImperativeHandle useDebugValue

其功能都比較簡單就不在此贅述。

還有一個比較重要的鉤子 useContext,是 createContext 功能在函數式組件中的實現。通過該功能可以實現很多強大的功能,可以是說官方的 Redux,很多人對此應該有不少的了解。該鉤子內容太多,後續單獨使用一個章節進行描述。

編寫自己的鉤子

其實從上面講解的內容來看,鉤子並不是什麼高深莫測的東西,它只是對我們常用邏輯的一些封裝,接下來就會通過具體的程式碼來教大家寫一個自己的鉤子。

最基本的鉤子

最基本的鉤子也就是返回包含了更多邏輯的 State 以及改變 State 方法的鉤子。拿計數器來說,其最基本的就是返回當前的數字以及減少/增加/重置等功能,明確完功能後可以開始動手做了。

import React, { useState } from 'react';    // 編寫我們自己的hook,名字以use開頭  function useCounter(initialValue) {    // 接受初始化的值生成state    const [count, changeCount] = useState(initialValue);    // 聲明減少的方法    const decrease = () => {      changeCount(count - 1);    }    // 聲明增加的方法    const increase = () => {      changeCount(count + 1);    }    // 聲明重置計數器方法    const resetCounter = () => {      changeCount(0);    }    // 將count數字與方法返回回去    return [count, { decrease, increase, resetCounter }]  }    export default function myHooksView() {    // 在函數組件中使用我們自己編寫的hook生成一個計數器,並拿到所有操作方法的對象    const [count, controlCount] = useCounter(10);    return (        <div>          當前數量:{count}              <button onClick={controlCount.decrease}>減少</button>              <button onClick={controlCount.increase}>增加</button>              <button onClick={controlCount.resetCounter}>重置</button>      </div>    )  }

在上面的例子中,我們將在 useCounter 這個鉤子中創建了一個關聯了 initialValue 的狀態,並創建減少/增加/重置的方法,最終將其通過 return 返回出去。這樣在其他組件需要用到該功能的地方,通過調用該方法拿到其返回值,即可實現對 useCounter 組件封裝邏輯的復用。

演示效果如圖:

返回 DOM 的鉤子

返回 DOM 其實和最基本的 Hook 邏輯是相同的,只是在返回的數據內容上有一些差異,具體還是看程式碼,以一個 Modal 框為例。

import React, { useState } from 'react';  import { Modal } from 'antd';    function useModal() {    const [visible, changeVisible] = useState(false);      const toggleModalVisible = () => {      changeVisible(!visible);    };      return [(      <Modal        visible={visible}        onOk={toggleModalVisible}        onCancel={toggleModalVisible}      >        彈窗內容        </Modal>    ), toggleModalVisible];  }    export default function HookDemo() {    const [modal, toggleModal] = useModal();    return (      <div>        {modal}        <button onClick={toggleModal}>打開彈窗</button>      </div>    );  }

這樣我們就實現了一個返回了彈窗內容以及改變彈窗顯示狀態的 Hook,其實可以封裝的內容還有很多很多,可以通過配置項的設置實現更豐富的封裝。

演示效果如圖:

鉤子/最終總結

鉤子總結

從上面的表格中我們可以看出,在官方提供的 Hook 中,除了基本的 useStateuseRef 外,其他鉤子都存在第二個參數,第一個方法的執行與第二個參數相互關聯。於是我們可以得出一個結論,在使用了 Hook 的函數式組件中,我們在使用副作用/引用子組件時都需要時刻注意對程式碼進行性能上的優化

最終總結

我在前面的總結里是這麼評價 React Hooks 的:

Hooks 組件的目標並不是取代 class component 組件,而是增加函數式組件的使用率,明確通用工具函數與業務工具函數的邊界,鼓勵開發者將業務通用的邏輯封裝成 React Hooks 而不是工具函數

希望看完這篇文章的你也有自己的一些看法,歡迎拍磚討論。

招賢納士

招人,前端,隸屬政采雲前端大團隊(ZooTeam),50 余個小夥伴正等你加入一起浪~ 如果你想改變一直被事折騰,希望開始能折騰事;如果你想改變一直被告誡需要多些想法,卻無從破局;如果你想改變你有能力去做成那個結果,卻不需要你;如果你想改變你想做成的事需要一個團隊去支撐,但沒你帶人的位置;如果你想改變既定的節奏,將會是「5年工作時間3年工作經驗」;如果你想改變本來悟性不錯,但總是有那一層窗戶紙的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望參與到隨著業務騰飛的過程,親手參與一個有著深入的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我覺得我們該聊聊。任何時間,等著你寫點什麼,發給 [email protected]