動態

詳情 返回 返回

掌握 React Hooks:現代 React 開發的必備技能 - 動態 詳情

大家好,我是長林啊!一個愛好 JavaScript、Go、Rust 的全棧開發者;致力於終身學習和技術分享。

本文首發在我的微信公眾號【長林啊】,歡迎大家關注、分享、點贊!

Hooks 是 React 官方團隊在 React 16.8 版本中正式引入的概念。通俗的講,Hooks 只是一些函數,Hooks 可以用於在函數組件中引入狀態管理和生命週期方法;如果希望讓 React 函數組件擁有狀態管理和生命週期方法,我們不需要再去將 React 函數組件重構成 React 類組件,而是直接使用 React Hooks。

與原來的 React Class 的組件不同,React Hooks在類組件中不起作用的,React不僅提供了一些內置的 Hooks,比如useState,還可以自定義 Hooks,用於管理重複組件之間的狀態。

初識 React Hooks

什麼是 Hooks?

顧名思義,Hook 就是“鈎子”的意思。在 React 中,Hooks 就是把某個目標結果鈎到某個可能會變化的數據源或者事件源上,那麼當被鈎到的數據或事件發生變化時,產生這個目標結果的代碼會重新執行,產生更新後的結果

Hooks 是一組特殊的函數,它們讓你在不編寫類的情況下使用 React 的狀態管理和生命週期特性。通過 Hooks,開發者可以在函數組件中管理狀態、執行副作用、以及利用其他 React 特性,使得函數組件具備了以前只有類組件才有的能力。Hooks 借鑑自函數式編程,但同時在使用上也有一些限制。

為什麼引入 Hooks?

  • 簡化組件邏輯: 類組件常常帶來複雜的狀態管理和生命週期方法嵌套問題。Hooks 使得邏輯分離和重用變得更加容易。
  • 提高可讀性和複用性: 函數組件的寫法通常更簡潔,更易於理解。使用 Hooks 後,組件的邏輯可以被抽象為自定義 Hooks,使得代碼更加模塊化和可複用。
  • 避免複雜的 class 組件: Hooks 讓函數組件能夠擁有類組件的所有功能,而不需要複雜的 this 綁定或生命週期方法的學習成本。

React Hooks 的優點

在 React 中,理想的UI模型可以被表示為 UI=f(state)。在這個模型中,

  • UI: 代表視圖
  • state: 代表應用的狀態
  • f:代表的是渲染過程

Hooks 帶來的最大好處就是邏輯複用邏輯分離

邏輯複用

以窗口大小綁定為例,假設有多個組件需要在用户調整瀏覽器窗口大小時進行佈局的重新調整,那麼我們就需要將這種邏輯抽取出來,作為一個公共模塊供多個組件使用。根據 React 的理念,我們會根據Size的大小在JSX中渲染不同的組件。

function render() {
    if (size === "small") return <SmallComponent />;
    else return <LargeComponent />;
}

這段代碼是一個用於渲染組件的功能,它依賴於一個名為 size 的變量。如果 size 的值為 "small",那麼就渲染 SmallComponent 組件;如果 size 的值不是 "small",那麼就渲染 <LargeComponent/> 組件。雖然這段代碼看起來很簡單,但在以前的 Class 組件中,我們甚至需要使用一個相對複雜的設計模式來解決這個問題,那就是高階組件。因此,接下來我們可以通過實際的代碼來對比一下:

import React from 'react';

// 定義一個高階組件 withWindowSize
const withWindowSize = Component => {
    // 定義一個新的組件 WrappedComponent,這個組件的主要作用是監聽窗口大小的變化
    class WrappedComponent extends React.PureComponent {
        constructor(props) {
            super(props);
            // 在組件的state中保存當前窗口的大小
            this.state = {
                size: this.getSize(),
            };
        }

        // 在組件被掛載到DOM後,添加一個resize事件監聽器
        componentDidMount() {
            window.addEventListener('resize', this.handleResize);
        }

        // 在組件被卸載前,移除resize事件監聽器
        componentWillUnmount() {
            window.removeEventListener('resize', this.handleResize);
        }

        // 獲取當前窗口的大小,如果窗口寬度大於500,則返回'large',否則返回'small'
        getSize() {
            return window.innerWidth > 500 ? 'large' : 'small';
        }

        // 定義resize事件的處理函數,當窗口大小變化時,更新state中的size
        handleResize = () => {
            const currentSize = this.getSize();
            this.setState({ size: currentSize });
        };

        // 在render方法中,將窗口的大小作為屬性傳遞給被包裝的組件
        render() {
            return <Component size={this.state.size} />;
        }
    }
    // 返回新的組件
    return WrappedComponent;
};

export default withWindowSize;

withWindowSize 函數接收一個 React 組件作為參數,並返回一個新的 React 組件。新的組件會監聽窗口大小的變化,並將窗口大小作為屬性傳遞給原始的組件。這樣,原始的組件就可以根據窗口大小來動態調整自己的行為或呈現方式。調用方式如下:

// Render.jsx
import React from 'react';
import withWindowSize from './with-window-size';

class SmallComponent extends React.Component {
    render() {
        return <p>Small Component</p>;
    }
}

class LargeComponent extends React.Component {
    render() {
        return <p>Large Component</p>;
    }
}

class MyComponent extends React.Component {
    render() {
        const { size } = this.props;
        if (size === 'small') return <SmallComponent />;
        else return <LargeComponent />;
    }
}

// 使用 withWindowSize 產生高階組件,用於產生 size 屬性傳遞給真正的業務組件
export default withWindowSize(MyComponent);

上面代碼可在 clin211/react-awesome 中查看,效果如下圖:
<img src="https://files.mdnice.com/user/8213/3de1e702-4ba1-46ce-99ed-4454acd218e0.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

上面 Render.jsx 文件中也可以看出,為了傳遞一個外部的狀態,我們不得不定義一個沒有 UI 的外層組件,而這個組件只是為了封裝一段可重用的邏輯。更為糟糕的是,高階組件幾乎是 Class 組件中實現代碼邏輯複用的唯一方式,其缺點其實比較顯然:

  • 高階組件的代碼難以理解,也不夠直觀。
  • 會增加很多額外的組件節點,每一個高階組件都會多一層節點,這會給調試帶來很大的負擔。

最後,React 團隊終於提出了全新的解決方案——Hooks。

同樣的邏輯如果用 Hooks 和函數組件該如何實現?

import { useState, useEffect } from 'react';

// 定義一個函數getSize,用於獲取當前窗口的大小。
const getSize = () => {
    // 如果窗口寬度大於500,則返回'large',否則返回'small'
    return window.innerWidth > 500 ? 'large' : 'small';
};

// 定義自定義Hook useWindowSize。
const useWindowSize = () => {
    // 使用useState Hook初始化窗口大小的狀態變量size和相應的設置函數setSize。
    const [size, setSize] = useState(getSize());

    // 使用useEffect Hook添加一個副作用函數,該函數在組件掛載和更新時執行。
    useEffect(() => {
        // 在副作用函數內部,定義一個處理函數handler,用於設置窗口大小的狀態。
        const handler = () => {
            setSize(getSize());
        };
        // 為窗口的resize事件添加處理函數handler。
        window.addEventListener('resize', handler);

        // 返回一個清理函數,在組件卸載前執行,用於移除窗口的resize事件監聽器。
        return () => {
            window.removeEventListener('resize', handler);
        };
    }, []); // 傳入空數組作為依賴列表,表示這個副作用函數只在組件掛載時執行一次。

    // 返回當前窗口的大小。
    return size;
};

