banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

淺談 NextJS RSC/SSR 中數據水合和持久化數據(二)

上回说道在 SSR 中如何用 React Query 实现資料水合。這期來談談如何用 Jotai 實現。

使用 Jotai 對資料的管理後期對資料的修改和對 UI 的反應也會更加方便。

改造#

注入資料#

layout.tsx 我們依然使用 queryClient 去獲取資料,然後我們需要把獲取到的資料直接傳入 Jotai 的 atom 中。後期 client 端的組件的消費資料全部從 atom 中獲取。大概是這樣的:

// layout.tsx
export default async (
  props: NextPageParams<{
    id: string
  }>,
) => {
  const id = props.params.id
  const query = queries.note.byNid(id)
  const data = await getQueryClient().fetchQuery(query)

  return (
    <>
      <CurrentNoteDataProvider data={data} /> //
      確保第一位,需要在組件渲染之前完成注入
      {props.children}
    </>
  )
}

上面的代碼簡略,我這裡實現了一個 <CurrentNoteDataProvider /> 的組件,主要是把當前頁面的資料源直接灌入一個 atom 中。稍後我會再介紹這個 Provider 的實現。

注意看,和之前不同的是,我們這裡不再使用 React Query 提供的 Hydrate 去 Query 的水合,Client 端的組件之後也不會再使用 useQuery 拿資料了。由於資料都在 Jotai 中,對資料的掌管變得非常簡單,你想過濾什麼資料就很方便了。

現在服務端的 QueryClient 可以是一個單例,而不是為每次 React Tree 的構建而創建一個新的實例,創建新的實例雖然能避免跨請求資料的污染,但是卻不能享受到 Query 快取帶來的福利。所以現在,我們改造 getQueryClient 方法。

// query-client.server.ts
const sharedClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 3,
      cacheTime: 1000 * 3,
    },
  },
})
export const getQueryClient = () => sharedClient

如遇到需要鑑權的資料,如何解決資料污染呢。可以在 layout 直接判斷和處理,需不需要將資料注入到 CurrentDataProvider。或者可以選擇性的注入資料,僅僅保留公開的內容。

// layout.tsx
export default async (
  props: NextPageParams<{
    id: string
  }>,
) => {
  const id = props.params.id
  const query = queries.note.byNid(id)
  const data = await getQueryClient().fetchQuery(query)

  const filteredData = omit(data, ['some-private-fieled']) // <---- 不會影響快取的過濾
  return (
    <>
      <CurrentNoteDataProvider data={filteredData} />

      {props.children}
    </>
  )
}

消費資料#

我們來到一個 Client 端的組件,需要消費到當前頁面的資料。下面是一個簡單組件,用於顯示文章的標題。

export const NoteTitle = () => {
  const title = useCurrentNoteDataSelector((data) => data?.data.title)
  if (!title) return null
  return (
    <h1 className="mt-8 text-left font-bold text-base-content/95">{title}</h1>
  )
}

我們同樣實現了一個 hook 名為 useCurrentNoteDataSelector ,直接從剛剛注入的資料中提取 title 欄位,而不消費其他任何欄位,後期動態修改頁面資料源,這樣會非常方便實現細粒度的更新。

其他組件需要使用任何資料也是如此,都可以透過 Selector 實現細粒度渲染。

實現 Provider 和 Hooks#

這是一套通用方法,如果頁面上不只存在一個資料源,則可以創建多個類似的鉤子。

所以我們創建一個工廠函數 createDataProvider,批量生產這些。

'use client'

import { memo, useCallback, useEffect } from 'react'
import { produce } from 'immer'
import { atom, useAtomValue } from 'jotai'
import { selectAtom } from 'jotai/utils'
import type { FC, PropsWithChildren } from 'react'

import { useBeforeMounted } from '~/hooks/common/use-before-mounted'
import { noopArr } from '~/lib/noop'
import { jotaiStore } from '~/lib/store'

