Next App Router 模式下,如何同步服務端 Redux 初始狀態?

鹿鹿isNotDiefined發表於2024-11-18

大家的閱讀是我發帖的動力,本文首發於我的部落格:deerblog.gu-nami.com/,歡迎大家來玩,轉載請註明出處

🎈前言

Next.js 是一個廣受歡迎的 React 服務端渲染(Server Side Rendering,SSR)框架。Next.js 的頁面會先在服務端渲染一次,然後把結果傳給瀏覽器,也就是客戶端,再在客戶端渲染一遍,並且執行客戶端特有的邏輯。如果使用 Redux,一般情況下,在服務端渲染的時候,初始化了的 Redux 全域性狀態會被建立。然而伺服器返回的是隻有 HTML 標籤的頁面,客戶端無法獲得服務端 Redux 的狀態,會引起水合錯誤,依賴 Redux 的元件渲染異常等影響體驗的問題。我們需要同步服務端的 Redux 狀態。

在 Page Router 模式下,Redux 同步狀態已經有了成熟的解決方案,可以使用 next-redux-wrapper 完成,但是它並不適用於 App Router 模式的應用。這裡參考 Redux 文件 的方法,給出一些個人在 Next.js 上同步 Redux 狀態的小技巧,也只是一些個人做法,大佬們肯定有更優雅的方法的。

🎀解決思路

先來看createStore的入參:

export declare function createStore<S, A extends Action, Ext, StateExt>(
  reducer: Reducer<S, A>,
  preloadedState?: PreloadedState<S>,
  enhancer?: StoreEnhancer<Ext>
): Store<S & StateExt, A> & Ext

第二個引數preloadedStatestore的初始狀態,只要在服務端 / 客戶端中,都傳入一樣的內容,就可以建立兩個狀態一模一樣的store

服務端渲染時,初始狀態可在服務端元件(React Server Component,RSC)中直接查詢獲得。在客戶端中,如果走網路請求,則在首次渲染中是拿不到服務端狀態的。我們不妨把狀態寫入 HTML 中,帶往客戶端,然後客戶端就可以同步服務端 Redux 的狀態了。

我們先來以我部落格的統計資料為例,這是目前的效果:

🎉服務端渲染階段初始化 Redux

先寫一個建立 Redux 的程式碼(程式碼很大一部分是從老專案中遷移的,當時並沒有用上 Redux Toolkit,請見諒):

import { thunk } from 'redux-thunk'
import { compose, createStore, applyMiddleware, StoreEnhancer, Store, EmptyObject } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'

import rootReducer, { combinedStateType } from './rootReducers'

let storeEnhancers: StoreEnhancer
if (process.env.NODE_ENV === 'production') {
  storeEnhancers = compose(applyMiddleware(thunk))
} else {
  storeEnhancers = compose(composeWithDevTools(applyMiddleware(thunk)))
}

export type reduxStoreType = Store<EmptyObject & combinedStateType>

export const configureStore = (initialState = {}) => {
  return createStore(rootReducer, initialState, storeEnhancers)
}

我們可以在Provider中完成初始化,畢竟 RSC 需要以元件的形式組織程式碼,而且後面useSelectoruseDispatch之類的鉤子也需要它。這裡ReduxProvider接收data引數作為初始狀態。

import { configureStore, reduxStoreType } from "./index"
export default function ReduxProvider ({
  children, data
}: {
  children: React.ReactNode, data: any
}) {
  const storeRef = useRef<reduxStoreType | null>(null)
  const initialState = data
  if (!storeRef.current) {
    storeRef.current = configureStore(initialState)
  }
  return <Provide store={storeRef.current}>{children}</Provider>
}

匯出一個獲取方法,以便 React 元件外的程式碼可以使用 Redux。

// ...
let reduxStore: reduxStoreType | null = null
export default function ReduxProvider (/* ... */) {
  // ...
  storeRef.current = reduxStore = configureStore(initialState)
  // ...
}
export const getStore = () => reduxStore

在 src/app/layout.tsx 中使用這個ReduxProvider。到這裡,其實服務端初始化 Redux 已經完成了。

import { getArticleData } from "@/request/ssr/article";
import RootLayoutInner from "./layoutInner";
import ReduxProvider from "@/redux/reduxProvider";

