React Hooks 完全使用指南

Hook 是什麼

Hook 是 React 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性。

Hook 是 React 團隊在 React 16.8 版本中提出的新特性,在遵循函數式組件的前提下,爲已知的 React 概念提供了更直接的 API:props,state,context,refs 以及聲明週期,目的在於解決常年以來在 class 組件中存在的各種問題,實現更高效的編寫 react 組件

class 組件的不足

Hook 的優勢

Hook 使用規則

Hook 就是 Javascript 函數,使用它們時有兩個額外的規則:

在組件中 React 是通過判斷 Hook 調用的順序來判斷某個 state 對應的 useState的,所以必須保證 Hook 的調用順序在多次渲染之間保持一致,React 才能正確地將內部 state 和對應的 Hook 進行關聯

useState

useState 用於在函數組件中調用給組件添加一些內部狀態 state,正常情況下純函數不能存在狀態副作用,通過調用該 Hook 函數可以給函數組件注入狀態 state

useState 唯一的參數就是初始 state,會返回當前狀態和一個狀態更新函數,並且 useState 返回的狀態更新函數不會把新的 state 和舊的 state 進行合併,如需合併可使用 ES6 的對象結構語法進行手動合併

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

方法使用

import React, { useState } from 'react';

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

  return (
    <div>
      <button onClick={() => setCount(count - 1)}>-</button>
      <input type="text" value={count} onChange={(e) => setCount(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

等價 class 示例

useState 返回的狀態類似於 class 組件在構造函數中定義 this.state,返回的狀態更新函數類似於 class 組件的 this.setState

import React from 'react';

export default class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <div>
        <button onClick={() => this.setState({ count: this.state.count - 1 })}>-</button>
        <input type="text" value={this.state.count} onChange={(e) => this.setState({ count: e.target.value })} />
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>+</button>
      </div>
    );
  }
}

函數式更新

如果新的 state 需要通過使用先前的 state 計算得出,可以往 setState 傳遞函數,該函數將接收先前的 state,並返回一個更新後的值

import React, { useState } from 'react'

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

  const lazyAdd = () ={
    setTimeout(() ={
      // 每次執行都會最新的state,而不是使用事件觸發時的state
      setCount(count => count + 1);
    }, 3000);
  } 

  return (
    <div>
      <p>the count now is {count}</p>
      <button onClick={() => setCount(count + 1)}>add</button>
      <button onClick={lazyAdd}>lazyAdd</button>
    </div>
  );
}

惰性初始 state

如果初始 state 需要通過複雜計算獲得,則可以傳入一個函數,在函數中計算並返回初始的 state,此函數只會在初始渲染時被調用

import React, { useState } from 'react'

export default function Counter(props) {
  // 函數只在初始渲染時執行一次,組件重新渲染時該函數不會重新執行
  const initCounter = () ={
    console.log('initCounter');
    return { number: props.number };
  };
  const [counter, setCounter] = useState(initCounter);

  return (
    <div>
      <button onClick={() => setCounter({ number: counter.number - 1 })}>-</button>
      <input type="text" value={counter.number} onChange={(e) => setCounter({ number: e.target.value})} />
      <button onClick={() => setCounter({ number: counter.number + 1 })}>+</button>
    </div>
  );
}

跳過 state 更新

調用 State Hook 的更新函數時,React 將使用 Object.is 來比較前後兩次 state,如果返回結果爲 true,React 將跳過子組件的渲染及 effect 的執行

import React, { useState } from 'react';

export default function Counter() {
  console.log('render Counter');
  const [counter, setCounter] = useState({
    name: '計時器',
    number: 0
  });

  // 修改狀態時傳的狀態值沒有變化,則不重新渲染
  return (
    <div>
      <p>{counter.name}{counter.number}</p>
      <button onClick={() => setCounter({ ...counter, number: counter.number + 1})}>+</button>
      <button onClick={() => setCounter(counter)}>++</button>
    </div>
  );
}

useEffect

