likes
comments
collection
share

深入浅出 SWR

作者站长头像
站长
· 阅读数 5

这篇博客是笔者在公司内部的一场技术分享,内容来自 PPT 的整理。

前言

快速学习新事物的方法论

深入浅出 SWR

我想通过讲解 SWR,向大家分享认识方法论,这是我认为本篇文章最重要的内容

SWR 介绍

首先来看看 SWR 是什么?

  • SWR 是一个方法(函数)
  • SWR 是一个自定义的 React Hook
  • SWR 是一个管理 HTTP 请求的 React Hook

SWR 功能

SWR 提供非常多的功能。包括不限于请求状态封装、去除重复请求、缓存 HTTP 响应、依赖请求、轮询等等

请求状态封装

不用 SWR 实现一个简单的 HTTP GET 请求

const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  async function fetchUsers() {
    try {
      setIsLoading(true);
      const response = await fetch(url).json((res) => res.json());
      setData(response);
    } catch (e) {
      setError(e);
    } finally {
      setIsLoading(false);
    }
  }

  void fetchUsers();
}, []);

需要在组件内维护三个状态,分别是 dataisLoading error,并且还要维护状态的变化

使用 SWR 实现上面的逻辑

const { data, isLoading, error } = useSWR(url);

使用 SWR 只需要一行代码就完全实现上面的业务逻辑,非常简单

重复请求去除和响应数据缓存

未使用 SWR,每一个 Avatar 组件在渲染的时候,都会发起 HTTP 请求,同一个接口会调用三次

function Avatar() {
  const [data, setData] = useState({});
  useEffect(() => {
    fetchUsers()
      .then((data) => setData(data))
      .catch(err);
  }, []);
  return <img src={data.avatar_url} />;
}

function App() {
  // 发三次 HTTP 请求
  return (
    <>
      <Avatar />
      <Avatar />
      <Avatar />
    </>
  );
}

使用 SWR

function Avatar() {
  const { data, error } = useSWR("/api/user", fetcher);

  return <img src={data.avatar_url} />;
}

function App() {
  // 只会发一次 HTTP 请求
  return (
    <>
      <Avatar />
      <Avatar />
      <Avatar />
    </>
  );
}

使用 SWR 后,当 Avatar 组件渲染的时候,接口调用会被 SWR 拦截,三次调用接口会处理成一次调用,HTTP 成功响应后的数据缓存在内存中,使用数据的地方直接从内存中读取

支持依赖请求

不使用 SWR 处理接口依赖

function MyProjects() {
  const [projects, setProjects] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    async function fetchProjects() {
      try {
        setIsLoading(true);
        const user = await fetch("/api/user");
        const projects = await fetch("/api/projects?uid=" + user.id);
        setProjects(projects);
      } catch (error) {
        console.error(error);
      } finally {
        setIsLoading(false);
      }
    }

    void fetchProjects();
  }, []);

  if (isLoading) return "loading...";

  return "You have " + projects.length + " projects";
}

使用 SWR 处理接口依赖

function MyProjects() {
  const { data: user } = useSWR("/api/user");
  const { data: projects } = useSWR(() => "/api/projects?uid=" + user.id);

  // 传递函数时,SWR 会用返回值作为 key
  // 如果函数抛出错误或返回 falsy 值,SWR 会知道某些依赖还没准备好
  // 这种情况下,当 user 未加载时,user.id 抛出错误

  if (!projects) return "loading...";

  return "You have " + projects.length + " projects";
}

轮询

只需要设置 refreshInterval 配置项,就可以实现轮询

比如需要每秒请求该接口,可以这样配置

useSWR(url, { refreshInterval: 1000 });

React Suspense

<Suspense> 是 React16.8 推出的新组件,主要用来解决网络 IO 问题

在前端业务中,有特别多网络 IO 的地方,比如读写数据库、HTTP 请求等等。而 <Suspense> 是 React 给出网络 IO 的优雅解决方案。SWR 支持 Suspense 模式,请看下面的代码

import { Suspense } from "react";
import useSWR from "swr";

function Profile() {
  const { data } = useSWR("/api/user", fetcher, { suspense: true });
  return <div>hello, {data.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Profile />
    </Suspense>
  );
}

不使用 Suspense 模式实现上述逻辑

function Profile() {
  const { data, isLoading } = useSWR("/api/user");

  if (isLoading) return "loading...";

  return <div>hello, {data.name}</div>;
}

function App() {
  return <Profile />;
}

通过对比发现,Suspense 模式自动对 isLoading 状态进行 UI 处理,不需要开发人员手动处理