export default useWindowSize; // 導出自定義Hook useWindowSize。

這段代碼是一個自定義的 React Hook,名為 useWindowSize,用於監聽和返回窗口的大小,當窗口大小發生變化時,使用這個 Hook 的組件就都會重新渲染。不理解上面的代碼也沒關係,因為上面用了兩個 Hooks,後面會詳細介紹相關 Hooks,而且代碼中也有註釋,結合註釋也能理解。

如使用方式也很簡單:

import { Component } from 'react';
import Render, { LargeComponent, SmallComponent } from './Render';
import useWindowSize from './hooks/useWindowSize';


function App() {
    const size = useWindowSize();
    return (
        <div className="App">
            // 其他邏輯....
            <h3>
                window size: {size}
                {size === 'small' ? <SmallComponent /> : <LargeComponent />}
            </h3>
        </div>
    );
}

export default App;

上面的詳細代碼可以在 clin211/react-awesome 中查看。傳送門

邏輯分離

Hooks 能夠讓針對同一個業務邏輯的代碼儘可能聚合在一塊兒。這是過去在 Class 組件中很難做到的。因為在 Class 組件中,你不得不把同一個業務邏輯的代碼分散在類組件的不同生命週期的方法中。

React 社區曾用一張圖直觀地展現了對比結果:

<img src="https://files.mdnice.com/user/8213/daac4763-6786-4b35-b498-03e77277c79c.png" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" /><span style="display: block; text-align: center;font-size:12px;color:grey;">圖片來源於互聯網</span>

圖的左側是 Class 組件,右側是函數組件結合 Hooks。藍色和黃色代表不同的業務功能。可以看到,在 Class 組件中,代碼是從技術角度組織在一起的,例如在 componentDidMount 中都去做一些初始化的事情。而在函數組件中,代碼是從業務角度組織在一起的,相關代碼能夠出現在集中的地方,從而更容易理解和維護。

React Hooks 的基本規則

  • 只在頂層調用 Hooks

    function Counter() {
        // 在函數組件頂層
        const [count, setCount] = useState(0);
        // ...
    }
  • 只在 React 函數組件或者自定 Hooks 中調用 Hooks

    function useWindowWidth() {
        // 在自定義 Hooks 頂層
        const [width, setWidth] = useState(window.innerWidth);
        // ...
    }

    或者

    function Home(){
        // 函數組件中調用
        const [name, setName] = useState("clina");
        // ...
    }

    不支持在其他任何情況下調用以 use 開頭的 Hook,例如:

    • 不要在條件語句或循環中調用 Hook。
    • 不要在條件性的 return 語句之後調用 Hook。
    • 不要在事件處理函數中調用 Hook。
    • 不要在類組件中調用 Hook。
    • 不要在傳遞給 useMemouseReduceruseEffect 的函數內部調用 Hook。
    • 不要在 try/catch/finally 代碼塊中調用 Hook。

React Hooks 有哪些?

我們已經瞭解了Hooks的概念,並通過實例對比,明白了Hooks的優勢。我們再來看看 React(基於React 18.3.1版本) 中具體有哪些 Hooks 呢?

  • useState
  • useEffect
  • useReducer
  • useMemo
  • useCallback
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue
  • useDeferredValue
  • useInsertionEffect
  • useTransition
  • useSyncExternalStore
  • useId

