• <noscript id="e0iig"><kbd id="e0iig"></kbd></noscript>
  • <td id="e0iig"></td>
  • <option id="e0iig"></option>
  • <noscript id="e0iig"><source id="e0iig"></source></noscript>
  • React Hooks 最佳實踐

    標簽: 編程語言  python  java  面試  javascript

    ?

    本文由 網易云音樂前端團隊[1] 授權轉發。網易云團隊持續招人中,如果你恰好準備換工作,又恰好喜歡云音樂,那就請發送簡歷到 [email protected] 或發送簡歷到微信 「docschina-bot」

    ?

    寫在前面

    在過去的幾個月里,React Hooks 在我們的項目中得到了充分利用。在實際使用過程中,我發現 React Hooks 除了帶來簡潔的代碼外,也存在對其使用不當的情況。

    在這篇文章中,我想總結我過去幾個月來對 React Hooks 使用,分享我對它的看法以及我認為的最佳實踐,供大家參考。

    本文假定讀者已經對 React-Hooks 及其使用方式有了初步的了解。您可以通過 官方文檔[2] 進行學習。

    函數式組件

    簡而言之,就是在一個函數中返回 React Element。

    const App = (props) => {
        const { title } = props;
        return (
            <h1>{title}</h1>
        );  
    };
    
    

    一般的,該函數接收唯一的參數:props 對象。從該對象中,我們可以讀取到數據,并通過計算產生新的數據,最后返回 React Elements 以交給 React 進行渲染。此外也可以選擇在函數中執行副作用。

    在本文中,我們給函數式組件的函數起個簡單一點的名字:render 函數。

    const appElement = App({ title: "XXX" });
    ReactDOM.render(
        appElement,
        document.getElementById('app')
    );
    

    在上方的代碼中,我們自行調用了 render 函數以期執行渲染。然而這在 React 中不是正常的操作。

    正常操作是像下方這樣的代碼:

    // React.createElement(App, {
    //     title: "XXX"
    // });
    const appElement = <App title="XXX" />;
    ReactDOM.render(
        appElement,
        document.getElementById('app')
    );
    

    在 React 內部,它會決定在何時調用 render 函數,并對返回的 React Elements 進行遍歷,如果遇到函數組件,React 便會繼續調用這個函數組件。在這個過程中,可以由父組件通過 props 將數據傳遞到該子組件中。最終 React 會調用完所有的組件,從而知曉如何進行渲染。

    這種把 render 函數交給 React 內部處理的機制,為引入狀態帶來了可能。

    在本文中,為了方便描述,對于 render 函數的每次調用,我想稱它為一幀。

    每一幀擁有獨立的變量

    在引入狀態之前,我們需要明白這一點。

    我們通過 例一 進行觀察:

    Edit 1. 每一幀擁有獨立的變量
    function Example(props) {
        const { count } = props;
        const handleClick = () => {
            setTimeout(() => {
                alert(count);
            }, 3000);
        };
        return (
            <div>
                <p>{count}</p>
                <button onClick={handleClick}>Alert Count</button>
            </div>
        );
    }
    

    重點關注 <Example> 函數組件的代碼,其中的 count 屬性由父組件傳入,初始值為 0,每隔一秒增加 1。點擊 "Alert Count" 按鈕,將延遲 3 秒鐘彈出 count 的值。操作后發現,彈窗中出現的值,與頁面中文本展示的值不同,而是等于點擊 "alert Count" 按鈕時 count 的值。

    如果更換為 class 組件,它的實現是 <Example2> 這樣的:

    class Example2 extends Component {
        handleClick = () => {
            setTimeout(() => {
                alert(this.props.count);
            }, 3000);
        };
    
        render() {
            return (
                <div>
                    <h2>Example2</h2>
                    <p>{this.props.count}</p>
                    <button onClick={this.handleClick}>Alert Count</button>
                </div>
            );
        }
    }
    

    此時,點擊 "Alert Count" 按鈕,延遲 3 秒鐘彈出 count 的值,與頁面中文本展示的值是一樣的。

    在某些情況下,<Example> 函數組件中的行為才符合預期。如果將 setTimeout 類比到一次 Fetch 請求,在請求成功時,我要獲取的是發起 Fetch 請求前相關的數據,并對其進行修改。

    如何理解其中的差異呢?

    <Example2> class 組件中,我們是從 this 中獲取到的 props.countthis 是固定指向同一個組件實例的。在 3 秒的延時器生效后,組件重新進行了渲染,this.props 也發生了改變。當延時的回調函數執行時,讀取到的 this.props 是當前組件最新的屬性值。

    而在 <Example> 函數組件中,每一次執行 render 函數時,props 作為該函數的參數傳入,它是函數作用域下的變量。

    <Example> 組件被創建,將運行類似這樣的代碼來完成第一幀:

    const props_0 = { count: 0 };
    
    const handleClick_0 = () => {
        setTimeout(() => {
            alert(props_0.count);
        }, 3000);
    };
    return (
        <div>
            <h2>Example</h2>
            <p>{props_0.count}</p>
            <button onClick={handleClick_0}>alert Count</button>
        </div>
    );
    

    當父組件傳入的 count 變為 1,React 會再次調用 Example 函數,執行第二幀,此時 count1

    const props_1 = { count: 1 };
    
    const handleClick_1 = () => {
        setTimeout(() => {
            alert(props_1.count);
        }, 3000);
    };
    return (
        <div>
            <h2>Example</h2>
            <p>{props_1.count}</p>
            <button onClick={handleClick_1}>alert Count</button>
        </div>
    );
    

    由于 propsExample 函數作用域下的變量,可以說對于這個函數的每一次調用中,都產生了新的 props 變量,它在聲明時被賦予了當前的屬性,他們相互間互不影響。

    換一種說法,對于其中任一個 props ,其值在聲明時便已經決定,不會隨著時間產生變化。handleClick 函數亦是如此。例如定時器的回調函數是在未來發生的,但 props.count 的值是在聲明 handleClick 函數時就已經決定好的。

    如果我們在函數開頭使用解構賦值,const { count } = props,之后直接使用 count,和上面的情況沒有區別。

    狀態

    可以簡單的認為,在某個組件中,對于返回的 React Elements 樹形結構,某個位置的 element ,其類型與 key 屬性均不變,React 便會選擇重用該組件實例;否則,比如從 <A/> 組件切換到了 <B/> 組件,會銷毀 A,然后重建 B,B 此時會執行第一幀。

    在實例中,可以通過 useState 等方式擁有局部狀態。在重用的過程中,這些狀態會得到保留。而如果無法重用,狀態會被銷毀。

    例如 useState,為當前的函數組件創建了一個狀態,這個狀態的值獨立于函數存放。useState 會返回一個數組,在該數組中,得到該狀態的值和更新該狀態的方法。通過解構,該狀態的值會賦值到當前 render 函數作用域下的一個常量 state 中。

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

    當組件被創建而不是重用時,即在組件的第一幀中,該狀態將被賦予初始值 initialState,而之后的重用過程中,不會被重復賦予初始值。

    通過調用 setState ,可以更新狀態的值。

    每一幀擁有獨立的狀態

    需要明確的是,state 作為函數中的一個常量,就是普通的數據,并不存在諸如數據綁定這樣的操作來驅使 DOM 發生更新。在調用 setState 后,React 將重新執行 render 函數,僅此而已。

    因此,狀態也是函數作用域下的普通變量。我們可以說每次函數執行擁有獨立的狀態。

    為了加深印象,我們來看 例二,它是 React 官網某個例子的復雜化:

    Edit 每一幀擁有獨立的狀態
    function Example2() {
        const [count, setCount] = useState(0);
    
        const handleClick = () => {
            setTimeout(() => {
                setCount(count + 1);
            }, 3000);
        };
    
        return (
            <div>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>
                    setCount
                </button>
                <button onClick={handleClick}>
                    Delay setCount
                </button>
            </div>
        );
    }
    

    在第一幀中,p 標簽中的文本為 0。點擊 "Delay setCount",文本依然為 0。隨后在 3 秒內連續點擊 "setCount" 兩次,將會分別執行第二幀和第三幀。你將看到 p 標簽中的文本由 0 變化為 1, 2。但在點擊 "Delay setCount" 3 秒后,文本重新變為 1。

    // 第一幀
    const count_1 = 0;
    
    const handleClick_1 = () => {
        const delayAction_1 = () => {
            setCount(count_1 + 1);
        };
        setTimeout(delayAction_1, 3000);
    };
    
    //...
    <button onClick={handleClick_1}>
    //...
    
    // 點擊 "setCount" 后第二幀
    const count_2 = 1;
    
    const handleClick_2 = () => {
        const delayAction_2 = () => {
            setCount(count_2 + 1);
        };
        setTimeout(delayAction_2, 3000);
    };
    
    //...
    <button onClick={handleClick_2}>
    //...
    
    // 再次點擊 "setCount" 后第三幀
    const count_3 = 2;
    
    const handleClick_3 = () => {
        const delayAction_3 = () => {
            setCount(count_3 + 1);
        };
        setTimeout(delayAction_3, 3000);
    };
    
    //...
    <button onClick={handleClick_3}>
    //...
    

    counthandleClick 都是 Example2 函數作用域中的常量。在點擊 "Delay setCount" 時,定時器設置 3000ms 到期后的執行函數為 delayAction_1,函數中讀取 count_1 常量的值是 0,這和第二幀的 count_2 無關。

    獲取過去或未來幀中的值

    對于 state,如果想要在第一幀時點擊 "Delay setCount" ,在一個異步回調函數的執行中,獲取到 count 最新一幀中的值,不妨向 setCount 傳入函數作為參數[3]

    其他情況下,例如需要讀取到 state 及其衍生的某個常量,相對于變量聲明時所在幀過去或未來的值,就需要使用 useRef,通過它來擁有一個在所有幀中共享的變量。

    如果要與 class 組件進行比較,useRef 的作用相對于讓你在 class 組件的 this 上追加屬性。

    const refContainer = useRef(initialValue);
    

    在組件的第一幀中,refContainer.current 將被賦予初始值 initialValue,之后便不再發生變化。但你可以自己去設置它的值。設置它的值不會重新觸發 render 函數。

    例如,我們把第 n 幀的某個 props 或者 state 通過 useRef 進行保存,在第 n + 1 幀可以讀取到過去的,第 n 幀中的值。我們也可以在第 n + 1 幀使用 ref 保存某個 props 或者 state,然后在第 n 幀中聲明的異步回調函數中讀取到它。

    例二 進行修改,得到 例三,看看具體的效果:

    Edit 獲取過去或未來幀中的值
    function Example() {
        const [count, setCount] = useState(0);
    
        const currentCount = useRef(count);
    
        currentCount.current = count;
    
        const handleClick = () => {
            setTimeout(() => {
                setCount(currentCount.current + 1);
            }, 3000);
        };
    
        return (
            <div>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>
                    setCount
                </button>
                <button onClick={handleClick}>
                    Delay setCount
                </button>
            </div>
        );
    }
    

    setCount 后便會執行下一幀,在函數的開頭,currentCount 始終與最新的 count state 保持同步。因此,在 setTimeout 中可以通過此方法獲取到回調函數執行時當前的 count 值。

    接下來再通過 例四 了解如何獲取過去幀中的值:

    Edit 獲取過去幀中的值
    function Example4() {
        const [count, setCount] = useState(1);
    
        const prevCountRef = useRef(1);
        const prevCount = prevCountRef.current;
        prevCountRef.current = count;
    
        const handleClick = () => {
            setCount(prevCount + count);
        };
    
        return (
            <div>
                <p>{count}</p>
                <button onClick={handleClick}>SetCount</button>
            </div>
        );
    }
    

    這段代碼實現的功能是,count 初始值為 1,點擊按鈕后累加到 2,隨后點擊按鈕,總是用當前 count 的值和前一個 count 的值進行累加,得到新的 count 的值。

    prevCountRef 在 render 函數執行的過程中,與最新的 count state 進行了同步。由于在同步前,我們將該 ref 保存到函數作用域下的另一個變量 prevCount 中,因此我們總是能夠獲取到前一個 count 的值。

    同樣的方法,我們可以用于保存任何值:某個 prop,某個 state 變量,甚至一個函數等。在后面的 Effects 部分,我們會繼續使用 refs 為我們帶來好處。

    每一幀可以擁有獨立的 Effects

    如果弄清了前面的『每一幀擁有獨立的變量』的概念,你會發現,若某個 useEffect/useLayoutEffect 有且僅有一個函數作為參數,那么每次 render 函數執行時該 Effects 也是獨立的。因為它是在 render 函數中選擇適當時機的執行。

    對于 useEffect 來說,執行的時機是完成所有的 DOM 變更并讓瀏覽器渲染頁面后,而 useLayoutEffect 和 class 組件中 componentDidMount, componentDidUpdate一致——在 React 完成 DOM 更新后馬上同步調用,會阻塞頁面渲染。

    如果 useEffect 沒有傳入第二個參數,那么第一個參數傳入的 effect 函數在每次 render 函數執行是都是獨立的。每個 effect 函數中捕獲的 props 或 state 都來自于那一次的 render 函數。

    我們可以再觀察一個例子:

    function Counter() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
            setTimeout(() => {
                console.log(`You clicked ${count} times`);
            }, 3000);
        });
    
        return (
            <div>
                <p>You clicked {count} times</p>
                <button onClick={() => setCount(count + 1)}>
                    Click me
            </button>
            </div>
        );
    }
    

    在這個例子中,每一次對 count 進行改變,重新執行 render 函數后,延遲 3 秒打印 count 的值。

    如果我們不停地點擊按鈕,打印的結果是什么呢?

    我們發現經過延時后,每個 count 的值被依次打印了,他們從 0 開始依次遞增,且不重復。

    如果換成 class 組件,嘗試使用 componentDidUpdate 去實現,會得到不一樣的結果:

    componentDidUpdate() {
        setTimeout(() => {
            console.log(`You clicked ${this.state.count} times`);
        }, 3000);
    }
    

    this.state.count 總是指向最新的 count 值,而不是屬于某次調用 render 函數時的值。

    因此,在使用 useEffect 時,應當拋開在 class 組件中關于生命周期的思維。他們并不相同。在 useEffect 中刻意尋找那幾個生命周期函數的替代寫法,將會陷入僵局,無法充分發揮 useEffect 的能力。

    在比對中執行 Effects

    React 針對 React Elements 前后值進行對比,只去更新 DOM 真正發生改變的部分。對于 Effects,能否有類似這樣的理念呢?

    某個 Effects 函數一旦執行,函數內的副作用已經發生,React 無法猜測到函數相比于上一次做了哪些變化。但我們可以給 useEffect 傳入第二個參數,作為依賴數組 (deps),避免 Effects 不必要的重復調用。

    這個 deps 的含義是:當前 Effect 依賴了哪些變量。

    但有時問題不一定能解決。比如官網就有 這樣的例子[4]

    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const id = setInterval(() => {
            setCount(count + 1);
        }, 1000);
        return () => clearInterval(id);
    }, [count]);
    

    如果我們頻繁修改 count,每次執行 Effect,上一次的計時器被清除,需要調用 setInterval 重新進入時間隊列,實際的定期時間被延后,甚至有可能根本沒有機會被執行。

    但是下面這樣的實踐方式也不宜采用:

    在 Effect 函數中尋找一些變量添加到 deps 中,需要滿足條件:其變化時,需要重新觸發 effect。

    按照這種實踐方式,count 變化時,我們并不希望重新 setInterval,故 deps 為空數組。這意味著該 hook 只在組件掛載時運行一次。Effect 中明明依賴了 count,但我們撒謊說它沒有依賴,那么當 setInterval 回調函數執行時,獲取到的 count 值永遠為 0。

    遇到這種問題,直接從 deps 移除是不可行的。靜下來分析一下,此處為什么要用到 count?能否避免對其直接使用?

    可以看到,在 setCount 中用到了 count,為的是把 count 轉換為 count + 1 ,然后返回給 React。React 其實已經知道當前的 count,我們需要告知 React 的僅僅是去遞增狀態,不管它現在具體是什么值。

    所以有一個最佳實踐:狀態變更時,應該通過 setState 的函數形式來代替直接獲取當前狀態。

    setCount(c => c + 1);
    

    另外一種場景是:

    const [count, setCount] = useState(0);
    
    useEffect(() => {
        const id = setInterval(() => {
            console.log(count);
        }, 1000);
        return () => clearInterval(id);
    }, []);
    

    在這里,同樣的,當count 變化時,我們并不希望重新 setInterval。但我們可以把 count 通過 ref 保存起來。

    const [count, setCount] = useState(0);
    const countRef = useRef();
    countRef.current = count;
    
    useEffect(() => {
        const id = setInterval(() => {
            console.log(countRef.current);
        }, 1000);
        return () => clearInterval(id);
    }, []);
    

    這樣,count 的確不再被使用,而是用 ref 存儲了一個在所有幀中共享的變量。

    另外的情況是,Effects 依賴了函數或者其他引用類型。與原始數據類型不同的是,在未優化的情況下,每次 render 函數調用時,因為對這些內容的重新創建,其值總是發生了變化,導致 Effects 在使用 deps 的情況下依然會頻繁被調用。

    對于這個問題,官網的 FAQ[5] 已經給出了答案:對于函數,使用 useCallback 避免重復創建;對于對象或者數組,則可以使用 useMemo。從而減少 deps 的變化。

    使用 ESLint 插件

    使用 ESLint 插件 eslint-plugin-react-hooks@>=2.4.0,很有必要。

    該插件除了幫你檢查使用 Hook 需要遵循的兩條規則[6]外,還會向你提示在使用 useEffect 或者 useMemo 時,deps 應該填入的內容。

    如果你正在使用 VSCode,并且安裝了 ESLint 擴展。當你編寫 useEffect 或者 useMemo ,且 deps 中的內容并不完整時,deps 所在的那一行便會給出警告或者錯誤的提示,并且會有一個快速修復的功能,該功能會為你自動填入缺失的 deps。

    對于這些提示,不要暴力地通過 eslint-disable 禁用。未來,你可能再次修改該 useEffect 或者 useMemo,如果使用了新的依賴并且在 deps 中漏掉了它,便會引發新的問題。有一些場景,比如 useEffect 依賴一個函數,并且填入 deps 了。但是這個函數使用了 useCallback 且 deps 出現了遺漏,這種情況下一旦出現問題,排查的難度會很大,所以為什么要讓 ESLint 沉默呢?

    嘗試用上一節的方法進行分析,對于一些變量不希望引起 effect 重新更新的,使用 ref 解決。對于獲取狀態用于計算新的狀態的,嘗試 setState 的函數入參,或者使用 useReducer 整合多個類型的狀態。

    使用 useMemo/useCallback

    useMemo 的含義是,通過一些變量計算得到新的值。通過把這些變量加入依賴 deps,當 deps 中的值均未發生變化時,跳過這次計算。useMemo 中傳入的函數,將在 render 函數調用過程被同步調用。

    可以使用 useMemo 緩存一些相對耗時的計算。

    除此以外,useMemo 也非常適合用于存儲引用類型的數據,可以傳入對象字面量,匿名函數等,甚至是 React Elements。

    const data = useMemo(() => ({
        a,
        b,
        c,
        d: 'xxx'
    }), [a, b, c]);
    
    // 可以用 useCallback 代替
    const fn = useMemo(() => () => {
        // do something
    }, [a, b]);
    
    const memoComponentsA = useMemo(() => (
        <ComponentsA {...someProps} />
    ), [someProps]);
    

    在這些例子中,useMemo 的目的其實是盡量使用緩存的值。

    對于函數,其作為另外一個 useEffect 的 deps 時,減少函數的重新生成,就能減少該 Effect 的調用,甚至避免一些死循環的產生;

    對于對象和數組,如果某個子組件使用了它作為 props,減少它的重新生成,就能避免子組件不必要的重復渲染,提升性能。

    未優化的代碼如下:

    const data = { id };
    
    return <Child data={data}>;
    

    此時,每當父組件需要 render 時,子組件也會執行 render。如果使用 useMemo 對 data 進行優化:

    const data = useMemo(() => ({ id }), [id]);
    
    return <Child data={data}>;
    

    當父組件 render 時,只要滿足 id 不變,data 的值也不會發生變化,子組件也將避免 render。

    對于組件返回的 React Elements,我們可以選擇性地提取其中一部分 elements,通過 useMemo 進行緩存,也能避免這一部分的重復渲染。

    在過去的 class 組件中,我們通過 shouldComponentUpdate 判斷當前屬性和狀態是否和上一次的相同,來避免組件不必要的更新。其中的比較是對于本組件的所有屬性和狀態而言的,無法根據 shouldComponentUpdate 的返回值來使該組件一部分 elements 更新,另一部分不更新。

    為了進一步優化性能,我們會對大組件進行拆分,拆分出的小組件只關心其中一部分屬性,從而有更多的機會不去更新。

    而函數組件中的 useMemo 其實就可以代替這一部分工作。為了方便理解,我們來看 例五

    Edit 使用 useMemo 緩存 React Elements
    function Example(props) {
        const [count, setCount] = useState(0);
        const [foo] = useState("foo");
    
        const main = (
            <div>
                <Item key={1} x={1} foo={foo} />
                <Item key={2} x={2} foo={foo} />
                <Item key={3} x={3} foo={foo} />
                <Item key={4} x={4} foo={foo} />
                <Item key={5} x={5} foo={foo} />
            </div>
        );
    
        return (
            <div>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>setCount</button>
                {main}
            </div>
        );
    }
    

    假設 <Item> 組件,其自身的 render 消耗較多的時間。默認情況下,每次 setCount 改變 count 的值,便會重新對 <Example> 進行 render,其返回的 React Elements 中3個 <Item> 也重新 render,其耗時的操作阻塞了 UI 的渲染。導致按下 "setCount" 按鈕后出現了明顯的卡頓。

    為了優化性能,我們可以將 main 變量這一部分單獨作為一個組件 <Main>,拆分出去,并對  <Main> 使用諸如 React.memo , shouldComponentUpdate 的方式,使 count 屬性變化時,<Main> 不重復 render。

    const Main = React.memo((props) => {
        const { foo }= props;
        return (
            <div>
                <Item key={1} x={1} foo={foo} />
                    <Item key={2} x={2} foo={foo} />
                    <Item key={3} x={3} foo={foo} />
                    <Item key={4} x={4} foo={foo} />
                    <Item key={5} x={5} foo={foo} />
            </div>
        );
    });
    

    而現在,我們可以使用 useMemo,避免了組件拆分,代碼也更簡潔易懂:

    function Example(props) {
        const [count, setCount] = useState(0);
        const [foo] = useState("foo");
    
        const main = useMemo(() => (
            <div>
                <Item key={1} x={1} foo={foo} />
                <Item key={2} x={2} foo={foo} />
                <Item key={3} x={3} foo={foo} />
                <Item key={4} x={4} foo={foo} />
                <Item key={5} x={5} foo={foo} />
            </div>
        ), [foo]);
    
        return (
            <div>
                <p>{count}</p>
                <button onClick={() => setCount(count + 1)}>setCount</button>
                {main}
            </div>
        );
    }
    

    惰性初始值

    對于 state,其擁有 惰性初始化的方法[7]。可能有人不明白它的作用。

    someExpensiveComputation 是一個相對耗時的操作。如果我們直接采用

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

    注意,雖然 initialState 只在初始化時有其存在的價值,但是 someExpensiveComputation 在每一幀都被調用了。只有當使用惰性初始化的方法:

    const [state, setState] = useState(() => {
        const initialState = someExpensiveComputation(props);
        return initialState;
    });
    

    someExpensiveComputation 運行在一個匿名函數下,該函數當且僅當初始化時被調用,從而優化性能。

    我們甚至可以跳出計算 state 這一規定,來完成任何昂貴的初始化操作。

    useState(() => {
        someExpensiveComputation(props);
        return null;
    });
    

    避免濫用 refs

    useEffect 的依賴頻繁變化,你可能想到把頻繁變化的值用 ref 保存起來。然而,useReducer 可能是更好的解決方式:使用 dispatch 消除對一些狀態的依賴。官網的 FAQ[8] 有詳細的解釋。

    最終可以總結出這樣的實踐:

    useEffect 對于函數依賴,嘗試將該函數放置在 effect 內,或者使用 useCallback 包裹;useEffect/useCallback/useMemo,對于 state 或者其他屬性的依賴,根據 eslint 的提示填入 deps;如果不直接使用 state,只是想修改 state,用 setState 的函數入參方式(setState(c => c + 1))代替;如果修改 state 的過程依賴了其他屬性,嘗試將 state 和屬性聚合,改寫成 useReducer 的形式。當這些方法都不奏效,使用 ref,但是依然要謹慎操作。

    避免濫用 useMemo

    使用 useMemo 當 deps 不變時,直接返回上一次計算的結果,從而使子組件跳過渲染。

    但是當返回的是原始數據類型(如字符串、數字、布爾值)。即使參與了計算,只要 deps 依賴的內容不變,返回結果也很可能是不變的。此時就需要權衡這個計算的時間成本和 useMemo 額外帶來的空間成本(緩存上一次的結果)了。

    此外,如果 useMemo 的 deps 依賴數組為空,這樣做說明你只是希望存儲一個值,這個值在重新 render 時永遠不會變。

    比如:

    const Comp = () => {
        const data = useMemo(() => ({ type: 'xxx' }), []);
        return <Child data={data}>;
    }
    

    可以被替換為:

    const Comp = () => {
        const { current: data } = useRef({ type: 'xxx' });
        return <Child data={data}>;
    }
    

    甚至:

    const data = { type: 'xxx' };
    const Comp = () => {
        return <Child data={data}>;
    }
    

    此外,如果 deps 頻繁變動,我們也要思考,使用 useMemo 是否有必要。因為 useMemo 占用了額外的空間,還需要在每次 render 時檢查 deps 是否變動,反而比不使用 useMemo 開銷更大。

    受控與非受控

    在一個自定義 Hooks,我們可能有這樣一段邏輯:

    useSomething = (inputCount) => {
        const [ count, setCount ] = setState(inputCount);
    };
    

    這里有一個問題,外部傳入的 inputCount 屬性發生了變化,使其與 useSomething Hook 內的 count state 不一致時,是否想要更新這個 count

    默認不會更新,因為 useState 參數代表的是初始值,僅在 useSomething 初始時賦值給了 count state。后續 count 的狀態將與 inputCount 無關。這種外部無法直接控制 state 的方式,我們稱為非受控。

    如果想被外部傳入的 props 始終控制,比如在這個例子中,useSomething 內部,count 這一 state 的值需要從 inputCount 進行同步,需要這樣寫:

    useSomething = (inputCount) => {
        const [ count, setCount ] = setState(inputCount);
        setCount(inputCount);
    };
    

    setCount后,React 會立即退出當前的 render 并用更新后的 state 重新運行 render 函數。這一點,官網文檔[9] 是有說明的。

    在這種的機制下,state 由外界同步的同時,內部又有可能通過 setState 來修改 state,可能引發新的問題。例如 useSomething 初始時,count 為 0,后續內部通過 setCount 修改了 count 為 1。當外部函數組件的 render 函數重新調用,也會再一次調用 useSomething,此時傳入的 inputCount 依然是 0,就會把 count 變回 0。這很可能不符合預期。

    遇到這樣的問題,建議將 inputCount 的當前值與上一次的值進行比較,只有確定發生變化時執行 setCount(inputCount)

    當然,在特殊的場景下,這樣的設定也不一定符合需求。官網的這篇文章[10] 有提出類似的問題。

    實踐:useSlider

    通過一個滑動選擇器自定義 hook userSlider 的實現,我們可以回答上面的這個問題,順便對本文做一個總結。

    image

    userSlider 需要實現的邏輯是:按住滑動選擇器的圓形手柄區域并拖動可以調節數值大小,數值范圍為 0 到 1。

    userSlider 只負責邏輯的實現,UI 樣式由組件自行完成。為了模擬真實業務,另外通過文本展示了當前的數值。并有幾個按鈕用于切換數值的初始值,這是為了切換分類后,當前的滑動選擇器需要重置到某個數值。

    按照常規的邏輯,我們實現了以下代碼:

    Edit useSlider 問題

    當前的問題是,useEffect 涉及到多個 state 的獲取與計算。導致鼠標按下、移動、彈起的幾個操作中因為對 stata 的修改,useEffect 頻繁刷新,且涉及到了鼠標按下、移動、彈起事件監聽的取消與重新綁定,這帶來了性能問題以及較難觀察到的 BUG。

    和前面的 setInterval 例子相似,我們不希望在狀態變動時,刷新 useEffect。由于此處涉及到多個狀態:是否滑動中、鼠標位置、上一次鼠標的問題、選擇器的可滑動寬度,如果整合到一個 state 中,會面臨代碼不清晰,缺少內聚性的問題,我們嘗試用 useReducer 做一次替換。

    const reducer = (state, action) => {
        switch (action.type) {
            case "start":
                return {
                    ...state,
                    lastPos: action.x,
                    slideRange: action.slideWidth,
                    sliding: true
                };
            case "move": {
                if (!state.sliding) {
                    return state;
                }
                const pos = action.x;
                const delta = pos - state.lastPos;
                return {
                    ...state,
                    lastPos: pos,
                    ratio: fixRatio(state.ratio + delta / state.slideRange)
                };
            }
            case "end": {
                if (!state.sliding) {
                    return state;
                }
                const pos = action.x;
                const delta = pos - state.lastPos;
                return {
                    ...state,
                    lastPos: pos,
                    ratio: fixRatio(state.ratio + delta / state.slideRange),
                    sliding: false
                };
            }
            default:
                return state;
        }
    };
    
    //...
    
    const handleThumbMouseDown = useCallback(ev => {
        const hotArea = hotAreaRef.current;
        dispatch({
            type: "start",
            x: ev.pageX
          slideWidth: hotArea.clientWidth
        });
    }, []);
    
    useEffect(() => {
        const onSliding = ev => {
            dispatch({
                type: "move",
                x: ev.pageX
            });
        };
        const onSlideEnd = ev => {
            dispatch({
                type: "end",
                x: ev.pageX
            });
        };
        document.addEventListener("mousemove", onSliding);
        document.addEventListener("mouseup", onSlideEnd);
    
        return () => {
            document.removeEventListener("mousemove", onSliding);
            document.removeEventListener("mouseup", onSlideEnd);
        };
    }, []);
    

    這樣處理后,effect 只要執行一次即可。

    接下來還有一個問題沒有處理,目前 initRatio 是作為初始值傳入的,useSlider 內部的 ratio 是不受外部控制的。

    以一個音樂均衡器的設置為例:當前滑動選擇器代表的是低頻端(31)的增益值,用戶通過拖動滑塊可以設置這個值的大小(-12 到 12 dB 范圍,我們設置到了 3 dB)。同時我們提供了一些預設選項,一旦選擇預設選項,如『流行』風格,當前滑塊需要重置到特定值 -1 dB。為此, useSlider 需要提供控制狀態的方法。

    image

    根據前一節的介紹,在 useSlider 的開頭,我們可以將屬性 initRatio 的當前值與上一次的值進行比較,若發生變化,則執行 setRatio。但仍然有場景無法滿足:用戶選擇了『流行』這一預設,然后拖動滑塊進行了調節,之后又重新選擇『流行』這一預設,此時 initRatio 沒有任何變化,但我們期望 ratio 重新變為 initRatio

    解決這個問題的辦法是,在 useSlider 內部添加一個 setRatio 方法。

    const setRatio = useCallback(
        ratio =>
            dispatch({
                type: "setRatio",
                ratio
            }),
        []
    );
    

    將該方法輸出供外部用于對 ratio 控制。initRatio 不再控制 ratio 的狀態,僅用于設置初始值。

    可以看下最終的實現方案:

    Edit useSlider 最終版

    該方案中,除了完成以上需求,還支持在選擇器的其他區域點擊直接跳轉到對應的數值;支持設定選擇器為垂直還是水平方向。供大家參考。

    結束語

    忘掉 class 組件的生命周期,重新審視函數式組件的意義,是用好 React Hooks 的關鍵一步。希望這篇文章能幫助大家進一步理解并獲取到一些最佳實踐。當然,不同的 React Hooks 使用姿勢可能帶來不同的最佳實踐,歡迎大家交流。

    相關資料

    • A Complete Guide to useEffect[11]

    • 官方文檔[12]

    Reference

    [1]

    網易云音樂前端團隊: https://github.com/x-orpheus

    [2]

    官方文檔: https://zhreactjs.org/docs/hooks-intro.html

    [3]

    傳入函數作為參數: https://reactjs.org/docs/hooks-reference.html#functional-updates

    [4]

    這樣的例子: https://zh-hans.reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often

    [5]

    官網的 FAQ: https://zh-hans.reactjs.org/docs/hooks-faq.html#is-it-safe-to-omit-functions-from-the-list-of-dependencies

    [6]

    使用 Hook 需要遵循的兩條規則: https://zh-hans.reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

    [7]

    惰性初始化的方法: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state

    [8]

    官網的 FAQ: https://zh-hans.reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often

    [9]

    官網文檔: https://zh-hans.reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops

    [10]

    官網的這篇文章: https://zh-hans.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html

    [11]

    A Complete Guide to useEffect: https://overreacted.io/a-complete-guide-to-useeffect/

    [12]

    官方文檔: https://zh-reactjs.org/docs/hooks-intro.html

    • 最后

    • 歡迎關注「前端瓶子君」,回復「交流」加入前端交流群!

    • 歡迎關注「前端瓶子君」,回復「算法」自動加入,從0到1構建完整的數據結構與算法體系!

    • 在這里,瓶子君不僅介紹算法,還將算法與前端各個領域進行結合,包括瀏覽器、HTTP、V8、React、Vue源碼等。

    • 在這里(算法群),你可以每天學習一道大廠算法編程題(阿里、騰訊、百度、字節等等)或 leetcode,瓶子君都會在第二天解答喲!

    • 另外,每周還有手寫源碼題,瓶子君也會解答喲!

    • 》》面試官也在看的算法資料《《

    • “在看和轉發”就是最大的支持

    版權聲明:本文為lunahaijiao原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接和本聲明。
    本文鏈接:https://blog.csdn.net/lunahaijiao/article/details/107421097

    智能推薦

    react hooks入門-useState

    Hooks全部入門 useState useEffect 1. useState 效果展示 2 useReducer...

    HTML中常用操作關于:頁面跳轉,空格

    1.頁面跳轉 2.空格的代替符...

    freemarker + ItextRender 根據模板生成PDF文件

    1. 制作模板 2. 獲取模板,并將所獲取的數據加載生成html文件 2. 生成PDF文件 其中由兩個地方需要注意,都是關于獲取文件路徑的問題,由于項目部署的時候是打包成jar包形式,所以在開發過程中時直接安照傳統的獲取方法沒有一點文件,但是當打包后部署,總是出錯。于是參考網上文章,先將文件讀出來到項目的臨時目錄下,然后再按正常方式加載該臨時文件; 還有一個問題至今沒有解決,就是關于生成PDF文件...

    電腦空間不夠了?教你一個小秒招快速清理 Docker 占用的磁盤空間!

    Docker 很占用空間,每當我們運行容器、拉取鏡像、部署應用、構建自己的鏡像時,我們的磁盤空間會被大量占用。 如果你也被這個問題所困擾,咱們就一起看一下 Docker 是如何使用磁盤空間的,以及如何回收。 docker 占用的空間可以通過下面的命令查看: TYPE 列出了docker 使用磁盤的 4 種類型: Images:所有鏡像占用的空間,包括拉取下來的鏡像,和本地構建的。 Con...

    requests實現全自動PPT模板

    http://www.1ppt.com/moban/ 可以免費的下載PPT模板,當然如果要人工一個個下,還是挺麻煩的,我們可以利用requests輕松下載 訪問這個主頁,我們可以看到下面的樣式 點每一個PPT模板的圖片,我們可以進入到詳細的信息頁面,翻到下面,我們可以看到對應的下載地址 點擊這個下載的按鈕,我們便可以下載對應的PPT壓縮包 那我們就開始做吧 首先,查看網頁的源代碼,我們可以看到每一...

    猜你喜歡

    Linux C系統編程-線程互斥鎖(四)

    互斥鎖 互斥鎖也是屬于線程之間處理同步互斥方式,有上鎖/解鎖兩種狀態。 互斥鎖函數接口 1)初始化互斥鎖 pthread_mutex_init() man 3 pthread_mutex_init (找不到的情況下首先 sudo apt-get install glibc-doc sudo apt-get install manpages-posix-dev) 動態初始化 int pthread_...

    統計學習方法 - 樸素貝葉斯

    引入問題:一機器在良好狀態生產合格產品幾率是 90%,在故障狀態生產合格產品幾率是 30%,機器良好的概率是 75%。若一日第一件產品是合格品,那么此日機器良好的概率是多少。 貝葉斯模型 生成模型與判別模型 判別模型,即要判斷這個東西到底是哪一類,也就是要求y,那就用給定的x去預測。 生成模型,是要生成一個模型,那就是誰根據什么生成了模型,誰就是類別y,根據的內容就是x 以上述例子,判斷一個生產出...

    styled-components —— React 中的 CSS 最佳實踐

    https://zhuanlan.zhihu.com/p/29344146 Styled-components 是目前 React 樣式方案中最受關注的一種,它既具備了 css-in-js 的模塊化與參數化優點,又完全使用CSS的書寫習慣,不會引起額外的學習成本。本文是 styled-components 作者之一 Max Stoiber 所寫,首先總結了前端組件化樣式中的最佳實踐原則,然后在此基...

    基于TCP/IP的網絡聊天室用Java來實現

    基于TCP/IP的網絡聊天室實現 開發工具:eclipse 開發環境:jdk1.8 發送端 接收端 工具類 運行截圖...

    精品国产乱码久久久久久蜜桃不卡