export const createDataProvider = <Model>() => {
  const currentDataAtom = atom<null | Model>(null)
  const CurrentDataProvider: FC<
    {
      data: Model
    } & PropsWithChildren
  > = memo(({ data, children }) => {
    useBeforeMounted(() => {
      jotaiStore.set(currentDataAtom, data)
    })

    useEffect(() => {
      jotaiStore.set(currentDataAtom, data)
    }, [data])

    useEffect(() => {
      return () => {
        jotaiStore.set(currentDataAtom, null)
      }
    }, [])

    return children
  })

  CurrentDataProvider.displayName = 'CurrentDataProvider'
  const useCurrentDataSelector = <T>(
    selector: (data: Model | null) => T,
    deps?: any[],
  ) => {
    const nextSelector = useCallback((data: Model | null) => {
      return data ? selector(data) : null
    }, deps || noopArr)

    return useAtomValue(selectAtom(currentDataAtom, nextSelector))
  }

  const setCurrentData = (recipe: (draft: Model) => void) => {
    jotaiStore.set(
      currentDataAtom,
      produce(jotaiStore.get(currentDataAtom), recipe),
    )
  }

  const getCurrentData = () => {
    return jotaiStore.get(currentDataAtom)
  }

  return {
    CurrentDataProvider,
    useCurrentDataSelector,
    setCurrentData,
    getCurrentData,
  }
}

在工廠函數中我們首先創建一個最基本的 currentDataAtom 。這個 atom 用於托管頁面資料源。然後 CurrentDataProvider 傳入一個 props 名為 data,然後在頁面渲染前就把資料扔到 currentDataAtom 這步很重要。我們需要確保在渲染頁面組件之前,currentDataAtom 內資料已經準備就緒。所以我們需要 useBeforeMounted 來實現同步注入資料。實現如下:

// use-before-mounted.ts
import { useRef } from 'react'

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

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

::: banner info

上面的代碼在開發環境中使用,可能會得到一個 Warning,告訴你不應該在 render 函數中直接使用 setState

useBeforeMounted(() => {
  // React 會發出警告,但這是合理的,你可以忽略。
  // 不要排斥這種方式,因為在新版 React 文檔中會告訴你善用這種方式去優化性能。
  jotaiStore.set(currentDataAtom, data)
})

:::

useCurrentDataSelector 就比較簡單了,就是 Jotai 提供的 selectAtom 套了個殼。

以上就是最基本的創建方法了。

現在我們創建一個 CurrentNoteProvider

const {
  CurrentDataProvider,
  getCurrentData,
  setCurrentData,
  useCurrentDataSelector,
} = createDataProvider<NoteWrappedPayload>()

非常簡單。

響應式修改資料#

Jotai 的好處是狀態和 UI 分離,有了上面的方法之後,現在我們不再需要過於關注資料的變化帶來的 UI 更新的問題。在任何地方我們都能透過修改資料去驅動 UI 的更新。

現在我們有一個 Socket 連接,當收到 NOTE_UPDATE 事件之後,立即更新資料,驅動 UI 更新。我們可以這樣寫。

// event-handler.ts

import {
  getCurrentNoteData,
  setCurrentNoteData,
} from '~/providers/note/CurrentNoteDataProvider'

import { EventTypes } from '~/types/events'

export const eventHandler = (
  type: EventTypes,
  data: any,
  router: AppRouterInstance,
) => {
  switch (type) {
    case 'NOTE_UPDATE': {
      const note = data as NoteModel
      if (getCurrentNoteData()?.data.id === note.id) {
        setCurrentNoteData((draft) => {
          // <----- 直接修改資料
          Object.assign(draft.data, note)
        })
        toast('手記已更新')
      }
      break
    }
    default: {
      if (isDev) {
        console.log(type, data)
      }
    }
  }
}

脫離 UI 之後,修改資料變得十分簡單,我們直接獲取到 Jotai atom 內部的資料源,判斷和事件中的 id 是否一致,然後直接更新資料。我們無需關心 UI 如何,只要使用 setCurrentNoteData 更新資料,UI 會立刻更新。而且這一舉動是非常細粒度的。未涉及到的組件永遠不會發生更新。可以透過一下文件查看更多。

https://github.com/Innei/sprightly/blob/14234594f44956e6f56f1f92952ce82db37ef4df/src/socket/handler.ts

資料隔離#

使用了上面的方法保證了頁面上資料源和 UI 分離。現在又有一個新的問題。頁面組件過於依賴 CurrentDataAtom 的資料,但是 CurrentDataAtom 卻只有一個。

現在我們的頁面主要存在多個組件,假設 NoteTitle 存在兩個,第一個需要展示 NoteId 為 15 的標題,而第二個需要展示 NoteId 為 16 的標題。

