前言

最近在看三国演义,开篇第一段话:话说天下大势,分久必合,合久必分。我把这段话用来解释next.js+redux水合作用再恰当不过了。

什么是水合?

水合是我们在next.js项目中引入next-redux-wrapper插件之后给出的一个新概念,它是连接和统一客户端和服务端数据的一个重要纽带。

英文名叫HYDRATE,中文叫水合又叫水化,我在网上搜索的回答:

合物指的是含有水的化合物,其范围相当广泛。其中水可以以配位键与其他部分相连,如水合金属离子,也可以是以共价键相结合,如水合三氯乙醛。也可以指是天然气中某些组分与水分在一定温度、压力条件下形成的白色晶体,外观类似致密的冰雪,密度为0.88\~0.90 g/cm^3^。水合物是一种笼形晶体包络物,水分子借氢键结合形成笼形结晶,气体分子被包围在晶格之中。

看得我一头雾水,于是结合我自己的理解我来解释下何为水合,如果解释的不对,也希望大家对我批评指正。

通俗的说就是同一个水源出来多个分支的水流,最后水流又重新汇聚成新的水源,再重复这个过程。有点类似git上面的分支,有一个总分支master,还有子分支dev/test/uat等等,分开开发完又合并到总分支master。

不知道我这样解释能不能帮助你们理解,而在代码层面就是:打开一个新页面,或者切换新的路由的时候,Redux数据源Store会分流到所有Pages中的页面,最后在Reducer中合并服务端和客户端数据成新的Store数据源,再重复这样的过程。

详细的过程next-redux-wrapper插件官网给出了解释

Using next-redux-wrapper ("the wrapper"), the following things happen on a request:

  • Phase 1: getInitialProps/getStaticProps/getServerSideProps

    • The wrapper creates a server-side store (using makeStore) with an empty initial state. In doing so it also provides the Request and Response objects as options to makeStore.
    • In App mode:

      • The wrapper calls the _app's getInitialProps function and passes the previously created store.
      • Next.js takes the props returned from the _app's getInitialProps method, along with the store's state.
    • In per-page mode:

      • The wrapper calls the Page's getXXXProps function and passes the previously created store.
      • Next.js takes the props returned from the Page's getXXXProps method, along with the store's state.
  • Phase 2: SSR

    • The wrapper creates a new store using makeStore
    • The wrapper dispatches HYDRATE action with the previous store's state as payload
    • That store is passed as a property to the _app or page component.
    • Connected components may alter the store's state, but the modified state will not be transferred to the client.
  • Phase 3: Client

    • The wrapper creates a new store
    • The wrapper dispatches HYDRATE action with the state from Phase 1 as payload
    • That store is passed as a property to the _app or page component.
    • The wrapper persists the store in the client's window object, so it can be restored in case of HMR.

Note: The client's state is not persisted across requests (i.e. Phase 1 always starts with an empty state). Hence, it is reset on page reloads. Consider using Redux persist if you want to persist state between requests.

为什么要用水合?

水合的目的是达到服务端和客户端数据的和解最后统一数据源。

如果我们不用水合就会出现下面两个问题(目前为止我遇到的问题):

1、当打开页面或者导航到新页面后,客户端数据会丢失

2、路由切换页面的时候当前页面会出现重复渲染问题,可以参考我之前写得一篇文章:Next.js-页面重复渲染引出的水合问题,就是因为客户端数据丢失,导致触发useSelector方法,最终导致重复渲染。

怎样在实际项目中应用水合?

接下来,我们花时间重点介绍如何解决水合问题(默认你们都安装了next-redux-wrapper插件)。

首先,我们参考next-redux-wrapper文档配置一下next.js项目,这里就不做介绍,大家可以看看它的在线文档,下面是我的配置代码,大家可以参考下。

store.js

import {configureStore, combineReducers, MiddlewareArray} from '@reduxjs/toolkit';
import {createWrapper, HYDRATE} from 'next-redux-wrapper';
import logger from "redux-logger";

const combinedReducers = combineReducers({
  [authSlice.name]: authSlice.reducer,
  [userSlice.name]: userSlice.reducer,
  [homeSlice.name]: homeSlice.reducer,
  [notifySlice.name]: notifySlice.reducer,
  [fileSpaceSlice.name]: fileSpaceSlice.reducer,
  [rankSlice.name]: rankSlice.reducer,
});

export const store = configureStore({
  reducer: combinedReducers,
  devTools: false,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
})

const makeStore = () => store

export const wrapper = createWrapper(makeStore, { storeKey: 'key',debug:false })

_app.js

import {useState, useEffect} from 'react'
import {Provider} from 'react-redux'
import {wrapper} from '@/store'

const MyApp = ({Component, ...rest}) => {
  const {store, props} = wrapper.useWrappedStore(rest);

  return <Provider store={store}>
    <Component {...props.pageProps} />
  </Provider>
}

export default MyApp

暂停一下,虽然我们现在已经配置好了,但是还没有真正的解决水合问题,解决水合问题,重点是解决如何合并服务端和客户端数据,我们来看看next-redux-wrapper插件官网给出的解决办法,如下所示:

    import {HYDRATE} from 'next-redux-wrapper';

    // create your reducer
    const reducer = (state = {tick: 'init'}, action) => {
      switch (action.type) {
        case HYDRATE:
          const stateDiff = diff(state, action.payload) as any;
          const wasBumpedOnClient = stateDiff?.page?.[0]?.endsWith('X'); // or any other criteria
          return {
            ...state,
            ...action.payload,
            page: wasBumpedOnClient ? state.page : action.payload.page, // keep existing state or use hydrated
          };
        case 'TICK':
          return {...state, tick: action.payload};
        default:
          return state;
      }
    };

