Skip to content

Hooks · useRequest

起因

在自身使用 React 实现业务逻辑的时候,经常会遇到组件内部请求数据的场景,如果不依赖第三方我们一般会使用如下代码:

jsx
import React, { useEffect, useState } from 'react'

function ajaxData() {
  return fetch('https://jsonplaceholder.typicode.com/users').then((response) =>
    response.json()
  )
}

function App() {
  const [loading, setLoading] = useState(false)
  const [data, setData] = useState([])

  useEffect(() => {
    setLoading(true)
    ajaxData()
      .then((res) => {
        setData(res)
        console.log('no optimize res', res)
      })
      .finally(() => {
        console.log('no optimize finally')
        setLoading(false)
      })
  }, [])

  if (loading) {
    return <>Loading...</>
  }

  return (
    <>
      Main Content
      <div>
        {data.map((v) => (
          <div key={v.id}>{v.name}</div>
        ))}
      </div>
    </>
  )
}

export default function Root() {
  const [key, setKey] = useState(0)

  return (
    <div style={{ position: 'relative' }}>
      <App key={key} />
      <div style={{ position: 'absolute', right: 0, top: 0 }}>
        <button className="demo-btn" onClick={() => setKey((pre) => pre + 1)}>
          refresh
        </button>
      </div>
    </div>
  )
}

结果展示

但是以上例子会存在内存泄露的风险, 5-6 行当因为组件销毁的情况发生时,例如马上跳转了其它页面,因为闭包原因,你会发现它仍然会执行。此处你可以尝试多点击几次 refresh 按钮,来复现这种情况。

js
useEffect(() => {
  setLoading(true)
  ajaxData()
    .then((res) => {
      setData(res.data)
      console.log(res)
    })
    .finally(() => {
      console.log('finally')
      setLoading(false)
    })
}, [])

针对以上情况,优化方案如下

  • 监听组件被移除状态,如果为移除状态,则不进行之后的操作
  • 终止 Promise 相关操作

同时在代码复用层面,以上也可以进行优化,毕竟大家也不想每个组件内部的请求都重复以上的逻辑,因此当以上优化处理后,可以增加自定义 hooks 进行包装.

  • 封装自定义 hooks,复用以上逻辑

优化

我门首先增加组件销毁的状态判断,代码如下:

jsx
import React, { useEffect, useRef, useState } from 'react'

async function ajaxData() {
  return fetch('https://jsonplaceholder.typicode.com/users').then((response) =>
    response.json()
  )
}

function App() {
  const unMountedRef = useRef()
  const [loading, setLoading] = useState(false)
  const [data, setData] = useState([])

  useEffect(() => {
    setLoading(true)
    unMountedRef.current = false
    const promise = ajaxData()

    promise
      .then((res) => {
        if (!unMountedRef.current) {
          setData(res)
          console.log('optimize res', res)
        }
      })
      .finally(() => {
        if (!unMountedRef.current) {
          console.log('optimize finally')
        }
        setLoading(false)
      })

    return () => {
      unMountedRef.current = true
    }
  }, [])

  if (loading) {
    return <>Loading...</>
  }

  return (
    <>
      Main Content
      <div>
        {data.map((v) => (
          <div key={v.id}>{v.name}</div>
        ))}
      </div>
    </>
  )
}

export default function Root() {
  const [key, setKey] = useState(0)

  return (
    <div style={{ position: 'relative' }}>
      <App key={key} />
      <div style={{ position: 'absolute', right: 0, top: 0 }}>
        <button className="demo-btn" onClick={() => setKey((pre) => pre + 1)}>
          refresh
        </button>
      </div>
    </div>
  )
}

结果展示

以上代码当当组件销毁后,不会再执行回掉中的操作,避免了内存溢出的风险。让我门再进一步,将接口请求也取消试试,代码如下:

jsx
import React, { useEffect, useRef, useState } from 'react'

function ajaxData() {
  const controller = new AbortController()
  const signal = controller.signal
  const promise = fetch('https://jsonplaceholder.typicode.com/users', {
    signal
  }).then((response) => response.json())
  return Object.assign(promise, {
    abort(...reset) {
      controller.abort(reset)
    }
  })
}

function App() {
  const unMountedRef = useRef()
  const [loading, setLoading] = useState(false)
  const [data, setData] = useState([])

  useEffect(() => {
    setLoading(true)
    unMountedRef.current = false
    const promise = ajaxData()

    promise
      .then((res) => {
        if (!unMountedRef.current) {
          setData(res)
          console.log('optimize-fetch res', res)
        }
      })
      .finally(() => {
        if (!unMountedRef.current) {
          console.log('optimize-fetch finally')
          setLoading(false)
        }
      })

    return () => {
      promise.abort('component destroy')
      unMountedRef.current = true
    }
  }, [])

  if (loading) {
    return <>Loading...</>
  }

  return (
    <>
      Main Content
      <div>
        {data.map((v) => (
          <div key={v.id}>{v.name}</div>
        ))}
      </div>
    </>
  )
}

export default function Root() {
  const [key, setKey] = useState(0)

  return (
    <div style={{ position: 'relative' }}>
      <App key={key} />
      <div style={{ position: 'absolute', right: 0, top: 0 }}>
        <button className="demo-btn" onClick={() => setKey((pre) => pre + 1)}>
          refresh
        </button>
      </div>
    </div>
  )
}

