封装自己的useRequest

背景

在前后端分离的项目中,我们需要通过异步请求去获取数据,在这个异步请求的过程中需要做特别多的处理,例如:

  • 展示loading,表示正在请求数据
  • 每个异步操作都需要使用try-catch捕获错误
  • 请求错误时,需要处理错误情况

团队中的每个人在处理接口的时候,都需要编写重复的代码。所以这篇文章以循序渐进的方式来讲解如何封装一个强大的hooks,来解决上面的痛点,让开发只需要关注业务逻辑。

目标和收益

useRequest能够提供以下功能:

  • 手动请求/自动请求
  • 条件请求/依赖请求
  • 轮询/防抖/依赖请求
  • 分页/加载更多

期望能够实现以下调用方式:

const { loading, run, data } = useRequest(() => {

  // 这里写具体的异步请求

}, {

  // 一些配置参数

});
复制代码

在上面代码中看到,useRequest可以传入两个参数,第一个参数是函数类型这里叫requestFn,用于异步请求,第二个参数是一些扩展的参数这里称为options,结果返回一个对象

参数说明类型
loadingrequestFn 是否正在执行boolean
datarequestFn 返回的数据,默认为 undefinedany
run执行 requestFnFunction
errorrequestFn 抛出的异常,默认为 undefinedundefined / Error

第二个参数用于一些扩展功能,下面约定一下参数:

标题说明类型默认值
auto初始化自动执行requestFnbooleanfalse
onSuccessrequestFn resolve触发Function-
onErrorrequestFn reject触发Function-
cacheKey设置了这个值以后,会启动缓存机制string-

技术方案

在编写hooks的过程中,我们通过循序渐进的方式逐步对hooks进行扩展,实现一个功能强大的异步请求状态管理库

基本封装

不考虑第二个可选参数,只考虑传入requestFn的情况

实现基本框架

function useRequest<D, P extends any[]>(requestFn: RequestFn<D, P>, options: BaseOptions<D, P> = {}) {

  const [loading, setLoading] = useState(false);

  const [data, setData] = useState<D>();

  const [err, setErr] = useState();

  const run = useCallback(async (...params: P) => {

    setLoading(true);

    let res;

    try {

      res = await requestFn(...params);

      setData(res);

    } catch (error) {

      setErr(error);

    }

    setLoading(false);

    return res;

  }, []);

  return {

    loading,

    data,

    err,

    run,

  };

}
复制代码

上述代码就能够实现基本的异步管理

  • 当异步请求状态更改时,loading能够及时更新
  • 当请求抛出错误的时候,err也能及时更新
  • 能够决定什么时候执行异步函数

上面代码已经能够实现最基础的能力,在此之上,下面扩展自动执行的能力

还是使用useEffect监听auto值的变化

const { defaultParams } = options

useEffect(() => {

    if (auto) {

      run(...(defaultParams as P));

    }

  }, [auto]);
复制代码

当auto为true的时候,直接运行异步函数。实现上述功能以后,我们就可以像以下方式调用:

function generateName(): Promise<string> {

  return new Promise(resolve => {

    setTimeout(() => {

      resolve('name');

    }, 5000);

  });

}

function Index() {

  const { run, data, loading, err } = useRequest(async () => await generateName());

  useEffect(() => {

    run();

  }, []);

  console.log(data, loading);

  console.log(err);

  return (

    <div style={{ fontSize: 14 }}>

      <p>data: {data}</p>

      <p>loading: {String(loading)}</p>

    </div>

  );

}
复制代码

当调用run的时候,异步接口被调用, useRequest返回的值都能够及时更新

缓存

如何对请求进行缓存这是面试中常提到的问题。我们期望实现这样的功能

  • 首次请求后能够缓存结果

  • 再次请求的时候,能够从缓存中获取

  • 并且在读取缓存的时候,能够自动发起请求拉取最新的资源来更新缓存

首先我们需要思考缓存系统的设计,缓存系统需要满足以下特点

  • 能够添加缓存

  • 能够缓存过期时间

  • 删除缓存

这里我们使用Map的方式来进行缓存,Map是一组键值对的结构,具有极快的查找速度,借助get、set API完成对数据的存储

class BaseCache {

  protected value: Map<CachedKeyType, T>

  constructor() {

    this.value = new Map<CachedKeyType, T>();

  }



  public getValue = (key: CachedKeyType): T => {

    return this.value.get(key )

  }



  public setValue = (key: CachedKeyType, data: T): void => {

    this.setValue(key, data)

  }



  public remove = (key: CachedKeyType) => {

    this.value.delete(key)

  }

}
复制代码

