banner
innei

innei

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

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

因為最近在重寫個人站點,嘗試了 NextJS 全新的 RSC 架構之後,也踩了很多坑。打算用此文記錄一些實踐。

在 SSR 架構中,如果請求數據在服務端,在轉到 CSR 渲染時依賴 SSR 的數據時,必須要保證在 CSR 時拿到的數據和服務端一致,只有這樣才能保證兩端渲染一致,水合成功,否則就會出現 Hydration failed because the initial UI does not match what was rendered on the server. 的錯誤,雖然說這個錯誤不會導致頁面崩潰,使用下來也不會有明顯的 LCP 降低,但是在開發過程中就很糟心了,會出現大量的 NextJS 紅色彈窗,以及生產環境中的 Sentry 轟炸(如果接入了 Sentry)。下圖是現在 kami 的糟心體驗。因為實在是改不動了,所以才有了重寫的想法。

!Sentry 上報接口 429 限流了

在 RSC 架構中,也是以 SSR 為基礎的,只是現在路由完全由 Server 接管,所以在原本 NextJS 中的 router 完全被取代了。路由渲染的開始從頂層組件開始向下都由 Server 渲染之後返回 Client,理論上如果沒有碰到 use client 的組件,瀏覽器這邊都不需要進行渲染。在大部分項目中,業務不可能這麼簡單,例如我的數據會隨著服務端事件的推送而改變。

有一點需要注意的,必須要保證瀏覽器水合時數據一致,如果做不到,只能放棄該組件的 SSR 渲染。最常規的方法,但是他不能做更多的事。

// app/pageImpl.tsx
'use client'

export default (props: { data: PageData }) => {
	// ...
}

// app/page.tsx
import PageImpl from './pageImpl'
export default async () => {
  const data = await fetch('...')
  
  return <PageImpl data={data} />
}

以上是我最開始嘗試的數據傳遞方式,用這個方式,完全沒有問題,只要保證傳遞的 data 都是可被 JSON 序列化的即可。

但是用了上面的方式,通過 props 傳遞的數據是不可變的,頁面的組件由此數據驅動,需要根據後續各種事件去改變這個數據,就需要引入狀態管理。

回到已經爛透的 kami,它是怎麼做的。頁面需要的數據請求完之後,在服務端根據獲取的數據渲染完了頁面返回 HTML 到了瀏覽器,瀏覽器在開始渲染的第一幀是頁面的完整態,但是此時頁面還不是 interactive 狀態,直到 JS 加載後 React 開始介入進行 hydrate 但是由於頁面的數據不是根據 props 傳遞的,而是都是從 store 提取,此時 store 沒有完成水合導致 hydrate 後的第一幀進入了沒有數據的頁面的加載態,導致 React 報錯 Hydration Error,從而轉向 Client Render。

image

之前使用的 Zustand 看似並沒有提供很好的解決方案。這次我打算用 Jotai 完成這部分的遷移。我們的頁面數據依然由 Store 驅動,而不是用 props 透傳。

React Query 方案#

我嘗試了 React Query 作為媒介,React Query 天然的提供了 Hydrate 組件,一定程度上可以解決這個問題,但是如果使用了 React Query 作為數據管理,將無法對每個組件的粒度化進行控制。React Query 的 select 能力不太靈活,而且在一些嘗試中發現即使使用了 select 也無法精確粒度化到每個組件的更新。

真的簡單嗎?#

如果使用 React Query 方案,簡單的場景只需要下面這樣操作就行了。

建立 ReactQueryProvider 和 Hydrate 組件,這是兩個 client component。