實驗性的 Hook:`

  • useFormStatus
  • useActionState
  • useOptimistic

雖然 React Hooks 看着不少,但也不必全都精通。在常見的開發場景中,使用最頻繁的 Hooks大概有三個:useStateuseEffectuseContext。首先理解這三個基礎的 Hooks,然後在此基礎上:

  • 學習 useRef 的基礎用法。
  • 當你需要優化組件性能,減少不必要的渲染時,useMemouseCallback 會非常有用。
  • 對於複雜的狀態管理,useReducer 能提供強大的幫助。
  • 如果你需要封裝組件並提供命令式的接口,那麼 useRefuseImperativeHandle 就會派上用場。
  • 最後,當你需要在頁面上進行優先級較高的更新優化時,useDeferredValueuseTransition 會是你的得力助手。

useState

在 React 中,理想的 UI 模型可以被表示為 UI=f(state)。在這個模型中:

  • UI 代表視圖
  • state代表應用的狀態
  • f 代表的是渲染過程,點擊、拖拽等交互事件會改變狀態,而狀態改變會觸發重新渲染。
    <img src="https://files.mdnice.com/user/8213/989e184c-ed0a-4fda-ad45-7bc82d77f4be.png" style="width:20em;" />

上面的例子中,我們也用到了 useState,在組件頂層調用 useState來聲明一個狀態變量,它返回一個包含兩個成員的數組;也就是説 useState 是用來操作組件 state 的 Hook。

import { useState } from 'react';

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

    function handleClick() {
        setCount(count + 1);
    }

    return (
        <button onClick={handleClick}>
            You pressed me {count} times
        </button>
    );
}

這段代碼中,useState 中的 0 是初始值,它可以是任何類型的值;如果這個初始值是傳入的一個函數時,這個函數應該是一個純函數,且函數沒有任何參數,最後返回一個任何類型的值

useState 返回一個由兩個值組成的數組:

  1. 當前的 state,在首次渲染的時候,直接使用初始化時的值。
  2. set 函數,每次調用這個函數,都會觸發並重新渲染。可以直接傳遞新狀態,也可以傳遞一個根據先前狀態來計算新狀態的函數:

    import React, { useState } from 'react';
    
    const Profile = () => {
        const [age, setAge] = useState(18);
    
        const handleClick = () => {
            setAge(age + 1); // setAge((prev) => prev + 1);
        };
    
        return (
            <div>
                <button onClick={handleClick}>set age</button>
                <p>current age: {age}</p>
            </div>
        );
    };
    
    export default Profile;

    常見問題

  3. 閉包陷阱

    用一個例子來解釋:在這個組件中,我們有一個名為 count 的狀態,一個用於增加 count 的按鈕,以及一個用於顯示彈窗的按鈕。彈窗會在 3 秒後顯示當時點擊的次數。

    import React, { useState } from 'react';
    
    function ClosureTrap() {
     const [count, setCount] = useState(0);
    
     const handleAlert = () => {
         setTimeout(() => {
             alert('You clicked on: ' + count);
         }, 3000);
     };
    
     return (
         <div>
             <p>You clicked {count} times</p>
             <button onClick={() => setCount(count + 1)}>Click me</button>
             <button onClick={handleAlert}>Show alert</button>
         </div>
     );
    }
    
    export default ClosureTrap;

    如果你點擊增加按鈕幾次,然後點擊顯示彈窗按鈕,你可能會期望彈窗顯示的是你點擊增加按鈕的次數。但實際上,無論你點擊增加按鈕多少次,彈窗總是顯示你在點擊顯示彈窗按鈕時的點擊次數。

    這是為什麼呢?沒錯!就是閉包陷阱導致的,在 handleAlert 函數中,setTimeout 的回調函數是一個閉包,它捕獲了 count的值。但是,當你點擊增加按鈕時,handleAlert 函數並沒有重新運行,所以它捕獲的 count 值並沒有更新。這就是所謂的閉包陷阱。

    要解決這個問題,我們可以使用函數式更新,以便總是使用最新的狀態值,如下所示:

    import React, { useState, useRef, useEffect } from 'react';
    
    function ClosureTrap() {
     const [count, setCount] = useState(0);
     // 用於存儲count的引用,它的值會隨count變化而變化且不會更新UI
     const countRef = useRef(count);
    
     const handleAlert = () => {
         setTimeout(() => {
             alert('You clicked on: ' + count);
         }, 3000);
     };
    
     // 當count變化時,更新countRef
     useEffect(() => {
         countRef.current = count;
     }, [count]);
    
     return (
         <div>
             <p>You clicked {count} times</p>
             <button onClick={() => setCount(count + 1)}>Click me</button>
             <button onClick={handleAlert}>Show alert</button>
         </div>
     );
    }
    
    export default ClosureTrap;

    這段代碼中用到了兩個 Hooks 沒有介紹,後面會詳細介紹;改完之後,運行再查看就是期望的效果了。關於閉包陷阱的詳細解釋,可以看看這篇文章《從根上理解 React Hooks 的閉包陷阱》。

  4. 批處理合並更新

    import React, { useState } from 'react';
    
    const User = () => {
     const [age, setAge] = useState(18);
    
     const handleClick = () => {
         setAge(age + 1);
         setAge(age + 1);
         setAge(age + 1);
     };
    
     return (
         <div>
             <button onClick={handleClick}>set age</button>
             <p>current age: {age}</p>
         </div>
     );
    };
    
    export default User;

    點擊一次 set age 按鈕後,頁面渲染的 age 應該是多少?21?當你執行後你會發現是 19,為什麼呢?因為 React 會將這些更新批量處理,以優化性能。在這個過程中,它只會使用最後一次更新的值。setAge(age + 1) 被調用了三次,但是每次調用時的 age 值都是同一個,也就是 18。所以,無論調用多少次,age 的值都只會增加 1。這就是為什麼點擊一次click後,age的值沒有增加三次的原因。如何解決這個問題呢?函數時更新:

    const handleClick = () => {
     setAge(prevAge => prevAge + 1);
     setAge(prevAge => prevAge + 1);
     setAge(prevAge => prevAge + 1);
    };
  5. 修改引用類型的數據時數據更新了,UI沒有更新

    import React, { useState } from 'react';
    
    const UpdateObject = () => {
     const [person, setPerson] = useState({ name: 'clin', age: 18 });
    
     const handleClickUpdateAge = () => {
         person.age = 20;
         setPerson(person);
         console.log('person', person);
     };
    
     return (
         <div>
             current name: {person.name}, age: {person.age}
             <button onClick={handleClickUpdateAge}>update age</button>
         </div>
     );
    };
    
    export default UpdateObject;

    運行後,效果如下:
    <img src="https://files.mdnice.com/user/8213/20caf986-3fd7-40da-8037-8169a0cf78bf.png" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

    從代碼上來看,每次執行完更新數據後都會打印修改後的數據,在控制枱中確實也能看到數據是被修改了的,為什麼沒有重新渲染組件呢?這是因為對象在 JavaScript 中是引用類型,它們的值在內存中的地址是不變的,並不能簡單地通過比較新值與舊值來判斷是否發生更新。因此,即使對象引用的值已經發生了改變,但它們的地址仍然相同,React 就會認為值沒有改變,不會觸發重新渲染。

    要解決這個問題,可以使用以下方法之一:使用不可變的數據類型,如字符串、數字和布爾值,而不是引用類型的對象,這樣就不會出現值被修改但地址相同的問題。

    接着來解決一下上面的問題,有兩種方式:深拷貝和擴展運算符的方式。我們這裏就以擴展運算符的形式去解決組件渲染的問題:

    import React, { useState } from 'react';
    
    const UpdateObject = () => {
     const [person, setPerson] = useState({ name: 'clin', age: 18 });
    
     const handleClickUpdateAge = () => {
         const newPerson = { ...person }; // JSON.parse(JSON.stringify(person));
         newPerson.age = 20;
         setPerson(newPerson);
     };
    
     return (
         <div>
             current name: {person.name}, age: {person.age}
             <button onClick={handleClickUpdateAge}>update age</button>
         </div>
     );
    };
    
    export default UpdateObject;

    上面的代碼在 clin211/react-awesome 中查看;傳送門。

useEffect

useEffect ,顧名思義,用於執行一段副作用。

什麼是副作用呢?通常來説,副作用是指一段和當前執行結果無關的代碼。比如説要修改函數外部的某個變量,要發起一個請求,等等。也就是説,在函數組件的當次執行過程中,useEffect 中代碼的執行是不影響渲染出來的 UI 的。

useEffect(callback, dependencies);

參數:

  • callback 執行函數。
  • dependencies 可選的依賴項數組,依賴項是可選的。

    • 有依賴項時,那麼只有依賴項中的值發生改變的時候,它才會執行。
    • 沒有依賴項時,那麼 callback 就會在每次函數組件執行完後都執行。
import React, { useState, useEffect } from 'react';

function Article({ id }) {
    // 設置一個本地 state 用於保存 blog 內容
    const [blogContent, setBlogContent] = useState({});

    useEffect(() => {
        // useEffect 的 callback 要避免直接的 async 函數,需要封裝一下
        const fetchData = async () => {
            // 當 id 發生變化時,將當前內容清楚以保持一致性
            setBlogContent(null);
            // 發起請求獲取數據
            const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
            // 將獲取的數據放入 state
            setBlogContent(await res.json());
        };
        fetchData();
    }, [id]); // 使用 id 作為依賴項,變化時則執行副作用

    // 如果沒有 blogContent 則認為是在 loading 狀態
    const isLoading = !blogContent;
    return <div>{isLoading ? 'Loading...' : blogContent?.body}</div>;
}


export default Article;

兩個特殊用法

  • 沒有依賴項,則每次render後都會執行。例如:

    import React, { useState, useEffect } from 'react';
    
    function Effect() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
            // 每次 render 完一定執行
            console.log('re-rendered');
        });
    
        return (
            <>
                <p>Hello Effect</p>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>count每次改變都會執行沒有依賴項的useEffect</button>
            </>
        );
    }
    
    export default Effect;
  • 空數組作為依賴項,則只在首次執行是觸發,對應到 class 組件就是 componentDidMount。例如:

    import React, { useState, useEffect } from 'react';
    
    function Effect() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
            // 組件首次渲染時執行,等價於 class 組件中的 componentDidMount
            console.log('did mount');
        }, []);
    
        return (
            <>
                <p>Hello Effect</p>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>render</button>
            </>
        );
    }
    
    export default Effect;

useEffect 還允許你返回一個函數,用於在組件銷燬的時候做一些清理的操作。比如移除事件的監聽。這個機制就幾乎等價於類組件中的 componentWillUnmount。舉個例子,在組件中,我們需要監聽窗口的大小變化,以便做一些佈局上的調整:

import { useState, useEffect } from 'react';

const useWindowSize = () => {
    // 設置一個 size 的 state 用於保存當前窗口尺寸
    const [size, setSize] = useState({});
    
    useEffect(() => {
        // 窗口大小變化事件處理函數
        const handler = () => {
            setSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };
        // 監聽 resize 事件
        window.addEventListener('resize', handler);

        // 返回一個 callback 在組件銷燬時調用
        return () => {
            // 移除 resize 事件
            window.removeEventListener('resize', handler);
        };
    }, []);
    
    return size;
};

export default useWindowSize;

useEffect 在以下四種時機去執行一個回調函數產生副作用:

  • 每次 render 後執行:不提供第二個依賴項參數。比如useEffect(() => {})
  • 僅第一次 render 後執行:提供一個空數組作為依賴項。比如useEffect(() => {}, [])
  • 第一次以及依賴項發生變化後執行:提供依賴項數組。比如useEffect(() => {}, [deps])
  • 組件銷燬後執行:返回一個回調函數。比如useEffect() => { return () => {} }, [])

Hooks中的依賴

Hooks 提供了讓你監聽某個數據變化的能力。這個變化可能會觸發組件的刷新,也可能是去創建一個副作用,又或者是刷新一個緩存。那麼定義要監聽哪些數據變化的機制,其實就是指定 Hooks 的依賴項。

在定義依賴項時,我們需要注意以下三點:

  • 依賴項中定義的變量一定是會在回調函數中用到的,否則聲明依賴項其實是沒有意義的。
  • 依賴項一般是一個常量數組,而不是一個變量;因為一般在創建 callback 的時候,你其實非常清楚其中要用到哪些依賴項了。
  • React 會使用淺比較來對比依賴項是否發生了變化,所以要特別注意數組或者對象類型如果你是每次創建一個新對象,即使和之前的值是等價的,也會被認為是依賴項發生了變化。這是一個剛開始使用 Hooks 時很容易導致 Bug 的地方。

使用 Eslint 插件幫助檢查 Hooks 的使用

React 官方為我們提供了一個 ESLint 的插件,專門用來檢查 Hooks 是否正確被使用,它就是 eslint-plugin-react-hooks;通過這個插件,如果發現缺少依賴項定義這樣違反規則的情況,就會報一個錯誤提示(類似於語法錯誤的提示),方便進行修改,從而避免 Hooks 的錯誤使用。

在 eslint 配置中加入 react-hooks/rules-of-hooksreact-hooks/exhaustive-deps 兩條規則:
module.exports = {
    env: {
        browser: true,
        es2023: true,
        node: true
    },
    extends: ['plugin:react/recommended', 'airbnb'],
    parserOptions: {
        ecmaFeatures: {
            jsx: true
        },
        ecmaVersion: 12,
        sourceType: 'module'
    },
    plugins: ['react', 'react-hooks'],
    rules: {
        // 4個空格縮進
        indent: [2, 4, {"SwitchCase": 1}],
        // 檢查 Hooks 的使用規則
        'react-hooks/rules-of-hooks': 'error',
        // 檢查依賴項的聲明
        'react-hooks/exhaustive-deps': 'warn',
        // 允許JSX的.js擴展名
        'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }]
    }
};

useLayoutEffect

useLayoutEffect 是 React Hooks 中的一個鈎子,它與 useEffect 相似,都用於在組件渲染後執行副作用函數。但是,useLayoutEffectuseEffect 之間的主要區別在於它們執行的時機。

useEffect 會在瀏覽器完成佈局和繪製後,在一個延遲事件中被調用。因此,它不會阻塞瀏覽器更新屏幕,這使得它適合大多數副作用場景,如數據獲取、訂閲或手動更改DOM。

相比之下,useLayoutEffect 會在所有的 DOM 變更之後同步調用,但是會在瀏覽器進行任何新的繪製之前運行。這使得它在讀取佈局並同步重新渲染的場景中非常有用。在瀏覽器執行繪製前修改 DOM 可以同步地更新屏幕,而不會有任何視覺上的抖動。

這裏有一個簡單的例子説明useLayoutEffect的使用:

import React, { useLayoutEffect, useRef } from 'react';

function Example() {
    const divRef = useRef();

    useLayoutEffect(() => {
        console.log(divRef.current.getBoundingClientRect());
    }, []);

    return <div ref={divRef}>Hello World</div>;
}

在這個例子中,我們使用 useLayoutEffect 獲取並打印 div 元素的邊界信息。因為 useLayoutEffect 在所有DOM變更後立即執行,所以當它運行時,div 元素已經被渲染到屏幕上,所以 getBoundingClientRect 返回的信息是準確的。

常見問題

  • 性能問題:由於 useLayoutEffect 是同步執行的,如果執行時間過長,可能會阻塞瀏覽器的渲染,導致性能問題。因此,只有在確實需要同步操作時才使用它。
  • 服務器端渲染(SSR)問題useLayoutEffect 在服務器端渲染時不會運行,因為它依賴於瀏覽器的 DOM API。如果代碼在 SSR 中出現問題,可能需要回退到 useEffect 或進行其他處理。
  • 過度使用:避免在不需要同步 DOM 操作的情況下使用 useLayoutEffect,因為這可能會導致不必要的性能負擔。

useContext

useContext 是 React Hooks 中的一個鈎子,它能讓你在組件中無需通過 props 就可以訪問上下文(Context)的數據。這對於在組件樹中深層次傳遞數據非常有用,避免了繁瑣的 props 逐層傳遞。
<img src="https://files.mdnice.com/user/8213/0f862ecb-04a9-49ec-8862-d00d98597687.png" style="max-width: 30em" />

下面來寫一個獲取主題的例子(clin211/react-awesome 中查看代碼):

import React, { useContext } from 'react';

// 創建一個全局上下文對象
const ThemeContext = React.createContext('light');

function ComponentA() {
    // 使用useContext讀取當前的主題
    const theme = useContext(ThemeContext);
    return (
        <div>
            ComponentA, the current theme is {theme}
            <ComponentD />
            <ComponentE />
        </div>
    );
}

function ComponentB() {
    // 使用useContext讀取當前的主題
    const theme = useContext(ThemeContext);
    return <div>ComponentB, the current theme is {theme}</div>;
}

function ComponentC() {
    return <div>ComponentC</div>;
}

function ComponentD() {
    return <div>ComponentD</div>;
}

function ComponentE() {
    // 使用useContext讀取當前的主題
    const theme = useContext(ThemeContext);
    return <div>ComponentE, the current theme is {theme}</div>;
}

function Context() {
    return (
        <ThemeContext.Provider value="dark">
            <ComponentA />
            <ComponentB />
            <ComponentC />
        </ThemeContext.Provider>
    );
}

export default Context;

運行後可以在瀏覽器中查看到如下效果:

<img src="https://files.mdnice.com/user/8213/9ea1f0bd-0117-472c-9493-45281287f5d6.png" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

這段代碼主要演示了 React 中如何使用 useContext Hook和 Context API 來在組件樹中共享一些全局數據。在這個例子中,共享的全局數據是一個主題(theme)。

  • 首先,使用 React.createContext 創建了一個全局上下文對象 ThemeContext,並初始化為'light'
  • 然後定義了一些組件,如 ComponentA、ComponentB 等。在 ComponentA、ComponentB 和 ComponentE 中,我們使用 useContext Hook 來讀取當前的主題。
  • ComponentA、ComponentD 和 ComponentE 作為 ComponentA 的子組件,在 ComponentA 中被渲染。
  • 在 Context 組件中,我們使用 ThemeContext.Provider 來提供一個主題值,這個值會被所有的子組件(包括 ComponentA、ComponentB 和 ComponentC )以及它們的子組件所共享。在這個例子中,我們設置主題值為 'dark'
  • 當我們在 ComponentA、ComponentB 和 ComponentE 中使用 useContext 讀取主題時,都會得到 'dark'

我們可以使用 createContext 來創建一個 context 對象,然後通過 Provider 來更新這個 context 對象中的值。在函數式組件中,我們可以利用 useContext 這個 Hook 來獲取 context 的值

常見問題

  • 過度使用:如果過度使用 context,可能會導致組件之間的耦合增加,使得狀態管理變得複雜。
  • 性能問題:如果 context 值頻繁更新,並且很多組件都使用了這個 context,可能會導致不必要的渲染。React 16.3.0 引入了 React.memoReact.useMemo 來解決這個問題。
  • 命名衝突:如果你的應用中有多個 context,確保它們的命名是唯一的,以避免混淆。

總的來説,使用 useContext 可以簡化跨組件的狀態共享,但需要謹慎使用,以避免引入不必要的複雜性和性能問題。在設計 context 時,考慮其粒度和組件樹的結構,以確保其有效性和可維護性。

useReducer

useReducer 是 React Hooks 中的一個鈎子,它提供了一種使用 reducer 函數來管理函數組件中複雜的狀態邏輯。與 useState 相比,useReducer 的優勢在於它允許你將狀態邏輯分解為更小的、易於管理的部分。

useReducer 接受兩個參數:一個 reducer 函數和一個初始狀態。它返回一個狀態和一個 dispatch 函數。

reducer 函數接受當前的狀態和一個動作作為參數,並返回新的狀態。這個函數類似於 Redux 中的 reducer,它根據接收到的動作來更新狀態。

假設你有一個計數器組件,其狀態包括計數和錯誤信息:

import React, { useReducer } from 'react';

// 定義 reducer 函數
function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            return { ...state, count: state.count + 1 };
        case 'decrement':
            return { ...state, count: state.count - 1 };
        case 'setError':
            return { ...state, error: action.payload };
        default:
            throw new Error();
    }
}

// 計數器組件
function Counter() {
    // 使用 useReducer 鈎子初始化狀態和 dispatch 函數
    const [state, dispatch] = useReducer(reducer, { count: 0, error: null });

    return (
        <div>
            <p>Count: {state.count}</p>
            {state.error && <p>Error: {state.error}</p>}
            <button onClick={() => dispatch({ type: 'increment' })}>+</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
            <button
                onClick={() =>
                    dispatch({
                        type: 'setError',
                        payload: 'Something went wrong',
                    })
                }>
                Set Error
            </button>
        </div>
    );
}

運行後,可以在瀏覽器中查看效果如下:

<img src="https://files.mdnice.com/user/8213/0c9d39f6-d2bd-4142-ae79-14b71dab619e.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

上面列子創建了一個簡單的計數器,並能夠處理錯誤信息:

  1. useReducer 鈎子接受兩個參數,一個是 reducer 函數,另一個是初始狀態。這裏的初始狀態是一個對象 { count: 0, error: null }
  2. reducer 函數是一個處理狀態更新的函數,它根據傳入的 action 的類型來決定如何更新狀態。
  3. 在 Counter 組件內部,我們使用 useReducer 創建了一個狀態 state 和一個 dispatch 函數。state 是當前的狀態,包含 counterror 兩個屬性。dispatch 函數用於觸發狀態更新。
  4. 組件內部有三個按鈕,點擊按鈕會調用 dispatch 函數並傳入一個 action。每個 action 都是一個包含 type 屬性的對象,type 決定了要執行哪種類型的狀態更新。
  5. 當點擊 increment 按鈕時,會觸發一個類型為 incrementactionreducer 函數會接收到這個 action 並將 count 狀態增加1。
  6. 當點擊 decrement 按鈕時,會觸發一個類型為 decrementactionreducer 函數會接收到這個 action 並將 count狀態減少 1。
  7. 當點擊 setError 按鈕時,會觸發一個類型為 setErroractionreducer 函數會接收到這個 action 並將 error 狀態設置為 action 中的 payload

數據可變性

如果你直接修改原始的 state 返回,是觸發不了重新渲染的。下面我們用個例子來看一下:

import React, { useReducer } from 'react';

function reducer(state, action) {
    switch (action.type) {
        case 'increment':
            state.count += 1;
            return state;
        case 'decrement':
            state.count -= 1;
            return state;
        case 'setError':
            return { ...state, error: action.payload };
        default:
            throw new Error();
    }
}

function Immutable() {
    const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
    return (
        <div>
            <p>Count: {state.count}</p>
            {state.error && <p>Error: {state.error}</p>}
            <button onClick={() => dispatch({ type: 'increment' })}>+</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
            <button
                onClick={() =>
                    dispatch({
                        type: 'setError',
                        payload: 'Something went wrong',
                    })
                }>
                Set Error
            </button>
        </div>
    );
}

export default Immutable;

運行後,效果如下:

<img src="https://files.mdnice.com/user/8213/9179dc86-1fcf-4da0-8be0-a7a0ee5a5cc6.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

如果直接修改原始的 state 返回,是不能觸發重新渲染的,必須返回一個新的對象才行。原因與 useState 中的應用類型數據不能更新是一樣的,上面的介紹 useState 有詳細的解釋,這裏就贅述了。

如果要解決這個問題,可以對應用類型的數據進行解構或者創建一個新的對象;如果對象結果很複雜,每次都創建一個新的對象也比較繁瑣,而且性能也不好。比如下面這個數據結構:

const state = {
    a: {
        b: {
            c: {
                d: {
                    e: {
                        f: 1,
                    },
                },
            },
        },
    },
};

我要修改 f 的值,要麼一層一層的使用擴展運算符,要麼創建一個新的對象然後鏈式的修改。上面這個例子還好,每一層的屬性不多,使用鏈式的方式就能解決,如果每層的屬性都很多鏈式就不能解決這個問題,既然我們有這個問題,別人肯定也有這個問題;社區一找發現還有不少解決方案,其中包括:immutable.js、immer、limu等等。其中 immer 是這些解決方案中脱穎而出的一個,接下來也嘗試用 immer 來解決上面的問題。

在 react 社區,有一個基於 immer 實現的庫 use-immer,接下來用 use-immer 解決上面的問題:

  • 安裝 use-immer 庫

    npm i immer use-immer
  • 優化上面的示例

    import React from 'react';
    import { useImmerReducer } from 'use-immer';
    
    function reducer(state, action) {
        switch (action.type) {
            case 'increment':
                state.count += 1;
                return state;
            case 'decrement':
                state.count -= 1;
                return state;
            case 'setError':
                return { ...state, error: action.payload };
            default:
                throw new Error();
        }
    }
    
    function ImmutableUseImmer() {
        const [state, dispatch] = useImmerReducer(reducer, {
            count: 0,
            error: null,
        });
        return (
            <div>
                <p>Count: {state.count}</p>
                {state.error && <p>Error: {state.error}</p>}
                <button onClick={() => dispatch({ type: 'increment' })}>+</button>
                <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
                <button
                    onClick={() =>
                        dispatch({
                            type: 'setError',
                            payload: 'Something went wrong',
                        })
                    }>
                    Set Error
                </button>
            </div>
        );
    }
    
    export default ImmutableUseImmer;

    其實也就是將 內置 useReducer 換成 use-immer 提供的 useImmerReducer,如下圖:

    優化後效果如下:
    <img src="https://files.mdnice.com/user/8213/c1f12b74-3cc8-4a94-b7cd-38cec203c369.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

上面的所有示例代碼都可以在 clin211/react-awesome 中查看。傳送門

使用 useReducer 的常見問題

  • 直接修改狀態在 reducer 函數中,應該總是返回一個新的狀態對象,而不是直接修改傳入的狀態。這是因為 React 使用淺比較來確定是否需要重新渲染組件。如果狀態對象的內存地址沒有改變,那麼 React 就不會觸發組件的重新渲染。
  • 忘記包括默認操作reducer 函數中,應該總是包括一個 default 操作。如果沒有default操作,那麼當傳入一個未知的 action 類型時,reducer 函數將不會返回任何東西,這可能會導致狀態變為undefined。
  • reducer 函數中執行副作用reducer 函數應該是純函數,也就是説,它的輸出只應該由其輸入決定,而且不應該有任何副作用。這意味着你不應該在 reducer 函數中執行諸如網絡請求或訪問全局變量之類的操作。
  • 動作對象的結構在調用 dispatch 函數時,傳入的動作對象通常應該包含一個 type 屬性,以及可選的 payload 屬性type 屬性確定了要執行哪種類型的狀態更新,而 payload 屬性包含了任何需要進行狀態更新的數據。
  • 異步操作useReducer 本身並不能處理異步操作。如果你需要在 reducer 中處理異步操作,例如數據獲取,你可能需要使用其他的解決方案,如中間件或自定義的異步處理鈎子。

useRef

useRef 是React Hooks中的一個鈎子,它可以創建一個持久的、可變的引用

useRef 接受一個參數,這個參數將成為返回的 ref 對象的當前值。useRef 返回的對象具有一個 current屬性,這個 current 在組件的整個聲明週期內保持不變,我們可以自由地更改這個屬性。

主要應用場景

  • 訪問 DOM 元素:最常用的用途就是訪問 DOM`元素。通過將 ref 對象傳遞給元素的 ref 屬性,可以直接訪問和操作 DOM 元素。
  • 保存可變值useRef 也常常用於保存任何可變值,它的值在所有渲染中都會保持不變。
  • 保存上一次的 props 或 state:有時我們可能需要比較 propsstate 的當前值和上一次的值。這時可以使用 useRef 來保存這些值,以便在需要時進行比較。
  • 觸發強制更新:雖然不是推薦的做法,但是在某些情況下,你可能會需要強制更新組件。useRef 可以配合使用 useState 來實現強制更新。
  • 存儲定時器或其他副作用的ID:當你使用 setTimeoutsetInterval 時,你可能會需要在組件卸載時清除它們。你可以使用 useRef 來存儲這些ID,然後在 useEffect 的清除函數中清除它們。
