自定义请求Hook和数据请求库SWR

发布于:2024-04-19 ⋅ 阅读:(24) ⋅ 点赞:(0)

目录

useEffect 从 API 获取数据

请求失败->Error State

优化

首次加载->数据获取(白屏):isLoading状态State+骨架屏

多处请求:封装->自定义Hook

 因异步请求,达到顺序非发送顺序->Race Condition:清除副作用return

长请求->AbortController取消请求

缓存Cache:Map

失效->刷新缓存Cache Refresh

React 18 Concurrent Rendering->render打断+cache全局变量

useSyncExternalStore 订阅外部更新

其他问题

数据请求库SWR

请求错误重试: 指数退避算法 重发请求

全局配置App.tsx

数据突变(mutate)

第二个参数options 选项

普通函数中触发重新请求:import mutate  from 'swr';

推荐使用:二次封装

请求间依赖

防抖

参考链接


useEffect 从 API 获取数据

网络请求属于一个渲染副作用

type ListItem = {
  id?: string | number;
  name?: string;
};

function App() {
  const [list, setList] = useState<ListItem[]>([]);

  useEffect(() => {
    fetch("/api/list")
      .then((res: Response) => res.json())
      .then((data: ListItem[]) => setList(data));
  }, []);

  return (
    <ul>
      {list.map((item: ListItem) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

请求失败->Error State

function App() {
  const [list, setList] = useState<ListItem[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
    fetch("/api/list")
      .then((res: Response) => res.json())
      .then((data: ListItem[]) => {
        setIsLoading(false);
        setList(data);
      })
      .catch((error) => {
        setError(true);
        setIsLoading(false);
      });
  }, []);

  // 加载状态,数据获取期间展示骨架屏
  if (isLoading) {
    return <Skeleton />;
  }

  // 数据请求出错
  if (error) {
    // 上报错误...
    // 支持重试...
    return <div>请求出错啦~</div>;
  }

  return (
    <ul>
      {list.map((item: ListItem) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

优化

首次加载->数据获取(白屏):isLoading状态State+骨架屏

function App() {
  const [list, setList] = useState<ListItem[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
    fetch("/api/list")
      .then((res: Response) => res.json())
      .then((data: ListItem[]) => {
        setIsLoading(false);
        setList(data);
      });
  }, []);

  // 加载状态,数据获取期间展示骨架屏
  if (isLoading) {
    return <Skeleton />;
  }

  return (
    <ul>
      {list.map((item: ListItem) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

多处请求:封装->自定义Hook

type FetchOptions = {
  method?: string;
};

function useFetch(url: string, options: FetchOptions) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
    fetch(url, options)
      .then((res) => res.json())
      .then((data) => {
        setIsLoading(false);
        setData(data);
      })
      .catch((err) => {
        setError(true);
        setIsLoading(false);
      });
  }, [url, options]);

  return { data, isLoading, error };
}
function ComponentFoo() {
  const { data, isLoading, error } = useFetch("/api/foo");

  if (isLoading) {
    // ...
  }

  if (error) {
    // ...
  }
}

function ComponentBar() {
  const { data, isLoading, error } = useFetch("/api/bar");

  if (isLoading) {
    // ...
  }

  if (error) {
    // ...
  }
}

 因异步请求,达到顺序非发送顺序->Race Condition:清除副作用return

针对切换列表项,获取数据被覆盖场景

function useFetch(url: string, options: FetchOptions) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<boolean>(false);

  useEffect(() => {
    let isCancelled = false;

    setIsLoading(true);
    fetch(url, options)
      .then((res) => res.json())
      .then((data) => {
        if (!isCancelled) {
          setIsLoading(false);
          setData(data);
        }
      })
      .catch((err) => {
        if (!isCancelled) {
          setError(true);
          setIsLoading(false);
        }
      });

    return () => {
      isCancelled = true;
      setIsLoading(false);
    };
  }, [url, options]);

  return { data, isLoading, error };
}

长请求->AbortController取消请求

const isAbortControllerSupported: boolean = typeof AbortController !== "undefined";

function useFetch(url: string, options: FetchOptions) {
  // ...

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    setIsLoading(true);
    fetch(url, options).then({
      // ...
    });

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    };
  }, [url, options]);

  return { data, isLoading, error };
}

缓存Cache:Map

const isAbortControllerSupported = typeof AbortController !== "undefined";
// 使用 Map 更快的访问缓存
const cache = new Map();

function useFetch(url: string, options: FetchOptions) {
  // ...

  useEffect(() => {
    // ...

    // 如果有缓存数据,不再发起网络请求
    if (cache.has(url)) {
      setData(cache.get(url));
      setIsLoading(false);
    } else {
      setIsLoading(true);
      fetch(url, options)
        .then((res) => res.json())
        .then((data) => {
          if (!isCancelled) {
            // 缓存 url 对应的接口数据
            cache.set(url, data);
            setData(data);
            setIsLoading(false);
          }
        });
      // ...
    }

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    };
  }, [url, options]);

  return { data, isLoading, error };
}
失效->刷新缓存Cache Refresh

刷新时机举例

  • 标签页失去焦点
  • 定时重复更新
  • 网络状态改变

以“标签页失去焦点”缓存刷新为例

const isAbortControllerSupported = typeof AbortController !== "undefined";
const cache = new Map();
const isSupportFocus = typeof document !== "undefined" && typeof document.hasFocus === "function";

function useFetch(url: string, options: FetchOptions) {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const removeCache = useCallback(() => {
    cache.delete(url);
  }, [url]);

  const revalidate = useCallback(() => {
    // 重新 fetch 数据更新缓存
  }, []);

  useEffect(() => {
    const onBlur = () => removeCache();
    const onFocus = () => revalidate();

    window.addEventListener("focus", onFocus);
    window.addEventListener("blur", onBlur);

    return () => {
      window.removeEventListener("focus", onFocus);
      window.removeEventListener("blur", onBlur);
    };
  });

  // fetch 相关逻辑
  // useEffect(() => ...

  return { data, isLoading, error };
}
React 18 Concurrent Rendering->render打断+cache全局变量

低优先级的任务在 render 阶段可能会被打断、暂停甚至终止,而我们在实现 useFetch 缓存的时候,cache 是一个全局变量,一个 useFetch 调用 cache.set 后无法通知其他 useFetch 更新,可能会导致多个组件缓存数据的不一致,

试想下面的场景,我们开启了 Concurrent Mode,渲染了两个组件 <Foo /><Bar /> 都使用了 useFetch 从同一个 url 获取数据,它们共享一份缓存数据,但 React 为了响应用户在 <Bar /> 组件更高优先级的交互,暂停了 <Foo /> 的更新,导致了两个组件更新是不同步的,而恰巧在这两次更新期间,<Bar /> 调用了 useFetch 导致缓存刷新,发上了改变,但 <Foo /> 仍然使用的是上次缓存的数据,导致了最终的缓存不一致。

useSyncExternalStore 订阅外部更新

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

const cache = {
  __internalStore: new Map(),
  __listeners: new Set(),
  set(key) {
    this.__internalStore.set(key);
    this.__listeners.forEach((listener) => listener());
  },
  delete(key) {
    this.__internalStore.delete(key);
    this.__listeners.forEach((listener) => listener());
  },
  subscribe(listener) {
    this.__listeners.add(listener);
    return () => this.__listeners.delete(listener);
  },
  getSnapshot() {
    return this.__internalStore;
  },
};

function useFetch(url: string, options: FetchOptions) {
  // 获取最新同步的 cache
  const currentCache = useSyncExternalStore(
    cache.subscribe,
    useCallback(() => cache.getSnapshot().get(url), [url]),
  );

  // 缓存刷新逻辑
  // useEffect(() => {})...

  useEffect(() => {
    let isCancelled = false;
    let abortController = null;
    if (isAbortControllerSupported) {
      abortController = new AbortController();
    }

    if (currentCache) {
      setData(currentCache);
      setIsLoading(false);
    } else {
      setIsLoading(true);
      fetch(url, { signal: abortController?.signal, ...requestInit })
        .then((res) => res.json())
        .then((data) => {
          if (!isCancelled) {
            cache.set(url, data);
            setData(data);
            setIsLoading(false);
          }
        })
        .catch((err) => {
          // if (!isCancelled) ...
        });
    }

    return () => {
      isCancelled = true;
      abortController?.abort();
      setIsLoading(false);
    };
  }, [url, options]);
}

其他问题

  • Error Retry:在数据加载出现问题的时候,要进行有条件的重试(如仅 5xx 时重试,403、404 时放弃重试)
  • Preload:预加载数据,避免瀑布流请求
  • SSR、SSG:服务端获取的数据用来提前填充缓存、渲染页面、然后再在客户端刷新缓存
  • Pagination:针对大量数据、分页请求
  • Mutation:响应用户输入、将数据自动发送给服务端
  • Optimistic Mutation:用户提交输入时先更新本地 UI、形成「已经修改成功」的假象,同时异步将输入发送给服务端;如果出错,还需要回滚本地 UI,比如点赞
  • Middleware:各类的日志、错误上报、Authentication 中间件

数据请求库SWR

“SWR” 这个名字来自于 stale-while-revalidate:一种由 HTTP RFC 5861 推广的 HTTP 缓存失效策略。这种策略首先从缓存中返回数据(过期的),同时发送 fetch 请求(重新验证),最后得到最新数据。

  • 内置缓存和重复请求去除:内置缓存机制,自动缓存请求结果,请求相同的数据直接返回缓存结果,避免重复请求
  • 实时更新:支持组件挂载、用户聚焦页面、网络恢复等时机的实时更新
  • 智能错误重试:可以根据错误类型和重试次数来自动重试请求
  • 间隔轮询:可以通过设置 refreshInterval 选项来实现数据的定时更新
  • 支持 SSR/ISR/SSG:可以在服务端获取数据并将数据预取到客户端,提高页面的加载速度和用户体验
  • 支持 TypeScript:提供更好的类型检查和代码提示
  • 支持 React Native,可以在移动端应用中直接使用
import useSWR from 'swr'

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher)

  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

请求错误重试: 指数退避算法 重发请求

背景:封装aioxs 响应拦截器,实现当满足某种错误条件时进行错误重试

错误重试的功能默认开启,可配置 重试次数 和 重试时延

useSWR('/api/user', fetcher, {
  onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
    // 404 时不重试。
    if (error.status === 404) return

    // 特定的 key 时不重试。
    if (key === '/api/user') return

    // 最多重试 10 次。
    if (retryCount >= 10) return

    // 5秒后重试。
    setTimeout(() => revalidate({ retryCount: retryCount }), 5000)
  }
})

