nextjs集成next-redux-wrapper实践

907 阅读6分钟

集成背景

最近在开发企业微信自建应用的h5项目,使用的nextjs, 有个需求需要在nextjs中登录后把用户信息放到全局store中;项目中已经用到了redux,需要一个支持nextjs同构的redux库,经过google选择了next-redux-wrapper;

本篇主要写一下如何进行集成,next-redux-wrapper实现同构的思路和踩到的坑和解决方式

集成步骤

依赖包安装;

pnpm i redux react-redux  @reduxjs/toolkit next-redux-wrapper redux-persist
  1. 新建store目录,新建index.ts, user.ts,代码重要部分进行了注释;
// index.ts
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import { persistReducer, persistStore } from "redux-persist";
import storage from "redux-persist/lib/storage";
import userReducer from "./user";

// 单独创建rootReducer供服务端和客户端创建store使用;
const rootReducer = combineReducers({
  user: userReducer,
});

const makeStore = () => {
  const isServer = typeof window === "undefined";
  // 区分客户端和服务端,服务端不需要持久存储,客户端存在在localStorage中;
  if (isServer) {
    return configureStore({
      reducer: rootReducer,
      devTools: true,
    });
  } else {
    const persistConfig = {
      key: "yourproject",
      whiteList: ["user"],
      storage,
    };
    const persistedReducer = persistReducer(persistConfig, rootReducer);
    const store = configureStore({
      reducer: persistedReducer,
      devTools: process.env.NODE_ENV !== "production",
    });
    // @ts-ignore 只使用客户端渲染不需要此种做法,只需导出persistor即可;
    store.__persistor = persistStore(store);
    return store;
  }
};

export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export const wrapper = createWrapper(makeStore);
// user.ts
import { createSlice } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";

export type UserState = API.User.UserInfo;

const initialState: UserState = {
  username: '', // 示例
  avatar: '',
};

const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {
    setUser: (state, action) => {
      return action.payload;
    },
  },
  extraReducers: {
    // hydrated 用于获取服务端注入的state并选择更新
    [HYDRATE]: (state, action) => {
      return {
        ...action.payload.user,
      };
    },
  },
});

export const { setUser } = userSlice.actions;
export default userSlice.reducer;
  1. 在_app.tsx中使用
import type { AppProps } from "next/app";
import { Provider } from "react-redux";
import { wrapper } from "@/store";
import { PersistGate } from "redux-persist/integration/react";

const App = ({ Component, ...rest }: AppProps) => {

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

  return (
      <Provider store={store}>
        {/* @ts-ignore 此处对应store/index.ts中store.__persistor的设置 */}
        <PersistGate persistor={store.__persistor} loading={<div></div>}>
            <div className={styles.wrapper}>
              <Component {...props.pageProps} />
            </div>
        </PersistGate>
      </Provider>
  );
};

export default App;

  1. 在页面中使用