import React, { useRef } from 'react';

function TextInputWithFocusButton() {
    const inputEl = useRef(null);

    const onButtonClick = () => {
        // `current`指向了真實的input元素
        inputEl.current.focus();
    };

    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>Focus the input</button>
        </>
    );
}

export default TextInputWithFocusButton;

在這個例子中,我們使用 useRef 創建了一個 ref,並將它賦值到了一個 input 元素上。然後我們在一個按鈕的點擊事件處理函數中,通過 ref 的 current 屬性訪問到了這個 input 元素,並調用了它的 focus 方法。

Tips:

useRefcreateRef 的主要區別在於:useRef 返回的 ref 對象在組件的整個生命週期內保持不變,而 createRef 每次渲染都會返回一個新的 ref 對象。

常見坑點

  • 組件的每次渲染,返回的值都不變;示例如下:

    import React, { useState, useRef } from 'react';
    
    function Unalterable() {
        const [count, setCount] = useState(0);
    
        return (
            <div>
                <p>點擊次數: {count} </p>
                <p>
                    時間戳: <Time />
                </p>
                <button onClick={() => setCount(count + 1)}>Click me</button>
            </div>
        );
    }
    
    function Time() {
        const ref = useRef(new Date().getTime());
        console.log('🚀 ~ Time ~ ref:', ref.current);
        return <div>{ref.current}</div>;
    }
    
    export default Unalterable;

    效果如下:

    <img src="https://files.mdnice.com/user/8213/f4fd0bd0-11a5-4249-b68b-ab356f21614c.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

    你會發現,即使 count 的值在每次渲染時都會改變,但是 countRef.current 的值在組件的每次渲染中都是不變的,它始終引用的是上一次渲染時 ref 的值,並沒有重新獲取最新的時間戳。這就是 useRef 返回的值在每次渲染中都不變的原因。

  • ref.current 發生變化並不會造成 UI 重新渲染;示例如下:

    import React, { useRef } from 'react';
    
    function NotRerender() {
        const count = useRef(0);
    
        const setCount = () => {
            count.current += 1;
            console.log('🚀 ~ NotRerender ~ count:', count.current);
        };
    
        return (
            <div>
                NotRerender {count.current}
                <button onClick={setCount}>set count</button>{' '}
            </div>
        );
    }
    
    export default NotRerender;

    在瀏覽器中查看效果如下:

    <img src="https://files.mdnice.com/user/8213/8b46adff-4e2c-4c4d-bacb-ebef92bd8817.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

  • 不可以在 render 裏更新 ref.current

    import React, { useState, useRef } from 'react';
    
    function PreventRefChangeInRender() {
        const [count, setCount] = useState(0);
        const ref = useRef(0);
        ref.current++;
    
        const handleOnSetCount = () => {
            setCount(count + 1);
            console.log('🚀 ~ PreventRefChangeInRender ~ count:', ref.current);
        };
        return (
            <div>
                PreventRefChangeInRender
                <p>current count:{count}</p>
                <button onClick={handleOnSetCount}>+</button>
            </div>
        );
    }
    
    export default PreventRefChangeInRender;

    效果如下圖:

    <img src="https://files.mdnice.com/user/8213/67531bb6-81ac-462d-b2ab-13baacefb6e3.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

    從上面的效果圖中也可以看到,在頁面中點擊“+”按鈕,觸發 handleOnSetCount 函數更新 count 的值,然後在控制枱中打印當前 count 的引用;從圖中右側的控制枱中可以看出,當你點擊按鈕時,count 狀態的值將增加1,同時在控制枱中打印出點擊時 ref.current 的值。需要注意的是,由於 ref.current++ 的存在,每次點擊按鈕時打印出的 ref.current 的值都會比上一次大 1。另外,由於 setCount 的更新可能是異步的,因此控制枱中打印的 ref.current 的值可能會比實際的 count 值大 1。

  • 如果給一個組件設定了 ref 屬性,但是對應的值不是有 useRef 創建的,React 會報錯無法正常渲染

    這句其實也不難理解,看看下面的例子:

    import React, { useState } from 'react';
    
    function Panic() {
        const [count, setCount] = useState('');
    
        return (
            <div>
                <h1 ref={count}>Panic</h1>
            </div>
        );
    }
    
    export default Panic;

    運行效果如下:

