WebSocket 与页面数据刷新

上节说道页面数据以及全局数据的传递和使用。但是这些数据都是服务端获取的,之后在客户端水合之后就变成了静态的数据,那么如何能够让这些数据能够根据后端数据的变化而变化呢。

实时刷新前端数据和服务器数据同步是目前 SaaS 平台中参见的提升 UX 的手段。

这里我们需要借助 WebSocket 的能力。

在开始之前,我们首先需要梳理下数据流。前面提到的三种方法:

  • 把 RSC 中获取到的数据通过 props 透传到子组件,后续子组件都通过消费 props 获取数据源。
  • 把 RSC 中获取到的数据通过 context 方式传递,后续子组件都通过消费 context 获取数据源
  • 使用 cache 函数缓存数据源,后续 RSC 都可以复用缓存的数据源。此方式仅限于 RSC。

前两种可以理解为同一种方法,透传 props 的方式在结构比较复杂的场景中并不适用,更倾向于使用 context 传递。

而最后一种方式,你必须全部使用 RSC 去编写所有需要使用数据的组件,对于这些需要根据 WS 事件驱动数据更新的场景,也不是很好用。使用这个方式不仅写法复杂,也会造成数据的重复获取和滞后。

Next.js RSC 中的数据刷新

既然提到了 RSC,那么来说一说在这种业务下的 Next.js RSC 的使用。

Next.js 的 RSC 中驱动数据的更新的方式可以使用 revalidateTags 或者 revalidatePath。这里我们使用 revalidateTag 举例。

这个案例我们编写一个前端展示服务器时间的 Demo。利用 WebSocket 事件推送当前的服务器事件。在应用 SSR 时首先需要通过 API 获取当前的服务器时间,后续则通过 WS 事件。

Important

此为 Demo,在正式生成环境中此方法显示服务器时间是错误的,你可能需要考虑 NTP 协议对时的算法。

简单编写一个 WSS。

const wssServer = createServer()
const wss = new WebSocket.Server({ server: wssServer })
wssServer.listen(19998)

setInterval(() => {
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(
        JSON.stringify({
          type: 'update',
          now: Date.now(),
        }),
      )
    }
  })
}, 1000)

每 1 秒向 WS Client 发送 update 事件,并且附加当前时间。

// app/page.tsx
const ServerTime = async () => {
  const serverTime = await fetch('http://localhost:19988/api/sever-time', {
    // 首先通过 API 获取时间
    next: {
      tags: ['time'],
    },
  }).then((r) => r.text())
  return <>Server Time: {serverTime}</>
}

export default ServerTime

编写 WS 连接组件。

// app/ws.tsx
'use client'

import { useEffect } from 'react'

import { revalidateTime } from './revalidate'

export const WS = () => {
  useEffect(() => {
    const ws = new WebSocket('ws://localhost:19998')
    ws.onmessage = (message) => {
      const payload = JSON.parse(message.data)
      if (payload.type === 'update') {
        revalidateTime()
      }
    }

    return () => {
      ws.close()
    }
  }, [])
  return null
}

编写 revalidateTime 方法,需要在 WS 事件触发。

'use server'

// 必须在 server 触发 revalidate
import { revalidateTag } from 'next/cache'

export const revalidateTime = () => {
  revalidateTag('time') // fetch 中的 tags
}

那么,我们可以看到这样的效果。当 WS 事件到达,浏览器就会重新请求 RSC payload 去刷新这个页面。

这样是无法直接消费 WS 事件的数据乐观更新页面数据,而是重新需要 RSC 造成了资源开销和数据更新滞后。

Excalidraw Loading...

但是注意的是,上面的方法仅限于使用 Next.js 提供的 fetch 方法,在一些场景中你可以还是会选择其他的请求库,例如 axios 或者 XHR 等等,对于这些时候此方法并不适用,局限性比较大。

Important

为了避免又要被某人喷,这里注明下。

revalidateTagsrevalidatePath 其实并不是为这个场景准备的,这里只是提供一个思路。

这两个方法的使用场景基本都是在 Next.js 编写的 全栈应用 中的,在这些应用中不仅 API 和 DB 都在 Next.js 中管理,当 DB 数据变更就可以在调用 revalidateTags 或者 revalidatePath 刷新数据。

而在 WS 场景中使用此方法只能说投机取巧,因此我不建议使用,但是通过这个例子你可以了解这个方法的大致用法。

上面的 DEMO 位于: nextjs-book/tree/main/demo/ws

使用 Jotai 更新数据源粒度化组件重渲染

而这个场景,个人认为最好的方式就是通过 Context 方式去传递数据了,但是 Context 如果直接使用 React useState 托管的数据向下传递,那么在更新比较复杂的对象时,无法让使用到数据源的组件细粒度更新。所以这里选择 Jotai 作为 Store。你也可以使用其他状态库,如果依旧选择 Context 这里也提供建议使用 use-context-selector

我们需要的实现的数据流向大概为:

Excalidraw Loading...

页面在服务端渲染的数据,注入到 DataProvider 中,然后内部实现把数据扔到 Jotai Atom 去管理。子组件需要使用数据源则直接消费 Context 获取 Atom,再用 Jotai 提供的 selector 方法提取需要用到的字段,以实现细粒度更新。

那么在服务端渲染时给到的数据,在注入到 DataProvider 之后,就不会再使用了。所有需要用到这个数据源的地方都应该从 Jotai 中取用。

现在我们来编写一个 Demo 说明一下这个事。

首先编写一个 DataProvider。

