使用 Hooks 建立非同步元件

吳曉軍發表於2019-04-08

基於 Class 的思維方式

在 Hooks 之前,如果需要在元件中執行非同步任務,例如資料的增刪改查,我們只能使用 class 元件,這是因為,一方面,我們需要狀態來儲存任務的載入狀況、錯誤以及資料,另一方面,我們也需要生命週期來排程任務:

class List extends React.Component {
  state = {
    loading: false,
    error: null,
    data: [],
    page: 1,
    pageSize: 10
  }
  
  componentDidMount() {
    fetch()
  }
  
  componentDidUpdate(prevProps, prevState) {
    const { page, pageSize } = this.state
    if (prevState.page != page || prevState.pageSize != pageSize) {
      fetch()
    }
  }
  
  handlePaginationChange = (page, pageSize) => {
    this.setState({
      page,
      pageSize
    })
  }
  
  fetch = () => {
    // 設定載入態,重置錯誤
    this.setState({
      loading: true,
      error: null
    })
  
    API.fetchList({
      page: this.state.page,
      pageSize: this.state.pageSize
    }).then(data => {
      this.setState({
        loading: false,
        data
      })
    }).catch(error => {
      this.setState({
        loading: false,
        error,
        data: []
      })
    })
  }
  
  render() {
    const { page, pageSize, loading, data, error } = this.state
    return error 
      ? <Error error={error}> 
      : (
        <>
          <Table
            data={this.state.data}
            loading={loading}
          />
          <Pagination 
            page={page} 
            pageSize={pageSize} 
            onChange={this.handlePaginationChange}
          />
        </>
      )
  }
}
複製程式碼

上例的列表元件為我們展示了,基於 Class 建立一個非同步元件,我們需要:

  1. 建立並維護非同步服務所需要的狀態:loading, error 與 data
  2. 在元件生命期中,呼叫非同步任務,還需要留意任務的呼叫粒度控制(如上例中 componentDidUpdate 中的 if 分支)

基於 Hook 的思維方式

我們知道,當 React 推出了 Hooks 後,為原本單薄的函式元件帶來了:

  • 狀態管理:通過 useState hook
  • 副作用治理:通過 useEffect hook

這兩個能力能夠讓函式元件像類元件一樣,建立非同步任務,維護對應的資料狀態:

const List = props => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)
  const [data, setData] = useState(null)
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = uesState(10)
  
  useEffect(() => {
    setLoading(true)
    setError(null)
    API.fetchList({
      page,
      pageSize
    }).then(data => {
      setLoading(false)
      setData(data)
    }).catch(error => {
      setLoading(false)
      setError(error)
    })
  }, [page, pageSize])
  
  const handlePaginationChange = useCallback((page, pageSize) => {
    setPage(page)
    setPageSize(pageSize)
  }, [])
  
  
  return error 
    ? <Error error={error}> 
    : (
        <>
          <Table
            data={data}
            loading={loading}
          />
          <Pagination 
            page={page} 
            pageSize={pageSize} 
            onChange={handlePaginationChange}
          />
        </>
      )
}
複製程式碼

使用 hooks 與函式元件來建立非同步元件時,我們的關注的是:

  • 非同步副作用是什麼:即 useEffect 的第一個引數
  • 非同步副作用的依賴是什麼,即 useEffect 的第二個引數

上例中,非同步副作用即 API.fetchList,這個副作用依賴了兩個引數:pagepageSize

乍看之下,貌似我們仍在用 “狀態 + 副作用” 的方式編排非同步元件,但現在:

  • 我們不用再關注各個生命期:一個 useEffect 足夠
  • 我們不用再命令式地排程任務:只需要宣告任務的依賴,當依賴變動時,任務能被自動排程

這樣我們能夠用 React 進一步的實踐函式響應式程式設計(FRP)。類似的模式並不新鮮,幾年前 Cycle.js 就已經這麼做了:

import {run} from '@cycle/run'
import {div, label, input, hr, h1, makeDOMDriver} from '@cycle/dom'

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')

  const name$ = input$.map(ev => ev.target.value).startWith('')

  const vdom$ = name$.map(name =>
    div([
      label('Name:'),
      input('.field', {attrs: {type: 'text'}}),
      hr(),
      h1('Hello ' + name),
    ])
  )

  return { DOM: vdom$ }
}

run(main, { DOM: makeDOMDriver('#app-container') })
複製程式碼

不同的是,Cycle.js 偏愛 Hyperscript,React 則是 JSX,也沒有使用 FRP 框架或者 Observable(RxJS 或者 xstream)去組織依賴。

useService:響應式服務排程

上文中,使用了 hooks 和函式元件來建立非同步元件,相較於基於 class 建立的非同步元件,useEffect 砍掉了生命期,也砍掉了生命期內的命令式地排程粒度控制,程式碼著實精簡不少。但是這還不夠,我們仍能觀察到一些樣板程式碼:

  • 服務狀態的建立及維護:loading、error 與 data
  • 服務呼叫粒度的控制:當引數變動時,服務就該被排程,但現在,我們仍在 useEffect 中顯式地宣告它們

在繼續精簡程式碼之前,我們還需要明確一點:Hooks 的到來,並不只是為函式元件帶來了狀態管理及副作用治理的能力,我們使用 Hooks,也不只是去重複 class 元件能做的事兒。它的到來,更帶來了獨立於 HOC 和 render props 之外的是新的邏輯複用方式,我們可以將其歸納為:

使用 Hooks 建立非同步元件

即,配置了一個 Hook 之後,若宣告瞭依賴,則每當依賴變動,將獲得新的資料。基於此,我們就可以概括出非同步任務的對應的 Hook:

使用 Hooks 建立非同步元件

