瞭解React Hooks及其常用的幾個鉤子函式

TNTWEB發表於2022-02-16

寫在前面

React Hooks 是 React 團隊在兩年前的 16.8 版本推出的一套全新的機制。作為最主流的前端框架,React 的 API 十分穩定,這次更新的釋出,讓眾多恐懼新輪子的前端大佬們虎軀一震,畢竟每一次更新又是高成本的學習,這玩意到底好使麼?

答案是好用的,對於 React 的開發者而言,只是多了一個選擇。過去的開發方式是基於Class元件的,而hooks是基於函式元件,這意味著 這兩種開發方式可以並存 ,而新的程式碼可以根據具體情況採用 Hooks 的方式來實現就行了。這篇文章主要就來介紹一下 Hooks 的優勢常用的幾個鉤子函式

Hooks的優勢

1.類元件的不足

  • 程式碼量多

    相較於函式元件的寫法,使用類元件程式碼量要略多一點,這個是最直觀的感受。

  • this指向

    類元件中總是需要考慮this的指向問題,而函式元件則可以忽略。

  • 趨向複雜難以維護

    在高版本的React中,又更新了一些生命週期函式,因為這些函式互相解耦,很容易造成分散不集中的寫法,漏掉關鍵邏輯和多了冗餘邏輯,導致後期debug困難。相反,hooks可以把關鍵邏輯都放在一起,不顯得那麼割裂,除錯起來也易懂一點。

  • 狀態邏輯難複用

    在元件之間複用狀態邏輯很難,可能要用到 render props (渲染屬性)或者 HOC (高階元件),但無論是渲染屬性,還是高階元件,都會在原先的元件外包裹一層父容器(一般都是 div 元素),導致層級冗餘。

2. Hooks帶來的好處

  • 邏輯複用

    在元件之前複用狀態邏輯,往往需要藉助高階元件等複雜的設計模式,這些高階元件會產生冗餘的元件節點,讓除錯變得困難,下面用一個demo來對比一下兩種實現方式。

Class

在class元件場景下,定義了一個高階元件,負責監聽視窗大小變化,並將變化後的值作為 props 傳給下一個元件。

const useWindowSize = Component => {
  // 產生一個高階元件 HOC,只包含監聽視窗大小的邏輯
  class HOC extends React.PureComponent {
    constructor(props) {
      super(props);
      this.state = {
        size: this.getSize()
      };
    }
    componentDidMount() {
      window.addEventListener("resize", this.handleResize); 
    }
    componentWillUnmount() {
      window.removeEventListener("resize", this.handleResize);
    }
    getSize() {
      return window.innerWidth > 1000 ? "large" :"small";
    }
    handleResize = ()=> {
      const currentSize = this.getSize();
      this.setState({
        size: this.getSize()
      });
    }
    render() {
      // 將視窗大小傳遞給真正的業務邏輯元件
      return <Component size={this.state.size} />;
    }
  }
  return HOC;
};

接下來可以在自定義元件中可以呼叫 useWindowSize 這樣的函式來產生一個新元件,並自帶 size 屬性,例如:

class MyComponent extends React.Component{
  render() {
    const { size } = this.props;
    if (size === "small") return <SmallComponent />;
    else return <LargeComponent />;
  }
}
// 使用 useWindowSize 產生高階元件,用於產生 size 屬性傳遞給真正的業務元件
export default useWindowSize(MyComponent); 

下面看下Hooks的實現方式

Hooks
const getSize = () => {
  return window.innerWidth > 1000 ? "large" : "small";
}
const useWindowSize = () => {
  const [size, setSize] = useState(getSize());
  useEffect(() => {
  const handler = () => {
      setSize(getSize())
    };
    window.addEventListener('resize', handler);
    return () => {
      window.removeEventListener('resize', handler);
    };
  }, []);
  
  return size;
};

使用:

const Demo = () => {
  const size = useWindowSize();
  if (size === "small") return <SmallComponent />;
  else return <LargeComponent />;
};

從上面的例子中通過 Hooks 的方式對視窗大小進行了封裝,從而將其變成一個可繫結的資料來源。這樣當視窗大小發生變化時,使用這個 Hook 的元件就都會重新渲染。而且程式碼也更加簡潔和直觀,不會產生額外的元件節點,也不顯得那麼冗餘了。

  • 業務程式碼更加聚合