關於 useRef 的所有示例代碼都可以在 clin211/react-awesome 上查看。傳送門

useMemo

useMemo 是一個 React Hook,它在每次重新渲染的時候能夠緩存計算的結果。

useMemo(calculateValue, dependencies)

calculateValue要緩存計算值的函數。它應該是一個沒有任何參數的純函數,並且可以返回任意類型

  • React 將會在首次渲染時調用該函數;
  • 在之後的渲染中,如果 dependencies 沒有發生變化,React 將直接返回相同值。
  • 否則,將會再次調用 calculateValue 並返回最新結果,然後緩存該結果以便下次重複使用。

dependencies:所有在 calculateValue 函數中使用的響應式變量組成的數組。響應式變量包括 props、state 和所有你直接在組件中定義的變量和函數。如果你在代碼檢查工具中 配置了 React,它將會確保每一個響應式數據都被正確地定義為依賴項。依賴項數組的長度必須是固定的並且必須寫成 [dep1, dep2, dep3] 這種形式。React 使用 Object.is 將每個依賴項與其之前的值進行比較。

dependencies 參數跟前面的 useEffect 是一樣的:

  • 不傳輸組,每次都會重新計算
  • 空數組,只會計算一次
  • 依賴對應的值,對應的值發生變化重新執行 calculateValue 函數

