Edit This Page

計算衍生資料

Reselect 是一個簡單的 library 可以用來建立會自動記憶、可組合的 selector function。可以用 Reselect 的 selector 來有效率的從 Redux store 計算衍生資料。

使用 Memoized Selector 的動機

讓我們再來看看 Todos 清單的範例

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'

const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case 'SHOW_ALL':
      return todos
    case 'SHOW_COMPLETED':
      return todos.filter(t => t.completed)
    case 'SHOW_ACTIVE':
      return todos.filter(t => !t.completed)
  }
}

const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state.todos, state.visibilityFilter)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

在上面的範例中,mapStateToProps 呼叫 getVisibleTodos 以計算 todos。這運作得很好,不過有一個缺點:在每次 component 更新的時候都會計算 todos。如果 state tree 很大,或是計算的代價很大,在每次更新的時候重複計算可能會造成效能問題。Reselect 可以幫助我們避免這些不需要的重複計算。

建立一個 Memoized Selector

我們會想要把 getVisibleTodos 換成一個 memoized selector,它會在 state.todos 或是 state.visibilityFilter 的值變更的時後重新計算 todos,但當變更發生在 state tree 其他 (不相關的) 的部分的時候則不會。

Reselect 提供一個 createSelector function 來建立 memoized selector。createSelector 接收一個 input-selector 陣列和一個轉換 function 當作它的參數。如果用一個會造成 input-selector 的值改變的方式去變動 Redux 的 state tree,selector 將會以 input-selector 的值作為參數呼叫它的轉換 function 並回傳結果。如果 input-selector 的值跟先前呼叫 selector 的時候一樣,它會回傳先前的計算結果而不會呼叫轉換 function。

讓我們來定義一個叫做 getVisibleTodos 的 memoized selector 來取代上面的 non-memoized 版本:

selectors/index.js

import { createSelector } from 'reselect'

const getVisibilityFilter = (state) => state.visibilityFilter
const getTodos = (state) => state.todos

export const getVisibleTodos = createSelector(
  [ getVisibilityFilter, getTodos ],
  (visibilityFilter, todos) => {
    switch (visibilityFilter) {
      case 'SHOW_ALL':
        return todos
      case 'SHOW_COMPLETED':
        return todos.filter(t => t.completed)
      case 'SHOW_ACTIVE':
        return todos.filter(t => !t.completed)
    }
  }
)

在上面的範例中,getVisibilityFiltergetTodos 就是 input-selector。因為它們沒有轉換它們選擇的資料,所以被建立成普通的非 memoized selector function。而另一方面,getVisibleTodos 是一個 memoized selector。它接收 getVisibilityFiltergetTodos 作為 input-selector,以及一個計算過濾後的 todos 清單的轉換 function。

組合 Selector

一個 memoized selector 可以是另一個 memoized selector 的 input-selector。這裡 getVisibleTodos 被用來當作 selector 的 input-selector,以藉由 keyword 進一步的過濾 todos:

const getKeyword = (state) => state.keyword

const getVisibleTodosFilteredByKeyword = createSelector(
  [ getVisibleTodos, getKeyword ],
  (visibleTodos, keyword) => visibleTodos.filter(
    todo => todo.text.indexOf(keyword) > -1
  )
)

把 Selector 連結到 Redux Store

如果你是使用 React Redux,你可以在 mapStateToProps() 內呼叫 selector 當作正規的 function 使用:

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { getVisibleTodos } from '../selectors'

const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

在 Selector 存取 React Props

這個部份我們假設我們需要擴充我們的應用程式,使它能夠支援多個 Todo 清單。請注意,這個擴充的完整實作需要改變 reducer、component、action 等等。這和我們要討論的主題沒有直接關係,為了方便我們省略這個部份。

到目前為止,我們只看到 selector 接收 Redux store state 作為參數,但是 selector 也可以接收 props。

這是一個 App component ,它 render 三個 VisibleTodoList component,每個元件都有一個 listId prop:

components/App.js

import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'

const App = () => (
  <div>
    <VisibleTodoList listId="1" />
    <VisibleTodoList listId="2" />
    <VisibleTodoList listId="3" />
  </div>
)

每個 VisibleTodoList container 應該根據 listId prop 去選擇不同的 state 部分,因此讓我們修改 getVisibilityFiltergetTodos 來接受 props 參數:

selectors/todoSelectors.js

import { createSelector } from 'reselect'