全局配置App.tsx

value 中传入你的全局配置

<SWRConfig value={options}>
  <Component/>
</SWRConfig>

数据突变(mutate)

当数据改变需要重新请求,可用useSWRConfig() 所返回的 mutate 函数,来广播重新验证的消息给其他的 SWR hook,如果数据发生了变更 swr 会更新缓存并重新渲染

即用同一个 key 调用 mutate(key) 

import useSWR, { useSWRConfig } from 'swr'

function App () {
  const { mutate } = useSWRConfig()

  return (
    <div>
      <Profile />
      <button onClick={() => {
        // 将 cookie 设置为过期
        document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'

        // 告诉所有具有该 key 的 SWR 重新验证
        mutate('/api/user')
      }}>
        Logout
      </button>
    </div>
  )
}

第二个参数options 选项

  • optimisticData:立即更新客户端缓存的数据,通常用于 optimistic UI。
  • revalidate:一旦完成异步更新,缓存是否重新请求。
  • populateCache:远程更新的结果是否写入缓存,或者是一个以新结果和当前结果作为参数并返回更新结果的函数。
  • rollbackOnError:如果远程更新出错,是否进行缓存回滚。

普通函数中触发重新请求:import mutate  from 'swr';

当我们 目前操作的用户权限突然被调低 了,在获取数据时后端响应了状态码 403 ,我们想要在 axios 的响应拦截中配置一个:如果遇到状态码为 403 的响应数据就重新获取一下用户的权限以重新渲染页面,将一些当前用户权限不该显示的内容隐藏