下面舉一個最常見的計時器的例子。

class
let timer = null
componentDidMount() {
    timer = setInterval(() => {
        // ...
    }, 1000)
}
// ...
componentWillUnmount() {
    if (timer) clearInterval(timer)
}
Hooks
useEffect(() => {
    let timer = setInterval(() => {
        // ...
    }, 1000)
    return () => {
        if (timer) clearInterval(timer)
    }
}, [//...])

Hooks的實現方式能讓程式碼更加集中,邏輯也更清晰。

  • 寫法簡潔

這個就不舉例了,可以從字面意思理解,使用函式元件確實能少些很多程式碼,懂得都懂,嘻嘻~

幾個內建Hooks的作用以及使用思考

useState :讓函式元件具有維持狀態的能力

const[count, setCount]=useState(0);

優點:

讓函式元件具有維持狀態的能力,即:在一個函式元件的多次渲染之間,這個 state 是共享的。便於維護狀態。

缺點:

一旦元件有自己狀態,意味著元件如果重新建立,就需要有恢復狀態的過程,這通常會讓元件變得更復雜。

用法:

  1. useState(initialState) 的引數 initialState 是建立 state 的初始值。
它可以是任意型別,比如數字、物件、陣列等等。
  1. useState() 的返回值是一個有著兩個元素的陣列。第一個陣列元素用來讀取 state 的值,第二個則是用來設定這個 state 的值。
在這裡要注意的是,state 的變數(例子中的 count)是隻讀的,所以我們必須通過第二個陣列元素 setCount 來設定它的值。
  1. 如果要建立多個 state ,那麼我們就需要多次呼叫 useState。

什麼樣的值應該儲存在 state 中?

通常來說,我們要遵循的一個原則就是:state 中不要儲存可以通過計算得到的值

  • 從 props 傳遞過來的值。有時候 props 傳遞過來的值無法直接使用,而是要通過一定的計算後再在 UI 上展示,比如說排序。那麼我們要做的就是每次用的時候,都重新排序一下,或者利用某些 cache 機制,而不是將結果直接放到 state 裡。
  • 從 URL 中讀到的值。比如有時需要讀取 URL 中的引數,把它作為元件的一部分狀態。那麼我們可以在每次需要用的時候從 URL 中讀取,而不是讀出來直接放到 state 裡。
  • 從 cookie、localStorage 中讀取的值。通常來說,也是每次要用的時候直接去讀取,而不是讀出來後放到 state 裡。

useEffect:執行副作用

useEffect(fn, deps);

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

什麼是副作用?

通常來說,副作用是指一段和當前執行結果無關的程式碼。比如說要修改函式外部的某個變數,要發起一個請求,等等。

也就是說,在函式元件的當次執行過程中, useEffect 中程式碼的執行是不影響渲染出來的 UI 的。

對應到 Class 元件,那麼 useEffect 就涵蓋了 ComponentDidMount、componentDidUpdate 和 componentWillUnmount 三個生命週期方法。不過如果你習慣了使用 Class 元件,那千萬不要按照把 useEffect 對應到某個或者某幾個生命週期的方法。你只要記住,useEffect 是每次元件 render 完後判斷依賴並執行就可以了。

useEffect 還有兩個特殊的用法:沒有依賴項,以及依賴項作為空陣列。我們來具體分析下。

  1. 沒有依賴項,則每次 render 後都會重新執行。例如:
useEffect(() => {
  // 每次 render 完一定執行
  console.log('渲染...........');
});
  1. 空陣列作為依賴項,則只在首次執行時觸發,對應到 Class 元件就是 componentDidMount。例如:
useEffect(() => {
  // 元件首次渲染時執行,等價於 class 元件中的 componentDidMount
  console.log('did mount........');
}, []);

小結用法:

總結一下,useEffect 讓我們能夠在下面四種時機去執行一個回撥函式產生副作用:

  1. 每次 render 後執行:不提供第二個依賴項引數。
比如useEffect(() => {})。
  1. 僅第一次 render 後執行:提供一個空陣列作為依賴項。
比如useEffect(() => {}, [])。
  1. 第一次以及依賴項發生變化後執行:提供依賴項陣列。
比如useEffect(() => {}, [deps])。
  1. 元件 unmount 後執行:返回一個回撥函式。
比如useEffect() => { return () => {} }, [])。

useCallback:快取回撥函式

useCallback(fn, deps)

為什麼要使用useCallback?

在 React 函式元件中, 每一次 UI 的變化,都是通過重新執行整個函式來完成的 ,這和傳統的 Class 元件有很大區別:函式元件中並沒有一個直接的方式在多次渲染之間維持一個狀態。

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = () => setCount(count+1);
  return <button onClick={handleIncrement}>+</button>
}

