使用 Hooks 创建异步组件

3,270 阅读7分钟

基于 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 之外的是新的逻辑复用方式,我们可以将其归纳为:

即,配置了一个 Hook 之后,若声明了依赖,则每当依赖变动,将获得新的数据。基于此,我们就可以概括出异步任务的对应的 Hook:

即,我们定义了异步任务,并声明其依赖为请求参数,那么,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 编辑器》进行了深一步的探究,感兴趣的读者可以关注下,目前它在连载中

参考资料