按照上面的結構,這個需求基本是不可能實現的,因為在 NoteTitle 中都使用了 useCurrentDataSelector 獲取資料,而 Atom 卻只有一個。

image

為了解決這個問題,我們需要知道 React Context 的三個特徵

  • 當組件不在 Provider 內部時,useContext 會返回一個 Context 的預設值,而這個預設值我們是可以定義的。
  • 如果在 Provider 內部時,則會消費傳入 Provider 的值。
  • 如果在多層相同的 Provider 內部時,則會消費離組件最近的 Provider 的值。

綜上所述,我們可以對 CurrentDataProvider 進行改造。

// createDataProvider.tsx

export const createDataProvider = <Model,>() => {
  const CurrentDataAtomContext = createContext(
    null! as PrimitiveAtom<null | Model>,
  )
  const globalCurrentDataAtom = atom<null | Model>(null)
  const CurrentDataAtomProvider: FC<
    PropsWithChildren<{
      overrideAtom?: PrimitiveAtom<null | Model>
    }>
  > = ({ children, overrideAtom }) => {
    return (
      <CurrentDataAtomContext.Provider
        value={overrideAtom ?? globalCurrentDataAtom}
      >
        {children}
      </CurrentDataAtomContext.Provider>
    )
  }
  const CurrentDataProvider: FC<
    {
      data: Model
    } & PropsWithChildren
  > = memo(({ data, children }) => {
    const currentDataAtom =
      useContext(CurrentDataAtomContext) ?? globalCurrentDataAtom

    useBeforeMounted(() => {
      jotaiStore.set(currentDataAtom, data)
    })

    useEffect(() => {
      jotaiStore.set(currentDataAtom, data)
    }, [data])

    useEffect(() => {
      return () => {
        jotaiStore.set(currentDataAtom, null)
      }
    }, [])

    return children
  })

  CurrentDataProvider.displayName = 'CurrentDataProvider'

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

    return useAtomValue(selectAtom(currentDataAtom, nextSelector))
  }

  const useSetCurrentData = () =>
    useSetAtom(useContext(CurrentDataAtomContext) ?? globalCurrentDataAtom)

  const setGlobalCurrentData = (recipe: (draft: Model) => void) => {
    jotaiStore.set(
      globalCurrentDataAtom,
      produce(jotaiStore.get(globalCurrentDataAtom), recipe),
    )
  }

  const getGlobalCurrentData = () => {
    return jotaiStore.get(globalCurrentDataAtom)
  }

  return {
    CurrentDataAtomProvider,
    CurrentDataProvider,
    useCurrentDataSelector,
    useSetCurrentData,
    setGlobalCurrentData,
    getGlobalCurrentData,
  }
}

我們新增加了 React Context,把原本的 currentDataAtom 改成了 globalCurrentDataAtom 用於頂層頁面資料,頂層頁面資料吃到預設值,也就是我們不需要修改原來的任何代碼。增加了 Scope 的 CurrentProvider可以在組件內部實現資料隔離。

現在我們對第二個需要展示和當前頁面資料的不同的 NoteTitle,只要包上 CurrentDataProvider 就行了。

image

// layout.tsx

export default async (
  props: NextPageParams<{
    id: string
  }>,
) => {
  const id = props.params.id
  const query = queries.note.byNid(id)
  const data = await getQueryClient().fetchQuery(query)

  return (
    <>
      // 確保第一位,需要在組件渲染之前完成注入 // 這裡我們不需要包括
      props.children
      <CurrentNoteDataProvider data={data} />
      {props.children}
    </>
  )
}

// page.tsx

export default function Page() {
  return (
    <>
      <NoteTitle />
      // 這裡需要包括,並傳入不同的 data
      <CurrentNoteDataProvider data={otherData}>
        <NoteTitle />
      </CurrentNoteDataProvider>
    </>
  )
}

基於上面的方法,就可以做到資料隔離,頁面存在多個由 Jotai 管理的資料源互不干擾。

基於這個特性,我熬夜實現了 Shiro 的 Peek 功能。

image

https://github.com/Innei/Shiro/commit/e1b0b57aaea0eec1b695c4f1961297b42b935044

OK,今天就先說這麼多。

此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/programming/nextjs-rsc-ssr-data-hydration-persistence-two

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。