React Hooks工程實踐總結

yuxiaoliang發表於2019-12-02

    最近在專案中基本上全部使用了React Hooks,歷史專案也用React Hooks重寫了一遍,相比於Class元件,React Hooks的優點可以一句話來概括:就是簡單,在React hooks中沒有複雜的生命週期,沒有類元件中複雜的this指向,沒有類似於HOC,render props等複雜的元件複用模式等。本篇文章主要總結一下在React hooks工程實踐中的經驗。

  • React hooks中的渲染行為
  • React hooks中的效能優化
  • React hooks中的狀態管理和通訊

原文首發至我的部落格: github.com/fortheallli…


一、React hooks中的渲染行為

1.React hooks元件是如何渲染的

    理解React hooks的關鍵,就是要明白,hooks元件的每一次渲染都是獨立,每一次的render都是一個獨立的作用域,擁有自己的props和states、事件處理函式等。概括來講:

每一次的render都是一個互不相關的函式,擁有完全獨立的函式作用域,執行該渲染函式,返回相應的渲染結果

而類元件則不同,類元件中的props和states在整個生命週期中都是指向最新的那次渲染.

React hooks元件和類元件的在渲染行為中的區別,看起來很繞,我們可以用圖來區別,

未命名檔案 (6)的副本

    上圖表示在React hooks元件的渲染過程,從圖中可以看出,react hooks元件的每一次渲染都是一個獨立的函式,會生成渲染區專屬的props和state. 接著來看類元件中的渲染行為:

未命名檔案 (6)

    類元件中在渲染開始的時候會在類元件的建構函式中生成一個props和state,所有的渲染過程都是在一個渲染函式中進行的並且,每一次的渲染中都不會去生成新的state和props,而是將值賦值給最開始被初始化的this.props和this.state。

2.工程中注意React hooks的渲染行為

    理解了React hooks的渲染行為,就指示了我們如何在工程中使用。首先因為React hooks元件在每一次渲染的過程中都會生成獨立的所用域,因此,在元件內部的子函式和變數等在每次生命的時候都會重新生成,因此我們應該減少在React hooks元件內部宣告函式。

寫法一:

function App() {
  const [counter, setCounter] = useState(0);
  function formatCounter(counterVal) {
    return `The counter value is ${counterVal}`;
  }
  return (
    <div className="App">
      <div>{formatCounter(counter)}</div>
      <button onClick={() => setCounter(prevState => ++prevState)}>
        Increment
      </button>
    </div>
  );
}

複製程式碼

寫法二:

function formatCounter(counterVal) {
  return `The counter value is ${counterVal}`;
}
function App() {
 const [counter, setCounter] = useState(0);
 return (
   <div className="App">
     <div>{formatCounter(counter)}</div>
     <button onClick={()=>onClick(setCounter)}>
       Increment
     </button>
   </div>
 );
}

複製程式碼

    App元件是一個hooks元件,我們知道了React hooks的渲染行為,那麼寫法1在每次render的時候都會去重新宣告函式formatCounter,因此是不可取的。我們推薦寫法二,如果函式與元件內的state和props無相關性,那麼可以宣告在元件的外部。如果函式與元件內的state和props強相關性,那麼我們下節會介紹useCallback和useMemo的方法。

    React hooks中的state和props,在每次渲染的過程中都是重新生成和獨立的,那麼我們如果需要一個物件,從開始到一次次的render1 , render2, ...中都是不變的應該怎麼做呢。(這裡的不變是不會重新生成,是引用的地址不變的意思,其值可以改變)

我們可以使用useRef,建立一個“常量”,該常量在元件的渲染期內始終指向同一個引用地址。

通過useRef,可以實現很多功能,比如在某次渲染的時候,拿到前一次渲染中的state。

function App(){
   const [count,setCount] = useState(0)
   const prevCount = usePrevious(count);
   return (
    <div>
      <h1>Now: {count}, before: {prevCount}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
   );
}
function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}
複製程式碼

上述的例子中,我們通過useRef()建立的ref物件,在整個usePrevious元件的週期內都是同一個物件,我們可以通過更新ref.current的值,來在App元件的渲染過程中,記錄App元件渲染中前一次渲染的state.

這裡其實還有一個不容易理解的地方,我們來看usePrevious:

function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}
複製程式碼

