手摸手造轮子 | React网络请求场景使用 useRequest 就够了

1,299 阅读6分钟

前言

灵感来源于 ahooks 的 useRequest

背景

我们先看个需求:做一个展示图片的页面。
你的代码可能是这样子的:
img.tsx

import react, { useEffect, useState } from "react";
import MyFetch from "../../lib/fetch";

export const PhotoExhibition = () => {
  const [imgUrl, setImgUrl] = useState("");

  useEffect(() => {
    MyFetch({
      url: "https://api.uomg.com/api/rand.img1?sort=汽车&format=json",
    }).then((res) => {
      setImgUrl(res.imgurl);
    });
  }, []);

  return (
    <>
      <img src={imgUrl} alt="汽车" />
    </>
  );
};

手摸手第一版

我们想通过hooks改造它,这时 useRequest 将横空出世。 我们先做些简单的功能,需求:

  • 返回网络请求的数据 - data
  • 我们将请求与hook相结合 - useRequest useRequest.tsx
import { useState, useEffect } from "react";
import MyFetch, { IReq } from "../../../../lib/fetch";

function useRequest (
  req: IReq,
) {
  const [data, setData] = useState<any>(null);

  useEffect(() => {
    service();
  }, []);

  const service = () => {
    MyFetch(req)
      .then((res) => {
        setData(res);
      })
      .catch((err) => new Error(err));
  };

  return {
    data,
  };
}

export default useRequest;

自己基于fetch封装的请求文件,自然你也可以使用优秀的 axios 代替
fetch.tsx

export interface IRes {
  //与后端约定的格式
  code: number;
  message: string;
  data: any;
}

interface IInitHeader extends RequestInit {
  body?: BodyInit | null | any;
}

export interface IReq {
  url: string;
  body?: BodyInit | null | any;
  method?: "GET" | "POST" | "PUT" | "DELETE";
  requestInit?: RequestInit;
}

const checkStatus = (res: Response) => {
  // 处理状态码
  // if (res.status === 401) {
  //   window.location.href = "/login";
  // }

  // if (res.status === 405 || res.status === 403) {
  //   location.href = "/403";
  // }

  if (res.status === 404) {
    new Error("code: 404, 接口不存在");
  }

  return res;
};

const addCustomHeader = (init?: IInitHeader) => {
  // 自定义添加头部信息
  init = init || {};
  init.headers = Object.assign(init?.headers || {}, {
    // 这里添加项目固定请求头信息
    // 'X-SSO-USER': 'admin',
  });
  return init;
};

// 用于取消请求
export let controller = new AbortController();

export default function MyFetch(req: IReq) {
  let { url, body, method, requestInit: init } = req;
  if (!init) init = {};

  // if (!init.credentials) init.credentials = "include"; // 默认same-origin,include不管同源请求,还是跨域请求,一律发送 Cookie。
  if (body && typeof body === "object") init.body = JSON.stringify(body); // 转化JSON 字符串
  if (method) init.method = method; // 使用传入请求方法, 不传则GET
  if (body && !method) init.method = "POST"; // 兼容, 存在body忘传method,默认请求方法POST
  if (init.method) init.method = init.method.toUpperCase(); // 兼容,字符串转换为大写

  if (["POST", "PUT", "DELETE"].includes(init.method || "")) {
    // 提交JSON数据, 默认值是'text/plain;charset=UTF-8'
    init.headers = Object.assign(
      {},
      init.headers || {
        "Content-Type": "application/json",
      }
    );
  }

  init = addCustomHeader(init);
  controller = new AbortController(); // 每次需要新的controller,要不然取消请求后再次请求将报错
  init = { ...init, signal: controller.signal };
  return window
    .fetch(url, init)
    .then((res) => checkStatus(res)) //检验响应码
    .then((res) => res.json())
    .catch((err) => new Error(`请求失败!url: ${url} err: ${err}`));
}

export function formFetch(req: IReq) {
  let { url, requestInit: init } = req;
  // 表单fetch,用于上传文件等
  if (!init) init = {};

  init = addCustomHeader(init);
  return window
    .fetch(url, init)
    .then((res) => checkStatus(res))
    .then((res) => res.json())
    .catch((err) => new Error(`请求失败!url: ${url} err: ${err}`));
}

接下来对img.tsx进行改造
img.tsx

import react from "react";
import useRequest from "../../components/hooks/useRequest";

export const PhotoExhibition = () => {
  const { data: imgData } = useRequest({
    url: "https://api.uomg.com/api/rand.img1?sort=汽车&format=json",
  });

  return (
    <>
      <img src={imgData?.imgurl} alt="汽车" />
    </>
  );
};

我们发现代码相对原来变得简洁的许多,我们页面将不在关注 请求 层面的开发。
这时候第一版算是告一段落。

手摸手第二版

