likes
comments
collection
share

面试官: 你真的知道 React Query 吗?

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

一、React Query 概览

React Query 通常被描述为 React 缺少的数据获取(data-fetching)库,但是从更广泛的角度来看,它使 React 程序中的获取,缓存,同步和更新服务器状态变得轻而易举。你或许使用的框架不是 React,可能是 Vue。React Query 和 Vue Query 的核心逻辑是一样的,你只需要理解 React Query,换一个框架也会轻而易举。本文主要介绍的是 React Query ,在最后面我们会介绍 React Query 和 Vue Query 的异同。

在 React 中,如果我们要请求数据,最简单的例子是这样的:

export const mockFetch = (result, timeout = 1000) => {
  return new Promise((resolve) => {
    setTimeout(() => resolve(result), timeout);
  });
};

export default function Example1() {
  const [isLoading, setIsLoading] = useState(false);
  const [data, setData] = useState();

  useEffect(() => {
    setIsLoading(true);
    mockFetch(Math.random()).then((res) => {
      setIsLoading(false);
      setData(res);
    });
  }, []);

  return <div>{isLoading ? "loading..." : data}</div>;
}

再看一下我们是如何使用 React Query 的:

  1. 创建一个请求客户端 queryClient 的外部实例,它会管理默认配置和全局状态,并通过 QueryClientProvider 共享 queryClient
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div>
        <Example1 />
      </div>
    </QueryClientProvider>
  );
}
  1. 在组件中使用 useQuery
export default function Example1() {
  const query = useQuery("key1", () => mockFetch("Example1", 1000));
  return <div>{query.isLoading ? "loading..." : query.data}</div>;
}

这就是 React Query 中最简单例子,useQuery 主要接收 3 个参数:

  • queryKey:请求的唯一标识。
  • queryFn:请求的函数。
  • options:请求相关配置,我们在下面的文章会对 options 中的一些值进行介绍。

如果大家想提前了解。可以参考文档 react-query-v3.tanstack.com/reference/u…

二、准备

在使用 React Query 的时候,最容易让人困惑的就是 React Query 何时会发起请求,组件何时会重新渲染。我们将以 useQuery 这个 hook 入手,帮助大家理解 React Query 的一些基本原理,解答这些困惑。下面的文章会涉及大量的代码演示,我们在这里先定义一下基础的代码,之后会直接引用这些组件或函数,不再做重复声明了,除非需要修改这个组件。

  1. mockFetch,用来模拟请求,可以自定义返回值和请求响应时间。
export const mockFetch = (result, timeout = 1000) => {
  return new Promise((resolve) => {
    setTimeout(() => resolve(result), timeout);
  });
};
  1. App, 初始化 React Query。创建一个请求客户端 queryClient 的外部实例,它会管理默认配置和全局状态,并通过 QueryClientProvider 注入 React。
const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div>
        <Example1 />
        <Example2 />
      </div>
    </QueryClientProvider>
  );
}
  1. Example1, 使用 useQuery 的一个组件。
export default function Example1() {
  const query = useQuery("key1", () => mockFetch("Example1"), 1000);
  return <div>{query.isLoading ? "loading..." : query.data}</div>;
}
  1. Example2, 使用 useQuery 的一个组件。注意这里和 Example1queryKey 是相同的,都是 key1
export default function Example2() {
  const query = useQuery("key1", () => mockFetch("Example2"), 200);
  return <div>{query.isLoading ? "loading..." : query.data}</div>;
}

三、staleTimecacheTime?最容易被混淆的两个时间

想要理解 React Query,最先要了解什么是 SWR,SWR 来自于 stale-while-revalidate,一种由 HTTP RFC 5861(opens in a new tab) 推广的 HTTP 缓存失效策略。这种策略首先从缓存中返回数据(过期的),同时发送 fetch 请求(重新验证),最后得到最新数据。从而就衍生出了两个时间,staleTime 过期时间和 cacheTime 缓存时间。

  • staleTime,查询过期的时间,如果查询没有过期,数据会始终从缓存中返回,并且不会发送网络请求。如果查询过期(默认是 0 即立即过期),仍然会从缓存中获取数据,但是会有一个 refetch 在后台发生。
  • cacheTime,从缓存中删除非活跃的查询的持续时间,默认是五分钟,一旦没有注册观察者,查询就会过渡到非活跃的状态,即当一个查询的组件都被卸载时。

我们下面用一个例子来理解这两个时间,因为缓存想要被删除,需要组件被卸载,所以我们需要修改 App ,让 Example1 可以随时被卸载和注册。

export default function App() {
  const [count, setCount] = useState(1);
  ...
  return (
    <QueryClientProvider client={queryClient}>
      <div onClick={() => setCount(count + 1)}>
        ...
        <div>count {count}</div>
        {count % 2 === 0 && <Example1 />}
      </div>
    </QueryClientProvider>
  );
}

然后我们给 useQuery 添加 staleTimecacheTime 这两个参数。这里解释一下 isLoadingisFetching 的区别:

  • isLoading, 是查询处于 “hard” 加载的状态,这表明没有缓存数据并且正在请求中。
  • isFetching,正在请求,包含发生在后台的请求。
export default function Example1() {
  const query = useQuery("key1", () => mockFetch(Math.random()), {
    staleTime: 3 * 1000,
    cacheTime: 6 * 1000
  });
  return (
    <div>
      <div>{query.isFetching ? "fetching..." : query.data}</div>
      <div>{query.isLoading ? "loading..." : query.data}</div>
    </div>
  );
}

运行结果:

面试官: 你真的知道 React Query 吗?

根据上的的运行效果可以看出:

  1. 第一次组件重新 mount 的时候(count === 2),间隔时间在 3s 内,没有超过 staleTime,React Query 并不会重新发起请求。
  2. 第二次组件重新 mount 的时候(count === 4),间隔时间在 3s - 6s,超过 staleTime,但是没有超过 cacheTime,React Query 会重新请求,但是 cache 没有过期,data 取的是 cache 中的数据,所以请求发生在后台,isFetchingtrue,但是 isLoadingfalse
  3. 第三次组件重新 mount 的时候(count === 6),间隔时间超过 6s, 超过了 staleTimecacheTime,React Query 会重新请求,并且 cache 已经过期,将取不到 cache 中的数据,所以isFetchingisLoading都为 true

四、React Query 是如何处理请求的?

页面中出现使用相同 queryKey 的多个组件

如果一个页面中有多个组件使用了相同 queryKeyuseQuery, useQuery 具体会怎么处理请求?

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div>
        <Example1 />
        <Example2 />
      </div>
    </QueryClientProvider>
  );
}

运行结果:

面试官: 你真的知道 React Query 吗?

说明 React Query 只会发起一次请求,并且是第一次的请求。第二次的请求根本不会发生,因为第二次请求返回的时间要比第一次请求返回的时间要短,如果第二次请求发生,会有 “Example2” 这段文字的出现。我们对 App 进行一下修改,让 Example2 延时渲染:

export default function App() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    setTimeout(() => {
      setShow(true);
    }, 500);
  }, []);

  return (
    <QueryClientProvider client={queryClient}>
      <div>
        <Example1 />
        {show && <Example2 />}
      </div>
    </QueryClientProvider>
  );
}

运行结果:

面试官: 你真的知道 React Query 吗?

看上去是符合预期的,React Query 仍然只是发起了一次请求,但是将将 setTimeout 的时间改成 1500 后,运行结果发生了变化:

面试官: 你真的知道 React Query 吗?

结果发生了两次请求,第一次请求得到的结果是 “Example1”,过了一会发起第二次请求,结果是“ Example2” 覆盖了Example1。如果我们再将 staleTime 修改为 3000 后,运行结果又发生了变化:

面试官: 你真的知道 React Query 吗?