在此基础上再加上过期时间的逻辑,setCache以后超过过期时间后,自动删除缓存中的结果,更改如下:

const setCache = (key: CachedKeyType, cacheTime: number, data: any) => {

  const currentCache = cache.getValue(key);

  if (currentCache?.timer) {

    clearTimeout(currentCache.timer);

  }



  let timer: Timer | undefined = undefined;



  if (cacheTime > -1) {

    // 数据在不活跃 cacheTime 后,删除掉

    timer = setTimeout(() => {



      cache.remove(key);



    }, cacheTime);

  }

  const value = {

    data,

    timer,

    startTime: new Date().getTime(),

  }

  cache.setValue(key, value);

};
复制代码

上述代码中,当设置值的时候,首先判断定时器是否存在,如果存在则取消定时器,释放内存。如果当前设置缓存时间后,需要加个定时器,时间结束后及时删除当前缓存。最后存储在Cache里边有个两个部分

  • data: 请求的相关信息
  • timer: 定时器ID

如果要实现获取的时候直接从缓存中获取,并且在后台自动执行请求接口,那我们就需要缓存两个部分:

  1. 请求结果
  2. 请求相关参数

所以我们需要先构造一个请求对象,这个对象包含了以下功能:

  1. 提供主动调用异步函数的功能
  2. 保存请求结果
  3. 保存请求参数
  4. 能够提供一个钩子,用于保存值发生变化的时候触发

具体代码如下:

class Request<D, P extends any[]> {

  that: any = this;

  options: BaseOptions<D, P>;

  requestFn: BaseRequestFnType<D, P>;

  state: RequestsStateType<D, P> = {

    loading: false,

    run: this.run.bind(this.that),

    data: undefined,

    params: [] as any,

    changeData: this.changeData.bind(this.that),

  };



  constructor(requestFn: BaseRequestFnType<D, P>, options: BaseOptions<D, P>, changeState: (data: RequestsStateType<D, P>) => void) {

    this.options = options;

    this.requestFn = requestFn;

    this.changeState = this.changState;

  }



  async run(...params: P) {

    this.setState({

      loading: true,

      params,

    });

    let res;

    try {

      res = await this.requestFn(...params);

      this.setState({

        data: res,

        error: undefined,

        loading: false,

      });

      if (this.options.onSuccess) {

        this.options.onSuccess(res);

      }

      return res;

    } catch (error) {

      this.setState({

        data: undefined,

        error,

        loading: false,

      });

      if (this.options.onError) {

        this.options.onError(error);

      }

      return error;

    }

  }



  setState(s = {} as Partial<BaseReturnValue<D, P>>) {

    this.state = {

      ...this.state,

      ...s,

    };

    if (this.onChangeState) {

      this.onChangeState(this.state);

    }

  }



  changeData(data: D) {

    this.setState({

      data,

    });

  }

}
复制代码

这个对象保存了所有的请求相关的信息,所以在之前useRequest就需要更改,所有状态都需要从Request对象中获取,所以更改之前实现的useRequest。当我们调用接口的时候,我们先会尝试从cache中获取数据,如果cache存在,那么直接返回cache中的数据

const { cacheKey } = options

const cacheKeyRef = useRef(cacheKey)

cacheKeyRef.current = cacheKey

const [requests, setRequests] = useState<RequestsStateType<D, P> | null>(() => {

  if (cacheKey && cacheKeyRef.current) {

    return getCache(cacheKeyRef.current);

  }

  return null;

});
复制代码

初始值我们通过cacheKey直接从缓存中获取,如果存在,则返回缓存中的内容

此外我们也需要去设置缓存的值

  useUpdateEffect(() => {

  if (cacheKeyRef.current) {

    setCache(cacheKeyRef.current, cacheTime, requests);

  }

}, [requests]);
复制代码

监听requests值的变化,如果发生变化,那么就更新缓存中的值,对外提供的run函数也需要更改,在执行run的时候,我们需要区分缓存中是否存在值,如果存在使用缓存,如果不存在,就需要新建一个Request值,来保存相关数据,更改后的代码如下:

  const run = useCallback(async (...params: P) => {

    let currentRequest;

    if (cacheKeyRef.current) {

      currentRequest = getCache(cacheKeyRef.current);

    }



    if (!currentRequest) {

      const requestState = new Request(requestFn, options, onChangeState).state;

      setRequests(requestState);



      return requestState.run(...params);

    }

    return currentRequest.run();

  }, []);
复制代码

最后就是返回结果,如果有缓存内容,我们就需要使用缓存数据