// app/provider.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PropsWithChildren } from 'react'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      refetchInterval: 1000 * 60 * 5, // 5 minutes
      refetchOnWindowFocus: false,
      refetchIntervalInBackground: false,
    },
  },
})
export const ReactQueryProvider = ({ children }: PropsWithChildren) => {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

// app/hydrate.tsx
'use client'

import { Hydrate as RQHydrate } from '@tanstack/react-query'
import type { HydrateProps } from '@tanstack/react-query'

export function Hydrate(props: HydrateProps) {
  return <RQHydrate {...props} />
}

然後在 layout.tsx 引入。

import { QueryClient, dehydrate } from '@tanstack/react-query'
import { Hydrate } from './hydrate'

import { ReactQueryProvider } from './provider'
import { QueryKeys } from './query-key'
import { sleep } from '@/utiils'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 1000,
      staleTime: 1000,
    },
  },
})
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  await queryClient.fetchQuery({
    queryKey: [QueryKeys.ROOT],
    queryFn: async () => {
      await sleep(1000)
      const random = Math.random()
      console.log(random)
      return random
    },
  })
  const state = dehydrate(queryClient, {
    shouldDehydrateQuery: (query) => {
      if (query.state.error) return false
      if (!query.meta) return true
      const {
        shouldHydration,

        skipHydration,
      } = query.meta

      if (skipHydration) return false

      return (shouldHydration as Function)?.(query.state.data as any) ?? false
    },
  })

  return (
    <ReactQueryProvider>
      <Hydrate state={state}>
        <html lang="en">
          <body>{children}</body>
        </html>
      </Hydrate>
    </ReactQueryProvider>
  )
}

這裡注意的是,你必須在 Server 端也建立一個 QueryClient,在 Server Component 專用這個 QueryClient 而是 Client Component 不是同一個,而在 Hydrate 時使用 Server Side 的。所以在 layout.tsx 我們又建立了一個 QueryClient 供 Server Side Only 使用。我們在 RootLayout 定義了一個 Query fetch,模擬了一個隨機數據的獲取,並且等待這個異步請求完成再進入 Dehydrate 階段。請注意上面設定的 cacheTime 後面會講到。接下來驗證 Hydrate 是否生效。如果沒有出現 Hydrate Error 這表明沒有問題。

建立 page.tsx,並轉成 Client Component。

'use client'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from './query-key'

export default function Home() {
  const { data } = useQuery({
    queryKey: [QueryKeys.ROOT],
    queryFn: async () => {
      return 0
    },
    enabled: false,
  })
  return <p>Hydrate Number: {data}</p>
}

這裡我們禁用了 Query 的自動 refetch 的特徵,保證不要刷新數據,在這個例子中,只要頁面不是顯示 0 就是 OK 的。

image

我們看到隨機數和 Server 打印的一樣,並且沒有瀏覽器沒有任何 Hydrate 的報錯。

數據緩存#

前面提到了我們在 ServerSide Only 的 QueryClient 設定了 cacheTime,這個參數可不是你認為的數據緩存時間,而是 Query 實例的存在時間,在 React Query 中所有的 Query 都在 QueryCache 中托管,只要過了這個時間 Query 就會被銷毀,在 React Hook 中的 useQuery 中 Query 長期掛在組件中不需要感知這個數值,而在 QueryClient 手動 fetch 的數據也會產生 Query 實例,所以在 ServerSide 要先讓一個數據多次命中同一個 Query 切記不要設置太短的時間,默認值是 5 分鐘。

我們舉個例子,我設置 ServerSide 的 QueryClient cacheTime 為 10 毫秒,在 queryClient fetch data 時有異步任務插入,導致沒有進行到 dehydrate 時 Query 實例被銷毀的情況。



const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 10, // 設置 10ms,也許是為了不要讓 Server 長期命中 API 緩存保證數據最新。
    },
  },
})


export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  await queryClient.fetchQuery({
    queryKey: [QueryKeys.ROOT],
    queryFn: async () => {
      await sleep(1000)
      const random = Math.random()
      console.log(random)
      return random
    },
  })
  await sleep(10) // 模擬異步任務跳出,超過 10ms
  
  const state = dehydrate(queryClient, {
    shouldDehydrateQuery: (query) => {
      if (query.state.error) return false
      if (!query.meta) return true
      const {
        shouldHydration,

        skipHydration,
      } = query.meta

      if (skipHydration) return false

      return (shouldHydration as Function)?.(query.state.data as any) ?? false
    },
  })

  return (
    <ReactQueryProvider>
      <Hydrate state={state}>
        <html lang="en">
          <body>{children}</body>
        </html>
      </Hydrate>
    </ReactQueryProvider>
  )
}

此時再看瀏覽器頁面。已經沒有數據。

image

可想,要使用 React Query 並且又不想 Server 把 API 的緩存於自身還是有一點困難的。