请求又变成了一次。所一个页面中出现多个 queryKey 相同的 useQuery 时,并不一定只发起一次请求。实际发起请求的数量可能会和组件的渲染时机有关。

React Query 的请求原理

  1. 我们会创建一个管理所有 QueryQueryClient 实例,然后通过 QueryClientProvider 注入到 React 组件中。
const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      ...
    </QueryClientProvider>
  );
}
  1. 每次调用 useQuery ,会根据 queryKey 创建一个 Query 实例存入到QueryClient 实例中。注意相同 queryKey 会共享一个 Query 实例。
export class QueryCache {
  private queriesMap: QueryHashMap

  constructor() {
    this.queries = []
    this.queriesMap = {}
  }
  
  build() {
    ...
    let query = this.get(queryHash)

    if (!query) {
      query = new Query({...})
      this.add(query)
    }

    return query
  }
  
  add(query) {
    if (!this.queriesMap[query.queryHash]) {
      this.queriesMap[query.queryHash] = query
      this.queries.push(query)
    }
  }
 }
  1. 是否发起请求,是由 Query 内部的状态决定的。如果出现多个 queryKey 相同的 useQuery 时,首先会创建一个 Query,后面的组件共享第一个组件的 Query。后面的组件会读取 Query 的状态,决定是否会重新发起请求。如果当前组件 mount 的时候, staleTime 已经过期,并且没有在请求中,Query 就会重新发起请求。

上面就是 React Query 发起请求的基本原理,React Query 还有一些参数可能会让 React Query 重新发起请求。下面是 React Query 重新发起请求的一些场景。

React Query 可能发起请求的场景

  1. queryKey 发生改变时,React Query 会重新创建一个Query,此时 React Query 会重新发起请求。
  2. 组件 mount 时,可以通过 refetchOnMount 控制组件 mount 的时候,是否重新请求。当已经有了对应的 Query 实例并且 refetchOnMount 设置为 false,该组件 mount 的时候,不会重新发起请求。
  3. 窗口重新聚焦时,可以通过 refetchOnWindowFocus 控制。
  4. 网络重新连接时,可以通过 refetchOnReconnect 控制。
  5. 配置重新获取数据的间隔时,可以通过 refetchInterval 控制

五、使用 React Query 后,组件是如何渲染的?

React Query 渲染原理

当每次调用 useQuery 时,内部都会实例一个观察者对象 observer

const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        queryClient,
        defaultedOptions,
      ),
    )

observer 会订阅 Query 的状态,当这些 Query 状态发生变化时,notifyManager.batchCalls 会触发 React 的强制更新。

useSyncExternalStore(
    React.useCallback(
      (onStoreChange) =>
        isRestoring
          ? () => undefined
          : observer.subscribe(notifyManager.batchCalls(onStoreChange)),
      [observer, isRestoring],
    ),
    () => observer.getCurrentResult(),
    () => observer.getCurrentResult(),
  )

大家可能对 useSyncExternalStore 不太熟悉,useSyncExternalStore 的功能类似于 forceUpdate,用来从外部数据源读取和订阅状态,并且与并发特性兼容。 useSyncExternalStore 接收 3 个参数:

const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
  • subscribe:订阅函数 ,React 会给订阅函数传入一个 onStoreChange 函数,当外部数据源改变时,必须调用 onStoreChange 通知到 React。
  • getSnapshot:要订阅的状态。
  • getServerSnapshot:在 SSR 时要订阅的状态,可选。

结构共享,保证请求结果一致时,不会发生重新渲染

结构共享是 React Query 一个非常重要的开箱即用的优化,这个特性确保我们的数据在每一层级上的引用都是稳定的。我们新增一个案例,让请求返回一个对象。

export default function Example1() {
  const query = useQuery("key1", () => mockFetch({ value: 1 }));
  return (
    <div onClick={() => query.refetch()}>
      {query.isFetching ? "loading..." : "loaded"}
      <Example2 data={query.data} />
    </div>
  );
}

const Example2 = memo((props) => {
  console.log("rerender Example2");
  return <div>value: {props.data?.value}</div>;
});

运行结果:

面试官: 你真的知道 React Query 吗?

你会发现 Example2 并没有重新渲染,这说明 React Query 如果前后两次请求返回结果一致,返回的 data 引用也是一样的。我们再修改一下案例,让请求返回数组。

const Example2 = memo((props) => {
  console.log("rerender Example2");
  return <div>value: {props.data?.value}</div>;
});

const Example3 = memo((props) => {
  console.log("rerender Example3");
  return <div>value: {props.data?.value}</div>;
});

export default function Example1() {
  const query = useQuery("key1", () =>
    mockFetch([{ value: 1 }, { value: Math.random() }])
  );
  return (
    <div onClick={() => query.refetch()}>
      {query.isFetching ? "loading..." : "loaded"}
      <Example2 data={query.data?.[0]} />
      <Example3 data={query.data?.[1]} />
    </div>
  );
}

运行结果:

面试官: 你真的知道 React Query 吗?

你会发现只有 Example3 重新渲染, React Query 会对数组类型的数据做特殊处理,会逐项比较,只有内容发生变化的项,引用才会发生变化。如果你不需要这个优化可以将 structuralSharing 设为 false

下面是 React Query 实现结构共享的逻辑,会对数组和对象的子属性逐项进行对比,只有发生了变化,才会替换。


export function replaceData(prevData, data, options) {
  // Use prev data if an isDataEqual function is defined and returns `true`
  if (options.isDataEqual?.(prevData, data)) {
    return prevData
  } else if (typeof options.structuralSharing === 'function') {
    return options.structuralSharing(prevData, data)
  } else if (options.structuralSharing !== false) {
    // Structurally share data between prev and new data if needed
    return replaceEqualDeep(prevData, data)
  }
  return data
}

export function replaceEqualDeep(a: any, b: any): any {
  if (a === b) {
    return a
  }

  const array = isPlainArray(a) && isPlainArray(b)

  if (array || (isPlainObject(a) && isPlainObject(b))) {
    const aSize = array ? a.length : Object.keys(a).length
    const bItems = array ? b : Object.keys(b)
    const bSize = bItems.length
    const copy: any = array ? [] : {}

    let equalItems = 0

    for (let i = 0; i < bSize; i++) {
      const key = array ? i : bItems[i]
      copy[key] = replaceEqualDeep(a[key], b[key])
      if (copy[key] === a[key]) {
        equalItems++
      }
    }

    return aSize === bSize && equalItems === aSize ? a : copy
  }

  return b
}

replaceEqualDeep 函数比较复杂,如果有兴趣可以看一下 replaceEqualDeep 的测试用例。github.com/TanStack/qu…

notifyOnChangeProps ,追踪对应属性的变化

notifyOnChangeProps 选项,告诉 React Query,只有在这些数据发生变化时,才会通知当前观察者,重新渲染组件。

export default function Example1() {
  const { isFetching } = useQuery(
    "key1",
    () => {
      console.log("refetch");
      return mockFetch(Math.random());
    },
    {
      refetchInterval: 1000,
      notifyOnChangeProps: ["data"]
    }
  );
  return <div>{isFetching ? "fetching" : "fetched"}</div>;
}

运行结果:

面试官: 你真的知道 React Query 吗?

notifyOnChangeProps 没有包含 isFetching 时,即使重启发起了请求,数据发生了变化。isFetching 也不会发生变化,将值改为 ["data", "isFetching"] 后,isFetching 发生变化,组件也就会发生对应的变化。运行结果:

面试官: 你真的知道 React Query 吗?

有时会遇到忘记传入我们使用的属性,就像上面的例子,我们只监听了 data,但是我们使用了 isFetching,结果导致 isFetching 发生变化后,组件没有发生对应的变化。为了防止这种情况出现,我们可以将 notifyOnChangeProps 设置为 tracked ,这样 React Query 会追踪所有使用到的值,并将计算一个 list,这与你手动传递没有区别,只是你不必再考虑漏传属性的这个问题了。