接下来我们在第一版增加一些功能,我们希望请求是手动的,并不是一进页面就调接口,并希望给我们暴露更多的其它功能。
需求:

  • 增加请求接口的状态 - loading
  • 提供手动执行的方法 - run
  • 提供取消请求接口的方法 - cancel
  • 增加成功、失败、执行完成的回调 - onSuccess、onError、onFinally
  • 增加自动执行与手动执行的切换标识 - manual useRequest.tsx
import { useState, useEffect } from "react";
import MyFetch, { formFetch, controller, IReq } from "../../../../lib/fetch";

export interface Options<TData, TParams extends any[]> {
  manual?: boolean; // 如果设置为 true,则需要手动发送请求即手动调用run
  onSuccess?: (data: TData) => void; // 成功触发
  onError?: (e: Error) => void; // 失败触发
  onFinally?: (data: TData) => void; // 执行完成触发
}

export interface IParams {
  url?: string;
  body?: BodyInit | null | any;
}

function useRequest<TData, TParams extends any[]>(
  req: IReq,
  options?: Options<TData, TParams>
) {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<any>({});
  const { onFinally, onSuccess, onError, manual = false } = options || {};

  useEffect(() => {
    if (!manual) service();
  }, []);

  const service = (params?: IParams) => {
    if (params?.url) req.url = params.url;
    if (params?.body) req.body = params.body;

    setLoading(true);
    MyFetch(req)
      .then((res) => {
        setData(res);
        onSuccess && onSuccess(res);
      })
      .finally(() => {
        setLoading(false);
        onFinally && onFinally(data);
      })
      .catch(onError);
  };

  const run = (params?: IParams) => {
    service(params);
  };

  const cancel = () => {
    controller.abort();
  };

  return {
    loading,
    data,
    run,
    cancel,
  };
}

export default useRequest;

接下来对img.tsx进行改造
img.tsx

import react, { useState } from "react";
import useRequest from "../../components/hooks/useRequest";

export const PhotoExhibition = () => {
  const [sort, setSort] = useState("汽车");

  const {
    loading: imgLoading,
    data: imgData,
    run,
    cancel,
  } = useRequest(
    {
      url: `https://api.uomg.com/api/rand.img1?sort=${sort}&format=json`,
    },
    {
      manual: true,
      onSuccess: (result) => {
        console.log(result, "onSuccess");
      },
      onError: (error) => {
        console.log(error, "onError");
      },
      onFinally: (result) => {
        console.log(result, "onFinally");
      },
    }
  );

  return (
    <>
      <input
        type="text"
        value={sort}
        onChange={(e) => setSort(e.target.value)}
      />
      <button
        onClick={() => {
          run({
            url: `https://api.uomg.com/api/rand.img1?sort=${sort}&format=json`,
          });
        }}
      >
        search
      </button>
      <button onClick={cancel}>取消加载</button>
      <br />
      {imgLoading ? "加载中..." : <img src={imgData?.imgurl} alt="汽车" />}
    </>
  );
};

到了这里我们的 useRequest 基本满足项目的一般需求。
温馨提示:测试取消请求时可以把网络换成3g在测试。

image.png

手摸手第三版

好了不逼逼,直接说个常见的需求:有个input,我们并不希望它频繁触发请求。

  • 防抖 - debounce
  • 节流 - throttle

防抖

useRequest.tsx

import { useState, useEffect, useRef } from "react";
import MyFetch, { formFetch, controller, IReq } from "../../../../lib/fetch";
import { debounce, DebouncedFunc, throttle } from "lodash";

export interface Options<TData, TParams extends any[]> {
  manual?: boolean; // 如果设置为 true,则需要手动发送请求即手动调用run
  onSuccess?: (data: TData) => void; // 成功触发
  onError?: (e: Error) => void; // 失败触发
  onFinally?: (data: TData) => void; // 执行完成触发
  debounceWait?: number; // 防抖等待时间, 单位为毫秒,设置后,进入防抖模式
  throttleWait?: number; // 节流等待时间, 单位为毫秒,设置后,进入节流模式
}

export interface IParams {
  url?: string;
  body?: BodyInit | null | any;
}