這裡的疑問是:為什麼當value改變的時候,返回的ref.current指向的是value改變之前的值?

也就是說:

為什麼useEffect在return ref.current之後才執行?

為了解釋這個問題,我們來聊聊神奇的useEffect.

3.神奇的useEffect

    hooks元件的每一次渲染都可以看成一個個獨立的函式 render1,render2 ... rendern,那麼這些render函式之間是怎麼關聯的呢,還有上小節的問題,為什麼在usePrevious中,useEffect在return ref.current之後才執行。帶著這兩個疑問我們來看看在hooks元件中,最為神奇的useEffect。

    用一句話概括就是:

每一渲染都會生成不同的render函式,並且每一次渲染通過useEffect會生成一個不同的Effects,Effects在每次渲染後聲效。

每次渲染除了生成不同的作用域外,如果該hooks元件中使用了useEffect,通過useEffect還會生成一個獨有的effects,該effects在渲染完成後生效。

舉例來說:

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

複製程式碼

上述的例子中,完成的邏輯是:

  • 渲染初始的內容:<p>You clicked 0 times</p>
  • 渲染完成之後呼叫這個effect:{ document.title = 'You clicked 0 times' }。
  • 點選Click me
  • 渲染新的內容渲染的內容: <p>You clicked 1 times</p>
  • 渲染完成之後呼叫這個effect:() => { document.title = 'You clicked 1 times' }。

也就是說每次渲染render中,effect位於同步執行佇列的最後面,在dom更新或者函式返回後在執行。

我們在來看usePrevious的例子:

function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}
複製程式碼

因為useEffect的機制,在新的渲染過程中,先返回ref.current再執行deps依賴更新ref.current,因此usePrevios總是返回上一次的值。

現在我們知道,在一次渲染render中,有自己獨立的state,props,還有獨立的函式作用域,函式定義,effects等,實際上,在每次render渲染中,幾乎所有都是獨立的。我們最後來看兩個例子:

(1)

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>
  );
}

複製程式碼

(2)

function Counter() {
  const [count, setCount] = useState(0);
  
  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>
  );
}

複製程式碼

這兩個例子中,我們在3內點選5次Click me按鈕,那麼輸出的結果都是一樣的。

You clicked 0 times You clicked 1 times You clicked 2 times You clicked 3 times You clicked 4 times You clicked 5 times

    總而言之,每一次渲染的render,幾乎都是獨立和獨有的,除了useRef建立的物件外,其他物件和函式都沒有相關性.

二、React hooks中的效能優化

    前面我們講了React hooks中的渲染行為,也初步 提到了說將與state和props無關的函式,宣告在hooks元件外面可以提高元件的效能,減少每次在渲染中重新宣告該無關函式. 除此之外,React hooks還提供了useMemo和useCallback來優化元件的效能.

(1).useCallback

有些時候我們必須要在hooks元件內定義函式或者方法,那麼推薦用useCallback快取這個方法,當useCallback的依賴項不發生變化的時候,該函式在每次渲染的過程中不需要重新宣告

useCallback接受兩個引數,第一個引數是要快取的函式,第二個引數是一個陣列,表示依賴項,當依賴項改變的時候會去重新宣告一個新的函式,否則就返回這個被快取的函式.

function formatCounter(counterVal) {
  return `The counter value is ${counterVal}`;
}
function App(props) {
 const [counter, setCounter] = useState(0);
 const onClick = useCallback(()=>{
   setCounter(props.count)
 },[props.count]);
 return (
   <div className="App">
     <div>{formatCounter(counter)}</div>
     <button onClick={onClick}>
       Increment
     </button>
   </div>
 );
}
複製程式碼

上述例子我們在第一章的例子基礎上增加了onClick方法,並快取了這個方法,只有props中的count改變的時候才需要重新生成這個方法。

(2).useMemo

    useMemo與useCallback大同小異,區別就是useMemo快取的不是函式,快取的是物件(可以是jsx虛擬dom物件),同樣的當依賴項不變的時候就返回這個被快取的物件,否則就重新生成一個新的物件。

為了實現元件的效能優化,我們推薦:

在react hooks元件中宣告的任何方法,或者任何物件都必須要包裹在useCallback或者useMemo中。

(3)useCallback,useMemo依賴項的比較方法

    我們來看看useCallback,useMemo的依賴項,在更新前後是怎麼比較的