在函數組件主體內(React 渲染階段)改變 DOM、添加訂閱、設置定時器、記錄日誌以及執行其他包含副作用的操作都是不被允許的,因爲這可能會產生莫名其妙的 bug 並破壞 UI 的一致性

useEffect Hook 的使用則是用於完成此類副作用操作。useEffect 接收一個包含命令式、且可能有副作用代碼的函數

useEffect函數會在瀏覽器完成佈局和繪製之後,下一次重新渲染之前執行,保證不會阻塞瀏覽器對屏幕的更新

useEffect(didUpdate);

方法使用

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

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

  // useEffect 內的回調函數會在初次渲染後和更新完成後執行
  // 相當於 componentDidMount 和 componentDidUpdate
  useEffect(() ={
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>count now is {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

等價 class 示例

useEffect Hook 函數執行時機類似於 class 組件的 componentDidMountcomponentDidUpdate 生命週期,不同的是傳給 useEffect 的函數會在瀏覽器完成佈局和繪製之後進行異步執行

import React from 'react';

export default class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>count now is {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>+</button>
      </div>
    );
  }
}

清除 effect

通常情況下,組件卸載時需要清除 effect 創建的副作用操作,useEffect Hook 函數可以返回一個清除函數,清除函數會在組件卸載前執行。組件在多次渲染中都會在執行下一個 effect 之前,執行該函數進行清除上一個 effect

清除函數的執行時機類似於 class 組件componentDidUnmount 生命週期,這的話使用 useEffect 函數可以將組件中互相關聯的部分拆分成更小的函數,防止遺忘導致不必要的內存泄漏

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

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

  useEffect(() ={
    console.log('start an interval timer')
    const timer = setInterval(() ={
      setCount((count) => count + 1);
    }, 1000);
    // 返回一個清除函數,在組件卸載前和下一個effect執行前執行
    return () ={
      console.log('destroy effect');
      clearInterval(timer);
    };
  }[]);

  return (
    <div>
      <p>count now is {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

優化 effect 執行

默認情況下,effect 會在每一次組件渲染完成後執行。useEffect 可以接收第二個參數,它是 effect 所依賴的值數組,這樣就只有當數組值發生變化纔會重新創建訂閱。但需要注意的是:

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

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

  useEffect(() ={
    document.title = `You clicked ${count} times`;
  }[count]); // 僅在 count 更改時更新

  return (
    <div>
      <p>count now is {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

useContext

Context 提供了一個無需爲每層組件手動添加 props ,就能在組件樹間進行數據傳遞的方法,useContext 用於函數組件中訂閱上層 context 的變更,可以獲取上層 context 傳遞的 value prop 值

useContext 接收一個 context 對象(React.createContext的返回值)並返回 context 的當前值,當前的 context 值由上層組件中距離當前組件最近的 <MyContext.Provider>value prop 決定

const value = useContext(MyContext);

方法使用

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

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

// 爲當前 theme 創建一個 context
const ThemeContext = React.createContext();

export default function Toolbar(props) {
  const [theme, setTheme] = useState(themes.dark);

  const toggleTheme = () ={
    setTheme(currentTheme =(
      currentTheme === themes.dark
        ? themes.light
        : themes.dark
    ));
  };

  return (
    // 使用 Provider 將當前 props.value 傳遞給內部組件
    <ThemeContext.Provider value={{theme, toggleTheme}}>
      <ThemeButton />
    </ThemeContext.Provider>
  );
}

function ThemeButton() {
  // 通過 useContext 獲取當前 context 值
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <button style={{background: theme.background, color: theme.foreground }} onClick={toggleTheme}>
      Change the button's theme
    </button>
  );
}

等價 class 示例

useContext(MyContext) 相當於 class 組件中的 static contextType = MyContext 或者 <MyContext.Consumer>

useContext 並沒有改變消費 context 的方式,它只爲我們提供了一種額外的、更漂亮的、更漂亮的方法來消費上層 context。在將其應用於使用多 context 的組件時將會非常有用

import React from 'react';

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

const ThemeContext = React.createContext(themes.light);

function ThemeButton() {
  return (
    <ThemeContext.Consumer>
      {
        ({theme, toggleTheme}) =(
          <button style={{background: theme.background, color: theme.foreground }} onClick={toggleTheme}>
            Change the button's theme
          </button>
        )
      }
    </ThemeContext.Consumer>
  );
}

export default class Toolbar extends React.Component {
  constructor(props) {
    super(props);
    
    this.state = {
      theme: themes.light
    };

    this.toggleTheme = this.toggleTheme.bind(this);
  }

  toggleTheme() {
    this.setState(state =({
      theme:
        state.theme === themes.dark
          ? themes.light
          : themes.dark
    }));
  }

  render() {
    return (
      <ThemeContext.Provider value={{ theme: this.state.theme, toggleTheme: this.toggleTheme }}>
        <ThemeButton />
      </ThemeContext.Provider>
    )
  }
}

優化消費 context 組件

調用了 useContext 的組件都會在 context 值變化時重新渲染,爲了減少重新渲染組件的較大開銷,可以通過使用 memoization 來優化

假設由於某種原因,您有 AppContext,其值具有 theme 屬性,並且您只想在 appContextValue.theme 更改上重新渲染一些 ExpensiveTree

  1. 方式 1: 拆分不會一起更改的 context
function Button() {
  // 把 theme context 拆分出來,其他 context 變化時不會導致 ExpensiveTree 重新渲染
  let theme = useContext(ThemeContext);
  return <ExpensiveTree className={theme} />;
}
  1. 當不能拆分 context 時,將組件一分爲二,給中間組件加上 React.memo
function Button() {
  let appContextValue = useContext(AppContext);
  let theme = appContextValue.theme; // 獲取 theme 屬性
  return <ThemedButton theme={theme} />
}

const ThemedButton = memo(({ theme }) ={
  // 使用 memo 儘量複用上一次渲染結果
  return <ExpensiveTree className={theme} />;
});
  1. 返回一個內置 useMemo 的組件
function Button() {
  let appContextValue = useContext(AppContext);
  let theme = appContextValue.theme; // 獲取 theme 屬性

  return useMemo(() ={
    // The rest of your rendering logic
    return <ExpensiveTree className={theme} />;
  }[theme])
}

useReducer

useReducer 作爲 useState 的代替方案,在某些場景下使用更加適合,例如 state 邏輯較複雜且包含多個子值,或者下一個 state 依賴於之前的 state 等。

使用 useReducer 還能給那些會觸發深更新的組件做性能優化,因爲父組件可以向自組件傳遞 dispatch 而不是回調函數

const [state, dispatch] = useReducer(reducer, initialArg, init);

方法使用

import React, { useReducer } from 'react'

const initialState = { count: 0 };

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

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

初始化 state

useReducer 初始化 sate 的方式有兩種

// 方式1
const [state, dispatch] = useReducer(
 reducer,
  {count: initialCount}
);

// 方式2
function init(initialClunt) {
  return {count: initialClunt};
}

const [state, dispatch] = useReducer(reducer, initialCount, init);

useRef

useRef 用於返回一個可變的 ref 對象,其 .current 屬性被初始化爲傳入的參數(initialValue

useRef 創建的 ref 對象就是一個普通的 JavaScript 對象,而 useRef() 和自建一個 {current: ...} 對象的唯一區別是,useRef 會在每次渲染時返回同一個 ref 對象

const refContainer = useRef(initialValue);

綁定 DOM 元素

使用 useRef 創建的 ref 對象可以作爲訪問 DOM 的方式,將 ref 對象以 <div ref={myRef} /> 形式傳入組件,React 會在組件創建完成後會將 ref 對象的 .current 屬性設置爲相應的 DOM 節點

import React, { useRef } from 'react'

export default function FocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () ={
    inputEl.current.focus();
  };

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

綁定可變值

useRef 創建的 ref 對象同時可以用於綁定任何可變值,通過手動給該對象的.current 屬性設置對應的值即可

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

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

  const currentCount = useRef();
  // 使用 useEffect 獲取當前 count
  useEffect(() ={
    currentCount.current = count;
  }[count]);

  const alertCount = () ={
    setTimeout(() ={
      alert(`Current count is: ${currentCount.current}, Real count is: ${count}`);
    }, 3000);
  }

  return (
    <>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Count add</button>
      <button onClick={alertCount}>Alert current Count</button>
    </>
  );
}

性能優化(useCallback & useMemo)

useCallbackuseMemo 結合 React.Memo 方法的使用是常見的性能優化方式,可以避免由於父組件狀態變更導致不必要的子組件進行重新渲染

useCallback

useCallback 用於創建返回一個回調函數,該回調函數只會在某個依賴項發生改變時纔會更新,可以把回調函數傳遞給經過優化的並使用引用相等性去避免非必要渲染的子組件,在 props 屬性相同情況下,React 將跳過渲染組件的操作並直接複用最近一次渲染的結果

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

function SubmitButton(props) {
  const { onButtonClick, children } = props;
  console.log(`${children} updated`);

  return (
    <button onClick={onButtonClick}>{children}</button>
  );
}
// 使用 React.memo 檢查 props 變更,複用最近一次渲染結果
SubmitButton = React.memo(submitButton);

export default function CallbackForm() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const handleAdd1 = () ={
    setCount1(count1 + 1);
  }

  // 調用 useCallback 返回一個 memoized 回調,該回調在依賴項更新時纔會更新
  const handleAdd2 = useCallback(() ={
    setCount2(count2 + 1);
  }[count2]);

  return (
    <>
      <div>
        <p>count1: {count1}</p>
        <SubmitButton onButtonClick={handleAdd1}>button1</SubmitButton>
      </div>
      <div>
        <p>count2: {count2}</p>
        <SubmitButton onButtonClick={handleAdd2}>button2</SubmitButton>
      </div>
    </>
  )
}

useCallback(fn, deps) 相當於 useMemo(() => fn, deps),以上 useCallback 可替換成 useMemo 結果如下:

const handleAdd2 = useMemo(() ={
  return () => setCount2(count2 + 1);
}[count2]);

useMemo

把 “創建” 函數和依賴項數組作爲參數傳入 useMemo,它僅會在某個依賴項改變時才重新計算 memoized 值。這種優化有助於避免在每次渲染時都進行高開銷的計算

使用注意:

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

function counterText({ countInfo }) {
  console.log(`${countInfo.name} updated`);

  return (
    <p>{countInfo.name}{countInfo.number}</p>
  );
}
// // 使用 React.memo 檢查 props 變更,複用最近一次渲染結果
const CounterText = React.memo(counterText);

export default function Counter() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const countInfo1 = {
    name: 'count1',
    number: count1
  };
  // 使用 useMemo 緩存最近一次計算結果,會在依賴項改變時才重新計算
  const countInfo2 = useMemo(() =({
    name: 'count2',
    number: count2
  })[count2]);

  return (
    <>
      <div>
        <CounterText countInfo={countInfo1} />
        <button onClick={() => setCount1(count1 + 1)}>Add count1</button>
      </div>
      <div>
        <CounterText countInfo={countInfo2} />
        <button onClick={() => setCount2(count2 + 1)}>Add count2</button>
      </div>
    </>
  );
}

其他 Hook

useImperativeHandle

useImperativeHandle 可以讓你在使用 ref 時自定義暴露給父組件的實例值。在大多數情況下,應當避免使用 ref 這樣的命令式代碼。useImperativeHandle 應當與 React.forwardRef 一起使用:

import React, { useRef, useImperativeHandle, useState } from 'react'

function FancyInput(props, ref) {
  const inputRef = useRef();
  // 自定義暴露給父組件的 ref 實例值
  useImperativeHandle(ref, () =({
    focus: () ={
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} type="text" {...props} />;
}
// 通過 forwardRef 向父組件傳遞暴露的 ref
const ForwardFancyInput = React.forwardRef(FancyInput);

export default function Counter() {
  const [text, setText] = useState('');
  const inputRef = useRef();
  
  const onInputFocus = () ={
    inputRef.current.focus();
  };

  return (
    <>
      <ForwardFancyInput ref={inputRef} value={text} onChange={e => setText(e.target.value)} />
      <button onClick={onInputFocus}>Input focus</button>
    </>
  );
}

useLayoutEffect

useLayoutEffectuseEffect 類似,與 useEffect 在瀏覽器 layout 和 painting 完成後異步執行 effect 不同的是,它會在瀏覽器佈局 layout 之後,painting 之前同步執行 effect

useLayoutEffect 的執行時機對比如下:

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

export default function LayoutEffect() {
  const [width, setWidth] = useState('100px');

  // useEffect 會在所有 DOM 渲染完成後執行 effect 回調
  useEffect(() ={
    console.log('effect width: ', width);
  });
  // useLayoutEffect 會在所有的 DOM 變更之後同步執行 effect 回調
  useLayoutEffect(() ={
    console.log('layoutEffect width: ', width);
  });
  
  return (
    <>
      <div id='content' style={{ width, background: 'red' }}>內容</div>
      <button onClick={() => setWidth('100px')}>100px</button>
      <button onClick={() => setWidth('200px')}>200px</button>
      <button onClick={() => setWidth('300px')}>300px</button>
    </>
  );
}

// 使用 setTimeout 保證在組件第一次渲染完成後執行,獲取到對應的 DOM
setTimeout(() ={
  const contentEl = document.getElementById('content');
  // 監視目標 DOM 結構變更,會在 useLayoutEffect 回調執行後,useEffect 回調執行前調用
  const observer = new MutationObserver(() ={
    console.log('content element layout updated');
  });
  observer.observe(contentEl, {
    attributes: true
  });
}, 1000);

自定義 Hook

通過自定義 Hook,可以將組件邏輯提取到可重用的函數中,在 Hook 特性之前,React 中有兩種流行的方式來共享組件之間的狀態邏輯:render props高階組件,但此類解決方案會導致組件樹的層級冗餘等問題。而自定義 Hook 的使用可以很好的解決此類問題

創建自定義 Hook

自定義 Hook 是一個函數,其名稱以 “use” 開頭,函數內部可以調用其他的 Hook。以下就是實時獲取鼠標位置的自定義 Hook 實現:

import { useEffect, useState } from "react"

export const useMouse = () ={
  const [position, setPosition] = useState({
    x: null,
    y: null
  });

  useEffect(() ={
    const moveHandler = (e) ={
      setPosition({
        x: e.screenX,
        y: e.screenY
      });
    };

    document.addEventListener('mousemove', moveHandler);
    return () ={
      document.removeEventListener('mousemove', moveHandler);
    };
  }[]);

  return position;
}

使用自定義 Hook

自定義 Hook 的使用規則與 Hook 使用規則基本一致,以下是 useMouse 自定義 Hook 的使用過程:

import React from 'react';
import { useMouse } from '../hooks/useMouse';

export default function MouseMove() {
  const { x, y } = useMouse();
  return (
    <>
      <p>Move mouse to see changes</p>
      <p>x position: {x}</p>
      <p>y position: {y}</p>
    </>
  );
}

每次使用自定義 Hook 時,React 都會執行該函數來獲取獨立的 state 和執行獨立的副作用函數,所有 state 和副作用都是完全隔離的

參考文獻

React Hooks 官方文檔

詳解 React useCallback & useMemo

Preventing rerenders with React.memo and useContext hook

MutationObserver MDN

useLayoutEffect 和 useEffect 的區別

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qmX6u-Q_TdQcmFsd_cVPXA