结果展示

以上代同时当组件销毁的时候,取消了相关的web请求操作,同时又增加了销毁判断,减少闭包导致的内存溢出风险。

封装成 hook

接下来,我们一起将此功能进行封装,复用下相关逻辑,代码如下:

jsx
import React, { useState } from 'react'
import useRequest from './useRequest'

function ajaxData() {
  const controller = new AbortController()
  const signal = controller.signal
  const promise = fetch('https://jsonplaceholder.typicode.com/users', {
    signal
  }).then((response) => response.json())
  return Object.assign(promise, {
    abort(...reset) {
      controller.abort(reset)
    }
  })
}

function App() {
  const { loading, data } = useRequest(ajaxData)

  if (loading) {
    return <>Loading...</>
  }

  return (
    <>
      Main Content
      <div>
        {data.map((v) => (
          <div key={v.id}>{v.name}</div>
        ))}
      </div>
    </>
  )
}

export default function Root() {
  const [key, setKey] = useState(0)

  return (
    <div style={{ position: 'relative' }}>
      <App key={key} />
      <div style={{ position: 'absolute', right: 0, top: 0 }}>
        <button className="demo-btn" onClick={() => setKey((pre) => pre + 1)}>
          refresh
        </button>
      </div>
    </div>
  )
}
js
import { useEffect, useRef, useState } from 'react'

export default function useRequest(fn) {
  const unMountedRef = useRef()
  const [loading, setLoading] = useState(false)
  const [data, setData] = useState([])

  useEffect(() => {
    setLoading(true)
    unMountedRef.current = false
    const promise = fn()

    promise
      .then((res) => {
        if (!unMountedRef.current) {
          setData(res)
          console.log('optimize-hook res', res)
        }
      })
      .finally(() => {
        if (!unMountedRef.current) {
          console.log('optimize-hook finally')
          setLoading(false)
        }
      })

    return () => {
      if (promise && promise.abort) {
        promise.abort('component destroy')
      }

      unMountedRef.current = true
    }
  }, [])

  return {
    loading,
    data
  }
}

结果展示

再优化一点

以上hook使用,缺少参数传递,我们把这个加上,当前函数签名如下:

js
function useRequest<TBody>(
  service: Promise<TBody>,
  params?: Record<string, any>
): { loading: boolean, data: TBody }

具体实现如下,其中加入了以下功能:

  • params 改动会自动进行数据重新请求
  • params 进行了深度比较优化,优化触发频率
jsx
import React, { useState } from 'react'
import useRequest from './useRequestFinal'

function ajaxData(params = {}) {
  const controller = new AbortController()
  const signal = controller.signal
  const url = new URL('https://jsonplaceholder.typicode.com/comments')
  Object.keys(params).forEach((field) => {
    url.searchParams.set(field, params[field])
  })
  const promise = fetch(url, {
    signal
  }).then((response) => response.json())
  return Object.assign(promise, {
    abort(...reset) {
      controller.abort(reset)
    }
  })
}

function App() {
  const [params, setParams] = useState({
    postId: 1
  })

  const { loading, data } = useRequest(ajaxData, params)

  if (loading) {
    return <>Loading...</>
  }

  return (
    <>
      <div>
        <button
          className="demo-btn"
          onClick={() => {
            setParams((pre) => ({ postId: pre.postId + 1 }))
          }}
        >
          differ params
        </button>
        <button
          className="demo-btn"
          onClick={() => setParams({ postId: params.postId })}
        >
          same params
        </button>
      </div>
      Main Content
      <div>
        {data.map((v) => (
          <div key={v.id}>{v.name}</div>
        ))}
      </div>
    </>
  )
}

export default function Root() {
  const [key, setKey] = useState(0)

  return (
    <div style={{ position: 'relative' }}>
      <App key={key} />
      <div style={{ position: 'absolute', right: 0, top: 0 }}>
        <button className="demo-btn" onClick={() => setKey((pre) => pre + 1)}>
          refresh
        </button>
      </div>
    </div>
  )
}
js
import { useRef, useState } from 'react'
import { useCompareEffect } from '@slsanyi/hooks'

export default function useRequest(fn, params) {
  const unMountedRef = useRef()
  const [loading, setLoading] = useState(false)
  const [data, setData] = useState([])

  useCompareEffect(() => {
    setLoading(true)
    unMountedRef.current = false
    const promise = fn(params)

    promise
      .then((res) => {
        if (!unMountedRef.current) {
          setData(res)
          console.log('optimize-hook-final res', res)
        }
      })
      .finally(() => {
        if (!unMountedRef.current) {
          console.log('optimize-hook-final finally')
          setLoading(false)
        }
      })

    return () => {
      if (promise && promise.abort) {
        promise.abort('component destroy')
      }

      unMountedRef.current = true
    }
  }, [params])

  return {
    loading,
    data
  }
}

结果展示

总结

以上是简易实现 useRequest 的一种方式,有如下功能

  • 内置优化处理内存溢出问题,约定并且实现 web 请求自动取消 方式.
  • 按需请求,参数不一致才会进行 web 请求.

当然,其中也包含了一些可以提炼出来的能力

对于简单的项目,可以参照以上自行实现处理。但是对于公司项目来说,可以使用成熟的库更加合适 例如 ahooks 或者 reactuses,它们可以更好的帮助你管理和复用代码,当然前提是你需要习惯那种代码风格 😜