接下面我們用一個例子來理解這個 Hooks 的使用,示例如下:

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

function Todo() {
    const [count, setCount] = useState(0);
    const [todo, setTodo] = useState([]);

    // 過濾userId不為1的數據
    const filterTodo = useMemo(
        () => todo.filter(item => item.userId === 1),
        [todo]
    );

    const fetchTodo = async () => {
        const res = await fetch('https://jsonplaceholder.typicode.com/todos');
        const data = await res.json();
        setTodo(data);
    };

    useEffect(() => {
        fetchTodo();
    }, []);

    return (
        <div>
            <h1>Todo</h1>
            <p>
                count: {count}{' '}
                <button onClick={() => setCount(count + 1)}>+</button>
            </p>
            {filterTodo.map((item, index) => (
                <div key={item.id} className="todo-item">
                    {index + 1}、{item.title}
                </div>
            ))}
        </div>
    );
}

export default Todo;

上面這段代碼中,無論 count 怎麼變化, filterTodo 只要 todo 沒有變化,那麼它就永遠都會重用上一次的結算結果;只有 todo 中的數據變化後,filterData 才會重新計算。

既然 useMemo 無法帶來視覺上的差異,我們為什麼還要使用useMemo?

  • 重新計算的開銷:大量數據處理、循環或其他複雜邏輯的場景時,重複不必要的計算可能會導致瀏覽器的卡頓,從而影響用户體驗。
  • 渲染的開銷:當我們談論 React 性能時,經常考慮的不僅僅是計算的速度,還有避免不必要的渲染。如果某個子組件依賴於一個對象或數組,並且這個對象或數組在每次父組件渲染時都重新創建,即使實際的數據沒有改變,那麼子組件也會不必要地重新渲染。