Suspense 模式下,data 总是请求响应(因此你无需检查它是否是 undefined)。但如果发生错误,则需要使用错误边界(ErrorBoundary)来捕获它:

<ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}>
  <Suspense fallback={<h1>Loading posts...</h1>}>
    <Profile />
  </Suspense>
</ErrorBoundary>

总的来说,Suspense 模式通过组件在异步操作完成之前不渲染,异步操作完成后将结果返回给组件,避免渲染过程中的闪烁和错误,从而实现异步渲染和更好的用户体验。

分页和滚动位置恢复

表格分页

function App() {
  const [pageIndex, setPageIndex] = useState(0);

  return (
    <div>
      <Page index={pageIndex} />
      <div style={{ display: "none" }}>
        <Page index={pageIndex + 1} />
      </div>
      <button onClick={() => setPageIndex(pageIndex - 1)}>Previous</button>
      <button onClick={() => setPageIndex(pageIndex + 1)}>Next</button>
    </div>
  );
}

由于 SWR 的缓存,我们可以预加载下一页的页面。我们将下一页的页面渲染到隐藏的 div 元素中,这样 SWR 会触发下一页页面的数据获取。当用户导航到下一页时,数据就已经存在了

Optimistic UI

Optimistic UI 直译过来叫乐观 UI。大白话就是先用本地的数据更新 UI,同时发起 HTTP 请求更新数据,如果 HTTP 请求发生错误,UI 会回滚数据。在这整个过程中,UI 的变化不需要等待

使用 optimisticData 选项,可以手动更新本地数据,同时等待远程数据更改的完成。搭配 rollbackOnError 使用还可以控制何时回滚数据

function Profile() {
  const { mutate } = useSWRConfig();
  const { data } = useSWR("/api/user", fetcher);

  return (
    <>
      <h1>My name is {data.name}.</h1>
      <button
        onClick={async () => {
          const options = {
            optimisticData: newUser,
            rollbackOnError(error) {
              return error.name !== "AbortError"; // 如果超时中止请求的错误,不执行回滚
            },
          };
          mutate("/api/user", updateFn(newUser), options); //立即更新本地数据, 发送一个请求以更新数据, 触发重新验证(重新请求)确保本地数据正确
        }}
      >
        Uppercase my name!
      </button>
    </>
  );
}

预请求

SWR 提供 preload API 以编程方式预取资源并将结果存储在缓存中。preload 接受 keyfetcher 作为参数。在组件未渲染之前,调用 preload 方法发起 HTTP 请求,将结果缓存在内存中

import { useState } from "react";
import useSWR, { preload } from "swr";

const fetcher = (url) => fetch(url).then((res) => res.json());

// 渲染 User 组件之前发起 HTTP 请求
preload("/api/user", fetcher);

function User() {
  const { data } = useSWR("/api/user", fetcher);
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <div>
      <button onClick={() => setShow(true)}>Show User</button>
      {show ? <User /> : null}
    </div>
  );
}

由于在组件未渲染之前数据已经缓存在内存中,当组件渲染时用户不需要等待 HTTP 请求响应,可以显著提升用户体验

设计原理

“SWR” 这个名字来自 stale-while-revalidate:一种由 HTTP RFC 5861 推广的 HTTP 缓存失效策略

这种策略首先从缓存中返回数据(过期的),同时发送 fetch 请求(重新验证),当返回数据的时候用最新的数据替换运行的数据。数据的请求和替换的过程都是异步的,对于用户来说无需等待新请求返回时就能看到数据

stale-while-revalidate 是 HTTP 的响应头 cache-control 的一个属性值,它允许立马返回一个已经过时 (stale) 的响应。与此同时浏览器又在背后默默重新发起一次请求,响应的结果被存储起来下次使用。因此它很好的隐藏了服务器或者网络的响应延时

为什么这里说允许返回一个 stale 的响应呢?如何判断响应是 stale 的呢,这是因为 stale-while-revalidate 是和 max-age 一起使用的,如果时间超过了 max-age,则是作为 stale

Cache-Control: max-age=600, stale-while-revalidate=30

深入浅出 SWR

  1. 表示请求的结果在 600s 内都是新鲜(stale 的反义词)的,如果在 600s 内发起了相同请求,则直接返回磁盘缓存
  2. 如果在 600s~630s 内发起了相同的请求,则响应虽然已经过时(stale)了,但是浏览器会直接把之前的缓存结果返回,与此同时浏览器又在背后自己发一个请求,响应结果留作下次使用
  3. 如果超过 630s 后,发起了相同请求,则这就是一个普通请求,就和第一次请求一样,从服务器获取响应结果,并且浏览器并把响应结果缓存起来