思考下這個過程。 每次元件狀態發生變化的時候,函式元件實際上都會重新執行一遍 。在每次執行的時候,實際上都會建立一個新的事件處理函式 handleIncrement

這也意味著,即使 count 沒有發生變化,但是函式元件因為其它狀態發生變化而重新渲染時(函式元件重新被執行),這種寫法也會每次建立一個新的函式。建立一個新的事件處理函式,雖然不影響結果的正確性,但其實是沒必要的。因為這樣做不僅增加了系統的開銷,更重要的是: 每次建立新函式的方式會讓接收事件處理函式的元件,需要重新渲染

比如這個例子中的 button 元件,接收了 handleIncrement ,並作為一個屬性。如果每次都是一個新的,那麼這個 React 就會認為這個元件的 props 發生了變化,從而必須重新渲染。因此,我們需要做到的是: 只有當 count 發生變化時,我們才需要重新定一個回撥函式 。而這正是 useCallback 這個 Hook 的作用。

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

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = useCallback(
    () => setCount(count + 1),
    [count], // 只有當 count 發生變化時,才會重新建立回撥函式
  );
  return <button onClick={handleIncrement}>+</button>
}

useMemo:快取計算的結果

useMemo(fn, deps);
useCallback(fn, deps) 相當於 useMemo(() => fn, deps)。

這裡的 fn 是產生所需資料的一個 計算函式 。通常來說, fn 會使用 deps 中宣告的一些變數來生成一個結果,用來渲染出最終的 UI

這個場景應該很容易理解:如果某個 資料 是通過其它資料計算得到的,那麼只有當用到的資料,也就是依賴的資料發生變化的時候,才應該需要重新計算。

避免重複計算

通過 useMemo 這個 Hook,可以避免在用到的資料沒發生變化時進行的重複計算。雖然例子展示的是一個很簡單的場景,但如果是一個複雜的計算,那麼對於 提升效能 會有很大的幫助。

舉個例子:

const calc = (a, b) => {
    // 假設這裡做了複雜的計算,暫時用次冪模擬
    return a ** b;
}
const MyComponent = (props) => {
    const {a, b} = props;
    const c = calc(a, b);
    return <div>c: {c}</div>;
}

如果 calc 計算耗時 1000ms,那麼每次渲染都要等待這麼久,怎麼優化呢?

a, b 值不變的情況下,得出的 c 定是相同的。

所以我們可以用 useMemo 把值給快取起來,避免重複計算相同的結果。

const calc = (a, b) => {
    // 假設這裡做了複雜的計算,暫時用次冪模擬
    return a ** b;
}
const MyComponent = (props) => {
    const {a, b} = props;
    // 快取
    const c = React.useMemo(() => calc(a, b), [a, b]);
    return <div>c: {c}</div>;
}

useCallback 的功能其實是可以用 useMemo 來實現的:

 const myEventHandler = useMemo(() => {
   // 返回一個函式作為快取結果
   return () => {
     // 在這裡進行事件處理
   }
 }, [dep1, dep2]);

小結一下:

感覺到這有這種感覺,其實 hook 就是建立了一個繫結某個結果到依賴資料的關係。只有當依賴變了,這個結果才需要被重新得到。

useRef:在多次渲染之間共享資料

const myRefContainer =useRef(initialValue);

我們可以把 useRef 看作是在函式元件之外建立的一個容器空間。在這個容器上,我們可以通過唯一的 current 屬設定一個值,從而在函式元件的多次渲染之間共享這個值。

useRef 的重要的功能

1. 儲存跨渲染的資料

使用 useRef 儲存的資料一般是和 UI 的渲染無關的,因此當 ref 的值發生變化時,是不會觸發元件的重新渲染的,這也是 useRef 區別於 useState 的地方。

舉例:

 const [time, setTime] = useState(0);
 // 定義 timer 這樣一個容器用於在跨元件渲染之間儲存一個變數 
 const timer = useRef(null);

  const handleStart = useCallback(() => {
    // 使用 current 屬性設定 ref 的值
    timer.current = window.setInterval(() => { setTime((time) => time + 1); }, 100);
  }, []);

2. 儲存某個 DOM 節點的引用

是在某些場景中,我們必須要獲得真實 DOM 節點的引用,所以結合 React 的 ref 屬性和 useRef 這個 Hook,我們就可以獲得真實的 DOM 節點,並對這個節點進行操作。

React 官方例子:

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // current 屬性指向了真實的 input 這個 DOM 節點,從而可以呼叫 focus 方法
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

理解:

可以看到ref 這個屬性提供了獲得 DOM 節點的能力,並利用 useRef 儲存了這個節點的應用。這樣的話,一旦 input 節點被渲染到介面上,那我們通過 inputEl.current 就能訪問到真實的 DOM 節點的例項了

useContext:定義全域性狀態

為什麼要使用 useContext?

React 元件之間的狀態傳遞只有一種方式,那就是通過 props。缺點: 這種傳遞關係只能在父子元件之間進行。

那麼問題出現:跨層次,或者同層的元件之間要如何進行資料的共享?這就涉及到一個新的命題: 全域性狀態管理

react提供的解決方案: Context 機制。

具體原理:

React 提供了 Context 這樣一個機制, 能夠讓所有在某個元件開始的元件樹上建立一個 Context 。這樣這個元件樹上的所有元件,就都能訪問和修改這個 Context 了。

那麼在函式元件裡,我們就可以使用 useContext 這樣一個 Hook 來管理 Context。

使用:(這兒用了官方例子)

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};
// 建立一個 Theme 的 Context

const ThemeContext = React.createContext(themes.light);
function App() {
  // 整個應用使用 ThemeContext.Provider 作為根元件
  return (
    // 使用 themes.dark 作為當前 Context 
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// 在 Toolbar 元件中使用一個會使用 Theme 的 Button
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

// 在 Theme Button 中使用 useContext 來獲取當前的主題
function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{
      background: theme.background,
      color: theme.foreground
    }}>
      I am styled by theme context!
    </button>
  );
}

優點:

Context 提供了一個方便在多個元件之間共享資料的機制。

缺點:

Context 相當於提供了一個定義 React 世界中全域性變數的機制,而全域性變數則意味著兩點:

1. 會讓除錯變得困難,因為你很難跟蹤某個 Context 的變化究竟是如何產生的。

2. 讓元件的複用變得困難,因為一個元件如果使用了某個 Context ,它就必須確保被用到的地方一定有這個 ContextProvider 在其父元件的路徑上。

實際應用場景

由於以上缺點,所以在 React 的開發中,除了像 Theme、Language 等一目瞭然的需要全域性設定的變數外),我們很少會使用 Context 來做太多資料的共享。需要再三強調的是,Context 更多的是提供了一個強大的機制,讓 React 應用具備定義全域性的響應式資料的能力。

此外,很多狀態管理框架,比如 Redux,正是利用了 Context 的機制來提供一種更加可控的元件之間的狀態管理機制。因此,理解 Context 的機制,也可以讓我們更好地去理解 Redux 這樣的框架實現的原理。

最後

感覺這次的內容不多不少。其實瞭解學會了useState 和 useEffect 這兩個 核心 Hooks,基本能完成絕大多數 React 功能的開發了。

useCallback、useMemo、useRef 和 useContext。這幾個 Hook 都是為了解決函式元件中遇到的特定問題。

還有幾個比較邊緣的hook這裡就不再寫了,有興趣的大佬可以移步到官方文件上看看。

碼字不易,也辛苦大佬們指導交流~

團隊

TNTWeb - 騰訊新聞前端團隊,TNTWeb 致力於行業前沿技術探索和團隊成員個人能力提升。為前端開發人員整理出了小程式以及 web 前端技術領域的最新優質內容,每週更新 ✨,歡迎 star,github 地址:https://github.com/tnfe/TNT-Weekly

image.png

相關文章