即,我們定義了非同步任務,並宣告其依賴為請求引數,那麼,useService hook 將為我們返回任務的載入狀況、報錯以及資料,另外,當任意介面引數變動時,服務也會被自動排程。

現在,在元件中,一個 hook 就能搞定非同步任務:

const List = props => {
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = uesState(10)
  
  const { loading, error, response } = useService(API.fetchList, {page, pageSize})
  
  const handlePaginationChange = useCallback((page, pageSize) => {
    setPage(page)
    setPageSize(pageSize)
  }, [])
  
  
  return error 
    ? <Error error={error} /> 
    : (
        <>
          <Table
            data={this.state.data}
            loading={loading}
          />
          <Pagination 
            page={page} 
            pageSize={pageSize} 
            onChange={handlePaginationChange}
          />
        </>
      )
}
複製程式碼

useService 的實現大致如下,要留意的是,在此實現中,我們對先後兩次的引數進行了深度比較,保證只有在引數變動時,請求才會被髮出:

import { isEqual } from 'lodash'

function useService(service, params) {
  const prevParams = useRef(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)
  const [response, setResponse] = useState(null)
  
  useEffect(() => {
    if (!isEqual(prevParams.current, params)) {
      prevParams.current = params
      setLoading(true)
      setError(null)
      service(params)
      .then(response => {
        setLoading(false)
        setResponse(response)
      })
      .catch(error => {
        setLoading(false)
        setError(error)
      })
    }
  })
  
  return { loading, error, response }
}
複製程式碼

這個例子你可以在 CodeSandbox 檢視,嘗試切換頁碼,或者頁面大小,你能看到請求被髮出,而切換表格尺寸時,因為引數沒有發生變化,請求將不會發出,即 effect 不會被執行。

誠然,我們也可以使用 HOC 或者 render props 實現上面的複用,比如下面展示的這樣,但它們在層次上容易形成元件巢狀,不如 Hooks + 函式元件那樣簡潔直接:

class List extends React.Component {
  // ...
  
  render() {
    const { page, pageSize } = this.state
  
    return (
      <Service params={{page, pageSize}} service={fetchList}>
      {({loading, error, response}) => error 
        ? <Error error={error} /> 
        : (
            <>
              <Table
                data={response}
                loading={loading}
              />
              <Pagination 
                page={page} 
                pageSize={pageSize} 
                onChange={this.handlePaginationChange}
              />
            </>
          )}
      </Service>
    )
  }
}
複製程式碼

useServiceCallback:手動服務排程

有些時候,我們也需要手動控制一個服務的呼叫,例如建立、刪除等操作,對於這樣的需求,我們可以再封裝一個 useServiceCallback hook,除了 loading、error、response,它還能夠返回服務呼叫函式。

下例中,每當我們在 Auto Complete 元件中鍵入內容,都手動呼叫下搜尋服務進行搜尋:

const Search = props => {
  const [api, { loading, error, response }] = useServiceCallback(search);
  const handleSearch = useCallback(
    value => {
      if (value.length) {
        api({
          text: value
        });
      }
    },
    [api]
  );

  return (
    <AutoComplete
      dataSource={response || []}
      onSearch={handleSearch}
      placeholder="等待輸入..."
    />
  );
};
複製程式碼

它的實現你可以在 CodeSandbox 上檢視,並且我們還用 useServiceCallback 實現了 useService

Bonus:使用 RxJS 豐富非同步能力

在實際專案中,我們對於某個服務,可能還有這些訴求:

  • 競態處理:服務響應同時到來時,如何處理它們彼此間的競爭。例如在列表資料獲取中,我們希望只響應最後一次的資料。
  • 重試:某些服務失敗時,我們希望能夠重試
  • 載入延遲:我們希望能夠超過一定容忍期,再設定載入中,這樣能夠避免短暫的轉菊花帶來的閃動問題。
  • 粒度控制:單位時間週期內,只傳送一次請求

想要讓我們的 useService 優雅地實現這些能力,就不得不搬出 RxJS 了,它能讓我們宣告式地編排非同步流程。在 Hooks 推出後,我們也能更加自然的將 RxJS 能力注入到 Hooks 中,實現 RxJS 與 React 元件的解耦。

如何使用 RxJS 豐富我們 useService 的 hook 能力就不再本文贅述了,對此感興趣的同學,可以在 CodeSandbox 上看到實現和範例。現在,我們的 service hook 用起來仍然簡單,但是功能更加強大:

const List = props => {
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = uesState(10)
  
  const { loading, error, response } = useAdvancedService({
    service: API.fetchList,
    /** 載入延遲 */
    loadingDelay: 500,
    /** 重試次數 */
    retry: 3,
    /** 每次重試延遲 */
    retryDelay: 500,
    /** 競態處理策略 */
    race: 'switch',
    /** 成功回撥 */
    onSuccess: (resp) => {
      console.log('Fetch success', resp)
    },
    /** 失敗回撥 */
    onError: (error) => {
      console.error('Fetch error', error)
    }
  })
  
  const handlePaginationChange = useCallback((page, pageSize) => {
    setPage(page)
    setPageSize(pageSize)
  }, [])
  
  
  return error 
    ? <Error error={error} /> 
    : (
        <>
          <Table
            data={this.state.data}
            loading={loading}
          />
          <Pagination 
            page={page} 
            pageSize={pageSize} 
            onChange={handlePaginationChange}
          />
        </>
      )
}
複製程式碼

當然,當 React 官方的 cache + AsyncComponent 元件 stable 後,我們可能會有更完美的非同步元件撰寫方式和編排體驗,關於 RxJS 與 React Hooks 結合,我也在我的 《使用 RxJS 與 React 實現一個 SQL 編輯器》進行了深一步的探究,感興趣的讀者可以關注下,目前它在連載中

參考資料

相關文章