結合示例學習 React Hooks

這篇文檔將通過一個 demo,介紹 React 幾個 Hook 的使用方法和場景。包括:

  • useState
  • useEffect
  • useMemo
  • useCallback
  • 自定義 hook

閱讀此文檔之前,建議先仔細閱讀 React 官網文檔中的 Hooks 部分。感興趣的話,當然也強烈建議看看 Dan Abramov 在 React Conf 2018 上介紹 Hook 的演講視頻。

1. 什么是 Hook


Hook 是 React 16.8 加入的新特性,它帶來什么好處呢?解決什么問題呢?

  • 代碼復用更簡單:Hook 可以讓我們復用狀態邏輯, 而不需要改變組件層次結構,避免造成“Wrapper Hell”——組件層級結構復雜一層包一層。
  • 簡化代碼結構: 當組件有越來越多的狀態和副作用(Side Effect,指數據獲取,事件監聽,手動修改DOM 等操作)時,代碼也會變得更難理解。相互關聯的代碼不得不分散在不同的生命周期方法中,比如我們經常需要在componentDidMount中設置事件監聽,然后在componentWillUnmount 中得清除它們。另外 componentDidMount 還會包含一些無關聯的代碼,比如數據請求,這導致互不關聯的代碼被放在同一個方法中。而Hook 可以讓我們根據代碼之間的聯系,將一個組件拆分成小的 function,而不再是根據生命周期方法來拆分。
  • 開發更方便:對于剛開始學習使用 React 的開發者來說, class 組件容易讓人迷惑,比如 this 的用法,bind 事件監聽,什么時候用class 組件,什么時候用 function 組件等等。Hook 的出現讓開發者更容易快速地使用 React 的特性,而不一定需要使用 class。

其實 function 組件一直都有,但跟 class 組件相比,它最大的限制就是,它只有 props 而沒有 state 管理,也沒有生命周期方法。所以在 Hook 特性出來之前,創建 function 組件時,不得不考慮到,如果之后這個組件需要添加 state 怎么辦呢?那不是還得改寫成 class 組件嗎?多麻煩啊,不如一開始就直接創建 class 組件。

Hook 出來之后就不用擔心這個問題了:需要添加 state 的話,我們可以使用useState,需要添加生命周期相關的副作用時,我們可以使用 useEffect。這樣就避免改寫成 class 了。

2. 使用 Hook 的規則


  • 只能在 function 組件或者自定義 Hook 中調用 Hook
  • 只能在代碼頂層調用,不能在循環,條件,嵌套語句中調用Hook

React 提供了 linter 插件 來自動檢查 Hook 相關的代碼語法并修復錯誤,在初學 Hook 時有必要使用。

那么 Hook 能夠完全替代 Class 嗎?我們需要把 Class 用 Hook 重寫嗎?需要清楚以下幾點:

  1. hook 不能在 Class 中調用。但是在組件樹中,我們可以同時使用 class 組件或者 function 組件。
  2. 有一些不常用的生命周期函數目前沒有對應的 hook,比如getSnapshotBeforeUpdate,getDerivedStateFromError 或者componentDidCatch。
  3. 某些第三方庫可能跟 Hooks 不兼容。
  4. 重寫組件代價高,可以在新代碼中嘗試 Hooks。

3. Demo


接下來通過一個 demo 嘗試一下 Hook 這個新特性。

先創建一個function 組件——計時器 Timer,只顯示一段文本。

import React from 'react'
import ReactDOM from 'react-dom'

function Timer(){
    return (
        <h2> 10s passed...</h2>
    )
}

ReactDOM.render(
    <Timer />,
    document.getElementById('main')
);

3.1 使用 useState 添加 state


介紹

const [state, setState] = useState(initialState);

useState Hook 會返回一個 state 值,以及用于更新這個state 的方法。

示例1

比如我要添加一個按鈕使得可以重置計時器的時間:

import React, {useState} from 'react'

function Timer(props){
    // 獲取time state,以及更新time 的方法
    // 并用 props 給 time 一個初始值
    const [time, setTime] = useState(props.from)

    // 重置計時器
    var resetTimer=()=>{
        setTime(0)
    }

    return (
        <div>
            <h2>{time} s passed...</h2>
            <button onClick={resetTimer}>Reset</button>
        </div>
    )
}
ReactDOM.render(
    <Timer from={30}/>,
    document.getElementById('main')
);
示例1

3.2 使用 useEffect 添加副作用


介紹

