2023 年,把 SWR 用起来!

大家好,我是 Monch[1],今天想跟大家分享一个关于 React 数据请求的故事。

2023 年的一个早晨,你刚到公司座位坐下,PM 扔给了你一个需求,需要编写一个 React 应用,从接口获取一个列表的数据并渲染到页面。身经百战的你打开 Visual Studio Code 完成了项目的初始化,考虑到网络请求属于一个渲染副作用,于是你毫不犹豫的选择了 useEffect[2] 进行数据的获取,仅用了一分钟,你就完成了代码编写

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>
  );
}

成就满满的你熟练地打开 terminal,敲下 npm run dev,列表成功渲染,

0da9fbcbf1e8154a09b2715bb856cac4.jpeg

Loading State

你刷新了一次页面,发现首次加载到数据获取完成期间,页面出现了短暂的白屏,用户体验很不好,身为一个 "将极致的用户体验和最佳的工程实践作为探索的目标" 的前端工程师,你决定实现一个加载中的进度提示,于是引入了一个新的状态 isLoading,考虑到列表结构稳定,更好的视觉效果和用户体验,你选择了骨架屏作为加载提示,

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>
  );
}
3ff6cb536439f0f85d55e186562573f6.jpeg

Error State

加载状态虽然有了,但是你又意识到,接口还有可能报错,你还需要在数据请求出错时显示错误提示,必要时可能还需要上报错误日志,于是你又引入了一个新的状态 error,处理数据请求失败的情况,

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>
  );
}

Custom Hook

后来,你发现每个需要从接口获取数据的场景都要写上面类似的代码,不仅重复而且繁琐,机智的你想到了自定义 hook,于是你决定将数据请求的逻辑封装为一个 useFetchhook

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 };
}

这种方式非常有用,你在项目中大量地使用了 useFetch,数据请求的模板代码减少了很多,逻辑也更加简洁,

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) {
    // ...
  }
}

Request Race

你非常有成就感,useFetch 真是太好用了,直到有一天 PM 又扔给了你一个需求,这次你需要实现点击某个列表项的时候显示对应的详情,结果测试的时候你发现,快速地在多个列表项间切换点击时,有时候你点击的是下一个列表项,页面确渲染了上一个列表项的详情。机智的你很快就找到了原因,因为你没有在 useEffect 中声明如何清除你的副作用,发送网络请求是一个异步的行为,收到服务器数据的顺序并不一定是网络请求发送时的顺序,导致出现了 Race Condition

| =============== Request Detail 1 ===============> | setState()
| ===== Request Detail 2 ====> | setState() |

比如上面的第二个列表项详情数据返回比第一个快的情况,你的 data 就会被前一个数据覆盖,

于是你在 useFetch 里面写了一个清除副作用的逻辑,

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

感谢 JavaScript 闭包的力量,现在即使出现了请求的 Race Condition,你的数据也不会被覆盖掉了,不仅如此,机智的你还想到了在清除副作用时检测下浏览器是否支持 AbortController[3],如果支持的话尝试取消请求,

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

你迫不及待地将新的 useFetch 用在了列表中,然后来回地切换列表项,这次详情数据终于没有被覆盖了,但每次切换都会由于 url 改变,导致 useEffect 重新执行,触发一次新的网络请求,实际上频繁快速地切换触发的网络请求是不必要的,你考虑为 useFetch 加一个缓存,

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

知名前 Netscape 工程师 Phil Karlton[4] 曾说过,

There are only two hard things in Computer Science: cache invalidation and naming things.

一旦引入缓存,就需要考虑缓存失效的问题,什么时候刷新缓存,否则我们的 UI 显示的数据就可能会过时,机智的你想到了可以在下面的这些时机去刷新缓存,

  • 标签页失去焦点

  • 定时重复更新

  • 网络状态改变

  • ...

以上的缓存刷新方式对应了不同的应用场景,正常来说你应该让 useFetch 全部支持,为了让自己还能有精力多搬几年砖,你决定先实现一个标签页失去焦点的缓存刷新,

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 };
}

Concurrent Rendering

实现了缓存的 useFetch 如虎添翼,你本以为从此数据请求可以高枕无忧了,但是你发现你使用了新版本的 React 18 Concurrent Rendering[5],这个模式下,低优先级的任务在 render 阶段可能会被打断、暂停甚至终止,而我们在实现 useFetch 缓存的时候,cache 是一个全局变量,一个 useFetch 调用 cache.set 后无法通知其他 useFetch 更新,可能会导致多个组件缓存数据的不一致,

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

为了解决这个问题,你需要重写 cache 实现,在缓存更新时通知同一个 urluseFetch 自动执行来保持缓存一致性,机智的你还发现 React 18 提供了一个 useSyncExternalStorehook 来订阅外部的更新,

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

于是你打算再折腾一下,基于 useSyncExternalStore 重新实现了 cache

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]);
}

现在每当有一个 useFetch 写入 cache 时,所有使用了相同缓存的 useFetch 的组件都会同步到最新的缓存。

Request Deduplication & Merge

你信心满满地将 useFetch 用在了项目中,然后你发现同一个页面内,使用相同 urluseFetch 同步渲染的多个组件,在首次加载没有缓存时,仍然会向同一个 url 发送不止一次的请求,为了合并相同的请求,你可能还需要实现一个互斥锁(mutex lock)或者单例,然后你还要实现一个发布订阅,将接口响应数据广播到所有使用这个 urluseFetch

等等,还没完,作为一个基础通用的用于发送网络请求的工具 hook,你可能还需要实现,

  • Error Retry:在数据加载出现问题的时候,要进行有条件的重试(如仅 5xx 时重试,403、404 时放弃重试)

  • Preload:预加载数据,避免瀑布流请求

  • SSR、SSG:服务端获取的数据用来提前填充缓存、渲染页面、然后再在客户端刷新缓存

  • Pagination:针对大量数据、分页请求

  • Mutation:响应用户输入、将数据自动发送给服务端

  • Optimistic Mutation:用户提交输入时先更新本地 UI、形成「已经修改成功」的假象,同时异步将输入发送给服务端;如果出错,还需要回滚本地 UI,比如点赞

  • Middleware:各类的日志、错误上报、Authentication 中间件

所以你为什么不用类似 SWR[6] 一样现成的数据请求库,它能够覆盖上述所有的需求。

SWR

最终,你放弃了自己封装的 useFetch,尽管他已经支持了许多功能,转而拥抱了 SWR[7]

仅需一行代码,你就可以简化项目中数据请求的逻辑,

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>
}

作为一个由 vercel[8] 团队出品的 React Hooks 数据请求库,特性自然不会太少,

  • 内置缓存和重复请求去除:内置缓存机制,自动缓存请求结果,请求相同的数据直接返回缓存结果,避免重复请求

  • 实时更新:支持组件挂载、用户聚焦页面、网络恢复等时机的实时更新

  • 智能错误重试:可以根据错误类型和重试次数来自动重试请求

  • 间隔轮询:可以通过设置 refreshInterval 选项来实现数据的定时更新

  • 支持 SSR/ISR/SSG:可以在服务端获取数据并将数据预取到客户端,提高页面的加载速度和用户体验

  • 支持 TypeScript:提供更好的类型检查和代码提示

  • 支持 React Native,可以在移动端应用中直接使用

  • ...

在近一年的下载量趋势[9]上,与 React-Query[10] 不相上下,

2737564a94e793e91cbfe29f29899cfd.jpeg
image.png

重要的是,你不再需要为了数据请求的能力花费时间和精力去维护 useFetch 了,你需要的,SWR 都能给到。

参考链接

  • SWR, React Hooks for Data Fetching[11]

  • Sukka——为什么你不应该在 React 中直接使用 useEffect 从 API 获取数据[12]

  • Concurrent Rendering in React 18[13]

  • MDN docs, AbortController[14]

  • What is a race condition\?[15]

写在最后

本文首发于我的 博客[16],才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在评论区进行提问和勘误,

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

关于本文

作者:MonchLee

https://juejin.cn/post/7247028435339591740

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值