或者

    const reducer = (state, action) => {
      if (action.type === HYDRATE) {
        const nextState = {
          ...state, // use previous state
          ...action.payload, // apply delta from hydration
        };
        if (state.count) nextState.count = state.count; // preserve count value on client side navigation
        return nextState;
      } else {
        return combinedReducer(state, action);
      }
    };

上面的第1段代码,判断state和action.payload有没有不同,不同的话则合并.

第2段代码,判断state.count是否有值,有值则合并。

这些都可以解决现实项目中的一些问题,但是不能解决所有问题,于是我自己提出了一个解决方案:

每次进入一个页面的时候,我们记录下当前进入的是哪个页面,有了这个,我们就可以在Reducer中调度HYDRATE时判断是不是当前页面来合并数据。

下面我们来看看如何实现?我们取user.js页面为例子,

pages/user.js页面中的getServerSideProps方法

import {wrapper} from '@/store'
import {setCurrentHydrate} from '@/store/slices/systemSlice'

export const getServerSideProps = wrapper.getServerSideProps(store => async (ctx) => {
  await store.dispatch(setCurrentHydrate('user'))
  return {
    props: {
    }
  };
});

我们仔细看看上面这段代码,它在getServerSideProps阶段,调用了systemSlice中的setCurrentHydrate()方法,并且参数是'user',记录的就是当前页面,其它页面也是如此,唯一不同的点是参数不同。

setCurrentHydrate方法实现代码如下:

systemSlice.js

const initialState = {
  // 当前渲染的页面
  currentHydrate: ''
}

  reducers: {
    reset: () => initialState,
    setCurrentHydrate: (state, action) => {
      // 设置当前选中的页面name
      state.currentHydrate = action.payload;
    },
  },
export const {setCurrentHydrate} = systemSlice.actions

然后,我们以userSlice.js为例,编写如何合并客户端和服务端数据

import {HYDRATE} from "next-redux-wrapper";

const initialState = {
  a: null,
  b: null,
  c: null,
}


extraReducers: {
    // action.payload 是后台getServerSideProps方法返回的数据
    // 体现在__NEXT_REDUX_WRAPPER_HYDRATE__的action.payload数据中

    // state 是store中原始数据,如果是第一次进来 则是initialState数据
    // 体现在__NEXT_REDUX_WRAPPER_HYDRATE__的prev state数据中
    [HYDRATE]: (state, action) => {
      let nextState = {
        ...state,
        ...action.payload.user,
      }
      if(action.payload.system.currentHydrate !== 'user'){
        nextState.a = state.a
        nextState.b = state.b
        nextState.c = state.c
      }

      // nextState是合并后并保存到store中的数据
      // 体现在__NEXT_REDUX_WRAPPER_HYDRATE__的next state数据中
      return nextState
    },

看看action.payload.system.currentHydrate !== 'user'这个判断 ,意思是如果当前页面不是user,那么则合并客户端数据,否则不合并,代表了页面切换路由的时候会水合数据下的场景。

看到这里,我们解决了上面提的第1个问题,但是还有一个问题没有解决,就是所有pages下的页面useSelector方法会导致页面重复渲染问题,如何解决呢?

解决办法:还是通过判断自定义的hydrate变量来决定是否要重复渲染,代码如下:

import {createSelector} from "@reduxjs/toolkit";

  const { userInfo} = useSelector((state) => {
    return {
      ...state.auth,
      hydrate: state.system.currentHydrate
    }
  }, (_old, _new) => _old.hydrate !== _new.hydrate);

通过判断新老hydrate数据是否相同,不相同则不用重新渲染,否则重新渲染(useSelector的第二个变量意思是:true/false:重新渲染/不重新渲染)。

这样就解决了所有问题,完结撒花!

注意:

如果你使用了redux-logger打印状态日志插件,那么你会看到每次打开新页面或者路由跳转的时候控制台都会打印下面这样的代码:

image.png

说明:它是总水合,分开看的话对应reducer的[HYDRATE]方法里面的水合操作。

action.payload 是后台getServerSideProps方法返回的数据
prev state 是store中原始数据,如果是第一次进来 则是initialState数据
nextState 是合并后并保存到store中的数据

总结

每次进入一个页面的时候,我们记录下当前进入的是哪个页面,有了这个,我们就可以在Reducer中调度HYDRATE时判断是不是当前页面来合并数据。

为什么我要提这样的方案?

在回答这个问题之后,我们来看看三个场景:
1、第1次打开页面
2、刷新当前页面
3、导航到其它页面

这三个场景下,第1、2场景页面数据都是最新的,只拿到了服务端数据,而第3种情况下,可能在跳转前页面就已经有各种操作了,所以会产生客户端数据,这时候你如果跳转页面而没有正确水合的话,当前页面保存在Redux中的客户端数据就会清空,所以我的方案就是:

1、正确水合客户端和服务端数据
2、跳转页面之后,当前页面不要重复渲染

是不是有点难理解,如果大家没理解,可以再想想,或者发私信我。


Awbeci
3.1k 声望212 粉丝

Awbeci