前面有說到,React 經常提到的副作用(Side Effect)指的是數據獲取,事件監聽,手動修改DOM 等操作,因為這些操作可能會影響其他組件,且不能在正在渲染的過程中進行。副作用的操作通常是寫在生命周期方法 componentDidMount,componentDidUpdate 或者 componentWillUnmount中。

在function 組件中,我們使用 useEffect Hook 來達到類似的作用。useEffect 的作用和上面說的三個生命周期方法相同,相當于把它們合并到了一個 API 中。

useEffect(
  () => {
    // do some thing here...
    // add side effects here
    // 副作用操作卸載這里
      
    return () => {
      // clean up here
      // 清除定時器,解除監聽等操作寫在這里
    };
  },
  [dependencies]
);

useEffect 默認會在每次渲染完成后被調用。它可以接受兩個參數:

  • 第一個參數是一個function,相當于 componentDidMount 或 componentDidUpdate。這個function 還可以返回另一個用于清理的 function,后者的作用相當于 componentWillUnmount 。

  • 第二個參數是一個數組,只有當數組中的依賴發生變化時,useEffect 才會觸發。如果這個傳了一個空數組[],那就相當于 componentDidMount 和 componentWillUnmount 的結合,副作用操作只會觸發一次。如果傳了個不為空的數組[value],那就相當于 componentDidUpdate 和 componentWillUnmount 的結合,且只有當 value變化時,才會觸發。

在接下來的例子中會詳細說明 useEffect 的用法。

示例2

接著計時器的例子,接下來使用 useEffect,添加定時器,使 Timer 不斷更新:

import React, {useState, useEffect} from 'react'

function Timer(props){
    const [time, setTime] = useState(props.from)

    var resetTimer=()=>{
        setTime(0)
    }

    // 每次渲染后,如果time 發生變化,就會觸發
    useEffect(()=>{
        // 設置定時器,每秒把時間加一
        var interval = setInterval(()=>{
            setTime(time+1)
        },1000)

        // 返回一個清理方法
        return ()=>{
            // 清除定時器
            if(interval) clearInterval(interval)
        }
    },[time])   // 依賴 time

    return (
        <div>
            <h2>{time} s passed...</h2>
            <button onClick={resetTimer}>Reset</button>
        </div>
    )
}

需要注意的是,上面的 useEffect中,作為第一個參數,我傳遞了一個 function:

()=>{
    var interval = setInterval(()=>{
        setTime(time+1)
    },1000)

    // clean-up
    return ()=>{
        if(interval) clearInterval(interval)
    }
}

在這個方法中做兩件事:設置定時器,讓時間每秒更新;返回一個 clean-up 方法,清除定時器。作為第二個參數,我傳遞了[time],也就是說這個effect 是依賴 time 變化的,當 time 改變了,effect 才會被觸發。

因為設置了定時器,time 每秒都會更新,那么這個effect 每秒會被觸發一次。其結果就是,雖然時間確實每秒遞增,但實際上每次觸發這個effect,都會新建一個定時器,然后這個定時器被清除,然后再新建,再清除······這是因為:

The clean-up function runs before the component is removed from the UI to prevent memory leaks. Additionally, if a component renders multiple times (as they typically do), the previous effect is cleaned up before executing the next effect.

按照 Hook API 中說到的, clean-up 方法會在組件被移除時執行,以避免內存泄漏。

劃重點:當一個組件多次渲染時,新的 effect 會把舊的 effect 清理掉。

因此每次 effect 觸發時,會先清理掉前一個effect 創建的定時器,然后再創建一個新的定時器。這當然不是正常的定時器用法!我們只需要一個定時器,一直存在,只要不需要更新時間時,再清理掉它。

示例3

換句話說,本來我們是想在 componentDidMount中定義一個定時器,卻定義在componentDidUpdate中了,導致定時器沒必要的重復創建和清除。

怎么改成只創建一個定時器呢?按照 useEffect 的第二個參數的說明,如果我們傳遞一個空數組[]不就可以嗎?但實際上,如果配置了上文中提到的 Linter 工具eslint-plugin-react-hooks,就會發現這里不能傳遞空數組,因為useEffect 中用到了會發生變化的 time,那么第二個參數就一定要加上time。

如果第二個參數就是傳遞的[],會發生什么呢?定時器確實只創建一個,但每次setTime(time+1) 中的time始終是初始值30,頁面上始終是31 s passed...。

注意,useEffect 第二個參數傳遞空數組 [] 時表示

  • 這個 effect 不依賴任何 props 和 state,因此不需要重新執行;
  • 在這個 effect 中的 props 和 state 會一直是初始值。(因為創建了閉包)

要讓這個 effect 不依賴 time,且定時器不重復創建,解決辦法如下:

useEffect(()=>{
    var interval = setInterval(()=>{
        // setTime(time+1)
        // 不再依賴 time,且每次都能獲取到最新的 time 值
        setTime(t=>t+1)
    },1000)

    return ()=>{
        if(interval) clearInterval(interval)
    }
},[])

關于這部分,可以閱讀 React Hook FAQ 中的講解。

3.3 使用 useCallback 和 useMemo


接下來,我要修改一下 Timer 的 props,把一個對象傳遞到 props.config:from 為起始時間,to 為結束時間。

<Timer config={{from:20,to:30}}/>

示例4

相應的修改如下,在 effect 中判斷倒計時是否結束。注意這個effect 依賴了 props.config。


function Timer(props){
    const [time, setTime] = useState(props.config.from)

    //......
    useEffect(()=>{
        var interval = setInterval(()=>{
            // 獲取整個 config 對象
            const config = props.config
            setTime(t=>{
                // 倒計時結束,清理計時器
                if(t+1 >= config.to){
                    clearInterval(interval)
                }
                return t+1
            })
        },1000)

        return ()=>{
            if(interval) clearInterval(interval)
        }
    },[props.config]) // 依賴 props.config
    // ......
}

示例5

還有一種寫法,區別在于依賴的是 props.config.to

useEffect(()=>{
    var interval = setInterval(()=>{
        setTime(t=>{
            // 直接跟 props.config.to 比較
            if(t+1 >= props.config.to){
                clearInterval(interval)
            }
            return t+1
        })
    },1000)

    return ()=>{
        if(interval) clearInterval(interval)
    }
},[props.config.to]) // 依賴 props.config.to

這兩種寫法有什么區別嗎?功能上沒區別,都能實現從from到to的計時。但是有一個隱患?。?/p>

示例6

當我在另一個 function 組件中引入Timer組件時,這兩種寫法就有很大區別了。

比如我寫一個簡單的 function 組件,定義一個name state,提供一個輸入框可以修改name,然后引入 Timer。

function Parent(){
    const [name, setName] = useState('Evelyn');

    var onChangeName = (e)=>{
        setName(e.target.value)
    }

    return (
        <div>
            <input value={name} onChange={onChangeName} />
            <br/>
            Hello, {name}!!!
            <Timer config={{from:20,to:30}}/>
        </div>
    )
}
示例6

對于示例4和5的兩種寫法,當計時器還么結束時,改變輸入框的內容會發生什么呢?

  • 示例4:每次 name 發生變化時,Timer 中的定時器會被清理然后重新創建,也就是說 effect 被觸發了。
  • 示例5: Timer 中的定時器正常,effect 沒有被重新觸發。

示例4 中的 effect 依賴了 props.config,按道理 props.config 一直都是 {from:20,to:30},為什么父組件的 state 變化時,effect 會被再次觸發呢?

這是因為,父組件每次重新重新渲染時,整個function 組件中的變量等都會被重新創建,包括 <Timer config={{from:20,to:30}}/> 中的對象參數,雖然內容沒變,但是引用變了。

useEffect 是否再次觸發,依據的是第二個參數數組中的變量的引用相等性(Reference equality)。

var a = {from:20,to:30};
var b = {from:20,to:30};
a===b; // false
var d = a.to;
var e = b.to;
d===e; // true

關于 Reference equality 和 useCallback、useMemo 的關系,感興趣的話可以閱讀這篇文章 useCallback vs useMemo。

示例7

要解決示例4 的問題,我們可以使用useMemo。

useMemo返回一個記憶化(Memoized)的值,也就是保證了在兩次渲染之間這個值的引用不發生變化。在這個例子中,useMemo 依賴空數組保證了在渲染之間 {from:20,to:30}這個對象只創建了一次,config的引用不發生變化。

function Parent(){
    const [name, setName] = useState('Evelyn');

    var onChangeName = (e)=>{
        setName(e.target.value)
    }

    // 使用useMemo 創建一個 memoized 對象
    const config = useMemo(() => ({from:20,to:30}),[]);

    return (
        <div>
            <input value={name} onChange={onChangeName} />
            <br/>
            Hello, {name}!!!
            <Timer config={config}/>
        </div>
    )
}

useCallback 跟 useMemo 類似,只是 useCallback 返回的是第一個參數定義的記憶化的 function。

useCallback(fn,deps) // 返回一個記憶化的方法 fn

等同于

useMemo(()=>fn,deps)

useCallback 和 useMemo 的效果類似于shouldComponentUpdate,避免不需要的render。更豐富的使用場景就不在此贅述了,可以自行探索。

3.4 自定義Hook

