所有文章

React 狀態管理模式

狀態定義

React 應用有四種不同的狀態:

React 應用的四種狀態

  • 全局狀態:在組件樹外部維護的狀態,典型者為 Redux。此處的狀態能為應用中任意一處所訪問。
  • 組件狀態:在組件(container)中透過 setState 持有並維護的狀態。
  • 相對狀態:從父組件傳至子組件的狀態。
  • 供給狀態:被一個 provider 置入上下文(使用 React 的 Context API)的狀態,能被組件單獨取用而毋須在組件樹中傳遞。

Pattern 1: Prop-drilling

Prop-drilling pattern

這是最為常見的模式。其理念是僅有 containers(smart components)負責創建狀態,也僅有它們可以取用全局 store 的狀態。它們能透過 setState 創建其自己的狀態,並用 props 傳給組件樹。

當全局狀態需要變化,該變化會在 container 處激發(如 Redux 的 dispatch 一個 action)。

Prop-drilling pattern: 全局狀態的變動只能發生在 container

這個模式有三種類型的狀態:全局狀態、組件狀態及相對狀態。

優點在於職責定義清晰。我們知道全局狀態的變動只能發生在 container,而組件接收的狀態只能來自於 container。

缺點來自相對狀態。當傳遞多於兩層時,就會變得混亂。

Pattern 2: Redux-centric

Redux-centric pattern

與前一個模式不同之處在於,dumb components 現在不那麼 dumb 了。它們能自己訪問到全局狀態,而毋需 Container 傳遞。

但這帶來的壞處是模糊了職責的定義,現在 container 和 dumb components 皆能自行訪問全局狀態;dumb components 何時應接收相對狀態、何時應使用全局狀態亦不明確。

最重要的是,全局狀態並不遵循 React 組件的生命週期。如果使用相對狀態模式,當 container 卸載時,其狀態會重置,意味著從 container 接收數據的組件不再擁有那些 props。但是如果該組件是從全局拉取其狀態的,而當其卸載時,該狀態仍存在於全局。當它重新掛載時,將會收到如舊的數據。這通常引致 bug。組件現在與相關的全局狀態緊密耦合了,降低其復用性。

一個方法是在組件卸載時重置該部分全局狀態。但這也說明了如果需要這樣做,或許更宜使用組件內的狀態。

Pattern 3: Provided State

Provided state pattern

React v16.3 後正式支援 Context API。每個 context 都有一個 providerconsumer。在上圖中,Containers 是 context 的 providers,而 Components 是 consumers。Containers 能從全局狀態中承繼 state,亦能自己創造 state 並「provide」予 context。然後 Components 能從 context 中挑揀所需的 props,而毋須依賴 container 或全局狀態。

這種分工類似於 pattern 1——containers 是唯一能操縱並使用全局狀態的組件,components 只從 context 處獲取 props 並展現之。

這種模式解決了之前的問題:

  1. 由於 component 能從 context 處獲取其所需的任意內容,毋須從 container 傳遞,雖然一定程度上還需要組件間的相對狀態,但完全消弭了 prop-drilling 問題。
  2. 毋須再顧慮 component 位於組件樹中的何處,增強了復用性。在 pattern 1 中,一旦 component 被置於組件樹中並接收 props,它就不能被隨意移動——除非重構其 props 來源。
  3. 每個 context state 都與 provider 的生命週期綁定起來,當向 context 提供 state 的 container 卸載時,所有 state 都自動 reset,亦是減少耦合、提高復用。

一個 provided pattern 例子

// table.container.js

import React, { Component } from 'react'
import { Table, Body, Header, TableContext } from '../table.component'

export default class TableContainer extends Component {
  state = {
    activeRow: null,
    data: [{
      id: 1,
      year: '1961',
      make: 'Jaguar',
      model: 'E-Type'
    }, {
      id: 2,
      year: '1969',
      make: 'Farrari',
      model: '365 GT 2 + 2'
    }],
    columns: [{
      name: 'id',
      editable: false
    }, {
      name: 'year',
      editable: true,
    }, {
      name: 'make',
      editable: true
    }, {
      name: 'model',
      editable: true
    }]
  }

  render() {
    const { data, columns, activeRow } = this.state
    return (
      <TableContext.Provider
        value={{
          data,
          columns,
          activeRow,
          selectRow: id => this.setState({ activeRow: id }),
          updateRow: (...args) => this.updateRow(...args)
        }}>
        <Table>
          <Header />
          <Body />
        </Table>
        {activeRow && (
          <button onClick={() => this.setState({ activeRow: null })}>Done</button>
        )}
      </TableContext.Provider>
    )
  }

  updateRow(rowId, field, value) {
    const { data } = this.state
    const index = data.findIndex(({ id }) => id === rowId)
    data[index][field] = value
    this.setState({ data })
  }
}

// table.component.js

import React, { createContext } from 'react'
import { Input } from '../shared/inputs'

export const TableContext = createContext()

const Row = ({ row }) => (
  <TableContext.Consumer>
    {({ columns }) =>
      columns.map((field, i) => <td key={i}>{row[field.name]}</td>)
    }
  </TableContext.Consumer>
)

const EditRow = ({ row }) => (
  <TableContext.Consumer>
    {({ columns, updateRow }) =>
      columns.map((field, i) => (
        <td key={i}>
          {field.editable ? (
            <input
              value={row[field.name]}
              onChange={e => updateRow(row.id, field.name, e.target.value)} />
          ) : (
            row[field.name]
          )}
        </td>
      ))
    }
  </TableContext.Consumer>
)

export const Body = () => (
  <TableContext.Consumer>
    {({ columns, data, activeRow, selectRow }) => (
      <tbody>
        {data.map((row, i) => (
          <tr key={row.id} onClick={() => selectRow(row.id)}>
            {activeRow === row.id ? <EditRow row={row} /> : <Row row={row} />}
          </tr>
        ))}
      </tbody>
    )}
  </TableContext.Consumer>
)

export const Header = () => (
  <TableContext.Consumer>
    {({ columns }) => (
      <thead>
        <tr>{columns.map((field, i) => <th key={i}>{field.name}</th>)}</tr>
      </thead>
    )}
  </TableContext.Consumer>
)

export const Table = ({ children }) => <table>{children}</table>

完 :)

發佈於 2 Sep 2018

慎獨|純亦不已
Jason Lam on Twitter