Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2023 年了,你为什么还不用 SWR ? #52

Open
campcc opened this issue Jul 28, 2023 · 0 comments
Open

2023 年了,你为什么还不用 SWR ? #52

campcc opened this issue Jul 28, 2023 · 0 comments

Comments

@campcc
Copy link
Owner

campcc commented Jul 28, 2023

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

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,列表成功渲染,

image

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

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,如果支持的话尝试取消请求,

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 曾说过,

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,这个模式下,低优先级的任务在 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 一样现成的数据请求库,它能够覆盖上述所有的需求。

SWR

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

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

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 团队出品的 React Hooks 数据请求库,特性自然不会太少,

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

近一年的下载量趋势上,与 React-Query 不相上下,

image

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

参考链接

写在最后

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant