coverPiccoverPic

Next App Router 模式下,如何同步服务端 Redux 初始状态?

大家的阅读是我发帖的动力,本文首发于我的博客: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的入参:

ts
  1. export declare function createStore<S, A extends Action, Ext, StateExt>(
  2. reducer: Reducer<S, A>,
  3. preloadedState?: PreloadedState<S>,
  4. enhancer?: StoreEnhancer<Ext>
  5. ): Store<S & StateExt, A> & Ext

第二个参数preloadedStatestore的初始状态,只要在服务端 / 客户端中,都传入一样的内容,就可以创建两个状态一模一样的store

服务端渲染时,初始状态可在服务端组件(React Server Component,RSC)中直接查询获得。在客户端中,如果走网络请求,则在首次渲染中是拿不到服务端状态的。我们不妨把状态写入 HTML 中,带往客户端,然后客户端就可以同步服务端 Redux 的状态了。

我们先来以我博客的统计数据为例,这是目前的效果:

🎉服务端渲染阶段初始化 Redux

先写一个创建 Redux 的代码(代码很大一部分是从老项目中迁移的,当时并没有用上 Redux Toolkit,请见谅):

ts
  1. import { thunk } from 'redux-thunk'
  2. import { compose, createStore, applyMiddleware, StoreEnhancer, Store, EmptyObject } from 'redux'
  3. import { composeWithDevTools } from 'redux-devtools-extension'
  4. import rootReducer, { combinedStateType } from './rootReducers'
  5. let storeEnhancers: StoreEnhancer
  6. if (process.env.NODE_ENV === 'production') {
  7. storeEnhancers = compose(applyMiddleware(thunk))
  8. } else {
  9. storeEnhancers = compose(composeWithDevTools(applyMiddleware(thunk)))
  10. }
  11. export type reduxStoreType = Store<EmptyObject & combinedStateType>
  12. export const configureStore = (initialState = {}) => {
  13. return createStore(rootReducer, initialState, storeEnhancers)
  14. }

我们可以在Provider中完成初始化,毕竟 RSC 需要以组件的形式组织代码,而且后面useSelectoruseDispatch之类的钩子也需要它。这里ReduxProvider接收data参数作为初始状态。

ts
  1. import { configureStore, reduxStoreType } from "./index"
  2. export default function ReduxProvider ({
  3. children, data
  4. }: {
  5. children: React.ReactNode, data: any
  6. }) {
  7. const storeRef = useRef<reduxStoreType | null>(null)
  8. const initialState = data
  9. if (!storeRef.current) {
  10. storeRef.current = configureStore(initialState)
  11. }
  12. return <Provide store={storeRef.current}>{children}</Provider>
  13. }

导出一个获取方法,以便 React 组件外的代码可以使用 Redux。

ts
  1. // ...
  2. let reduxStore: reduxStoreType | null = null
  3. export default function ReduxProvider (/* ... */) {
  4. // ...
  5. storeRef.current = reduxStore = configureStore(initialState)
  6. // ...
  7. }
  8. export const getStore = () => reduxStore

在 src/app/layout.tsx 中使用这个ReduxProvider。到这里,其实服务端初始化 Redux 已经完成了。

ts
  1. import { getArticleData } from "@/request/ssr/article";
  2. import RootLayoutInner from "./layoutInner";
  3. import ReduxProvider from "@/redux/reduxProvider";
  4. export default async function RootLayout ({
  5. children,
  6. }: Readonly<{
  7. children: React.ReactNode;
  8. }>) {
  9. const data = {
  10. article: await getArticleData()
  11. }
  12. return <html lang="en">
  13. <head>
  14. {/* ... */}
  15. </head>
  16. <body>
  17. <ReduxProvider data={data}>
  18. <RootLayoutInner >
  19. { children }
  20. </RootLayoutInner>
  21. </ReduxProvider>
  22. </body>
  23. </html>
  24. }

getArticleData函数获取服务端的数据传进了ReduxProvider中,成为了 Redux 的初始状态,服务端已经可以渲染出具有 Redux 初始状态的页面了。

来看看现在的效果,emmmmmm… 似乎并不太好:

看看控制台… 给了我们几个水合错误。

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

可以发现服务端传回来的 HTML 是有数据的,但是客户端渲染时并没有拿到数据。接下来客户端就需要同步这个状态了。

🎨客户端同步 Redux 状态

我们可以通过<script>标签,在客户端把初始状态挂到window.DATA_TO_SYNC_REDUX上。在客户端环境中,直接从这里取初始化的值。

ts
  1. // ...
  2. const id = 'redux-initializer-json-data'
  3. export default function ReduxProvider (/* ... */) {
  4. children: React.ReactNode, data: any
  5. }) {
  6. const storeRef = useRef<reduxStoreType | null>(null)
  7. let initialState
  8. if (!BROWSER_ENV) {
  9. initialState = data
  10. } else {
  11. try {
  12. // @ts-ignore
  13. initialState = JSON.parse(window.DATA_TO_SYNC_REDUX)
  14. } catch (error) {
  15. logger.log(error)
  16. initialState = {}
  17. }
  18. }
  19. if (!storeRef.current) {
  20. storeRef.current = reduxStore = configureStore(initialState)
  21. }
  22. const text = `window.DATA_TO_SYNC_REDUX=\`${(JSON.stringify(data))}\``
  23. BROWSER_ENV && setTimeout(() => {
  24. document.getElementById(id)?.remove()
  25. }, 100)
  26. return [
  27. <script key={id} id={id}>{text}</script>,
  28. <Provider key='redux-provider' store={storeRef.current}>{children}</Provider>
  29. ]
  30. }

看起来到这里已经完成了,页面正常运行,但是一打开控制台,马上就给了我们一大堆报错,(虽然不管也行):

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

看起来我们<script>的代码不知道为什么在服务端被转码了,在客户端第一次渲染渲染时又被转了回来,造成了水合错误。这里搜了一下也没有发现什么优雅的解决方法,我们就手动转码一下,绕开 HTML 的特殊字符。

ts
  1. const tokens: Record<string, string> = {
  2. '!lt;': '<',
  3. '!gt;': '>',
  4. '!nbsp;': ' ',
  5. '!amp;': '&',
  6. '!quot;': '"'
  7. }
  8. const invTokens: Record<string, string> = {
  9. '<': '!lt;',
  10. '>': '!gt;',
  11. ' ': '!nbsp;',
  12. '&': '!amp;',
  13. '"': '!quot;'
  14. }
  15. export function pseudoHtml2Escape (htmlString: string) {
  16. return htmlString.replace(/(!(lt|gt|nbsp|amp|quot);)/ig, function (t: string) {
  17. return tokens[t]
  18. })
  19. }
  20. export function escape2PseudoHtml (escapeString: string) {
  21. const res = escapeString.replace(/(<|>| |&|")/g, function (_, t: string) {
  22. return invTokens[t]
  23. })
  24. return res
  25. }
  26. export default function ReduxProvider (/* ... */) {
  27. // ...
  28. initialState = JSON.parse(pseudoHtml2Escape(window.DATA_TO_SYNC_REDUX))
  29. // ...
  30. const text = `window.DATA_TO_SYNC_REDUX=\`${escape2PseudoHtml(JSON.stringify(data))}\``
  31. // ...
  32. }

到这里同步服务端状态已经完成了。来看看最终效果:

🎁结语

本文简单地实现了 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
0 条评论未登录用户
Ctrl or + Enter 评论
🌸 Run