Next.js-水合作用

Awbeci發表於2023-04-20

前言

最近在看三國演義,開篇第一段話:話說天下大勢,分久必合,合久必分。我把這段話用來解釋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方法會導致頁面重複渲染問題,如何解決呢?

解決辦法:還是透過判斷currentHydrate來決定是否要重複渲染,程式碼如下:

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

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

透過判斷新老hydrate資料是否相同,不相同則不用重新渲染,否則重新渲染。

這樣就解決了所有問題,完結撒花!

注意:

如果你使用了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、跳轉頁面之後,當前頁面不要重複渲染

是不是有點難理解,如果大家沒理解,可以再想想,或者發私信我。

相關文章