import is from 'shared/objectIs';
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  if (prevDeps === null) {
    return false;
  }

  
 if (nextDeps.length !== prevDeps.length) {
   return false
 }
 
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++)   {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}
複製程式碼

其中is方法的定義為:

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) 
  );
}

export default (typeof Object.is === 'function' ? Object.is : is);

複製程式碼

這個is方法就是es6的Object.is的相容性寫法,也就是說在useCallback和useMemo中的依賴項前後是通過Object.is來比較是否相同的,因此是淺比較。

三、React hooks中的狀態管理和通訊

  react hooks中的區域性狀態管理相比於類元件而言更加簡介,那麼如果我們元件採用react hooks,那麼如何解決元件間的通訊問題。

(1) UseContext

  最基礎的想法可能就是通過useContext來解決元件間的通訊問題。

比如:

function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContext(null)

function CounterDisplay() {
  let counter = useContext(Counter)
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  let counter = useCounter()
  return (
    <Counter.Provider value={counter}>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

複製程式碼

  在這個例子中通過createContext和useContext,可以在App的子元件CounterDisplay中使用context,從而實現一定意義上的元件通訊。

此外,在useContext的基礎上,為了其整體性,業界也有幾個比較簡單的封裝:

github.com/jamiebuilds… github.com/diegohaz/co…

但是其本質都沒有解決一個問題:

如果context太多,那麼如何維護這些context

  也就是說在大量元件通訊的場景下,用context進行元件通訊程式碼的可讀性很差。這個類元件的場景一致,context不是一個新的東西,雖然用了useContext減少了context的使用複雜度。

(2) Redux結合hooks來實現元件間的通訊

  hooks元件間的通訊,同樣可以使用redux來實現。也就是說:

在React hooks中,redux也有其存在的意義

  在hooks中存在一個問題,因為不存在類似於react-redux中connect這個高階元件,來傳遞mapState和mapDispatch, 解決的方式是通過redux-react-hook或者react-redux的7.1 hooks版本來使用。

  • redux-react-hook

  在redux-react-hook中提供了StoreContext、useDispatch和useMappedState來操作redux中的store,比如定義mapState和mapDispatch的方式為:

import {StoreContext} from 'redux-react-hook';

ReactDOM.render(
  <StoreContext.Provider value={store}>
    <App />
  </StoreContext.Provider>,
  document.getElementById('root'),
);

import {useDispatch, useMappedState} from 'redux-react-hook';

export function DeleteButton({index}) {
  // Declare your memoized mapState function
  const mapState = useCallback(
    state => ({
      canDelete: state.todos[index].canDelete,
      name: state.todos[index].name,
    }),
    [index],
  );

  // Get data from and subscribe to the store
  const {canDelete, name} = useMappedState(mapState);

  // Create actions
  const dispatch = useDispatch();
  const deleteTodo = useCallback(
    () =>
      dispatch({
        type: 'delete todo',
        index,
      }),
    [index],
  );

  return (
    <button disabled={!canDelete} onClick={deleteTodo}>
      Delete {name}
    </button>
  );
}
複製程式碼
  • react-redux 7.1的hooks版

   這也是官方較為推薦的,react-redux 的hooks版本提供了useSelector()、useDispatch()、useStore()這3個主要方法,分別對應與mapState、mapDispatch以及直接拿到redux中store的例項.

簡單介紹一下useSelector,在useSelector中除了能從store中拿到state以外,還支援深度比較的功能,如果相應的state前後沒有改變,就不會去重新的計算.

舉例來說,最基礎的用法:

import React from 'react'
import { useSelector } from 'react-redux'

export const TodoListItem = props => {
  const todo = useSelector(state => state.todos[props.id])
  return <div>{todo.text}</div>
}
複製程式碼

實現快取功能的用法:

import React from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'

const selectNumOfDoneTodos = createSelector(
  state => state.todos,
  todos => todos.filter(todo => todo.isDone).length
)

export const DoneTodosCounter = () => {
  const NumOfDoneTodos = useSelector(selectNumOfDoneTodos)
  return <div>{NumOfDoneTodos}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of done todos:</span>
      <DoneTodosCounter />
    </>
  )
}

複製程式碼

在上述的快取用法中,只要todos.filter(todo => todo.isDone).length不改變,就不會去重新計算.

相關文章