下面是 React Query 实现 tracked 的逻辑,给对应的 result 添加一个 get , 当属性被读取后,就会把对应的属性加入到 trackedResult 中。

trackResult(result) {
    const trackedResult = {} as QueryObserverResult<TData, TError>

    Object.keys(result).forEach((key) => {
      Object.defineProperty(trackedResult, key, {
        configurable: false,
        enumerable: true,
        get: () => {
          this.trackedProps.add(key as keyof QueryObserverResult)
          return result[key as keyof QueryObserverResult]
        },
      })
    })

    return trackedResult
}

select 对请求数据进行转换

请求返回的数据不一定完全符合我们的期望,我们可能需要对返回的数据进行转换,我们有如下方法可以进行数据转换:

  1. 在服务端,如果服务端可以将数据转换为我们想要的结构,那就完全不需要前端做什么,但是这种方法不一定完全可行。
  2. queryFn 中,我们可以在 queryFn 直接转换请求返回的数据。
const query = useQuery("key1", async () => {
    const data = await mockFetch(Math.random());
    return { value: data };
});

这样做的缺点是,每次 fetch 都会执行你的数据转换,如果转换的代价十分昂贵,这种方法的转换代价也会非常大。

  1. 使用 useMemo 缓存数据转换的结果。
const query = useQuery("key1", () => mockFetch(Math.random()));
const data = useMemo(() => {
    return { value: query.data };
}, [query.data]);

这样做的缺点是,没法利用 React Query 中的结构共享,如果 data 是一个数组,即便只有一项发生了变化,所有的组件也会重新渲染。而且可能还需要判断 query.data 是否是 undefined,对 undefined 进行特殊的处理。

  1. 使用 select 选项。
const query = useQuery("key1", () => mockFetch(Math.random()), {
    select: (data) => ({ value: data })
});

select 只有在 data 存在的时候才会被调用,所以你不用担心它是 undefined。并且可以利用 React Query 中的结构共享。

总结

我们介绍 React Query 是如何请求的和 React Query 如果引起组件渲染的了原理,简单总结一下 React Query 的流程:

  1. 与请求相关的底层逻辑都封装在了 Query 中。
  2. Query 被保管在外部的 queryClient 中。
  3. queryClient 通过 QueryClientProvider 注入到 React 中。 会在 App 顶层使用 Provider 全局注入到 React
  4. 组件使用 useQueryQuery 建立连接,订阅状态触发更新。

1 和 2 是请求 Query 的核心逻辑,它是与框架无关的。3 和 4 是与 React 框架结合,建立通信的部分。如果将 3 和 4 换一下,让 Query 与 Vue 结合,就有了 Vue Query。Vue Query 的 3 和 4 是这样的:

  1. queryClient 通过 provide 注入到 Vue 中
app.provide(clientKey, client)
  1. 组件更新响应式对象与 Query 建立连接,订阅状态触发更新。
const observer = new Observer(queryClient, defaultedOptions.value)
const state = reactive(observer.getCurrentResult())

watch(
    queryClient.isRestoring,
    (isRestoring) => {
      if (!isRestoring) {
        unsubscribe.value()
        unsubscribe.value = observer.subscribe((result) => {
          updateState(state, result)
        })
      }
    },
    { immediate: true },
)

React Query 中的核心逻辑 query-core 是与框架无关的,Vue Query 和 React Query 的原理是相通的,我们熟悉使用 React Query 后, 使用 Vue Query 也是轻而易举。

参考链接

react-query-v3.tanstack.com/reference/u…

react-query-v3.tanstack.com/guides/impo…

tkdodo.eu/blog/practi…

tkdodo.eu/blog/react-…

tkdodo.eu/blog/react-…

juejin.cn/post/716951…

beta.reactjs.org/reference/r…