如何避免 useMemo 的濫用

  • 當一個組件視覺上包裹其他組件時,應將 JSX 作為子組件傳遞。這樣可以讓 React 知道當包裹器組件更新狀態時,其子組件無需重新渲染。
  • 應優先使用本地狀態,除非必要,否則不要進行狀態提升。例如,表單狀態或組件是否被鼠標懸停等瞬時狀態,無需保存在組件樹的頂部。
  • 渲染邏輯應保持純粹。如果重新渲染組件會引發問題或明顯的視覺錯誤,那麼這就是需要修復的錯誤,而不是應該使用記憶化技術來避免的問題。
  • 避免在 Effect 中不必要地更新狀態。大部分React應用的性能問題都是由 Effect 引起的,因為它創建的更新鏈會導致組件反覆重新渲染。
  • 應儘量從 Effect 中移除不必要的依賴項。例如,將某些對象或函數移動到 Effect 內部或組件外部,通常比使用記憶化更簡單。

useMemo 中演示的所有示例都可以在 clin211/react-awesome 中查看。傳送門

useCallback

useCallback 是對 useMemo的特化,它可以返回一個緩存版本的函數,只有當它的依賴項改變時,函數才會被重新創建。也就是如果依賴沒有改變,函數引用保持不變,從而避免了因函數引用改變導致的不必要的重新渲染。

const cachedFn = useCallback(fn, dependencies)
  • fn: 想要緩存的函數;此函數可以接受任何參數並且可以返回任何值。在初次渲染時,React 將把函數返回給你(而不是調用它)
  • dependencies:依賴項;有關是否更新 fn 的所有響應式值的一個數組;跟 useEffectuseMemo 是一樣的。

返回你傳入的 fn 函數;在之後的渲染中, 如果依賴沒有改變,useCallback 返回上一次渲染中緩存的 fn 函數;否則返回這一次渲染傳入的 fn

下面我們用一個事例來演示;假設我們有一個 TodoList 組件,其中有一個 TodoItem 子組件:

import { useState } from 'react';

function TodoItem({ todo, onDelete }) {
    console.log('TodoItem render:', todo.id);
    return (
        <div>
            {todo.text}
            <button onClick={() => onDelete(todo.id)}>Delete</button>
        </div>
    );
}

function TodoList() {
    const [todos, setTodos] = useState([
        { id: 1, text: 'Learn React' },
        { id: 2, text: 'Learn useCallback' },
    ]);

    const handleDelete = id => {
        setTodos(todos => todos.filter(todo => todo.id !== id));
    };

    return (
        <div>
            {todos.map(todo => (
                <TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
            ))}
        </div>
    );
}

export default TodoList;

上述代碼中,每次 TodoList 重新渲染時,handleDelete 都會被重新創建,導致 TodoItem 也重新渲染。為了優化這一點,我們可以使用 useCallback

const handleDelete = useCallback(id => {
    setTodos(todos => todos.filter(todo => todo.id !== id));
}, []);

優化之後,handleDelete 只會在組件首次渲染時被創建一次。

useMemo 與 useCallback 的差異

  1. 用途與緩存的內容不同:

    • useMemo: 用於緩存複雜函數的計算結果或者構造的值。它返回緩存的結果。
    • useCallback: 用於緩存函數本身,確保函數的引用在依賴沒有改變時保持穩定。
  2. 底層關聯:

    從本質上説,useCallback(fn, deps) 就是 useMemo(() => fn, deps) 的語法糖:

    function useCallback(fn, dependencies) {
        return useMemo(() => fn, dependencies);
    }

那些場景下使用 useCallback

不是使用 useCallback 就能提升性能,以下場景就應該避免使用:

  • 過度優化:函數組件的重新渲染並不會帶來明顯的性能問題,過度使用useCallback可能會使代碼變得複雜且難以維護。
  • 簡單組件:對於沒有經過 React.memo 優化的子組件或者那些不會因為 prop 變化而重新渲染的組件,就沒必要使用 useCallback
  • 使代碼複雜化:如果使用 useCallback 僅僅是為了“可能會”有性能提升,而實際上並沒有明確的證據表明確實有性能問題,這可能會降低代碼的可讀性和可維護性。
  • 不涉及其它 Hooks 的函數:如果一個函數並不被用作其他 Hooks 的依賴,並且也不被傳遞給任何子組件,那麼沒有理由使用 useCallback

除此之外,還應該注意針對 useCallback 的依賴項的設計,警惕非必要依賴的混入,造成useCallback的效果大打折扣。例如這個非常典型的案例:

import React, { useState, useCallback } from 'react';

function Dependence() {
    const [todos, setTodos] = useState([]);
    const [inputValue, setInputValue] = useState('');

    const handleInputChange = event => {
        setInputValue(event.target.value);
    };

    const handleAddTodo = useCallback(
        text => {
            const newTodo = { id: Date.now(), text };
            setTodos(prevTodos => [...prevTodos, newTodo]);
        },
        [todos] // 這裏是問題所在,todos的依賴導致這個useCallback幾乎失去了其作用
    );

    return (
        <div>
            <input value={inputValue} onChange={handleInputChange} />
            <button onClick={() => handleAddTodo(inputValue)}>Add Todo</button>
            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>{todo.text}</li>
                ))}
            </ul>
        </div>
    );
}

export default Dependence;

在上面的示例中,每當 todos 改變,handleAddTodo 都會重新創建,儘管我們使用了 useCallback。這實際上並沒有給我們帶來預期的性能優化。正確的做法是利用 setTodos 的函數式更新,這樣我們就可以去掉 todos 依賴:

const handleAddTodo = useCallback(
    text => {
        const newTodo = { id: Date.now(), text };
        setTodos(prevTodos => [...prevTodos, newTodo]);
    },
    [] // 這裏是問題所在,todos的依賴導致這個useCallback幾乎失去了其作用
);

useCallback 所有的演示代碼都可以在 clin211/react-awesome 上查看,傳送門

useImperativeHandle

useImperativeHandle 是 React Hooks 中的一個特殊的 Hook,我們可以使用它來在父組件中直接操作子組件的實例方法。讓我們在開發過程中實現對組件的細粒度控制和精確的行為封裝。

通常情況下,我們不建議在函數組件中直接操作子組件的實例方法,因為這不符合 React 數據自頂向下(從父組件到子組件)流動的原則。但是在某些情況下,我們可能需要在父組件中直接調用子組件的某個方法,這時就可以使用 useImperativeHandle