if (requests) {

    return requests;

  } else {

    return {

      loading: auto,

      data: initData,

      error: undefined,

      run,

    };

  }
复制代码

尝试一下调用useRequest的值,发现requests的值始终不对, 始终都是Request对象中的初始值,这里是因为当异步函数调用的时候,我们没有及时更改requests值的内容,所以在写一个函数用于更改requests的内容

  const onChangeState = useCallback((data: RequestsStateType<D, P>) => {

    setRequests(data);

  }, []);
复制代码

把上述代码组合起来就能够对结果进行缓存了,下面写一个demo观察现象

function generateName(): Promise<number> {

  return new Promise(resolve => {

    setTimeout(() => {

      resolve(Math.random());

    }, 1000);

  });

}

function Article() {

  const { data } = useRequest(generateName, {

    cacheKey: 'generateName',

    auto: true,

  });

  return <p>123{data}</p>;

}



function Index() {

  const { run, data, loading } = useRequest(generateName, {

    cacheKey: 'generateName',

  });

  const [bool, setBool] = useState(false);



  useEffect(() => {

    run();

  }, []);



  return (

    <div style={{ fontSize: 14 }}>

      <p>data: {data}</p>

      <p>loading: {String(loading)}</p>

      <button onClick={() => setBool(!bool)} type="button">

        repeat

      </button>

      {bool && <Article />}

    </div>

  );

}
复制代码

Article和Index组件都调用了相同的接口,并且cachekey也是相同的,当Article能够马上获取到run函数第一次执行的值

首次运行的值是0.92,然后Article接口调用的时候获取到的值也是0.92,并且能够在后台去更新这个值,当下次再渲染Article组件的时候,值就更新为最新的

加载更多

在此基础上我们期望useRequest能够扩展加载更多的功能,需要满足以下的功能

  • 点击加载更多
  • 下拉加载更多

由于实际业务中数据太多没办法完全覆盖,所以这里只讲请求需要满足以下条件的情况:

请求body

- pageSize: 请求每页树

- offset: 偏移量

返回结构

- list: 数组内容

- total:返回一共多少条
复制代码

下面讲讲具体的实现

  const { initPageSize = 10, ref, threshold = 100, ...restOptions } = options;

  const [list, setList] = useState<LoadMoreItemType<D>[]>([]);

  const [loadingMore, setLoadingMore] = useState(false);

    const result = useRequest(requestFn, {

    ...restOptions,

    onSuccess: res => {

      setLoadingMore(false);

      console.log('onSuccess', res.list);

      setList(prev => prev.concat(res.list));

      if (options.onSuccess) {

        options.onSuccess(res);

      }

    },

    onError: (...params) => {

      setLoadingMore(false);

      if (options.onError) {

        options.onError(...params);

      }

    },

  });

   const { data, run, loading } = result;
复制代码

用list变量保存已经加载的变量,loadingMore表示是否正在加载,在用之前提供的useRequest进行异步请求,需要注意的是需要对onSuccess和onError进行二次封装,用于更改当前的数据状态

当ref传入时,就代表需要在一个容器里边做滑动到底部然后加载更多的操作

useEffect(() => {

    if (!ref || !ref.current) {

      return noop;

    }

    const handleScroll = () => {

      if (!ref || !ref.current) {

        return;

      }



      if (ref.current.scrollHeight - ref.current.scrollTop <= ref.current.clientHeight + threshold) {

        loadMore();

      }

    };

    ref.current.addEventListener('scroll', handleScroll);

    return () => {

      if (ref && ref.current) {

        ref.current.removeEventListener('scroll', handleScroll);

      }

    };

  }, [ref && ref.current, loadMore]);
复制代码

loadMore的代码基本就是将当前list的长度作为offset传入到之前useRequest提供的run当中

  const loadMore = useCallback(

    (customObj = {}) => {

      console.log(noMore, loading, loadingMore, 'customObj');

      if (noMore || loading || loadingMore) {

        return;

      }

      setLoadingMore(true);

      run({

        current,

        pageSize: initPageSize,

        offset: list.length,

        ...customObj,

      });

    },

    [noMore, loading, loadingMore, current, run, data, list]

  );
复制代码

这样加载更多,到底部的时候能够加载自动加载更多的功能就实现了,这里的示例就不在写了

总结

提供一个封装好的useRequest能够省去业务的大量重复代码,上面总体的实现借鉴于蚂蚁的开源hooks库,一些不够常用的功能没有一一的讲解,事实上我们还可以根据业务自己去封装一些通用逻辑,例如日志上报,通用的错误处理等等

分类:
前端
标签: