React Hooks 整理

1.useState

  • 使用單個 state 變量還是多個 state 變量

    useState 的出現,讓我們可以使用多個 state 變量來保存 state,比如:

    const [left, setLeft] = useState(0);
    const [top, setTop] = useState(0);
    

    但同時,我們也可以像 Class 組件的 this.state 一樣,將所有的 state 放到一個 object 中, 這樣只需一個 state 變量即可:

     const [state, setState] = useState({
       left: 0,
       top: 0
     });
    

    如果使用單個 state 變量,每次更新 state 時需要合并之前的 state。因為 useState 返回的 setState 會替換原來的值。這一點和 Class 組件的 this.setState 不同。this.setState 會把更新的字段自動合并到 this.state 對象中。

     const handleMouseMove = (e) => {
       setState((prevState) => ({
       ...prevState,
       left: e.pageX,
       top: e.pageY,
       }))
     };
    

    使用多個 state 變量可以讓 state 的粒度更細,更易于邏輯的拆分和組合。比如,我們可以將關聯的邏輯提取到自定義 Hook 中:

     function usePosition() {
       const [left, setLeft] = useState(0);
       const [top, setTop] = useState(0);
    
       useEffect(() => {
       // ...
       }, []);
    
       return [left, top, setLeft, setTop];
     }
    

    我們發現,每次更新 left 時 top 也會隨之更新。因此,把 top 和 left 拆分為兩個 state 變量顯得有點多余。
    在使用 state 之前,我們需要考慮狀態拆分的「粒度」問題。如果粒度過細,代碼就會變得比較冗余。如果粒度過粗,代碼的可復用性就會降低。那么,到底哪些 state 應該合并,哪些 state 應該拆分呢?我總結了下面兩點:

    1.將完全不相關的 state 拆分為多組 state。比如 size 和 position。
    2.如果某些 state 是相互關聯的,或者需要一起發生改變,就可以把它們合并為一組 state。 比如 left 和 top。

    function Box() {
      const [position, setPosition] = usePosition();
      const [size, setSize] = useState({width: 100, height: 100});
      // ...
    }
    
    function usePosition() {
      const [position, setPosition] = useState({left: 0, top: 0});
    
      useEffect(() => {
        // ...
      }, []);
    
      return [position, setPosition];
    }
    
  • 使用setState更新state的選擇
    傳值更新

    setState(newState);
    

    函數式更新
    如果新的 state 需要通過使用先前的 state 計算得出,那么可以將函數傳遞給 setState。該函數將接收先前的 state,并返回一個更新后的值。下面的計數器組件示例展示了 setState 的兩種用法:

    function Counter({initialCount}) {
      const [count, setCount] = useState(initialCount);
      return (
        <>
          Count: {count}
          <button onClick={() => setCount(initialCount)}>Reset</button>
          <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
          <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
        </>
      );
    }
    

    “+” 和 “-” 按鈕采用函數式形式,因為被更新的 state 需要基于之前的 state。但是“重置”按鈕則采用普通形式,因為它總是把 count 設置回初始值。
    如果你的更新函數返回值與當前 state 完全相同,則隨后的重渲染會被完全跳過。

    除此之外,我們還可以在其他地方活用函數式更新。
    有時候,你的 effect 可能會使用一些頻繁變化的值。你可能會忽略依賴列表中 state,但這通常會引起 Bug:

     function Counter() {
        const [count, setCount] = useState(0);
    
        useEffect(() => {
           const id = setInterval(() => {
           setCount(count + 1); // 這個 effect 依賴于 `count` state
           }, 1000);
           return () => clearInterval(id);
        }, []); // ?? Bug: `count` 沒有被指定為依賴
    
        return <h1>{count}</h1>;
     }
    

    傳入空的依賴數組 [],意味著該 hook 只在組件掛載時運行一次,并非重新渲染時。但如此會有問題,在 setInterval 的回調中,count 的值不會發生變化。因為當 effect 執行時,我們會創建一個閉包,并將 count 的值被保存在該閉包當中,且初值為 0。每隔一秒,回調就會執行 setCount(0 + 1),因此,count 永遠不會超過 1。
    指定 [count] 作為依賴列表就能修復這個 Bug,但會導致每次改變發生時定時器都被重置。事實上,每個 setInterval 在被清除前(類似于 setTimeout)都會調用一次。但這并不是我們想要的。要解決這個問題,我們可以使用 setState 的函數式更新形式。它允許我們指定 state 該 如何 改變而不用引用 當前 state:

    function Counter() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        const id = setInterval(() => {
        setCount(c => c + 1); // ? 在這不依賴于外部的 `count` 變量
        }, 1000);
         return () => clearInterval(id);
      }, []); // ? 我們的 effect 不適用組件作用域中的任何變量
    
       return <h1>{count}</h1>;
    }
    

    此時,setInterval 的回調依舊每秒調用一次,但每次 setCount 內部的回調取到的 count 最新值(在回調中變量命名為 c)。