'use client'

const useBeforeMounted = (fn: () => any) => {
  const effectOnce = useRef(false)

  if (!effectOnce.current) {
    effectOnce.current = true
    fn?.()
  }
}

export const createModelDataProvider = <Model,>() => {
  const ModelDataAtomContext = createContext(null! as PrimitiveAtom<Model>)
  const globalModelDataAtom = atom<Model>(null! as Model)
  const ModelDataAtomProvider: FC<
    PropsWithChildren<{
      overrideAtom?: PrimitiveAtom<Model>
    }>
  > = ({ children, overrideAtom }) => {
    return (
      <ModelDataAtomContext.Provider
        value={overrideAtom ?? globalModelDataAtom}
      >
        {children}
      </ModelDataAtomContext.Provider>
    )
  }
  const ModelDataProvider: FC<
    {
      data: Model
    } & PropsWithChildren
  > = memo(({ data, children }) => {
    const currentDataAtom =
      useContext(ModelDataAtomContext) ?? globalModelDataAtom

    const setData = useSetAtom(currentDataAtom)

    useBeforeMounted(() => {
      setData(data)
    })

    useEffect(() => {
      setData(data)
    }, [data])

    useEffect(() => {
      setData(data)
      return () => {
        setData(null!)
      }
    }, [])

    return children
  })

  ModelDataProvider.displayName = 'ModelDataProvider'

  const useModelDataSelector = <T,>(
    selector: (data: Model | null) => T,
    deps?: any[],
  ) => {
    const currentDataAtom =
      useContext(ModelDataAtomContext) ?? globalModelDataAtom
    const nextSelector = useCallback((data: Model | null) => {
      return data ? selector(data) : null
    }, deps || noopArr)

    return useAtomValue(selectAtom(currentDataAtom, nextSelector))!
  }

  const useSetModelData = () =>
    useSetAtom(useContext(ModelDataAtomContext) ?? globalModelDataAtom)

  const setGlobalModelData = (recipe: (draft: Model) => void) => {
    const jotaiStore = getGlobalStore()
    jotaiStore.set(
      globalModelDataAtom,
      produce(jotaiStore.get(globalModelDataAtom), recipe),
    )
  }

  const getGlobalModelData = () => {
    return getGlobalStore().get(globalModelDataAtom)
  }

  const useGetModelData = () => {
    const currentDataAtom =
      useContext(ModelDataAtomContext) ?? globalModelDataAtom
    const store = useStore()
    return () => {
      return store.get(currentDataAtom)
    }
  }

  const useModelData = () => {
    return useAtomValue(useContext(ModelDataAtomContext) ?? globalModelDataAtom)
  }

  return {
    ModelDataAtomProvider,
    ModelDataProvider,
    useModelDataSelector,
    useSetModelData,
    useGetModelData,
    useModelData,
    setGlobalModelData,
    getGlobalModelData,

    ModelDataAtomContext,
  }
}

这个 Providers 把初始数据输入到 ModelDataProvider 中,然后创建一个全局 atom 去托管这个数据,对外暴露一个方法可以修改这个数据。向下,利用 Context 隔离的特点,可以复写 Context 链上的 Atom,在同一个页面上可以同时展示不同的数据源,一个为全局数据,其他的为 Context 复写的 atom 数据。这个特征会在实战中讲解,这里只需要先关注 setGlobalModelData 即可。

上面代码的封装可以在这个仓库中找到:

首先,创建 ModelDataContext 相关:

'use client'

import type { Item } from './type'

import { createModelDataProvider } from '../../../lib/data-provider'

export const {
  ModelDataAtomContext,
  ModelDataAtomProvider,
  ModelDataProvider,
  getGlobalModelData,
  setGlobalModelData,
  useModelDataSelector,
  useGetModelData,
  useModelData,
} = createModelDataProvider<Item[]>()

layout.tsx 中,初始化 WS,和 jotai Provider。获取数据源,输入到 ModelDataProvider

export default async ({ children }: PropsWithChildren) => {
  const data = (await fetch('http://localhost:19988/api/data/list').then((r) =>
    r.json(),
  )) as Item[]
  return (
    <Providers>
      <ModelDataProvider data={data}>{children}</ModelDataProvider>
      <WS />
    </Providers>
  )
}

在 WS 中这样去更新数据:

'use client'

import { useEffect } from 'react'

import { setGlobalModelData } from './context'

export const WS = () => {
  useEffect(() => {
    const ws = new WebSocket('ws://localhost:19998')
    ws.onmessage = (message) => {
      const payload = JSON.parse(message.data)
      console.log('payload.data', payload)
      if (payload.type === 'create') {
        setGlobalModelData((prev) => [...prev, payload.data])
      }
    }

    return () => {
      ws.close()
    }
  }, [])
  return null
}

数据渲染组件:

'use client'

import { useModelData } from './context'

export const DataList = () => {
  const modelData = useModelData()

  return (
    <div>
      {modelData.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  )
}

现在编写这个页面,模拟两个端,左侧模拟为 C 端,右侧为 CMS 管理平台,进行数据录入。

可以看到,WS 事件触发的数据更新。

上述代码位于:

codesandbox VM 会限流,建议拉代码到本地试试,另外貌似 codesandbox 不支持 ws 连接,还是建议本地执行。

下一节,讲讲请求那些事。


最后更新于 2024/5/22 21:03:39

更新历史

本书还在编写中..

前往 https://innei.in/posts/tech/my-first-nextjs-book-here#comment 发表你的观点吧。