import axios from 'axios';
import { mutate } from 'swr';

axios.interceptors.response.use(
    (response) => response,
    (error) => {
      if (error.response) {
        switch (error.response.status) {
          case 403: {
            mutate('/user/me');
            break;
          }
          case 500: {
           // ... do something
            break;
          }
          default: {
             // ... do something
          }
        }
      }
      return Promise.reject(error);
    }
  );

推荐使用:二次封装

根据 数据类型 进行分类,并以 hook 的方式进行二次封装

import axios from 'axios';
import useSWR from 'swr';

import { UserResponse } from 'types/User';

const useUser = () => {
  const { data, error, isLoading, isValidating, mutate } = useSWR<UserResponse>('/user', (url) =>
    axios.get(url).then((res) => res.data.payload)
  );

  return {
    data,
    reload: mutate,
    isLoading,
    isValidating,
    isError: error,
  };
};

export default useUser;

isLoading 表示目前暂无缓存,正在进行初次加载。

 isValidating 则表示已经有缓存了,但是由于重新聚焦屏幕,或者手动触发数据更新数据重新验证的加载

请求间依赖

获取用户 id 后再发送新的请求

import axios from 'axios';
import useSWR from 'swr';

import useUser from './useUser';

const useUserDetail = () => {
  const { data } = useUser();
  const { data, error, isLoading, isValidating, mutate } = useSWR(
    data[0].id ? `users/${data[0].id}/detail` : null,
    (url: string) => axios.get(url).then((res) => res.data.payload)
  );

  return {
    data,
    reload: mutate,
    isLoading,
    isValidating,
    isError: error,
  };
};

export default useUserDetail;

防抖

只有在请求实践超过了 500ms 才响应时展示加载动画

import { Center, Spinner } from '@chakra-ui/react';
import { useDebounce } from 'ahooks';
import { memo } from 'react';

const TableLoading: React.FC<{ isOpen: boolean }> = ({ isOpen }) => {
  // To prevent the loading animation from flickering frequently, it will only be displayed if the loading time exceeds 500 ms
  const debouncedLoading = useDebounce(isOpen, {
    wait: 500,
  });

  return debouncedLoading ? (
    <Center
    >
      <Spinner />
    </Center>
  ) : null;
};

export default memo(TableLoading);

参考链接

官方文档

2023 年了,你为什么还不用 SWR ? - 掘金

都什么时代还在发传统请求?来看看 SWR 如何用 React Hook 实现优雅请求 - 掘金