源码分析

HTTP 请求的封装

const stateDependencies = useRef<StateDependencies>({}).current;

const data = isUndefined(cachedData) ? fallback : cachedData;
const error = cached.error;

const isValidating = isUndefined(cached.isValidating)
  ? defaultValidatingState
  : cached.isValidating;
const isLoading = isUndefined(cached.isLoading)
  ? defaultValidatingState
  : cached.isLoading;
const cached = useSyncExternalStore(
  useCallback(
    (callback: () => void) =>
      subscribeCache(
        key,
        (current: State<Data, any>, prev: State<Data, any>) => {
          if (!isEqual(prev, current)) callback();
        }
      ),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [cache, key]
  )
);

重复请求去除和响应数据缓存

if (shouldStartNewRequest) {
  //...
  FETCH[key] = [currentFetcher(fnArg as DefinitelyTruthy<Key>), getTimestamp()];
}

[newData, startAt] = FETCH[key];
newData = await newData;

if (shouldStartNewRequest) {
  setTimeout(cleanupState, config.dedupingInterval);
}

const cleanupState = () => {
  // Check if it's still the same request before deleting it.
  const requestInfo = FETCH[key];
  if (requestInfo && requestInfo[1] === startAt) {
    delete FETCH[key];
  }
};

通过 shouldStartNewRequest 状态去判断是否需要发起新的请求,去除重复的请求

轮询

function next() {
  //...
  if (interval && timer !== -1) {
    timer = setTimeout(execute, interval);
  }
}

function execute() {
  if() {
  // ...
  } else {
    next()
  }
}

SWR 利用 setTimeout递归实现轮询

预请求数据

export const preload = <
  Data = any,
  SWRKey extends Key = Key,
  Fetcher extends BareFetcher = PreloadFetcher<Data, SWRKey>
>(
  key_: SWRKey,
  fetcher: Fetcher
): ReturnType<Fetcher> => {
  const [key, fnArg] = serialize(key_);

  // 将响应的结果缓存在全局变量中
  const [, , , PRELOAD] = SWRGlobalState.get(cache) as GlobalState;

  // Prevent preload to be called multiple times before used.
  if (PRELOAD[key]) return PRELOAD[key];

  const req = fetcher(fnArg) as ReturnType<Fetcher>;
  PRELOAD[key] = req;
  return req;
};

组件未渲染之前,提前调用 preload 方法向服务器发送请求,把响应结果缓存在全局变量中,等组件渲染时直接从缓存中读取

Suspense 使用原理

在组件里,throw Promise 触发 Suspensefallback 逻辑。当组件处理异步逻辑的时候显示 fallback 的 UI,等待异步逻辑操作完成,再显示其他结果。简单的例子如下:

// Example.jsx
const Example = () => {
  throw Promise.resolve(9);
  return <></>;
};

// App.jsx
function App() {
  return (
    <ErrorBoundary fallback={<>errors</>}>
      <Suspense fallback={<>loading</>}>
        <Example />
      </Suspense>
    </ErrorBoundary>
  );
}

SWR 启用 Suspense 逻辑

const use = (promise) => {
  if (promise.status === "pending") {
    throw promise;  // <--- 当前状态是 pending 时,throw promise,执行 Suspense 的 fallback 逻辑
  } else if (promise.status === "fulfilled") {
    return promise.value as T;
  } else if (promise.status === "rejected") {
    throw promise.reason;
  } else {
    promise.status = "pending";
    promise.then(/**/);
    throw promise;
  }
};

边界问题

SWR 只是对请求相关的封装,当涉及跨组件之间状态处理时需要使用第三方状态管理工具

不管是 React 还是 Vue,都强调状态驱动。笔者本人将 React 组件内的状态分为两种,一种是 UI 状态,由 useState/props/useReducer 等等维护,另一种是数据状态,来自服务端。而 SWR 只管理数据状态,这就是 SWR 的边界问题,也可以说是 SWR 的适用场景。状态管理工具负责处理组件与组件之间的状态共享。比如比较火的状态管理工具 zustand

  • Flux 架构是不是解决状态管理的万金油方案?

是的

  • 为什么发布订阅模式可以跨组件传递状态?

因为发布订阅模式具有中心化的 Store, 存储了 Listeners

  • 状态管理库从类组件到函数组件本质上发生变化没?

本质上没有发生变化

上面三个问题是对状态管理的深度思考

总结

快速学习新事物的方法论

深入浅出 SWR

这是认识新事物的方法论,用于快速了解、掌握和使用新事物。这套方法论使用范围很广,既可以运用在工作中,也可以运用在生活中

参考链接