使用React Query和Web Sockets

本文翻译自 TkDodoUsing WebSockets with React Query

如何使用WebSockets与React Query处理实时数据是最近最常被问到的问题之一,因此我想尝试一下,并报告我的发现。这就是本文的内容 :)

什么是WebSockets

简而言之,WebSockets允许从服务器推送消息或"实时数据"到客户端(浏览器)。通常情况下,使用HTTP时,客户端向服务器发出请求,希望获取一些数据,服务器响应该数据或错误,然后连接关闭。

由于客户端是打开连接并发起请求的一方,这就没有机会让服务器在有更新可用时向客户端推送数据。

这就是WebSockets的作用。

就像其他HTTP请求一样,浏览器发起连接,但表示希望将连接升级为WebSocket。如果服务器接受了这个请求,它们将切换协议。这个连接不会终止,而是保持打开状态,直到任一方决定关闭它。现在,我们拥有了一个完全功能的双向连接,双方都可以传输数据。

这主要的优点是服务器现在可以向客户端推送选择性的更新。如果多个用户查看相同的数据,并且其中一个用户进行了更新。通常情况下,其他客户端在主动刷新之前不会看到该更新。而使用WebSockets可以实时推送这些更新。

React Query 集成

由于React Query主要是一个客户端异步状态管理库,所以我不会讨论如何在服务器上设置WebSockets。老实说,我从没做过,而且还取决于你在后端使用的技术。

React Query没有专门为WebSockets构建的内置功能。这并不意味着不支持WebSockets,或者它们与库的兼容性不好。只是当涉及到数据获取时,React Query在使用方式上非常不偏不倚:它只需要一个已解决或拒绝的Promise即可工作 - 其余的由你决定。

逐步进行

一般的思路是按照通常的方式设置查询,就像你不使用WebSockets一样。大多数情况下,你将拥有用于查询和更改实体的常规HTTP端点。

1const usePosts = () =>
2  useQuery({ queryKey: ['posts', 'list'], queryFn: fetchPosts })
3
4const usePost = (id) =>
5  useQuery({
6    queryKey: ['posts', 'detail', id],
7    queryFn: () => fetchPost(id),
8  })

此外,你可以设置一个全局的useEffect来连接到WebSocket终端。具体如何操作完全取决于你使用的技术。我看到过有人从Hasura订阅实时数据。有一篇很棒的文章介绍了如何连接到Firebase。在我的示例中,我将简单地使用浏览器原生的WebSocket API

 1const useReactQuerySubscription = () => {
 2  React.useEffect(() => {
 3    const websocket = new WebSocket('wss://echo.websocket.org/')
 4    websocket.onopen = () => {
 5      console.log('connected')
 6    }
 7
 8    return () => {
 9      websocket.close()
10    }
11  }, [])
12}

处理数据

在设置连接之后,当WebSocket上有数据传入时,我们很可能会有一些回调函数会被调用。再次强调,数据的具体内容完全取决于你的设置方式。受到 Tanner Linsley发布的这条信息的启发,我喜欢从后端发送事件而不是完整的数据对象:

 1const useReactQuerySubscription = () => {
 2  const queryClient = useQueryClient()
 3  React.useEffect(() => {
 4    const websocket = new WebSocket('wss://echo.websocket.org/')
 5    websocket.onopen = () => {
 6      console.log('connected')
 7    }
 8    websocket.onmessage = (event) => {
 9      const data = JSON.parse(event.data)
10      const queryKey = [...data.entity, data.id].filter(Boolean)
11      queryClient.invalidateQueries({ queryKey })
12    }
13
14    return () => {
15      websocket.close()
16    }
17  }, [queryClient])
18}

这就是在接收到事件时更新列表和详细视图的全部内容了。

  • { "entity": ["posts", "list"] } 将使post list无效
  • { "entity": ["posts", "detail"], id: 5 } 将使单个post无效
  • { "entity": ["posts"] } 将使所有和post有关的无效

查询无效与WebSockets非常匹配。这种方法避免了过度推送的问题,因为如果我们接收到的事件与我们当前不感兴趣的实体相关,什么都不会发生。例如,如果我们当前在个人资料页面,而我们接收到了post的更新,invalidateQueries将确保下次我们进入post页面时会重新获取数据。然而,它不会立即重新获取数据,因为我们没有活动的观察者。如果我们再也不去那个页面,推送的更新将是完全不必要的。

更新部分数据

当然,如果你有大型数据集,但是频繁接收小的更新,你可能仍然希望通过WebSocket推送部分数据。

post的标题发生了变化?只需推送标题。点赞数发生变化?也推送下来。

对于这些部分更新,你可以使用queryClient.setQueryData直接更新查询缓存,而不是使其无效。

如果你的同一数据有多个查询键,例如,如果你的查询键中包含多个过滤条件,或者如果你想使用同一条消息更新列表视图和详细视图,那么这将变得有些麻烦。queryClient.setQueriesData是该库的一个相对较新的功能,它允许你处理这种情况:

 1const useReactQuerySubscription = () => {
 2  const queryClient = useQueryClient()
 3  React.useEffect(() => {
 4    const websocket = new WebSocket('wss://echo.websocket.org/')
 5    websocket.onopen = () => {
 6      console.log('connected')
 7    }
 8    websocket.onmessage = (event) => {
 9      const data = JSON.parse(event.data)
10      queryClient.setQueriesData(data.entity, (oldData) => {
11        const update = (entity) =>
12          entity.id === data.id ? { ...entity, ...data.payload } : entity
13        return Array.isArray(oldData) ? oldData.map(update) : update(oldData)
14      })
15    }
16
17    return () => {
18      websocket.close()
19    }
20  }, [queryClient])
21}

对于我个人而言,这种方法有点太动态了,无法处理添加或删除,并且 TypeScript 对此也不太友好,所以我个人更倾向于使用查询无效。

尽管如此,在这里有一个CodeSandbox示例,我在其中处理了无效和部分更新两种类型的事件。(注意:在示例中,自定义hook有点复杂,因为我使用相同的WebSocket模拟了服务器的往返。如果你有一个真实的服务器,就不必担心这个问题)。

增加过期时间

React Query的默认过期时间是0。这意味着每个查询都会立即被视为过期,也就是说,当新的订阅者挂载或用户重新聚焦窗口时,它会重新获取数据。这是为了保持数据的最新状态。

这个目标与WebSockets的目标有很多重叠,因为WebSockets实时更新数据。如果服务器刚刚通过专用消息告诉我手动使其 无效,为什么我还需要重新获取数据呢?

因此,如果你无论如何都通过WebSockets更新所有数据,请考虑设置一个较长的staleTime。在我的示例中,我只是使用了Infinity。这意味着数据将通过useQuery进行初始获取,然后始终从缓存中获取。重新获取只会通过显式的查询无效来发生。

在创建QueryClient时,通过设置全局查询默认值可以最好地实现这一点。

1const queryClient = new QueryClient({
2  defaultOptions: {
3    queries: {
4      staleTime: Infinity,
5    },
6  },
7})

今天就到这里。如果你有任何问题,请随时在 Twitter 上与我联系,或者在下面留言。

comments powered by Disqus