function useRequest<TData, TParams extends any[]>(
  req: IReq,
  options?: Options<TData, TParams>
) {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<any>({});
  const {
    onFinally,
    onSuccess,
    onError,
    manual = false,
    debounceWait,
    throttleWait,
  } = options || {};

  const debouncedRef = useRef<DebouncedFunc<any>>();

  useEffect(() => {
    if (!manual) service();
  }, []);

  useEffect(() => {
    if (debounceWait) {
      debouncedRef.current = debounce((query: IReq) => {
        request(query);
      }, debounceWait);

      return () => {
        debouncedRef.current?.cancel();
      };
    }
  }, [debounceWait]);

  const request = (query: IReq) => {
    console.log(333);
    MyFetch(query)
      .then((res) => {
        setData(res);
        onSuccess && onSuccess(res);
      })
      .finally(() => {
        setLoading(false);
        onFinally && onFinally(data);
      })
      .catch(onError);
  };

  const service = (params?: IParams) => {
    if (params?.url) req.url = params.url;
    if (params?.body) req.body = params.body;

    setLoading(true);
    if (debounceWait) {
      console.log(222);
      return debouncedRef.current && debouncedRef.current(req);
    }

    // if (throttleWait) return throttle(request, throttleWait);
    request(req);
  };

  const run = (params?: IParams) => {
    service(params);
  };

  const cancel = () => {
    controller.abort();
  };

  return {
    loading,
    data,
    run,
    cancel,
  };
}

export default useRequest;

在img.tsx下多传下 debounceWait 就好
img.tsx

  const {
    loading: imgLoading,
    data: imgData,
    run,
    cancel,
  } = useRequest(
    {
      url: `https://api.uomg.com/api/rand.img1?sort=${sort}&format=json`,
    },
    {
      manual: true,
      debounceWait: 2000,
      onSuccess: (result) => {
        console.log(result, "onSuccess");
      },
      onError: (error) => {
        console.log(error, "onError");
      },
      onFinally: (result) => {
        console.log(result, "onFinally");
      },
    }
  );

接下来测试下

image.png 看起来是很OK的,我点了17下后延迟2s才触发。

温馨提示: 需要使用以下这段代码来确保 debouncedRef 的唯一性,如果你有更好的方法那更好。

 const debouncedRef = useRef<DebouncedFunc<any>>();

  useEffect(() => {
    if (!manual) service();
  }, []);

  useEffect(() => {
    if (debounceWait) {
      debouncedRef.current = debounce((query: IReq) => {
        request(query);
      }, debounceWait);

      return () => {
        debouncedRef.current?.cancel();
      };
    }
  }, [debounceWait]);

节流

节流与防抖同理,以下是最终的代码。
useRequest.tsx

import { useState, useEffect, useRef } from "react";
import MyFetch, { formFetch, controller, IReq } from "../../../../lib/fetch";
import { debounce, DebouncedFunc, throttle } from "lodash";

export interface Options<TData, TParams extends any[]> {
  manual?: boolean; // 如果设置为 true,则需要手动发送请求即手动调用run
  onSuccess?: (data: TData) => void; // 成功触发
  onError?: (e: Error) => void; // 失败触发
  onFinally?: (data: TData) => void; // 执行完成触发
  debounceWait?: number; // 防抖等待时间, 单位为毫秒,设置后,进入防抖模式
  throttleWait?: number; // 节流等待时间, 单位为毫秒,设置后,进入节流模式
}

export interface IParams {
  url?: string;
  body?: BodyInit | null | any;
}

function useRequest<TData, TParams extends any[]>(
  req: IReq,
  options?: Options<TData, TParams>
) {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState<any>({});
  const {
    onFinally,
    onSuccess,
    onError,
    manual = false,
    debounceWait,
    throttleWait,
  } = options || {};

  const debouncedRef = useRef<DebouncedFunc<any>>();
  const throttleRef = useRef<DebouncedFunc<any>>();

  useEffect(() => {
    if (!manual) service();
  }, []);

  useEffect(() => {
    if (debounceWait) {
      debouncedRef.current = debounce((query: IReq) => {
        request(query);
      }, debounceWait);

      return () => {
        debouncedRef.current?.cancel();
      };
    }
  }, [debounceWait]);

  useEffect(() => {
    if (throttleWait) {
      throttleRef.current = throttle((query: IReq) => {
        request(query);
      }, throttleWait);

      return () => {
        throttleRef.current?.cancel();
      };
    }
  }, [throttleWait]);

  const request = (query: IReq) => {
    MyFetch(query)
      .then((res) => {
        setData(res);
        onSuccess && onSuccess(res);
      })
      .finally(() => {
        setLoading(false);
        onFinally && onFinally(data);
      })
      .catch(onError);
  };

  const service = (params?: IParams) => {
    if (params?.url) req.url = params.url;
    if (params?.body) req.body = params.body;

    setLoading(true);
    if (debounceWait) return debouncedRef.current && debouncedRef.current(req);
    if (throttleWait) return throttleRef.current && throttleRef.current(req);
    request(req);
  };

  const run = (params?: IParams) => {
    service(params);
  };

  const cancel = () => {
    controller.abort();
  };

  return {
    loading,
    data,
    run,
    cancel,
  };
}

export default useRequest;

好了,第三版已完成。更多功能可根据项目需求自己开发。

总结

  • 写得不好,不喜勿喷。
  • 项目中许多常用的、重复的可以写成hook,以显得代码好看,彰显牛逼。