[譯] React-Redux 官方 Hooks 文件說明

塔希發表於2019-09-07

Hooks

React的新 "hooks" APIs 賦予了函式元件使用本地元件狀態,執行副作用,等各種能力。

React Redux 現在提供了一系列 hook APIs 作為現在 connect() 高階元件的替代品。這些 APIs 允許你,在不使用 connect() 包裹元件的情況下,訂閱 Redux 的 store,和 分發(dispatch) actions。

這些 hooks 首次新增於版本 v7.1.0。

在一個 React Redux 應用中使用 hooks

和使用 connect() 一樣,你首先應該將整個應用包裹在 <Provider> 中,使得 store 暴露在整個元件樹中。

const store = createStore(rootReducer)

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)
複製程式碼

然後,你就可以 import 下面列出的 React Redux hooks APIs,然後在函式元件中使用它們。

useSelector()

const result : any = useSelector(selector : Function, equalityFn? : Function)
複製程式碼

通過傳入 selector 函式,你就可以從從 Redux 的 store 中獲取 狀態(state) 資料。

警告: selector 函式應該是個純函式,因為,在任意的時間點,它可能會被執行很多次。

從概念上講,selector 函式與 connectmapStateToProps 的引數是差不多一樣的。selector 函式被呼叫時,將會被傳入Redux store的整個state,作為唯一的引數。每次函式元件渲染時, selector 函式都會被呼叫。useSelector()同樣會訂閱 Redux 的 sotre,並且在你每 分發(dispatch) 一個 action 時,都會被執行一次。

儘管如此,傳遞給 useSelector() 的各種 selector 函式還是和 mapState 函式有些不一樣的地方:

  • selector 函式可以返回任意型別的值,並不要求是一個 物件(object)。selector 函式的返回值會被用作呼叫 useSelector() hook 時的返回值。
  • 當 分發(dispatch) 了一個 action 時,useSelector() 會將上一次呼叫 selector 函式結果與當前呼叫的結果進行引用(===)比較,如果不一樣,元件會被強制重新渲染。如果一樣,就不會被重新渲染。
  • selector 函式不會接收到 ownProps 引數。但是 props 可以通過閉包獲取使用(下面有個例子) 或者 通過使用柯里化的 selector 函式。
  • 當使用 記憶後(memoizing) 的 selectors 函式時,需要一些額外的注意(下面有個例子幫助瞭解)。
  • useSelector() 預設使用嚴格比較 === 來比較引用,而非淺比較。(看下面的部分來了解細節)

譯者注: 淺比較並不是指 ==。嚴格比較 === 對應的是 疏鬆比較 ==,與 淺比較 對應的是 深比較

警告: 在 selectors 函式中使用 props 時存在一些邊界用例可能導致錯誤。詳見本頁的 使用警告 小節。

你可以在一個函式元件中多次呼叫 useSelector()。每一個 useSelector() 的呼叫都會對 Redux 的 store 建立的一個獨立的 訂閱(subscription)。由於 Redux v7 的 批量更新(update batching) 行為,對於一個元件來說,如果一個 分發後(dispatched) 的 action 導致元件內部的多個 useSelector() 產生了新值,那麼僅僅會觸發一次重渲染。

相等比較(Equality Comparisons) 和更新

當一個函式元件渲染時,傳入的 selector 函式會被呼叫,其結果會作為 useSelector() 的返回值進行返回。(如果 selector 已經執行過,且沒有發生變化,可能會返回快取後的結果)

不管怎樣,當一個 action 被分發(dispatch) 到 Redux store 後,useSelector() 僅僅在 selector 函式執行的結果與上一次結果不同時,才會觸發重渲染。在版本v7.1.0-alpha.5中,預設的比較模式是嚴格引用比較 ===。這與 connect() 中的不同, connect() 使用淺比較來比較 mapState 執行後的結果,從而決定是否觸發重渲染。這裡有些建議關於如何使用useSelector()

對於 mapState 來講,所有獨立的狀態域被繫結到一個物件(object) 上返回。返回物件的引用是否是新的並不重要——因為 connect() 會單獨的比較每一個域。對於 useSelector() 來說,返回一個新的物件引用總是會觸發重渲染,作為 useSelector() 預設行為。如果你想獲得 store 中的多個值,你可以:

  • 多次呼叫 useSelector(),每次都返回一個單獨域的值

  • 使用 Reselect 或類似的庫來建立一個記憶化的 selector 函式,從而在一個物件中返回多個值,但是僅僅在其中一個值改變時才返回的新的物件。

  • 使用 React-Redux shallowEqual 函式作為 useSelector()equalityFn 引數,如:

import { shallowEqual, useSelector } from 'react-redux'

// later
const selectedData = useSelector(selectorReturningObject, shallowEqual)
複製程式碼

這個可選的比較函式引數使得我們可以使用 Lodash 的 _.isEqual() 或 Immutable.js 的比較功能。

useSelector 例子

基本用法:

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

export const CounterComponent = () => {
  const counter = useSelector(state => state.counter)
  return <div>{counter}</div>
}
複製程式碼

通過閉包使用 props 來選擇取回什麼狀態:

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>
}
複製程式碼

使用記憶化的 selectors 函式

當像上方展示的那樣,在使用 useSelector 時使用單行箭頭函式,會導致在每次渲染期間都會建立一個新的 selector 函式。可以看出,這樣的 selector 函式並沒有維持任何的內部狀態。但是,記憶化的 selectors 函式 (通過 reselect 庫中 的 createSelector 建立) 含有內部狀態,所以在使用它們時必須小心。

當一個 selector 函式依賴於某個 狀態(state) 時,確保函式宣告在元件之外,這樣就不會導致相同的 selector 函式在每一次渲染時都被重複建立:

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 />
    </>
  )
}
複製程式碼

這種做法同樣適用於依賴元件 props 的情況,但是僅適用於單例的元件的形式

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

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

export const TodoCounterForIsDoneValue = ({ isDone }) => {
  const NumOfTodosWithIsDoneValue = useSelector(state =>
    selectNumOfTodosWithIsDoneValue(state, isDone)
  )

  return <div>{NumOfTodosWithIsDoneValue}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of done todos:</span>
      <TodoCounterForIsDoneValue isDone={true} />
    </>
  )
}
複製程式碼

如果, 你想要在多個元件例項中使用相同的依賴元件 props 的 selector 函式,你必須確保每一個元件例項建立屬於自己的 selector 函式(這裡解釋了為什麼這樣做是必要的)

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

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

export const TodoCounterForIsDoneValue = ({ isDone }) => {
  const selectNumOfTodosWithIsDone = useMemo(
    makeNumOfTodosWithIsDoneSelector,
    []
  )

  const numOfTodosWithIsDoneValue = useSelector(state =>
    selectNumOfTodosWithIsDone(state, isDone)
  )

  return <div>{numOfTodosWithIsDoneValue}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of done todos:</span>
      <TodoCounterForIsDoneValue isDone={true} />
      <span>Number of unfinished todos:</span>
      <TodoCounterForIsDoneValue isDone={false} />
    </>
  )
}
複製程式碼

被移除的:useActions()

useActions() 已經被移除

useDispatch()

const dispatch = useDispatch()
複製程式碼

這個 hook 返回 Redux store 的 分發(dispatch) 函式的引用。你也許會使用來 分發(dispatch) 某些需要的 action。

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

export const CounterComponent = ({ value }) => {
  const dispatch = useDispatch()

  return (
    <div>
      <span>{value}</span>
      <button onClick={() => dispatch({ type: 'increment-counter' })}>
        Increment counter
      </button>
    </div>
  )
}
複製程式碼

在將一個使用了 dispatch 函式的回撥函式傳遞給子元件時,建議使用 useCallback 函式將回撥函式記憶化,防止因為回撥函式引用的變化導致不必要的渲染。

譯者注:這裡的建議其實和 dispatch 沒關係,無論是否使用 dispatch,你都應該確保回撥函式不會無故變化,然後導致不必要的重渲染。之所以和 dispatch 沒關係,是因為,一旦 dispatch 變化,useCallback 會重新建立回撥函式,回撥函式的引用鐵定發生了變化,然而導致不必要的重渲染。

import React, { useCallback } from 'react'
import { useDispatch } from 'react-redux'

export const CounterComponent = ({ value }) => {
  const dispatch = useDispatch()
  const incrementCounter = useCallback(
    () => dispatch({ type: 'increment-counter' }),
    [dispatch]
  )

  return (
    <div>
      <span>{value}</span>
      <MyIncrementButton onIncrement={incrementCounter} />
    </div>
  )
}

export const MyIncrementButton = React.memo(({ onIncrement }) => (
  <button onClick={onIncrement}>Increment counter</button>
))
複製程式碼

useStore()

const store = useStore()
複製程式碼

這個 hook 返回傳遞給 元件的 Redux sotore 的引用。

這個 hook 也許不應該被經常使用。 你應該將 useSelector() 作為你的首選。但是,在一些不常見的場景下,你需要訪問 store,這個還是有用的,比如替換 store 的 reducers。

例子

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

export const CounterComponent = ({ value }) => {
  const store = useStore()

  // EXAMPLE ONLY! Do not do this in a real app.
  // The component will not automatically update if the store state changes
  return <div>{store.getState()}</div>
}
複製程式碼

自定義 context

<Provider> 元件允許你通過 context 引數指定一個可選的 context。在你構建複雜的可複用的元件時,你不想讓你自己的私人 store 與使用這個元件的使用者的 Redux store 發生衝突,這個功能是很有用的,

通過使用 hook creator 函式來建立自定義 hook,從而訪問可選的 context。

import React from 'react'
import {
  Provider,
  createStoreHook,
  createDispatchHook,
  createSelectorHook
} from 'react-redux'

const MyContext = React.createContext(null)

// Export your custom hooks if you wish to use them in other files.
export const useStore = createStoreHook(MyContext)
export const useDispatch = createDispatchHook(MyContext)
export const useSelector = createSelectorHook(MyContext)

const myStore = createStore(rootReducer)

export function MyProvider({ children }) {
  return (
    <Provider context={MyContext} store={myStore}>
      {children}
    </Provider>
  )
}
複製程式碼

使用警告

過期 Props 和 "喪屍子元件"

有關 React Redux 實現一個難點在於,當你以 (state, ownProps) 形式定義 mapStateToProps 函式時,怎麼保證每次都以最新的 props 呼叫 mapStateToProps。version 4 中,在一些邊緣情況下,經常發生一些bug,比如一個列表中的某項被刪除時, mapState 函式內部會丟擲錯誤。

從 version 5 開始,React Redux 試圖保證 ownProps 引數的一致性。在 version 7 中,通過在 connect() 內部使用一個自定義的 Subscription 類,實現了這種保證,也導致了元件被層層巢狀的形式。這確保了元件樹深處 connect() 後的元件,只會在離自己最近的 connect() 後的祖先元件更新後,才會被通知 store 更新了。但是,這依賴於每個 connect() 的例項副高 React 內部部分的 context,隨後 connect() 提供了自己獨特的 Subscription 例項,將元件巢狀其中,提供一個新的 conext 值給 <ReactReduxContext.Provider>,再進行渲染。

使用 hooks,意味著無法渲染 <ReactReduxContext.Provider>,也意味著沒有巢狀的訂閱層級。因此,“過期 Props” 和 "喪屍子元件" 的問題可能再次發生在你使用 hooks 而非 connect() 應用中。

詳細的說,“過期 Props”可能發生的狀況在於:

  • 某個 selector 函式依賴元件的 props 來取回資料。
  • 在某個 action 分發後,父元件將會重渲染然後傳遞新的props給子元件
  • 但是子元件的 selector 函式在子元件以新props渲染前,先執行了。

取決於使用的 props 和 stroe 當前的 狀態(state) 是什麼,這可能導致返回不正確的資料,甚至丟擲一個錯誤。

"喪屍子元件" 特別指代下面這種情況:

  • 在剛開始,多個巢狀 connect() 後的元件一起被掛載,導致子元件的訂閱先於其父元件。

  • 一個 action 被 分發(dispatch) ,刪除了 store 中的某個資料,比如某個待做事項。

  • 父元件會停止渲染對應的子元件

  • 但是,因為子元件的訂閱先於父元件,其訂閱時的回撥函式的執行先於父元件停止渲染子元件。當子元件根據props取回對應的資料時,這個資料已經不存在了,而且,如果取回資料程式碼的邏輯不夠小心的話,可能會導致一個錯誤被丟擲。