潛在的數據泄露#

如果你不是 Serverless Mode 運行這個 Next.js,由於 QueryClient 在服務端只有一個,但是訪問你的站點有很多用戶,他們訪問著不同的站點,QueryClient 就會緩存不同的請求數據。

在 A 用戶訪問站點時可能包含著 B 用戶訪問內容的水合數據。

舉個例子,編寫一個 Demo。我們把 ServerSide 的 cacheTime 註釋,回到默認的 5 分鐘。

建立 A 和 B 頁面。

// app/A/layout.tsx
import { queryClient } from '../queryClient.server'

export default async () => {
  await queryClient.fetchQuery({
    queryKey: ['A'],
    queryFn: async () => {
      return 'This is A'
    },
  })
  return null
}
// app/A/page.tsx
export default () => {
  return null
}

B 同理,把上面的 A 全改成 B。

訪問 /A/B。刷新頁面,查看 /A HTML 源碼。

image

我們可以看到訪問 /A 攜帶了 /B 的數據。

當訪問量上去之後,這個水合數據會變得非常龐大,這是我們不希望看到的。而且如果你把 Cookie 轉發到了服務端之後,可能會讓訪客看到一些不該看的東西。

如何規避,我的方案是根據 meta 去判斷。可以在 query 的定義中,自定義一個 meta 鍵值表示這個 query 的需不要 hydrate。然後按照當前的路由只 hydrate 當前路由的數據。對敏感內容(可鑑權也和部分查看的)強制跳過 hydrate。


  const dehydratedState = dehydrate(queryClient, {
    shouldDehydrateQuery: (query) => {
      if (query.state.error) return false
      if (!query.meta) return true
      const {
        shouldHydration,
        hydrationRoutePath,
        skipHydration,
        forceHydration,
      } = query.meta

      if (forceHydration) return true
      if (hydrationRoutePath) {
        const pathname = headers().get(REQUEST_PATHNAME)

        if (pathname === query.meta?.hydrationRoutePath) {
          if (!shouldHydration) return true
          return (shouldHydration as Function)(query.state.data as any)
        }
      }

      if (skipHydration) return false

      return (shouldHydration as Function)?.(query.state.data as any) ?? false
    },
  })

只需要修改 dehydrateState 即可。我這邊使用了 shouldHydration hydrationRoutePath skipHydration forceHydration 控制 hydrate 狀態。

參考使用方法:

   defineQuery({
      queryKey: ['note', nid],
      meta: {
        hydrationRoutePath: routeBuilder(Routes.Note, { id: nid }),
        shouldHydration: (data: NoteWrappedPayload) => {
          const note = data?.data
          const isSecret = note?.secret
            ? dayjs(note?.secret).isAfter(new Date())
            : false
          return !isSecret
        },
      },
      queryFn: async ({ queryKey }) => {
        const [, id] = queryKey

        if (id === LATEST_KEY) {
          return (await apiClient.note.getLatest()).$serialized
        }
        const data = await apiClient.note.getNoteById(+queryKey[1], password!)

        return { ...data }
      },
    })

看到這裡,你可能會說,需要這麼麻煩麼,在 RootLayout 組件內部重新創立一個新的 QueryClient 實例,不就能保證每個請求的數據不會被污染麼。確實 React Query 文檔中提到的方案的也是如此,但是這僅僅是在傳統 SSR 架構中適用,他也存在很多局限性,例如沒有使用這種方式,QueryClient 將不能被其他 Layout 調用,例如在子 Layout 中的 Data fetching 必須建立新的 QueryClient,然後再次使用 Hydrate 組件包裹會存在大量額外的開銷。

React 18.3 中提供的 cache(Next.js 已實現該方法) 方法或許可以解決這一方案,使用 cache(() => new QueryClient()) 包裹使得在此 React DOM 渲染中始終命中同一個 QueryClient。這樣的方案雖然解決了跨請求狀態污染,但是在高併發中無法享受到單實例帶來的請求 Dedupe 紅利,瞬間發出太多請求帶來的超負載也需要考慮。

這裡就不再過多贅述了。

總之在 React Query 還是需要考慮過多的問題,從而複雜度上升,促使我轉向其他方案。

Jotai#

寫累了。且聽下回分解。

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

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