React Next.js 集成状态管理器和共享Token
最近项目中使用 SSR 框架 next.js,过程中会遇到 token 存储,状态管理等一系列问题,现在总结并记录下来分享给大家。
Token 存储
SSR 和 SPA 最大的区别就是 SSR 会区分客户端 Client 和服务端 Server,并且 SSR 之间只能通过 cookie 才能在 Client 和 Server 之间通信,例如:token 信息,以往我们在 SPA 项目中是使用localStorage
或者sessionStorage
来存储,但是在 SSR 项目中 Server 端是拿不到的,因为它是浏览器的属性,要想客户端和服务端同时都能拿到我们可以使用 Cookie,所以 token 信息只能存储到 Cookie 中。
那么我们选用什么插件来设置和读取 Cookie 信息呢?插件也有好多种,比如:cookie、js-cookie、react-cookie、nookie、set-cookie-parser 等等,但是它们有个最大的问题就是需要手动去控制读取和设置,有没有一种插件或者中间件自动获取和设置 token 呢?答案是肯定的,就是接下来我们要用到的next-redux-cookie-wrapper
插件,这个插件的作用就是将 reducer 里面的数据自动储存到 cookie 中,然后组件获取 reducer 中的数据会自动从 cookie 中拿,它是next-redux-wrapper
插件推荐的,而next-redux-wrapper
插件是连接 redux 中 store 数据的插件,接下来会讲到。
数据持久化
SSR 项目我们不建议数据做持久化,除了上面的 token 以及用户名等数据量小的数据需要持久化外,其它的都应该从后台接口返回,否则就失去了使用 SSR 的目的(直接从服务端返回带有数据的 html)了,还不如去使用 SPA 来得直接。
状态管理
如果你的项目不是很大,且组件不是很多,你完全不用考虑状态管理,只有当组件数量很多且数据不断变化的情况下你需要考虑状态管理。
我们知道 Next.js 也是基于 React,所以基于 React 的状态管理器同样适用于 Next.js,比较流行的状态管理有:
- mobx
- redux
- redux-toolkit(redux 的简化版)
- recoil(react 官方出品)
- rematch(模块化做得比较好的)
这里有一篇文章专门介绍对比它们的,大家可以看看哪种比较适合自己。
最后我们选用的是 redux 的轻量级版本:redux-toolkit
。
下面我们会集成redux-toolkit
插件及共享 cookie 插件next-redux-cookie-wrapper
以及连接 next.js 服务端与 redux store 数据通信方法getServerSideProps
的插件next-redux-wrapper
。
集成状态管理器 Redux 及共享 Token 信息
首先我们先创建 next.js 项目,创建完之后,我们执行下面几个步骤来一步步实现集成。
- 创建 store/axios.js 文件
- 修改 pages/_app.js 文件
- 创建 store/index.js 文件
- 创建 store/slice/auth.js 文件
1. 创建 store/axios.js 文件
创建 axios.js 文件目的是为了统一管理 axios,方便 slice 中 axios 的设置和获取。
store/axios.js
import axios from 'axios'; import createAuthRefreshInterceptor from 'axios-auth-refresh'; import * as cookie from 'cookie'; import * as setCookie from 'set-cookie-parser'; // Create axios instance. const axiosInstance = axios.create({ baseURL: `${process.env.NEXT_PUBLIC_API_HOST}`, withCredentials: false, }); export default axiosInstance;
2、修改 pages/_app.js 文件
使用next-redux-wrapper
插件将 redux store 数据注入到 next.js。
pages/_app.js
import {Provider} from 'react-redux' import {store, wrapper} from '@/store' const MyApp = ({Component, pageProps}) => { return <Component {...pageProps} /> } export default wrapper.withRedux(MyApp)
3、创建 store/index.js 文件
- 使用
@reduxjs/toolkit
集成 reducer 并创建 store, - 使用
next-redux-wrapper
连接 next.js 和 redux, - 使用
next-redux-cookie-wrapper
注册要共享到 cookie 的 slice 信息。
store/index.js
import {configureStore, combineReducers} from '@reduxjs/toolkit'; import {createWrapper} from 'next-redux-wrapper'; import {nextReduxCookieMiddleware, wrapMakeStore} from "next-redux-cookie-wrapper"; import {authSlice} from './slices/auth'; import logger from "redux-logger"; const combinedReducers = combineReducers({ [authSlice.name]: authSlice.reducer }); export const store = wrapMakeStore(() => configureStore({ reducer: combinedReducers, middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend( nextReduxCookieMiddleware({ // 在这里设置你想在客户端和服务器端共享的 cookie 数据,我设置了下面三个数据,大家依照自己的需求来设置就好 subtrees: ["auth.accessToken", "auth.isLogin", "auth.me"], }) ).concat(logger) })); const makeStore = () => store; export const wrapper = createWrapper(store, {storeKey: 'key', debug: true});
4. 创建 store/slice/auth.js 文件
创建 slice,通过 axios 调用后台接口返回 token 和 user 信息并保存到 reducer 数据中,上面的nextReduxCookieMiddleware
会自动设置和读取这里的 token 和 me 及 isLogin 信息。
store/slice/auth.js
import {createAsyncThunk, createSlice} from '@reduxjs/toolkit'; import axios from '../axios'; import qs from "qs"; import {HYDRATE} from 'next-redux-wrapper'; // 获取用户信息 export const fetchUser = createAsyncThunk('auth/me', async (_, thunkAPI) => { try { const response = await axios.get('/account/me'); return response.data.name; } catch (error) { return thunkAPI.rejectWithValue({errorMsg: error.message}); } }); // 登录 export const login = createAsyncThunk('auth/login', async (credentials, thunkAPI) => { try { // 获取 token 信息 const response = await axios.post('/auth/oauth/token', qs.stringify(credentials)); const resdata = response.data; if (resdata.access_token) { // 获取用户信息 const refetch = await axios.get('/account/me', { headers: {Authorization: `Bearer ${resdata.access_token}`}, }); return { accessToken: resdata.access_token, isLogin: true, me: {name: refetch.data.name} }; } else { return thunkAPI.rejectWithValue({errorMsg: response.data.message}); } } catch (error) { return thunkAPI.rejectWithValue({errorMsg: error.message}); } }); // 初始化数据 const internalInitialState = { accessToken: null, me: null, errorMsg: null, isLogin: false }; // reducer export const authSlice = createSlice({ name: 'auth', initialState: internalInitialState, reducers: { updateAuth(state, action) { state.accessToken = action.payload.accessToken; state.me = action.payload.me; }, reset: () => internalInitialState, }, extraReducers: { // 水合,拿到服务器端的 reducer 注入到客户端的 reducer,达到数据统一的目的 [HYDRATE]: (state, action) => { console.log('HYDRATE', state, action.payload); return Object.assign({}, state, {...action.payload.auth}); }, [login.fulfilled]: (state, action) => { state.accessToken = action.payload.accessToken; state.isLogin = action.payload.isLogin; state.me = action.payload.me; }, [login.rejected]: (state, action) => { console.log('action=>', action) state = Object.assign(Object.assign({}, internalInitialState), {errorMsg: action.payload.errorMsg}); console.log('state=>', state) // throw new Error(action.error.message); }, [fetchUser.rejected]: (state, action) => { state = Object.assign(Object.assign({}, internalInitialState), {errorMsg: action.errorMsg}); }, [fetchUser.fulfilled]: (state, action) => { state.me = action.payload; } } }); export const {updateAuth, reset} = authSlice.actions;
这样就完成了所有插件的集成,接着我们运行网页,登录输入用户名密码,你会发现上面的数据都以密码的形式保存在 Cookie 中。
剩下代码
pages/login.js
import React, {useState, useEffect} from "react"; import {Form, Input, Button, Checkbox, message, Alert, Typography} from "antd"; import Record from "../../components/layout/record"; import styles from "./index.module.scss"; import {useRouter} from "next/router"; import {useSelector, useDispatch} from 'react-redux' import {login} from '@/store/slices/auth'; import {wrapper} from '@/store' const {Text, Link} = Typography; const layout = { labelCol: {span: 24}, wrapperCol: {span: 24} }; const Login = props => { const dispatch = useDispatch(); const router = useRouter(); const [isLoding, setIsLoading] = useState(false); const [error, setError] = useState({ show: false, content: "" }); function closeError() { setError({ show: false, content: "" }); } const onFinish = async ({username, password}) => { if (!username) { setError({ show: true, content: "请输入用户名" }); return; } if (!password) { setError({ show: true, content: "请输入密码" }); return; } setIsLoading(true); let res = await dispatch(login({ grant_type: "password", username, password })); if (res.payload.errorMsg) { message.warning(res.payload.errorMsg); } else { router.push("/"); } setIsLoading(false); }; function render() { return props.isLogin ? ( <></> ) : ( <div className={styles.container}> <div className={styles.content}> <div className={styles.card}> <div className={styles.cardBody}> <div className={styles.error}>{error.show ? <Alert message={error.content} type="error" closable afterClose={closeError}/> : null}</div> <div className={styles.cardContent}> <Form {...layout} name="basic" initialValues={{remember: true}} layout="vertical" onFinish={onFinish} // onFinishFailed={onFinishFailed} > <div className={styles.formlabel}> <b>用户名或邮箱</b> </div> <Form.Item name="username"> <Input size="large"/> </Form.Item> <div className={styles.formlabel}> <b>密码</b> <Link href="/account/password_reset" target="_blank"> 忘记密码 </Link> </div> <Form.Item name="password"> <Input.Password size="large"/> </Form.Item> <Form.Item> <Button type="primary" htmlType="submit" block size="large" className="submit" loading={isLoding}> {isLoding ? "正在登录..." : "登录"} </Button> </Form.Item> </Form> <div className={styles.newaccount}> 首次使用 Seaurl?{" "} <Link href="/join?ref=register" target="_blank"> 创建一个账号 </Link> {/* <a className="login-form-forgot" href="" > 创建一个账号</a> */} </div> </div> </div> <div className={styles.recordWrapper}> <Record/> </div> </div> </div> </div> ); } return render(); }; export const getServerSideProps = wrapper.getServerSideProps(store => ({ctx}) => { const {isLogin, me} = store.getState().auth; if(isLogin){ return { redirect: { destination: '/', permanent: false, }, } } return { props: {} }; }); export default Login;
注意
1、使用了next-redux-wrapper
一定要加 HYDRATE,目的是同步服务端和客户端 reducer 数据,否则两个端数据不一致造成冲突。
[HYDRATE]: (state, action) => { console.log('HYDRATE', state, action.payload); return Object.assign({}, state, {...action.payload.auth}); },
2、注意next-redux-wrapper
和next-redux-cookie-wrapper
版本。
"next-redux-cookie-wrapper": "^2.0.1", "next-redux-wrapper": "^7.0.2",
总结
- ssr 项目不要用持久化,而是直接从 server 端请求接口拿数据直接渲染,否则失去使用 SSR 的意义了;
- Next.js 分为静态渲染和服务端渲染,其实 SSR 项目如果你的项目很小,或者都是静态数据可以考虑直接使用客户端静态方法
getStaticProps
来渲染。
码云笔记 » React Next.js 集成状态管理器和共享Token