Skip to main content

NextJS SSR 出现 redux store 与浏览器不同步的解决

最近在学 NextJS,顺便用 NextJS 的 SSR 配合 node-wpapi 构建 WordPress 前端页面,这样让外网访问 NextJS SSR,就可以把 WordPress 服务隐藏起来,同时也可选择 NextJS 多机部署。

当然很快就遇到了问题,是 Redux 服务端跟客户端不同步的问题。

需求、动机

我需要的功能 (流程从左到右)

服务端 客户端
1. 创建 redux store
2. 读 Headers 信息,提前修改 store
3. 将 store 返回客户端
4. 拿到处理过的 store
5. 读取 store 中的数据
6. 按 store 数据渲染

问题出在,客户端拿到的 store,打印一下发现里面 都是初始值。像下面的图:

社区方案

ReduxJS 官方有提供 SSR 下 redux 的解决方案,其中就提出了服务端应该如何处理 redux。

文档原文

To send the data down to the client, we need to:

  • create a fresh, new Redux store instance on every request;
  • optionally dispatch some actions;
  • pull the state out of store;
  • and then pass the state along to the client.
  • On the client side, a new Redux store will be created and initialized with the state provided from the server. Redux’s only job on the server side is to provide the initial state of our app.

注意加粗部分,reduxjs 建议服务端提供 store 初始值,即 服务端创建 store 时是什么值,到客户端就应该是什么值

如果按照我的需求去处理,则会出现服务端和客户端 redux store 不一致的问题,浏览器也会报错,而最终客户端仍然用的是初始值。

个人思路

抱歉,我想直接看解决方案

如果服务器构建 redux store 不能改,但是 服务器可以通过 props 渲染给客户端,并且 NextJS SSR 也要求 props 能序列化、反序列化。那么可能有一种通过 props 传值的方法,将服务端修改过的 store 下发给客户端。

服务端示例 客户端示例
1. 服务端创建 redux store 5. 拿到 store,此时 store 为初始值
2. 服务端计算登录态,赋值到 props 6. 从 props.store 尝试解码出修改后的 store
3. 将 props 嵌入至全局组件 7. 新建全局组件,挂载(useEffect)时,将 props.store dispatch 到 store (仅触发一次)
4. SSR 结束 8. redux dispatch 调起组件更新,然后 store 完成同步

缺点: 禁用 JavaScript 无法调用到 useEffect

解决方案

NextJS 官方仓库已经有相关解决方案 (with-redux-wrapper)

当然此文归档为 snippet,就直接放 snippet,并附带用法。

# 安装 next-redux-wrapper
yarn add next-redux-wrapper
Wrapper 工具 src/store/redux-tool.ts
// src/store/redux-tool.ts
import { PayloadAction } from '@reduxjs/toolkit'
import { HYDRATE } from 'next-redux-wrapper'
import _ from 'underscore'

/**
 * @brief next-redux-wrapper 专用
 * @brief 注入后可以在服务端修改 redux store 再发给客户端
 * @brief 服务端和客户端正常调用 store.dispatch()
 * @example

```ts
// 这个 Example 将创建一个 store
import { configureStore, createSlice } from '@reduxjs/toolkit'

type RootState = { counter: number }

const counterReducer = createSlice({
  name: 'counter',
  initialState: 3,
  reducers: {
    set: (state, action) => action.payload,
    add: (state, action) => state++,
    sub: (state, action) => state--,
  },
  extraReducers: {
    ...ReduxTools.withHydrateReducer<RootState, number>('counter'),
  },
})
```
 */
function withHydrateReducer<RootState = {}, TargetState = any>(
  name: string,
  selectorFn?: (rootState: RootState) => TargetState
) {
  const selectFn =
    selectorFn !== undefined ? selectorFn : (state: any) => state[name]
  return {
    [HYDRATE]: (state: TargetState, action: PayloadAction<RootState>) => {
      let selectedState = selectFn(action.payload)
      if (_.isObject(selectedState) || _.isArray(selectedState)) {
        return {
          ...state,
          ...selectedState,
        }
      } else {
        return selectedState
      }
    },
  }
}

const ReduxTools = {
  withHydrateReducer,
}

export default ReduxTools
Wrapper 示例 src/store/index.ts
import { configureStore, createSlice } from '@reduxjs/toolkit'
import { createWrapper } from 'next-redux-wrapper'

// 快速创建 csrf reducer
// 返回一个对象,有以下常用属性:
// stateCSRF.reducer: Reducer
// stateCSRF.actions: 示例 dispatch( stateCSRF.actions.set("random") )
export const stateCSRF = createSlice({
  name: 'csrf',
  initialState: 'null',
  reducers: {
    set: (state, action) => {
      return action.payload
    },
  },
  // 用 ReduxTool 工具嵌入 reducer,注意 csrf 是与 name: 'csrf' 相同
  extraReducers: {
    ...ReduxTools.withHydrateReducer('csrf'),
  }
})

// 导出 wrapper,而不是 store
export const wrapper = createWrapper(() =>
  configureStore({
    reducer: {
      // 注意 csrf 是与 name: 'csrf' 相同
      'csrf': stateCSRF.reducer,
    },
  })
)
Redux Store 示例: 正常方法 dispatch
import { useSelector, useDispatch } from 'react-redux'
import { stateCSRF, wrapper } from '~/store'


// 客户端 dispatch 示例
const MyComponent = () => {
  // 注意 csrf 是与 name: 'csrf' (reducer 名称) 相同
  const csrf = useSelector(state => state.csrf)
  const dispatch = useDispatch()
  // 点击时由客户端 dispatch
  return <div onClick={() => dispatch( stateCSRF.actions.set("client random") )}>
    CSRF: { csrf }
  </div>
}

// 服务端 dispatch 示例
export const getServerSideProps = wrapper.getServerSideProps(
  (store) => async (ctx) => {
    // 服务端 dispatch
    store.dispatch(stateCSRF.actions.set('server random'))
  }
)


//
// 结果:
// 1. 首屏显示 server random
// 2. 点击 server random,内容改变至 client random
// 3. 禁用 JavaScript,刷新页面,显示 server random
//

export default MyComponent