上回说道在 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 會立刻更新。而且這一舉動是非常細粒度的。未涉及到的組件永遠不會發生更新。可以透過一下文件查看更多。
資料隔離#
使用了上面的方法保證了頁面上資料源和 UI 分離。現在又有一個新的問題。頁面組件過於依賴 CurrentDataAtom
的資料,但是 CurrentDataAtom
卻只有一個。
現在我們的頁面主要存在多個組件,假設 NoteTitle
存在兩個,第一個需要展示 NoteId 為 15 的標題,而第二個需要展示 NoteId 為 16 的標題。
按照上面的結構,這個需求基本是不可能實現的,因為在 NoteTitle
中都使用了 useCurrentDataSelector
獲取資料,而 Atom 卻只有一個。
為了解決這個問題,我們需要知道 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
就行了。
// 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 功能。
https://github.com/Innei/Shiro/commit/e1b0b57aaea0eec1b695c4f1961297b42b935044
OK,今天就先說這麼多。
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/programming/nextjs-rsc-ssr-data-hydration-persistence-two