const getVisibilityFilter = (state, props) =>
  state.todoLists[props.listId].visibilityFilter

const getTodos = (state, props) =>
  state.todoLists[props.listId].todos

const getVisibleTodos = createSelector(
  [ getVisibilityFilter, getTodos ],
  (visibilityFilter, todos) => {
    switch (visibilityFilter) {
      case 'SHOW_COMPLETED':
        return todos.filter(todo => todo.completed)
      case 'SHOW_ACTIVE':
        return todos.filter(todo => !todo.completed)
      default:
        return todos
    }
  }
)

export default getVisibleTodos

可以從 mapStateToPropsprops 傳遞到 getVisibleTodos:

const mapStateToProps = (state, props) => {
  return {
    todos: getVisibleTodos(state, props)
  }
}

所以現在 getVisibleTodos 可以存取 props,這一切看起來都運作的很正常。

但是這裡還有一個問題!

在多個 visibleTodoList container 的實體上使用 getVisibleTodos selector 會無法正常的 memoize:

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { getVisibleTodos } from '../selectors'

const mapStateToProps = (state, props) => {
  return {
    // 警告:以下的 SELECTOR 不能正確的 MEMOIZE
    todos: getVisibleTodos(state, props)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

當設定的參數和先前的參數相同時,透過 createSelector 建立的 selector 只會回傳快取的值。如果我們交替 render <VisibleTodoList listId="1" /><VisibleTodoList listId="2" />,共用的 selector 會交替取得 {listId: 1}{listId: 2} 做為 props 參數。這會導致每次呼叫時參數都會不同,所以 selector 會一直重新計算而不是回傳快取的值。我們會在接下來的部份看到要如何克服這個限制。

跨越多個 Component 共用 Selector

這個部份的範例需要 React Redux v4.3.0 或是更高的版本

為了跨越多個 VisibleTodoList component 共用一個 selector 以及保留 memoization,每個 component 實體需要有自己私有的 selector 副本。

讓我們建立一個叫做 makeGetVisibleTodos 的 function,在每次被呼叫的時候,可以回傳一個新的 getVisibleTodos selector 副本:

selectors/todoSelectors.js

import { createSelector } from 'reselect'

const getVisibilityFilter = (state, props) =>
  state.todoLists[props.listId].visibilityFilter

const getTodos = (state, props) =>
  state.todoLists[props.listId].todos

const makeGetVisibleTodos = () => {
  return createSelector(
    [ getVisibilityFilter, getTodos ],
    (visibilityFilter, todos) => {
      switch (visibilityFilter) {
        case 'SHOW_COMPLETED':
          return todos.filter(todo => todo.completed)
        case 'SHOW_ACTIVE':
          return todos.filter(todo => !todo.completed)
        default:
          return todos
      }
    }
  )
}

export default makeGetVisibleTodos

我們也需要給每個 container 的實體存取本身私有的 selector 方法。connectmapStateToProps 參數可以幫助我們。

如果提供給 connectmapStateToProps 參數回傳的是 function,而不是物件,它會被用來在對每個 container 的實體中建立一個獨立的 mapStateToProps function。

在下面的範例 makeMapStateToProps 建立一個新的 getVisibleTodos selector,並回傳一個可以獨立存取的新 selector 的 mapStateToProps function:

const makeMapStateToProps = () => {
  const getVisibleTodos = makeGetVisibleTodos()
  const mapStateToProps = (state, props) => {
    return {
      todos: getVisibleTodos(state, props)
    }
  }
  return mapStateToProps
}

如果我們傳遞 makeMapStateToPropsconnect,每一個 VisibleTodosList container 的實體都會得到它自己的 mapStateToProps function 以及一個私有的 getVisibleTodos selector。 現在不管這些 VisibleTodoList container 的 render 順序,Memoization 都將會正常的運作。

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { makeGetVisibleTodos } from '../selectors'

const makeMapStateToProps = () => {
  const getVisibleTodos = makeGetVisibleTodos()
  const mapStateToProps = (state, props) => {
    return {
      todos: getVisibleTodos(state, props)
    }
  }
  return mapStateToProps
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  makeMapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

接下來

查看 Reselect 官方文件和它的 FAQ。大部分 Redux 專案遇到因太多衍生運算和浪費地重複 render 而造成效能問題時,都已開始使用 Reselect 。所以在你開始建構某些大專案前確保你已熟悉它。讀它的原始碼也很有幫助,這讓你不會覺得它很神奇。