如何优雅地处理 loading

让我们想象一个场景:

“下班顺路买一斤包子带回来,如果看到卖西瓜的,买一个。”

现在,让我们用 react 来实现一下:

function GoHome() {
  const [loading, setLoading] = useState(false)

  const goForward = async () => {
    setLoading(true)
    const stuff = await lookAround()
    switch (stuff) {
      case '包子': 
        await buy(2, '斤', '包子')
        break
      case '西瓜':
        await buy(1, '个', '包子')
        break
      default:
        break
    }
    setLoading(false)
  }

  // do something with loading
}

这样显然是不行的,因为异步方法可能会抛错,导致 setLoading(false) 被跳过,那么我们可能会:

const goForward = async () => {
  setLoading(true)
  try {
    // ...
  } finally {
    setLoading(false)
  }
}

这样实在是不够优雅。

what about this:

function GoHome() {
  const [loading, withLoading] = useLoading()

  const goForward = withLoading(async () => {
    const stuff = await lookAround()
    switch (stuff) {
      case '包子': 
        await buy(2, '斤', '包子')
        break
      case '西瓜':
        await buy(1, '个', '包子')
        break
      default:
        break
    }
  })

  // do something with loading
}

以此延伸,我们通常会有这样的需求:点击按钮时发送请求,如果请求超过 500 ms, 再展示 loading

function HelloWorld() {
  return <Button onClick={withLoading(api, 500)}>
    点我一刀 999
  </Button>
}

实现不复杂,直接看全部代码

import { clamp } from 'lodash-es'
import { useCallback, useState } from 'react'

const InfiniteTimeout = 2 ** 31 - 1

/**
 * 同一个 withLoading 可以包装多个函数,
 * 同一个 withLoading 包装的所有函数全部执行完毕后才会关闭 loading;
 *
 * @usage
 * ```tsx
 * function Comp() {
 *   const [ loading, withLoading ] = useLoading()
 *
 *   const { data } = useSWR('xxx', withLoading(asyncTask1))
 *
 *   return <>
 *     <Spin spinning={loading} />
 *
 *     <Button
 *       onClick={withLoading(asyncTask2)}
 *     >
 *       async task
 *     </Button>
 *   </>
 * }
 * ```
 */
export function useLoading() {
  const [flag, setFlag] = useState(0)

  /**
   * @param fn 需要包装的函数
   * @param delayMs 延迟显示 loading (!!! 而非延迟执行函数)
   */
  const withLoading = useCallback(
    <Arg extends unknown[], Res>(
        fn: (...args: Arg) => Res | Promise<Res>,
        delayMs = 500
      ) =>
      async (...args: Arg) => {
        let timer = -1
        if (delayMs > 0) {
          timer = +setTimeout(() => {
            timer = -1
            setFlag((prev) => prev + 1)
          }, clamp(delayMs, 0, InfiniteTimeout))
        } else {
          setFlag((prev) => prev + 1)
        }
        try {
          return await fn(...args)
        } finally {
          clearTimeout(timer)
          if (timer < 0) {
            setFlag((prev) => prev - 1)
          }
        }
      },
    []
  )

  return [
    loading: flag > 0,
    withLoading,
  ] as const
}


后记:代码的实现很简单,但我感觉从代码风格上来说,由原来的命令式,改为了声明式,更 "react" 了,所以感觉很有意义,就此记录。(当然不会说是为了水一篇文了)

如非特别声明,本站作品均为原创,遵循【自由转载-保持署名-非商用-非衍生 创意共享 3.0 许可证】。

对于转载作品,如需二次转载,请遵循原作许可。