使用Next.js、TypeORM和Shadcn-UI从零搭建现代化SaaS系统(第二部分-状态管理和接口)
整理架构分析
首先我们页面应该分为登录前的页面和登录后的页面,登录前的页面是跟用户无关的推广页与一些SEO倒流页,这种页面一般不会做千人千面的展示,所以我们在这不需要得到用户信息。另一种页面是登陆后操作的页面,我们创建好用户之后,进入到我们的工作台,工作台相关的页面还有主要的Sass提供页面是需要登录的,我们先整体设计,我们所有的页面进去都是没有用户信息的,则一开始都是没有登录状态,然后进到页面之后,我们请求前端接口,如果有cookie或者localstorage里面存有token,我们则需要请求用户信息接口,如果获取得到用户信息,则表示用户登录成功,首页的login按钮则会变成跳转到工作台的按钮。
分析系统的状态管理
从上面的描述我们可以得出我们的appStore会有这些接口需要设计,是否登录和用户信息,这个也是贯穿整个系统的,接下来我们就想使用什么框架做状态管理了。React相关的状态管理有什么库,有Redux,Redux-Toolkit,Mbox,Recoil,Zustand,我对比了一圈之后,发现比较喜欢用Zustand,因为这个库比较灵活,也可以不依赖React使用,相当于可以在入口初始化的时候先预取接口信息,设置应用状态,这个特别在Admin之类的应用特别好用。可以把逻辑封装到Zustand里面。那我们后面就会使用Zustand做整个应用的数据管理。
分析接口如何方便调用(tRPC+React Query)
因为我们在Nextjs中使用Server Conpoment里面运行服务端的代码,意味着我们开启了Node服务,我们可以在Next的App Api Router里面写一些服务端的逻辑,但是经过我的实验,发现在那里写接口没问题,但是接口调用的是接口地址,调用的时候没有类型提示,这个就会变成很有意思了,要不我们在启动一个后端的项目,我一般会用NestJs写,然后再写一个type包利用declare namespace把接口信息统一管理,这样就不需要在各个系统引入,因为这个文章我们直接使用NextJs编写,我发现用tRPC这个写的时候会挺省心,直接返回了类型提示,并且还用上了ReactQuery,那我们的请求库和接口编写就用tRPC这个库来编写。
编码实现整个过程
引入Zustand做为状态管理
我们在src目录创建stores目录,这里存放所有的Zustand管理的状态。在这个目录我们首先创建一个appStpre.ts
cd client/src
mkdir stores
## 添加依赖包
pnpm add zustand immer
// client/src/stores/appStore.ts
import { createStore } from 'zustand/vanilla';
import { produce } from 'immer';
export type AppState = {
userProfile: {
email: string;
nickName:string
};
isLogin: boolean | undefined;
};
export type AppActions = {
setLogin: (login: boolean) => void;
setUserProfile: (login: boolean | undefined, userInfo?: Partial<AppState['userProfile']>) => void;
};
export type AppStore = AppState & AppActions;
// 初始化状态,默认isLoing都是undefined,这个等我们客户端接口返回之后才去设置他是true或者false
export const initAppStore = (): AppState => {
return {
userProfile: {
email: '',
nickName:""
},
isLogin: undefined
};
};
export const defaultInitState: AppState = {
isLogin: undefined,
userProfile: {
email: '',
nickName:""
}
};
export const createAppStore = (initState: AppState = defaultInitState) => {
return createStore<AppStore>()(set => {
return {
...initState,
setLogin: login => {
set(
produce((draft: AppState) => {
draft.isLogin = login;
})
);
},
setUserProfile: (login, userInfo = {}) => {
console.log('setUserProfile', login, userInfo);
set(
produce((draft: AppState) => {
draft.userProfile = {
...initAppStore().userProfile,
...userInfo
};
draft.isLogin = login;
})
);
}
};
});
};
添加Provider引入Zustand
// src/components/providers/appStoreProvider.tsx
'use client';
import { type ReactNode, createContext, useRef, useContext } from 'react';
import { useStore } from 'zustand';
import { type AppStore, createAppStore, initAppStore } from '@/stores/appStore';
export type AppStoreApi = ReturnType<typeof createAppStore>;
export const AppStoreContext = createContext<AppStoreApi | undefined>(undefined);
export interface AppStoreProviderProps {
children: ReactNode;
}
export const AppStoreProvider = ({ children }: AppStoreProviderProps) => {
// 只会初始化一次
const storeRef = useRef<AppStoreApi>();
if (!storeRef.current) {
storeRef.current = createAppStore(initAppStore());
}
return <AppStoreContext.Provider value={storeRef.current}>{children}</AppStoreContext.Provider>;
};
export const useAppStore = <T,>(selector: (store: AppStore) => T): T => {
const appStoreContext = useContext(AppStoreContext);
if (!appStoreContext) {
throw new Error(`useAppStore只能在AppStoreProvider运行`);
}
return useStore(appStoreContext, selector);
};
Layout 代码引入Provider
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import {AppStoreProvider} from "@/components/providers/appStoreProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<AppStoreProvider>
{children}
</AppStoreProvider>
</body>
</html>
);
}
至此我们引入了Zustand在Nextjs了,下面我们开始改造大工程了,我们需要引入trpc来完成接口改造 了
引入tRPC改造接口
# 添加依赖包
pnpm add @trpc/client@next @trpc/react-query@next @trpc/server@next @trpc/next@next @tanstack/react-query@latest zod
创建tTPC路由器
// client/src/trpc/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
添加tRPC测试接口
import { publicProcedure, router } from './trpc';
export const appRouter = router({
hello:publicProcedure.query(()=>{
return 'hello sass Start'
})
});
export type AppRouter = typeof appRouter;
添加tRPC的客户端
import { AppRouter } from 'client/src/trpc';
import { createTRPCReact } from '@trpc/react-query';
export const trpc = createTRPCReact<AppRouter>();
添加tRPC的provider并在Layout引入
// client/src/components/provoders/trpcQueryProvider.tsx
'use client';
import { PropsWithChildren, useState } from 'react';
import { isServer, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '@/app/_trpc/index';
import { httpBatchLink, httpLink } from '@trpc/client';
import { absoluteUrl } from '@/lib/utils';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000
}
}
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (isServer) {
// Server: always make a new query client
return makeQueryClient();
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
export const TRPCQueryProviders = ({ children }: PropsWithChildren) => {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: absoluteUrl('/api/trpc'),
headers: () => {
const token = isServer ? '' : localStorage.getItem('accessToken') || '';
return {
authorization: token != '' ? `Bearer ${token}` : token
} as any;
}
})
]
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient as any}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
};
接下来我们需要添加trpc的api路由
//client/src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/trpc';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({})
});
export { handler as GET, handler as POST };
修改layout引入这个provider
// client/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import {AppStoreProvider} from "@/components/providers/appStoreProvider";
import {TRPCQueryProviders} from "@/components/providers/trpcQueryProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next api",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<AppStoreProvider>
<TRPCQueryProviders>
{children}
</TRPCQueryProviders>
</AppStoreProvider>
</body>
</html>
);
}
至此我们的trpc也引入成功,这个教程就先到引入trpc吧,后面章节我们会加入邮箱验证登录,redis和整个仓库源码。
结语
这一个部分我先分析了一下如何做状态管理以及为什么需要引入trpc做为我们的接口。后面结束的章节我会使用nodemailer发送邮件验证做为注册登录,使用redis做验证码等功能,大家先有所期待吧