export default async function RootLayout ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const data = {
    article: await getArticleData()
  }
  
  return <html lang="en">
    <head>
      {/* ... */}
    </head>
    <body>
      <ReduxProvider data={data}>
        <RootLayoutInner >
          { children }
        </RootLayoutInner>
      </ReduxProvider>
    </body>
  </html>
}

getArticleData函式獲取服務端的資料傳進了ReduxProvider中,成為了 Redux 的初始狀態,服務端已經可以渲染出具有 Redux 初始狀態的頁面了。

來看看現在的效果,emmmmmm... 似乎並不太好:

看看控制檯... 給了我們幾個水合錯誤。

Uncaught Error: Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: "53" Client: "0"

可以發現服務端傳回來的 HTML 是有資料的,但是客戶端渲染時並沒有拿到資料。接下來客戶端就需要同步這個狀態了。

🎨客戶端同步 Redux 狀態

我們可以透過<script>標籤,在客戶端把初始狀態掛到window.DATA_TO_SYNC_REDUX上。在客戶端環境中,直接從這裡取初始化的值。

// ...
const id = 'redux-initializer-json-data'
export default function ReduxProvider (/* ... */) {
  children: React.ReactNode, data: any
}) {
  const storeRef = useRef<reduxStoreType | null>(null)
  let initialState
  if (!BROWSER_ENV) {
    initialState = data
  } else {
    try {
      // @ts-ignore
      initialState = JSON.parse(window.DATA_TO_SYNC_REDUX)
    } catch (error) {
      logger.log(error)
      initialState = {}
    }
  }

  if (!storeRef.current) {
    storeRef.current = reduxStore = configureStore(initialState)
  }

  const text = `window.DATA_TO_SYNC_REDUX=\`${(JSON.stringify(data))}\``
  BROWSER_ENV && setTimeout(() => {
    document.getElementById(id)?.remove()
  }, 100)

  return [
    <script key={id} id={id}>{text}</script>,
    <Provider key='redux-provider' store={storeRef.current}>{children}</Provider>
  ]
}

看起來到這裡已經完成了,頁面正常執行,但是一開啟控制檯,馬上就給了我們一大堆報錯,(雖然不管也行):

Uncaught Error: Text content does not match server-rendered HTML.
Warning: Text content did not match.
Server: "window.DATA_TO_SYNC_REDUX=`{&quot;article&quot;:{...}}`"
Client: "window.DATA_TO_SYNC_REDUX=`{"article":{...}}`"

看起來我們<script>的程式碼不知道為什麼在服務端被轉碼了,在客戶端第一次渲染渲染時又被轉了回來,造成了水合錯誤。這裡搜了一下也沒有發現什麼優雅的解決方法,我們就手動轉碼一下,繞開 HTML 的特殊字元。

const tokens: Record<string, string> = {
  '!lt;': '<',
  '!gt;': '>',
  '!nbsp;': ' ',
  '!amp;': '&',
  '!quot;': '"'
}
const invTokens: Record<string, string> = {
  '<': '!lt;',
  '>': '!gt;',
  ' ': '!nbsp;',
  '&': '!amp;',
  '"': '!quot;'
}
export function pseudoHtml2Escape (htmlString: string) {
  return htmlString.replace(/(!(lt|gt|nbsp|amp|quot);)/ig, function (t: string) {
    return tokens[t]
  })
}
export function escape2PseudoHtml (escapeString: string) {
  const res = escapeString.replace(/(<|>| |&|")/g, function (_, t: string) {
    return invTokens[t]
  })
  return res
}

export default function ReduxProvider (/* ... */) {
  // ...
  initialState = JSON.parse(pseudoHtml2Escape(window.DATA_TO_SYNC_REDUX))
  // ...
  const text = `window.DATA_TO_SYNC_REDUX=\`${escape2PseudoHtml(JSON.stringify(data))}\``
  // ...
}

到這裡同步服務端狀態已經完成了。來看看最終效果:

🎁結語

本文簡單地實現了 Next.js App Router 下客戶端同步服務端 Redux 狀態的方法。其中狀態的傳遞主要透過 HTML 程式碼來進行,總感覺是不是不太優雅。大體流程如下所示:

graph LR subgraph "服務端渲染" RootLayout -->|傳入資料|B[ReduxProvider]-->|初始化|Redux B -->|傳入初始值| script end subgraph "瀏覽器" script -->|記錄初始值| window ReduxProvider --> |獲取資料|window ReduxProvider-->|初始化|A[Redux] end

相關文章