React + NestJS购物平台练习【1】前端项目框架搭建
在现代电子商务发展迅速的今天,构建一个高效、易用的购物平台是开发者的一项关键技能。
该系列是全栈实践新坑,使用 React 和 NestJS 的技术栈、从零开始开发一个完整的购物平台(其实是先前开的几个全栈实践坑都让我意识到自己基础实力不足)。
1. 初始化 React + TypeScript 项目
-
使用以下命令创建 React 项目:
1
yarn create react-app shopping-nest --template typescript
-
导航至
shopping-nest
目录:1
cd shopping-nest
-
使用 Yarn 安装依赖:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 安装 ESLint 和 Prettier 相关依赖
yarn add @typescript-eslint/eslint-plugin @typescript-eslint/parser
yarn add -D eslint prettier eslint-config-prettier eslint-plugin-prettier
yarn add -D eslint-config-react-app
// 安装 react-router-dom
yarn add react-router-dom @types/react-router-dom
// 安装 axios
yarn add axios
// 安装 TailwindCSS 相关依赖
yarn add tailwindcss postcss autoprefixer
// 安装 UI 组件和图标库
yarn add @headlessui/react
yarn add lucide-react -
运行
yarn run start
检查一下是否会出问题。
2. 配置 ESLint 和 Prettier
- 我使用的是 Jetbrains WebStorm,记得要更新到 2024 的版本喔。
- ESLint 的版本为
9.13.0
。 - Prettier 的版本为
3.3.3
。
2.1. 配置 ESLint
-
运行以下命令:
1
npx eslint --init
-
根据自己的习惯选择。
-
生成的
mjs
配置文件差不多如下,我自己修改了files
值为src
目录下的文件。1
2
3
4
5
6
7
8
9
10
11
12
13import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
export default [
{files: ["src/**/*.{js,mjs,cjs,ts,jsx,tsx}"]},
{languageOptions: { globals: globals.browser }},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
];可以查看 ESLint 官方文档或者 TypeScript-ESLint 文档自行修改。我自己就保留默认的了。
2.2. 配置 Prettier
-
在项目目录处创建
.prettierrc
文件一个(项目目录这里默认为package.json
所在的目录)。 -
除了查看 Prettier 官方文档自己填写外,还可以使用一些工具生成 Prettier 配置内容。
我这里用了 Prettier Config Generator 生成:
1
2
3
4
5
6
7
8
9
10
11{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing":true,
"bracketSameLine": true,
"arrowParens": "avoid"
}
3. 配置 Tailwind CSS
3.1. 安装并初始化 TailWind CSS 配置
在项目目录下使用以下命令来生成 tailwind.config.js
和 postcss.config.js
:
1 | npx tailwindcss init -p |
然后修改 tailwind.config.js
的内容,将 content
配置为监控 src
文件夹下的所有文件,以便在这些文件中应用 TailWind 的样式:
1 | /** @type {import('tailwindcss').Config} */ |
在 src/index.css
文件的顶部添加以下内容,导入 TailWind 的核心样式、组件和工具:
1 | @tailwind base; |
3.2. 安装并配置 daisyUI 组件库
为了方便开发,安装 daisyUI
组件库,它提供了丰富的组件和自定义主题功能:
1 | yarn add daisyui |
然后在 tailwind.config.js
文件中引入 daisyUI
插件:
1 | import daisyui from "daisyui"; |
对于 daisyUI
的配置,可以根据其文档进行修改。
我安装 daisyUI
还有一个目的,那就是其自定义主题的功能。
在 daisyUI
的主题生成器里,你可以选择自己设计一套颜色方案,或者说随机出一套颜色方案。该页面中还有预览页面可供参考。
我对颜色不敏感,设计能力也很遭殃。这是我随机出的:
1 | export default { |
3.3. 配置 PostCSS
根据 PostCSS 官方说的:
PostCSS 是一种利用 JS 插件转换样式的工具。
这些插件可以检查 CSS、支持变量和混合体、转译未来的 CSS 语法、内联图片等。
postcss.config.js
的初始配置如下:
1 | export default { |
目前这是基本的配置。如果需要更多 PostCSS 功能,可以根据需求进一步配置。
4. 设置路由基础结构
路由系统是管理不同 URL 对应显示不同页面内容的机制。
4.1. 创建统一布局
作为开发者,在构建 Web 应用时,创建一个统一的布局非常重要。因为它能够为用户提供一致的界面的导航体验。
在大多数应用中,我们会有一些固定的部分,比方说导航栏、页脚,以及一个用于动态展示内容的区域。
1 | // src/layouts/MainLayout.tsx |
首先引入 Outlet
,它是 react-router-dom
提供的一个工具,允许在布局中插入不同的内容。
通过 Outlet
,我们可以渲染由路由定义的组件,也就是首页啦、关于页这些,也不需要每次都重写导航和布局。
1 | const MainLayout = () => { |
用 <header>
标签来定义页面的头部,这里我之后会引入导航栏组件,先放个 TODO
马克一下。
1 | <main> |
接下来是 <main>
标签,它是页面的核心内容区域。
<Outlet />
会根据当前路由,动态渲染不同的组件。在开发中,这个设计的好处是我们可以轻松地切换页面,并保持一致的布局框架。
4.2. 路由配置
在单页面应用,也就是 SPA 中,路由是关键。它决定了用户访问某个路径时应该显示哪个组件。
我们现在需要一个机制,让用户能够在不同页面之间切换,比如从首页切换到用户账户信息页。路由能够帮助我们将 URL 和组件相互关联,确保用户在访问特定路径时,看到对应的页面。
1 | // src/router.tsx |
首先导入 createBrowserRouter
,用它可以创建一个支持浏览器历史记录的路由系统。接着我们将先前定义好的 MainLayout
引入作为根布局。
1 | const router = createBrowserRouter([ |
这意味着,无论用户访问的子页面是什么,MainLayout
的结构都会保持一致,而页面主体部分会根据路由变化而动态加载。
4.3. 设置应用入口
App
组件是整个应用的入口。它负责将路由系统注入到 React 的组件树中,这样其他组件才能知道根据不同的路径应该显示什么内容。
为了加载整个路由配置,我们需要一个统一的入口,因此需要用到 RouterProvider
。它将之前配置的路由传递给应用,让各个子组件能够根据 URL 做出相应的渲染。
1 | // src/App.tsx |
引入 RouterProvider
和先前定义好的 router
配置。
1 | const App = () => { |
用 RouterProvider
包裹住应用的根组件,并把 router
传递给它。通过这种方式,整个应用的路由系统就生效了。
虽然话是这么说,但因为内部什么组件都没写好,运行时还是什么都看不到的……
5. 配置状态管理工具
在开发中,状态管理是前端应用的核心部分之一,尤其是在涉及到用户登录、登出、数据持久化等功能时。
Zustand 是一个轻量级的状态管理库,它相比于 Redux 等传统工具更加简洁易用。因为是个练习项目,我便选择了这个更小巧的状态管理库。
5.1. 简单的例子
5.1.1. 初始化 Zustand 状态管理器
在 src
目录下创建一个 stores
目录,用于存放状态管理相关的文件。
在本例子中,代码结构分为三部分:状态定义和处理(UserState
、actions.ts
),Zustand 状态创建和持久化(index.ts
),以及一些辅助函数(api.ts
、log.ts
和 selector.ts
)。
使用以下命令安装 Zustand:
1 | yarn add zustand |
stores
目录下创建 index.ts
,用作状态管理的入口,这样在应用的其他部分就可以方便地引入状态管理逻辑。
1 | import useUserStore from './user'; |
作为一个大致的参考,我选择去写一个用户状态。这里先引入一下这个还未开始写的自定义钩子,理想情况下,它应当允许我们访问和操作与用户相关的状态。
在整个应用中,我们将通过这个钩子获取当前用户信息或调用登录操作。
5.1.2. 定义用户状态类型和接口
stores
目录下创建 user
目录,接着又在 user
目录下创建 types.ts
。
1 | export interface User { |
- TypeScript 中的
interface
用于定义用户状态的结构。使用interface
能更直观地展示用户状态中的各项属性,同时在项目扩展时易于维护
这里定义了 User
和 UserState
,这两个接口分别描述了用户对象的结构和与用户相关的状态。
当然,作为一个参考,这些值后续一定会进行修改或者扩展。
-
User
接口不用多说。UserState
接口包括了:user
:当前登录的用户信息;没有用户登录的话就是null
isLoading
:是否正在进行异步操作,比如登录请求error
:当然是错误信息啦
-
setUser
是一个函数属性,接收一个User
或者null
类型的参数 -
login
函数属性接收LoginCredentials
参数,并返回一个Promise<User>
(使用 TypeScript 的时候这样写有助于检查函数的参数和返回值类型,减少类型错误)
5.1.3. 创建 Zustand 状态管理器
user
目录下创建 index.ts
。
我们通过 Zustand 来创建一个用户状态管理器。
1 | import { create } from 'zustand'; |
这里我们引入了 Zustand 的两个中间件:devtools
和 persist
。
devtools
允许我们在开发时使用 Redux DevTools 进行状态调试,方便查看状态的变化persist
实现状态的持久化,将用户状态保存在localStorage
中(先前使用 Redux 的时候,都是要手动使用localStorage
进行持久性保存。Zustand 则可以直接使用persist
中间件实现状态的持久化)。这样即使用户刷新页面,用户信息依然保留name: 'user-storage'
指定了持久化状态的存储键名
5.1.4. 定义用户状态的操作与异步行为
user
目录下创建 actions.ts
,定义状态和异步操作。
1 | import { StateCreator } from 'zustand'; |
在这个文件中,我们定义了用户状态的操作逻辑和异步操作。
对于大多数应用来说,登录是一个异步过程,我们需要在发起请求时更新 isLoading
状态,同时在请求失败时记录错误信息。
-
初始状态,也就是用户未登录时,
user
设为null
、isLoading
为false
,error
也为空。 -
setUser
是一个简单的同步方法,用于手动设置用户信息。 -
login
是一个异步函数,用于处理登录逻辑。开发中,典型的流程是:
- 设置
isLoading
为true
,以便显示加载状态 - 发起登录请求(因为还没写,就用
TODO
标记了。注意哈,现在这个时候跑指定报错) - 请求成功后,将返回的用户信息存储到状态中,并重置
isLoading
为false
- 如果请求失败,捕获错误,并更新
error
状态,用户可看到错误提示(现在当然不行)
- 设置
5.2. 进阶配置
我们已经配置了 Zustand 的基本用户状态管理。接下来,我们将借助 TypeScript,进一步优化和扩展状态管理的功能,包括状态持久化、自定义中间件和选择器等。
5.2.1. 状态持久化与部分存储
在生产环境中,为了提升用户体验,状态持久化是一个常见需求。Zustand 提供了 persist
中间件,帮助我们将部分状态保存在 localStorage
或其他存储中,以确保页面刷新后状态不会丢失。
在 stores/user/index.ts
中,我们定义了 persistOptions
,并在其中使用了 partialize
功能,将状态中关键的部分(如用户信息和更新时间)持久化:
1 | import { create } from 'zustand'; |
Pick<UserState, 'user' | 'lastUpdated'>
:使用Pick
类型将UserState
中的user
和lastUpdated
属性挑选出来,简化了持久化的内容PersistOptions
类型:类型声明让我们清楚地知道哪些状态会被持久化,避免错误持久化不必要的数据partialize
是一个用于选择性地存储状态对象中部分属性的函数。在我们的persistOptions
里,它的作用是从UserState
状态中挑出user
和lastUpdated
这两个属性,并将其存储到持久化的存储中
5.2.2. 订阅特定的状态
subscribeWithSelector
允许我们订阅特定的状态属性变化。与直接订阅整个状态的变化不同,它可以细化到仅在某些具体属性更新时触发回调,从而减少不必要的订阅响应。
继续写 stores/user/index.ts
:
1 | const useUserStoreBase = create<UserState>()( |
通过组合其他的 Zustand 插件,我们创建了一个订阅机制。这样做的好处是提高性能、避免不必要的渲染。
接下来这段代码订阅了 useUserStoreBase
中的 user
属性:
1 | useUserStoreBase.subscribe( |
(state) => state.user
是一个选择器函数,只返回state
中的user
属性,从而使订阅仅响应user
的变化- 当
user
属性变化时,回调触发。回调会根据user
是否存在(如user
为null
,或者用户登陆了新的信息)来输出不同的登录状态信息
5.2.3. 自定义日志中间件
为了方便调试,我们可以创建一个日志中间件。这个中间件会在每次状态更新时,记录状态变化信息。
在 stores/common/log.ts
中定义 log
函数,扩展 Zustand 的 set
方法,使其在应用状态变化时输出变更详情。
先写一个泛型类型,用于定义 set
函数所接收的各种更新方式:
1 | import { StateCreator } from 'zustand'; |
SetStateAction<T>
的作用是确保状态的更新类型符合期望,允许直接提供新的状态值、部分更新或基于当前状态的更新函数T
:泛型参数,表示整个状态对象的类型,例如UserState
- 类型定义:
T
:可以直接传入整个状态对象,用于完全替换现有状态Partial<T>
:可以传入部分状态对象,即只更新部分属性。Partial<T>
将状态对象的所有属性变为可选(state: T) => T | Partial<T>
:可以传入一个函数,这个函数接收当前状态作为参数,并返回新的状态或部分状态。这种方式允许在回调中基于现有状态动态生成更新值
这样写的目的是为了更灵活的状态更新方式,不仅可以直接替换状态,也可以部分更新或在回调函数中动态更新。
1 | export const log = <T extends object>( |
log
是一个高阶函数(也就是 HOC),接受一个 Zustand 的 StateCreator
配置函数,并返回一个经过增强的 StateCreator
,用于记录状态的变化。
log
的作用是对传入的 config
配置函数进行包装,以便在状态更新时打印更新的内容和更新后的状态,用于调试。
-
log
的内部逻辑:-
参数:
config
:一个 Zustand 的StateCreator
函数,负责创建状态。此函数会调用set
函数来更新状态
-
返回值:一个增强的
StateCreator
函数,用于替代原始config
函数 -
内部逻辑:
-
包装
set
函数:调用config
时,将自定义的set
函数传入-
自定义的
set
函数接收partial
和replace
两个参数:partial
:可以是新的状态值、部分状态值,也可以是一个返回状态的函数replace
:布尔值,表示是否完全替换现有状态
-
日志输出:
console.log('Applying', { partial, replace })
在更新前输出即将应用的部分状态或新状态console.log('New state: ', get())
在更新后输出新的
-
-
更新逻辑:
- 若
replace
为true
,则完全替换当前状态;否则只应用部分更新 - 调用
get
获取新的状态并打印日志
- 若
-
-
5.2.4. 状态选择器
在状态管理中,我们通常需要对状态进行选择,以便在不同组件中访问特定的状态字段。
createSelectors
帮助我们自动生成访问器,减少在不同组件中冗余的状态逻辑。
stores/common/selector.ts
中定义 createSelectors
,它会为状态中的每个字段创建一个 getter
函数,便于状态的解耦:
1 | import { StoreApi, UseBoundStore } from 'zustand'; |
WithSelectors<S>
定义了一个条件类型,用于增强传入的store
类型S
S extends { getState: () => infer T }
检查S
是否包含getState
方法,并从中推断出T
类型(状态对象的类型)- 返回:
- 若
S
满足条件,则返回S
并附加一个use
属性use
是一个对象,包含状态对象中每个键对应的getter
方法,这些方法返回T[K]
,即每个状态属性的值
- 若
S
不满足条件,则返回never
- 若
1 | export const createSelectors = < |
createSelectors
函数:
- 参数:接收一个 Zustand store 实例
_store
- 类型约束:
S extends UseBoundStore<StoreApi<T>>
:约束S
必须是一个UseBoundStore
类型的 storeT extends object
:状态对象T
必须是一个对象
- 逻辑:
- 将传入的 store
_store
进行类型转换,以便使用WithSelectors
增强后的类型 - 为
store
增加use
属性(一个空对象),作为存放每个状态属性getter
方法的容器 - 获取当前 store 的
state
对象 for
循环遍历state
对象的键(即状态对象的属性)- 对每个键
k
,在store.use
中创建一个对应的getter
方法store.use[k]
,返回state[k]
的值
- 对每个键
- 返回增强后的 store 实例
store
,其中包含use
对象和对应的getter
方法
- 将传入的 store
假设 store 的状态对象如下:
1 | const useStore = createSelectors( |
那么调用 useStore.use.user()
就会返回:
1 | { name: "Alice", age: 30 } |
5.2.5. API 请求的配置和错误处理
在前端状态管理中,一般会包含 API 请求的逻辑。
我们在 stores/user/actions
中,定义一个 createUserSlice
函数,它是 Zustand 中 UserState
的部分实现,用于管理用户相关的状态和操作。
首先导入依赖:
1 | import { StateCreator } from 'zustand'; |
定义状态和操作:
1 | const createUserSlice: StateCreator<UserState> = (set) => ({ |
user
:存储当前用户信息isLoading
:指示登录操作是否正在进行中error
:保存登录过程中发生的错误信息lastUpdated
:记录上次用户数据更新的时间戳
1 | // ... |
setUser
:一个同步方法,用于直接设置user
状态。接收一个User
对象或者null
,并调用set
更新状态
1 | // ... |
login
方法:
- 启动加载状态:调用
set({ isLoading: true, error: null })
将isLoading
设置为true
,并清除之前的错误 - API 请求:
await api.post<User>('/auth/login', credentials)
向服务器发送登录请求。返回的response.data
包含了用户信息 - 成功处理:
- 若请求成功,
set
更新状态,存储用户数据、停止加载、设置lastUpdated
时间戳,并清除错误 - 返回
user
,便于在调用login
的地方使用
- 若请求成功,
- 错误处理:
- 如果请求失败,捕获
error
并生成错误消息 - 更新
set
将isLoading
设置为false
,保存error
信息,并将user
设置为null
- 抛出错误,以便调用
login
的组件也能捕获并处理该错误
- 如果请求失败,捕获
1 | // ... |
logout
方法是登出功能,说白了就是将所有的状态设置为 null
,从而达到清除当前用户信息和错误的效果。
6. 设置 API 请求封装
至于 API 嘛,写在了 stores/common/api.ts
中:
1 | import axios from 'axios'; |
axios
配置了一个 API 实例 api
,设置了基本的请求和响应拦截器。
baseURL
为环境变量REACT_APP_API_URL
,还设置了 10 秒的超时时间
1 | api.interceptors.request.use( |
-
请求拦截器
api.interceptors.request.use
提供了请求发送前的自定义逻辑处理。可以在config
中添加认证信息(也就是老生常谈的 JWTAuthorization
头)。 -
如果请求在发送前就失败了,那么拦截器将直接拒绝该错误
1 | api.interceptors.response.use( |
响应拦截器 api.interceptors.response.use
允许在接收到响应时进行自定义处理。
- 响应成功会直接返回数据
- 请求出错,
error
就会被统一处理,然后传递给调用方处理
有很多功能先放 TODO
了,能差不多 GET 到意思就好。