使用 Hook 的 都是 function 組件,那么如果我們想要在多個function組件中使用一部分相同的邏輯,該怎么做呢?我們可以把那部分的邏輯抽離出來,放到一個新的方法中,這也就是自定義 Hook 的過程。

示例 8

基于示例 3 的代碼,抽離出來一個 Hook useTimer(from),返回[time,resetTimer],這樣就能在別的 function 組件中復用 useTimer 里的邏輯了。

function Timer(props){
    // 調用自定義的 Hook
    const [time, resetTimer] = useTimer(props.from)

    return (
        <div>
            <h2>{time} s passed...</h2>
            <button onClick={resetTimer}>Reset</button>
        </div>
    )
}

// 抽離出來定時器的邏輯,定義一個新的Hook
function useTimer(from){
    const [time, setTime] = useState(from)

    const resetTimer = ()=>{
        setTime(0)
    }

    useEffect(()=>{
        var interval = setInterval(()=>{
            setTime(t=>t+1)
        },1000)

        return ()=>{
            if(interval) clearInterval(interval)
        }
    },[])

    return [time, resetTimer]
}

示例9

比如在示例 8 的基礎上,我再定義一個定時器,但是是倒計時,并且記錄回合數。這時候可以復用自定義的 useTimer Hook。

// 正計時器
function Timer(props){
    const [time, resetTimer] = useTimer(props.from)
    return (
        <div>
            <h1>Timer 1</h1>
            <h2>{time}s passed...</h2>
            <button onClick={resetTimer}>Reset Timer</button>
        </div>
    )
}

// 定義新的組件,倒計時,記錄回合數,可重置
function Timer2(props){
    const [time, resetTimer] = useTimer(0)  // 調用自定義Hook useTimer
    const [round, setRound] = useState(0) // 回合數

    // 重置回合數和倒計時
    const resetRound = ()=>{
        setRound(0)
        resetTimer()
    }

    useEffect(()=>{
        // 如果倒計時結束,自動重新倒計時,并更新回合數
        if(time >= props.timePerRound){
            resetTimer()
            setRound(r=>r+1)
        }
    }, [time, props.timePerRound, resetTimer])

    return (
        <div>
            <h1>Timer 2</h1>
            <h2>Round {round}</h2>
            {/* 顯示倒計時 */}
            <h2>{props.timePerRound - time}s before the next round...</h2>
            <button onClick={resetRound}>Reset Round</button>
        </div>
    )
}

function Timers(){
    return (
        <div>
            <Timer from={0} />
            <Timer2 timePerRound={10} />
        </div>
    )
}

效果:


示例9

可以明顯地看出:

  • 抽離前后代碼的行為沒有改變,只是變得可以復用了而已。
  • 自定義 Hook 的名稱以 use 開頭。
  • 兩個組件Timer 和 Timer2 雖然都使用了useTimer Hook,但不共享state,自定義 Hook 中的 state 和 effect 都是完全隔離的。

只要理解了 Hook API,自定義 Hook 還是很簡單的。

Custom Hooks are a convention that naturally follows from the design of Hooks, rather than a React feature.

但不得不說,自定義 Hook 提供了共享代碼邏輯的靈活性,這在以前的 React 組件中是不可能的。我們可以編寫涵蓋各種用例的自定義 Hook,比如表單處理,動畫,事件訂閱,計時器等等。

4. 參考閱讀

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,238評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,430評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,134評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,893評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,653評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,136評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,212評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,372評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,888評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,738評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,482評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,179評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,588評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,829評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,610評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,916評論 2 372

推薦閱讀更多精彩內容

  • 你還在為該使用無狀態組件(Function)還是有狀態組件(Class)而煩惱嗎?——擁有了hooks,你再也不需...
    米亞流年閱讀 945評論 0 5
  • React是現在最流行的前端框架之一,它的輕量化,組件化,單向數據流等特性把前端引入了一個新的高度,現在它又引入的...
    老鼠AI大米_Java全棧閱讀 5,788評論 0 26
  • 你還在為該使用無狀態組件(Function)還是有狀態組件(Class)而煩惱嗎?——擁有了hooks,你再也不需...
    水落斜陽閱讀 82,344評論 11 100
  • 目錄 什么是 React Hooks? 為什么要創造 Hooks? Hooks API 一覽 Hooks 使用規則...
    一個笑點低的妹紙閱讀 1,089評論 0 2
  • 人們都說:知己難得,兩三即可。朋友難求,合適就好。 但是你也總能遇到你們一群“瘋子”,學歷不高,但是待人不計較。二...
    汐麓生閱讀 490評論 0 2