const Home = ({postList}:{postList: Post[]) => {
  const user = useSelector((state: RootState) => state.user);
  return (
    <main>
      <h1>{user.username}</h1>
      <PostList list={postList} />
    </main>
  );
}
//@ts-ignore
export const getServerSideProps = wrapper.getServerSideProps((store) => {
  return async function (context: GetServerSidePropsContext) {
     const data = await services.login({code: context.query.code as string});
     store.dispatch(setUser({ ...loginData, updateTime: Date.now() }));
     const postList = await services.getPostList({}, data.token);
     return {
         props: {
             postList
         }
     }
  }
});

export default Home;

原理探究

现在来探究下它是如何将服务端数据传输并应用到客户端store的:

文档上有说明它的工作原理:how-it-works

总的来说就是

  • 在服务端渲染时创建一个空的store, 然后服务端渲染时的action会改变该store的State,然后拿到包裹的内部函数(wrapper.getServerSideProps 这些函数的参数callback(store)(context))返回值, 跟store.getState()合并,最后作为最终的Page/App的属性传入,至此服务端部分完毕;
  • 客户端会监听routeChangeStart并触发HYDEATE action, 并将从page/app传入的特定属性作为payload,同时移除该特定的属性

从使用流程来看,首先通过createWrapper创建一个wrapper, 然后通过解构从warpper.useWrappedStore函数调用中获取store, 供给Provider来使用,当需要再服务端提交action时,使用wrapper.getServerSideProps中获取store来调用;

所以主要从这几个函数来看它的功能,应该就能知晓它的底层原理:

createWrapper

先来看createWrapper, 主要在内部声明了几个函数,然后返回了包含getServerSideProps,getStaticProps,getInitialAppProps,getInitialPageProps这几个高阶函数和useWrappedStore 自定义hooks 的对象;

在model/index 中调用createWrapper后生成该对象(记为wrapper), 供其他模块使用;

useWrappedStore

在_app.tsx中使用, 主要做了两件事;

  1. 初始化store(在客户端运行的代码会缓存store);
  2. 订阅路由变化事件,并在路由变化的时候触发hydate action, 将从_app.tsx中获取的初始属性作为payload(payload 不为空时才会触发);

可以看一下这段代码

const useHybridHydrate = (store: S, giapState: any, gspState: any, gsspState: any, gippState: any) => {
    const {events} = useRouter();
    const shouldHydrate = useRef(true);

    // We should only hydrate when the router has changed routes
    useEffect(() => {
        const handleStart = () => {
            shouldHydrate.current = true;
        };
        events?.on('routeChangeStart', handleStart);
        return () => {
            events?.off('routeChangeStart', handleStart);
        };
    }, [events]);
    // useMemo so that when we navigate client side, we always synchronously hydrate the state before the new page
    // components are mounted. This means we hydrate while the previous page components are still mounted.
    // You might think that might cause issues because the selectors on the previous page (still mounted) will suddenly
    // contain other data, and maybe even nested properties, causing null reference exceptions.
    // But that's not the case.
    // Hydrating in useMemo will not trigger a rerender of the still mounted page component. So if your selectors do have
    // some initial state values causing them to rerun after hydration, and you're accessing deeply nested values inside your
    // components, you still wouldn't get errors, because there's no rerender.
    // Instead, React will render the new page components straight away, which will have selectors with the correct data.
    useMemo(() => {
        if (shouldHydrate.current) {
            hydrateOrchestrator(store, giapState, gspState, gsspState, gippState);
            shouldHydrate.current = false;
        }
    }, [store, giapState, gspState, gsspState, gippState]);
}


const hydrateOrchestrator = (store: S, giapState: any, gspState: any, gsspState: any, gippState: any) => {
    if (gspState) {
        hydrate(store, giapState);
        hydrate(store, gspState);
    } else if (gsspState || gippState || giapState) {
        hydrate(store, gsspState ?? gippState ?? giapState); // hydrate 对 第二个入参也有判断,为空直接return掉了
    }
    // 全部为空,不会触发
};

从注释里可以清晰的看到为什么使用了useMemo,因为是在新页面还未挂载,旧页面还未卸载时触发hydrate会使当前页面的selector突然包含其他数据或者嵌套属性,有可能发生引用错误,但是使用useMemo不会使旧页面发生重新渲染,而新页面React会重新渲染使用从store中获取的新数据;

那么现在还是有个疑问,就是从_app.tsx中获取的初始属性从哪来呢?下面就需要看下getServerSideProps这个高阶函数了

warpper.getServerSideProps

这个是createWrapper 导出的内部函数,用它来包裹原getServiderSideProps的逻辑;

结合我们在PAGE里的使用,和它内部的源码:

// 内部源码
const getServerSideProps =
   <P extends {} = any>(callback: GetServerSidePropsCallback<S, P>): GetServerSideProps<P> =>
   async context =>
     await getStaticProps(callback as any)(context);
// page中的使用部分
export const getServerSideProps = wrapper.getServerSideProps((store) => {
  return async function (context: GetServerSidePropsContext) {
     // 获取 postList 省略
     return {
         props: {
             postList
         }
     }
  }
});

所以最终导出的是async context => await getStaticProps(callback as any)(context); 也就是请求到该页面后要调用的函数;

next-redux-wrapper 的 getStaticProps 通过 makeProps 来获取initialState, 从makeProps里就能够了解到intialState 就是从store.getState()里获取来的,然后放到了页面组件的属性中,供useWrappedStore使用

const getStaticProps =
    <P extends {} = any>(callback: GetStaticPropsCallback<S, P>): GetStaticProps<P> =>
    async context => {
        const {initialProps, initialState} = await makeProps({callback, context});
        return {
            ...initialProps,
            props: {
                ...initialProps.props,
                initialState,
            },
        } as any;
    };
    
const makeProps = async ({
    callback,
    context,
    addStoreToContext = false,
}: {
    callback: Callback<S, any>;
    context: any;
    addStoreToContext?: boolean;
}): Promise<WrapperProps> => {
    const store = initStore({context, makeStore});
    const nextCallback = callback && callback(store);
    const initialProps = (nextCallback && (await nextCallback(context))) || {};
    const state = store.getState();
    return {
        initialProps,
        initialState: getIsServer() ? getSerializedState<S>(state, config) : state,
    };
};

踩到的坑

结合redux-persist使用时,服务端的状态无法覆盖本地缓存的状态,例如stackoverflow的这这个问题storage is not being update using redux-persist with next-redux-wrapper (Typescript)

以下是模拟代码,刷新后会发现userName只停留在第一次获取的状态:

const Home = () => {
  const user = useSelector<RootState, RootState["user"]>((state) => state.user);
  return <div>{user.userName}</div>;
};

export const getServerSideProps = wrapper.getServerSideProps((store) => {
  store.dispatch(
    setUser({
      userName: new Date().toLocaleString(),
    })
  );
  return async function (context) {
    return {
      props: {},
    };
  };
});

export default Home;

那么如何解决这个问题呢,这个问题的原因是客户端从服务端拿到状态后,先触发的HYDRATE,然后再触发的reudx-persist的调和;阅读redux-persist的文档,可以发现它允许我们自定义状态合并的逻辑,state-reconciler,我是通过加上更新时间来比对调和本地和获取的状态的;解决方式如下:

function mergeByTime<T extends Record<string, any> & { updateTime: number }>(
  incomeState: T,
  initState: T
): T {
  let newState = incomeState,
    oldState = initState;
  if (newState.updateTime < oldState.updateTime) {
    oldState = incomeState;
    newState = initState;
  }
  return {
    ...oldState,
    ...newState,
  };
}

function mergeLevel<S extends KeyAccessState>(
  inboundState: S,
  originalState: S,
  reducedState: S,
  { debug }: PersistConfig<S>
): S {
  console.log({ inboundState, originalState, reducedState });
  const newState = { ...reducedState };
  console.log({ newState });
  // only rehydrate if inboundState exists and is an object
  if (inboundState && typeof inboundState === "object") {
    // @ts-ignore
    const keys: (keyof S)[] = Object.keys(inboundState);
    keys.forEach((key) => {
      // ignore _persist data
      if (key === "_persist") return;
      // 主要是这三行
      if (key === "user") {
        //@ts-ignore
        newState[key] = mergeByTime(inboundState[key], newState[key]);
        return;
      }
      if (isPlainEnoughObject(reducedState[key])) {
        // if object is plain enough shallow merge the new values (hence "Level2")
        newState[key] = { ...newState[key], ...inboundState[key] };
        return;
      }
      // otherwise hard set
      newState[key] = inboundState[key];
    });
  }
  return newState;
}

const makeStore = () => {
  const isServer = typeof window === "undefined";
  if (isServer) {
    return configureStore({
      reducer: rootReducer,
      devTools: true,
    });
  } else {
    const persistConfig = {
      key: "nextjs",
      whiteList: ["user"],
      blacklist: ["plan", "task"],
      storage,
      stateReconciler: mergeLevel,
    };

    const persistedReducer = persistReducer(persistConfig, rootReducer);
    const store = configureStore({
      reducer: persistedReducer,
      devTools: process.env.NODE_ENV !== "production",
    });
    // @ts-ignore
    store.__persistor = persistStore(store);
    return store;
  }
};