集成背景
最近在开发企业微信自建应用的h5项目,使用的nextjs, 有个需求需要在nextjs中登录后把用户信息放到全局store中;项目中已经用到了redux,需要一个支持nextjs同构的redux库,经过google选择了next-redux-wrapper;
本篇主要写一下如何进行集成,next-redux-wrapper实现同构的思路和踩到的坑和解决方式
集成步骤
依赖包安装;
css
pnpm i redux react-redux @reduxjs/toolkit next-redux-wrapper redux-persist
- 新建store目录,新建index.ts, user.ts,代码重要部分进行了注释;
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);
ts
// 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;
- 在_app.tsx中使用
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;
- 在页面中使用
tsx
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中使用, 主要做了两件事;
- 初始化store(在客户端运行的代码会缓存store);
- 订阅路由变化事件,并在路由变化的时候触发hydate action, 将从_app.tsx中获取的初始属性作为payload(payload 不为空时才会触发);
可以看一下这段代码
ts
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里的使用,和它内部的源码:
ts
// 内部源码
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使用
ts
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只停留在第一次获取的状态:
ts
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,我是通过加上更新时间来比对调和本地和获取的状态的;解决方式如下:
ts
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;
}
};