useSelector() 通過捕獲所有 selector 內部因為 store 更新丟擲的錯誤(但不包括渲染時更新導致的錯誤),來應對"喪屍子元件"的問題。當產生了一個錯誤時,元件會被強制重渲染,此時,selector 函式會重新執行一次。注意,只有當你的 selector 函式是純函式且你的程式碼不依賴於 selector 丟擲的某些自定義錯誤時,這個應對策略才會正常工作。

如果你更想要自己處理這些問題,這裡有一些建議,在使用 useSelector() 時,可能幫助你避免這些問題。

  • 在 selector 函式不要依賴 props 來取回資料。

  • 對於你必須要依賴props,而且props經常改變的情況,以及,你取回的資料可能被刪除的情況下,試著帶有防禦性的 selector 函式。不要直接取回資料,如:state.todos[props.id].name - 先取回 state.todos[props.id],然後檢驗值是否存在,再嘗試取回 todo.name

  • 因為 connect 增添了必要 Subscription 元件給 context provider,且延遲子元件訂閱的執行,一直到 connect() 的元件重渲染後,在元件樹中,將一個 connect() 的元件置於使用了 useSelector 的元件之上,將會避免上述的問題,只要 connect() 的元件和使用了 hooks 子元件觸發重渲染是由同一個 store 更新引起的。

注意:如果你想要這個問題更詳細的描述,這個聊天記錄詳述了這個問題,以及 issue #1179.

效能

正如上文提到的,在一個 action 被分發(dispatch) 後,useSelector() 預設對 select 函式的返回值進行引用比較 ===,並且僅在返回值改變時觸發重渲染。但是,不同於 connect(),useSelector()並不會阻止父元件重渲染導致的子元件重渲染的行為,即使元件的 props 沒有發生改變。

如果你想要類似的更進一步的優化,你也許需要考慮將你的函式元件包裹在 React.memo() 中:

const CounterComponent = ({ name }) => {
  const counter = useSelector(state => state.counter)
  return (
    <div>
      {name}: {counter}
    </div>
  )
}

export const MemoizedCounterComponent = React.memo(CounterComponent)
複製程式碼

Hooks 配方

我們精簡了原來 alpha 版本的 hooks API,專注於更精小的,更基礎的 API。不過,在你的應用中,你可能依舊想要使用一些我們以前實現過的方法。下面例子中的程式碼已經準備好被複制到你的程式碼庫中使用了。

配方:useActions()

這個 hook 存在於原來 alpha 版本,但是在版本 v7.1.0-alpha.4 中,Dan Abramov 的建議下被移除了。建議表明了在使用 hook 的場景下,“對 action creators 進行繫結”沒以前那麼有用,且會導致更多概念上理解負擔和增加語法上的複雜度。

譯者注:action creators 即用來生成 action 物件的函式。

在元件中,你應該更偏向於使用 useDispatch hook 來獲得 dispatch 函式的引用,然後在回撥函式中手動的呼叫 dispatch(someActionCreator()) 或某種需要的副作用。在你的程式碼中,你仍然可以使用bindActionCreators 函式繫結 action creators,或手動的繫結它們,比如 const boundAddTodo = (text) => dispatch(addTodo(text))。

但是,如果你自己想要使用這個 hook,這裡有個 複製即可用 的版本,支援將 action creators 作為一個獨立函式、陣列、或一個物件傳入。

import { bindActionCreators } from 'redux'
import { useDispatch } from 'react-redux'
import { useMemo } from 'react'

export function useActions(actions, deps) {
  const dispatch = useDispatch()
  return useMemo(() => {
    if (Array.isArray(actions)) {
      return actions.map(a => bindActionCreators(a, dispatch))
    }
    return bindActionCreators(actions, dispatch)
  }, deps ? [dispatch, ...deps] : [dispatch])
}
複製程式碼

配方:useShallowEqualSelector()

import { useSelector, shallowEqual } from 'react-redux'

export function useShallowEqualSelector(selector) {
  return useSelector(selector, shallowEqual)
}
複製程式碼

相關文章