useImperativeHandle 通常與 forwardRef 一起使用,以便將 ref 傳遞給函數組件。這是一個使用 useImperativeHandle 的例子:

import React, { useRef, useImperativeHandle, forwardRef } from 'react';

const ChildComponent = forwardRef((props, ref) => {
    useImperativeHandle(ref, () => ({
        sayHello() {
            alert('Hello from ChildComponent');
        },
    }));

    return <div>ChildComponent</div>;
});

function UseImperativeHandle() {
    const childRef = useRef();

    const handleClick = () => {
        childRef.current.sayHello();
    };

    return (
        <div>
            <ChildComponent ref={childRef} />
            <button onClick={handleClick}>Invoke Child Method</button>
        </div>
    );
}

export default UseImperativeHandle;

效果如下:

<img src="https://files.mdnice.com/user/8213/d4738a91-ad6f-4bb8-926a-ef6a6795d1c7.gif" style="border:1px solid rgb(222, 198, 251);border-radius: 8px" />

在這個例子中,ChildComponent 使用 useImperativeHandle 來暴露一個 sayHello 方法,這個方法可以在其父組件中通過 ref 被直接調用。當我們點擊 "Invoke Child Method" 按鈕時,就會調用 ChildComponent 中的 sayHello 方法,彈出一個包含 "Hello from ChildComponent" 的警告框。

useImperativeHandle 使 React 應用更強大更靈活,但是不應該為了靈活而過度使用。雖然 useImperativeHandle 鈎子能夠更好的封裝你想暴露的特定方法和屬性,還可以精確控制組件的行為;但也有一個侷限性:過度依賴 useImperativeHandle 可能會導致代碼難以理解和維護;如果依賴外部變量或狀態,還可能引起不必要的組件重新渲染;使用 useCallbackuseMemo 可以在一定程度上減少這樣的重新渲染。

useImperativeHanlde 鈎子的所有演示代碼都可以在 clin211/react-awesome 中查看。傳送門

自定義 Hooks

雖然 React 內置了一些 Hook,但有時,我們可能希望有一個特定目的的 Hook :例如獲取數據 useData,獲取連接 useConnect 等。雖然在 React 中找不到這些 Hooks,但 React 提供了非常靈活的方式讓你為自己的需求來創建自己的自定義 Hooks。

如何創建自定義 Hooks?

自定義 Hooks 在形式上其實非常簡單,就是聲明一個名字以 use 開頭的函數,比如 useCounter。這個函數在形式上和普通的 JavaScript 函數沒有任何區別,你可以傳遞任意參數給這個 Hook,也可以返回任何值。

Hooks 和普通函數在語義上是有區別的,就在於函數中有沒有用到其它 Hooks

看過上一篇文章的小夥伴可能會有印象,在上一篇文章用到的 useWindowSize 就是一個自定義 Hook,這裏再把它搬出來:

import { useState, useEffect } from 'react';

// 定義一個函數getSize,用於獲取當前窗口的大小。
const getSize = () => {
    // 如果窗口寬度大於500,則返回'large',否則返回'small'
    return window.innerWidth > 500 ? 'large' : 'small';
};

// 定義自定義Hook useWindowSize。
const useWindowSize = () => {
    // 使用useState Hook初始化窗口大小的狀態變量size和相應的設置函數setSize。
    const [size, setSize] = useState(getSize());

    // 使用useEffect Hook添加一個副作用函數,該函數在組件掛載和更新時執行。
    useEffect(() => {
        // 在副作用函數內部,定義一個處理函數handler,用於設置窗口大小的狀態。
        const handler = () => {
            setSize(getSize());
        };
        // 為窗口的resize事件添加處理函數handler。
        window.addEventListener('resize', handler);

        // 返回一個清理函數,在組件卸載前執行,用於移除窗口的resize事件監聽器。
        return () => {
            window.removeEventListener('resize', handler);
        };
    }, []); // 傳入空數組作為依賴列表,表示這個副作用函數只在組件掛載時執行一次。

    // 返回當前窗口的大小。
    return size;
};

export default useWindowSize; // 導出自定義Hook useWindowSize。

在組件中使用也很簡單:

// import ....

function App() {
    return (
        <h3>
            window size: {size}
            {size === 'small' ? <SmallComponent /> : <LargeComponent />}
        </h3>
    );
}

上面的例子就是把瀏覽器窗口的大小的邏輯提取了出來,成為了一個單獨的 Hook,一方面能讓這個邏輯重用,另一方面能讓放代碼更加語義化,並且易與理解和維護

自定義 Hook 的特點

從上面的例子中可以總結出自定義 Hook 的特點如下:

  1. 名字一定是以 use 開頭的函數,這樣 React 才能夠知道這個函數是一個 Hook;
  2. 函數內部一定調用了其它的 Hooks,可以是內置的 Hooks,也可以是其它自定義 Hooks。這樣才能夠讓組件刷新,或者去產生副作用。

自定義 Hook 的常用場景

  • 抽取業務邏輯
  • 封裝通用邏輯
  • 監聽瀏覽器狀態
  • 拆分複雜組件

開源的 React Hooks 庫

  • react-use: 一個必不可少的 React Hooks 集合。其包含了傳感器、用户界面、動畫效果、副作用、生命週期、狀態這六大類的Hooks。
  • ahooks: 一套由阿里巴巴開源的 React Hooks 庫,封裝了大量好用的 Hooks。
  • react-hook-form:一個功能強大,靈活且可擴展的表單,配備了易於使用的驗證功能。它可以在 React Web 和 React Native 中使用。
  • swr:一個用於獲取數據的 React Hooks 庫。只需一個 Hook,就可以顯着簡化項目中的數據獲取邏輯。
  • beautiful-react-hooks:可以顯著為你提升組件開發和 hooks 開發效率的一系列漂亮的 React hooks。
  • react-recipes:包含流行自定義鈎子的 React Hooks 實用程序庫。
  • rooks:一組基本的 React 自定義Hooks。

總結

我們對 React Hooks 的全面而深入的講解,包含了 useStateuseEffectuseContextuseMemouseCallbackuseRefuseImperativeHandle 以及如何創建自定義 Hook。還有幾個 Hook 並沒有在本文中介紹到,有了上面的基礎,學習另外的幾個 Hook,相信也不是什麼難事。

useStateuseEffect 是最基本的 Hooks,用於處理狀態和副作用。useContext 則是 React 的上下文 API 的 Hooks 版本,讓我們能夠更方便地在組件樹中共享狀態。useMemouseCallback 可以幫助我們優化性能,通過記憶複雜計算的結果和穩定回調函數的引用。useRefuseImperativeHandle 則是少數幾個能讓我們與 DOM 進行更直接交互的 Hooks。還介紹瞭如何創建自定義 Hooks 讓我們能複用組件邏輯,使代碼更加乾淨和可維護。最後,還推薦了社區比較流行的 Hooks 庫,用於提升我們的開發效率。

擴展閲讀:

  • Built-in React Hooks
  • Uncover the 12 Critical useState & useEffect Blunders Junior React Developers Can’t Afford to Make in 2024!
user avatar tianmiaogongzuoshi_5ca47d59bef41 頭像 chengxuyuanlaoliu2024 頭像 cyzf 頭像 haoqidewukong 頭像 zaotalk 頭像 nihaojob 頭像 freeman_tian 頭像 dirackeeko 頭像 aqiongbei 頭像 leexiaohui1997 頭像 longlong688 頭像 huajianketang 頭像
點贊 186 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.