2.useEffect

  • effect清除

    通常,組件卸載時需要清除 effect 創建的諸如訂閱或計時器 ID 等資源。要實現這一點,useEffect 函數需返回一個清除函數。為防止內存泄漏,清除函數會在組件卸載前執行。

    1. 清除訂閱
    useEffect(() => {
      const subscription = props.source.subscribe();
      return () => {
      // 清除訂閱
      subscription.unsubscribe();
     };
    });
    
    1. 避免組件unmount后的state update
     useEffect(() => {
      let ignore = false;
      async function fetchProduct() {
       const response = await fetch('http://myapi/product/' + productId);
       const json = await response.json();
       if (!ignore) setProduct(json);
      }
    
     fetchProduct();
     return () => { ignore = true };
    }, [productId]);
    
  • 使用useEffect deps過多

    使用 useEffect hook 時,為了避免每次 render 都去執行它的 callback,我們通常會傳入第二個參數「dependency array」(下面統稱為依賴數組)。這樣,只有當依賴數組發生變化時,才會執行 useEffect 的回調函數。

    function Example({id, name}) {
      useEffect(() => {
        // 由于依賴數組中不包含 name,所以當 name 發生變化時,無法打印日志
        console.log(id, name); 
      }, [id]);
    }
    

    在 React 中,除了 useEffect 外,接收依賴數組作為參數的 Hook 還有 useMemo、 useCallback 和 useImperativeHandle。我們剛剛也提到了,依賴數組中千萬不要遺漏回調函數內部依賴的值。但是,如果依賴數組依賴了過多東西,可能導致代碼難以維護.

    const refresh = useCallback(() => {
      // ...
    }, [name, searchState, address, status, personA, personB, progress, page, size]);
    

    不要說內部邏輯了,光是看到這一堆依賴就令人頭大!如果項目中到處都是這樣的代碼,可想而知維護起來多么痛苦。如何才能避免寫出這樣的代碼呢?

    首先,你需要重新思考一下,這些 deps 是否真的都需要?看下面這個例子:

     function Example({id}) {
       const requestParams = useRef({});
    
       useEffect(() => {
         requestParams.current = {page: 1, size: 20, id};
       });
    
       const refresh = useCallback(() => {
         doRefresh(requestParams.current);
       }, []);
    
    
       useEffect(() => {
         id && refresh();
       }, [id, refresh]); // 思考這里的 deps list 是否合理?
    }
    

    雖然 useEffect 的回調函數依賴了 id 和 refresh 方法,但是觀察 refresh 方法可以發現,它在首次 render 被創建之后,永遠不會發生改變了。因此,把它作為 useEffect 的 deps 是多余的。

    其次,如果這些依賴真的都是需要的,那么這些邏輯是否應該放到同一個 hook 中?

    function Example({id, name, address, status, personA, personB, progress}) {
      const [page, setPage] = useState();
      const [size, setSize] = useState();
    
      const doSearch = useCallback(() => {
       // ...
      }, []);
    
      const doRefresh = useCallback(() => {
       // ...
      }, []);
    
    
      useEffect(() => {
       id && doSearch({name, address, status, personA, personB, progress});
       page && doRefresh({name, page, size});
     }, [id, name, address, status, personA, personB, progress, page, size]);
    }
    

    可以看出,在 useEffect 中有兩段邏輯,這兩段邏輯是相互獨立的,因此我們可以將這兩段邏輯放到不同 useEffect 中:

    useEffect(() => {
     id && doSearch({name, address, status, personA, personB, progress});
    }, [id, name, address, status, personA, personB, progress]);
    
    useEffect(() => {
     page && doRefresh({name, page, size});
    }, [name,  page, size]);
    

    如果邏輯無法繼續拆分,但是依賴數組還是依賴了過多東西,該怎么辦呢?就比如我們上面的代碼:

    useEffect(() => {
     id && doSearch({name, address, status, personA, personB, progress});
    }, [id, name, address, status, personA, personB, progress]);
    

    這段代碼中的 useEffect 依賴了七個值,還是偏多了。仔細觀察上面的代碼,可以發現這些值都是「過濾條件」的一部分,通過這些條件可以過濾頁面上的數據。因此,我們可以將它們看做一個整體,也就是我們前面講過的合并 state:

     const [filters, setFilters] = useState({
       name: "",
       address: "",
       status: "",
       personA: "",
       personB: "",
      progress: ""
     });
    
    useEffect(() => {
      id && doSearch(filters);
    }, [id, filters]);
    

    如果 state 不能合并,在 callback 內部又使用了 setState 方法,那么可以考慮使用 setState callback 來減少一些依賴。比如:

     const useValues = () => {
      const [values, setValues] = useState({
        data: {},
        count: 0
      });
    
      const [updateData] = useCallback(
        (nextData) => {
         setValues({
           data: nextData,
           count: values.count + 1 // 因為 callback 內部依賴了外部的 values 變量,所以必須在依賴數組中指定它
         });
       },
       [values], 
       );
    
       return [values, updateData];
    };
    

    上面的代碼中,我們必須在 useCallback 的依賴數組中指定 values,否則我們無法在 callback 中獲取到最新的 values 狀態。但是,通過 setState 回調函數,我們不用再依賴外部的 values 變量,因此也無需在依賴數組中指定它。就像下面這樣:

     const useValues = () => {
      const [values, setValues] = useState({});
    
      const [updateData] = useCallback((nextData) => {
        setValues((prevValues) => ({
        data: nextData,
        count: prevValues.count + 1, // 通過 setState 回調函數獲取最新的 values 狀態,這時 callback 不再依賴于外部的 values 變量了,因此依賴數組中不需要指定任何值
       }));
      }, []); // 這個 callback 永遠不會重新創建
    
      return [values, updateData];
    };
    

    說了這么多,歸根到底都是為了寫出更加清晰、易于維護的代碼。如果發現依賴數組依賴過多,我們就需要重新審視自己的代碼。

    1.依賴數組依賴的值最好不要超過 3 個,否則會導致代碼會難以維護。
    2.如果發現依賴數組依賴的值過多,我們應該采取一些方法來減少它。
    3.去掉不必要的依賴。
    4.將 Hook 拆分為更小的單元,每個 Hook 依賴于各自的依賴數組。
    5.通過合并相關的 state,將多個依賴值聚合為一個。
    6.通過 setState 回調函數獲取最新的 state,以減少外部依賴。

  • useMemo

    該不該使用 useMemo?對于這個問題,有的人從來沒有思考過,有的人甚至不覺得這是個問題。不管什么情況,只要用 useMemo 或者 useCallback 「包裹一下」,似乎就能使應用遠離性能的問題。但真的是這樣嗎?有的時候 useMemo 沒有任何作用,甚至還會影響應用的性能。

    為什么這么說呢?首先,我們需要知道 useMemo本身也有開銷。useMemo 會「記住」一些值,同時在后續 render 時,將依賴數組中的值取出來和上一次記錄的值進行比較,如果不相等才會重新執行回調函數,否則直接返回「記住」的值。這個過程本身就會消耗一定的內存和計算資源。因此,過度使用 useMemo 可能會影響程序的性能。

    要想合理使用 useMemo,我們需要搞清楚 useMemo 適用的場景:

    • 有些計算開銷很大,我們就需要「記住」它的返回值,避免每次 render 都去重新計算。
    • 由于值的引用發生變化,導致下游組件重新渲染,我們也需要「記住」這個值。

    讓我們來看個例子:

     interface IExampleProps {
       page: number;
       type: string;
     }
    
    const Example = ({page, type}: IExampleProps) => {
       const resolvedValue = useMemo(() => {
         return getResolvedValue(page, type);
       }, [page, type]);
    
       return <ExpensiveComponent resolvedValue={resolvedValue}/>;
    };
    

    在上面的例子中,渲染 ExpensiveComponent 的開銷很大。所以,當 resolvedValue 的引用發生變化時,作者不想重新渲染這個組件。因此,作者使用了 useMemo,避免每次 render 重新計算 resolvedValue,導致它的引用發生改變,從而使下游組件 re-render。

    這個擔憂是正確的,但是使用 useMemo 之前,我們應該先思考兩個問題:

    1.傳遞給 useMemo 的函數開銷大不大?在上面的例子中,就是考慮 getResolvedValue 函數的開銷大不大。JS 中大多數方法都是優化過的,比如 Array.map、Array.forEach 等。如果你執行的操作開銷不大,那么就不需要記住返回值。否則,使用 useMemo 本身的開銷就可能超過重新計算這個值的開銷。因此,對于一些簡單的 JS 運算來說,我們不需要使用 useMemo 來「記住」它的返回值。

    2.當輸入相同時,「記憶」值的引用是否會發生改變?在上面的例子中,就是當 page 和 type 相同時,resolvedValue 的引用是否會發生改變?這里我們就需要考慮 resolvedValue 的類型了。如果 resolvedValue 是一個對象,由于我們項目上使用「函數式編程」,每次函數調用都會產生一個新的引用。但是,如果 resolvedValue 是一個原始值(string, boolean, null, undefined, number, symbol),也就不存在「引用」的概念了,每次計算出來的這個值一定是相等的。也就是說,ExpensiveComponent 組件不會被重新渲染。

    因此,如果 getResolvedValue 的開銷不大,并且 resolvedValue 返回一個字符串之類的原始值,那我們完全可以去掉 useMemo,就像下面這樣:

    interface IExampleProps {
     page: number;
     type: string;
    }
    
    const Example = ({page, type}: IExampleProps) => {
     const resolvedValue = getResolvedValue(page, type);
     return <ExpensiveComponent resolvedValue={resolvedValue}/>;
    };
    

    保持引用不變

     // 使用 useMemo
    function Example() {
      const users = useMemo(() => [1, 2, 3], []);
    
      return <ExpensiveComponent users={users} />
    }
    

    在上面的例子中,我們用 useMemo 來「記住」users 數組,不是因為數組本身的開銷大,而是因為 users 的引用在每次 render 時都會發生改變,從而導致子組件 ExpensiveComponent 重新渲染(可能會帶來較大開銷)。

    在編寫自定義 Hook 時,返回值一定要保持引用的一致性。因為你無法確定外部要如何使用它的返回值。如果返回值被用做其他 Hook 的依賴,并且每次 re-render 時引用不一致(當值相等的情況),就可能會產生 bug。比如:

    function Example() {
     const data = useData();
     const [dataChanged, setDataChanged] = useState(false);
    
     useEffect(() => {
       setDataChanged((prevDataChanged) => !prevDataChanged); // 當 data 發生變化時,調用 setState。如果 data 值相同而引用不同,就可能會產生非預期的結果。
     }, [data]);
    
     console.log(dataChanged);
    
     return <ExpensiveComponent data={data} />;
    }
    
    const useData = () => {
    // 獲取異步數據
    const resp = getAsyncData([]);
    
    // 處理獲取到的異步數據,這里使用了 Array.map。因此,即使 data 相同,每次調用得到的引用也是不同的。
    const mapper = (data) => data.map((item) => ({...item, selected: false}));
    
    return resp ? mapper(resp) : resp;
    };
    

    在上面的例子中,我們通過 useData Hook 獲取了 data。每次 render 時 data 的值沒有發生變化,但是引用卻不一致。如果把 data 用到 useEffect 的依賴數組中,就可能產生非預期的結果。另外,由于引用的不同,也會導致 ExpensiveComponent 組件 re-render,產生性能問題。

    因此,在使用 useMemo 之前,我們不妨先問自己幾個問題:

    1.要記住的函數開銷很大嗎?
    2.返回的值是原始值嗎?
    3.記憶的值會被其他 Hook 或者子組件用到嗎?

    一、應該使用 useMemo 的場景

    1.保持引用相等

    • 對于組件內部用到的 object、array、函數等,如果用在了其他 Hook 的依賴數組中,或者作為 props 傳遞給了下游組件,應該使用 useMemo。
    • 自定義 Hook 中暴露出來的 object、array、函數等,都應該使用 useMemo 。以確保當值相同時,引用不發生變化。
    • 使用 Context 時,如果 Provider 的 value 中定義的值(第一層)發生了變化,即便用了 Pure Component 或者 React.memo,仍然會導致子組件 re-render。這種情況下,仍然建議使用 useMemo 保持引用的一致性。
    1. 成本很高的計算

    二、無需使用 useMemo 的場景

    • 如果返回的值是原始值: string, boolean, null, undefined, number, symbol(不包括動態聲明的 Symbol),一般不需要使用 useMemo。
    • 僅在組件內部用到的 object、array、函數等(沒有作為 props 傳遞給子組件),且沒有用到其他 Hook 的依賴數組中,一般不需要使用 useMemo。
  • useRef

    useRef 返回一個可變的 ref 對象,其 .current 屬性被初始化為傳入的參數(initialValue)。返回的 ref 對象在組件的整個生命周期內保持不變。
    1.使用ref訪問子組件

    function TextInputWithFocusButton() {
     const inputEl = useRef(null);
     const onButtonClick = () => {
     // `current` 指向已掛載到 DOM 上的文本輸入元素
     inputEl.current.focus();
     };
     return (
       <>
        <input ref={inputEl} type="text" />
        <button onClick={onButtonClick}>Focus the input</button>
       </>
     );
    }
    

    除此之外,useRef()ref 屬性更有用。它可以很方便地保存任何可變值,其類似于在 class 中使用實例字段的方式。
    這是因為它創建的是一個普通 Javascript 對象。而 useRef() 和自建一個 {current: ...} 對象的唯一區別是,useRef 會在每次渲染時返回同一個 ref 對象。

    1. 使用useRef保證引用不變
      // 使用 useRef
    function Example() {
      const {current: users} = useRef([1, 2, 3]);
    
      return <ExpensiveComponent users={users} />
    }
    
    1. 使用ref保存可變變量
      實現獲取上一輪的 props 或 state
    function Counter() {
      const [count, setCount] = useState(0);
      const prevCount = usePrevious(count);
      return <h1>Now: {count}, before: {prevCount}</h1>;
    }
    
    function usePrevious(value) {
      const ref = useRef();
      useEffect(() => {
        ref.current = value;
      });
      return ref.current;
    }
    

    實現effect deps減少

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