写在前面:前几节课分别学了 Next.js 全栈、NestJS 后端、JWT 鉴权、CSS 布局。今天,这些碎片终于拼成了一幅完整地图------全栈项目容器化配置 。前端 React + TypeScript + Zustand + Axios,后端 NestJS,中间用 Vite 代理解决 CORS 跨域。课堂用一个 TodoList 把整条链路跑通了:后端 API 吐出 JSON → Axios 拦截器拆包 → API 函数标注类型 → Zustand store 存状态 → React 组件渲染。最妙的是------TypeScript 从头到尾给每个环节查护照,一个 Todo 对象从后端出生到屏幕显示,类型信息一个字符都没丢。以下所有代码均来自课堂真实文件。
一、CORS:浏览器的"出入境管制"
全栈项目第一个拦路虎不是代码,是 CORS。
readme 开篇就点出了这个场景:
"前端 react + ts + zustand + axios,后端接口 /api/todos。跨域问题------怎么解决跨域?vite 代理配置。"
"CORS 跨源资源共享(Cross-Origin Resource Sharing)。5173:3000。同源策略安全。"
前端跑在 Vite 的 5173 端口,后端 NestJS 跑在 3000 端口。浏览器一看------端口不一样,"源"不同,直接拦截。
这叫同源策略 ------浏览器的安全机制。你的前端页面来自 localhost:5173,它只能跟 5173 说话。想跟 3000 要数据?浏览器说:不行,你们不是一个源的,万一 3000 是个骗子呢?
Vite 代理:挂个"外交牌照"
解法在 readme 里:
"vite 代理配置。"
原理很简单------浏览器只管"页面发出的请求跟页面同不同源"。那好,前端不发到 3000 了,发到 5173/api/todos。同源,浏览器放行。然后 Vite 开发服务器在背后偷偷把请求转发到 localhost:3000。
bash
浏览器 → localhost:5173/api/todos (同源,放行 ✓)
↓ Vite 代理转发
localhost:3000/api/todos (后端 NestJS)
浏览器全程不知道请求被转发了------它只看到了一个同源请求。这就是 Vite 代理的本质:在浏览器和后端之间放一个中间人,让浏览器以为请求是发给自己的。
config.ts 里的 baseURL: '/api' 就是配合这个方案设计的------所有请求都走 /api 前缀,Vite 代理统一转发。
二、TypeScript:全栈的"通用护照"
跨域解决了,数据能通了。但前后端是两个人写的------后端返回的 JSON 长什么样?前端怎么知道有哪些字段?
没有 TypeScript 的时代,这全靠口头约定和文档。文档过期了?出 bug。后端偷偷改了字段名?出 bug。前端拼错了属性名?出 bug。
TypeScript 的方案------给数据办一本护照,过境时查验。
护照模板:Todo 接口
types/todo.ts,只有四行,却是整条链路的基石:
typescript
// src/model/todo.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
一个 Todo 有什么?id 是数字,title 是字符串,completed 是布尔值。就这三个字段,白纸黑字写在护照上。
这本护照从后端 NestJS 的 todos.service.ts(上一节课的文件)一直用到前端 React 组件------同一个 interface,全栈共享。后端签发的 JSON 对象,到前端每个环节都会被 TypeScript 检查:字段对不对?类型对不对?少了没有?多了没有?
三、Axios 拦截器:边境海关
config.ts 是这条链路的第一站------所有 HTTP 请求和响应都要过这道海关。
typescript
// src/api/request.ts
import axios from 'axios';
// 1. 创建独立的 axios 实例,避免污染全局配置
const service = axios.create({
baseURL: '/api', // 统一请求前缀,配合 Nginx 反向代理
timeout: 10000, // 全局超时时间设置
});
// 2. 请求拦截器:在请求发出前统一处理(如注入 Token)
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 3. 响应拦截器:统一处理响应数据或全局错误
service.interceptors.response.use(
(response) => {
// 直接返回 response.data,省去业务代码中重复的 .data 解析
return response.data;
},
(error) => {
// 全局错误处理:例如 401 跳转登录页,或弹出全局提示框
console.error('API Error:', error.message);
return Promise.reject(error);
}
);
export default service;
三段式设计,注释写得清清楚楚:
出境检查:请求拦截器
每个请求出发前,拦截器拦下来检查------有没有带 token?带了就自动塞进 Authorization: Bearer <token>。
这跟上一节课的 JWT 鉴权完美衔接------token 存在 localStorage 里,请求拦截器自动取出戴上。业务代码完全不用管 token 的事。
入境检查:响应拦截器
每个响应回来后,拦截器先拆包------Axios 原始响应是 { data, status, headers, config } 一大坨,拦截器直接 return response.data,把最内层的业务数据取出来。
还有错误处理------网络错误、超时、401 全局拦截,console.error 记录后 Promise.reject 往下传。注释还写了"例如 401 跳转登录页"------跟上一节课的路由守卫一脉相承。
为什么用 axios.create 而不是全局 axios?
注释第一行就说了:
"创建独立的 axios 实例,避免污染全局配置。"
直接用 axios.defaults 改全局配置,会污染项目中所有 axios 调用。axios.create() 创建一个独立实例,配置只在实例上生效------干净、隔离、可配置多个实例(比如不同 baseURL 的服务)。
四、API 函数层:贴类型的"报关行"
海关过了,数据进入 API 函数层。这一层的职责------给 HTTP 请求和响应贴上 TypeScript 类型标签。
api/todos.ts:
typescript
// src/api/todoApi.ts
import service from './config';
import { type Todo } from '../types/todo';
// 获取所有 Todos
export const fetchTodos = () => {
return service.get<Todo[]>('/todos');
};
// 新增 Todo
export const createTodo = (title: string) => {
return service.post<Todo>('/todos', { title });
};
// 更新 Todo 状态
export const updateTodo = (id: number, patch: Partial<Todo>) => {
return service.patch<Todo>(`/todos/${id}`, patch);
};
// 删除 Todo
export const deleteTodo = (id: number) => {
return service.delete(`/todos/${id}`);
};
注意每个函数的泛型标注:
| 函数 | HTTP 方法 | 泛型 | 含义 |
|---|---|---|---|
fetchTodos |
GET | <Todo[]> |
返回一个 Todo 数组 |
createTodo |
POST | <Todo> |
返回一个新建的 Todo |
updateTodo |
PATCH | <Todo> |
返回更新后的 Todo |
deleteTodo |
DELETE | 无 | 删除不需要返回类型 |
service.get<Todo[]>('/todos') 告诉 TypeScript:这个请求返回的数据是 Todo[] 类型。从这一行开始,数据就拥有了类型身份------不再是 any,不再是模糊的 JSON,而是一个有 id、title、completed 三个字段的 Todo 数组。
Partial<Todo>:局部更新护照
updateTodo 的参数 patch: Partial<Todo> 用了 TypeScript 的工具类型------上一节课在 NestJS 的 todos.controller.ts 里也用过。
Partial<Todo> 把 Todo 的所有字段变成可选的:
typescript
// 原始 Todo
{ id: number; title: string; completed: boolean }
// Partial<Todo> 之后
{ id?: number; title?: string; completed?: boolean }
所以 updateTodo(1, { completed: true }) 合法------只传要改的字段。updateTodo(1, { title: '新标题', completed: false }) 也合法。TypeScript 帮你检查:传了 Partial<Todo> 里不存在的字段?报错。传了 string 类型的 completed?报错。
工具类型不是花架子,是全栈类型安全的实战武器。
注释也强调了响应拦截器的功劳:
"因为响应拦截器已经处理了 response.data,这里直接返回强类型数据。"
API 函数不需要再写 .data 解包------拦截器已经做完了。API 函数只管一件事:贴类型标签。
五、Zustand Store:带类型的"仓库管理"
数据过了海关、贴了标签,进入 Zustand store------前端的状态仓库。
store/todoStore.ts:
typescript
import { create } from 'zustand';
import { type Todo } from '../types/todo';
import { fetchTodos as getTodos, createTodo } from '../api/todos';
interface TodoStore {
todos: Todo[];
fetchTodos: () => Promise<void>;
addTodo: (title: string) => Promise<void>;
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
fetchTodos: async () => {
const res = await getTodos(); // 请求通过 Nginx 代理
console.log(res);
set({ todos: res });
},
addTodo: async (title: string) => {
const res = await createTodo(title);
console.log(res);
// set((state) => ({ todos: [...state.todos, res] }));
},
}));
create<TodoStore>:给仓库立规矩
create<TodoStore> 的泛型标注是关键------它告诉 Zustand:这个 store 必须符合 TodoStore 接口。
TodoStore 接口定义了仓库里有什么:
typescript
interface TodoStore {
todos: Todo[]; // 数据:Todo 数组
fetchTodos: () => Promise<void>; // 动作:拉取列表
addTodo: (title: string) => Promise<void>; // 动作:新增一条
}
少了 todos?TypeScript 报错。fetchTodos 的返回类型不是 Promise<void>?报错。addTodo 的参数不是 string?报错。
接口即合同。 store 的实现必须严格履行 TodoStore 接口定义的每一条。
异步 action:async/await
注意 fetchTodos 和 addTodo 都是 async 函数------Zustand 原生支持异步 action,不需要额外中间件(对比 Redux 的 redux-thunk / redux-saga,简洁得多)。
fetchTodos 的流程:
- 调
getTodos()(即 API 层的fetchTodos)发 HTTP 请求 - Axios 拦截器已经拆包了,
res直接就是Todo[]数据 set({ todos: res })更新 store 状态
addTodo 里有一行被注释掉了:
typescript
// set((state) => ({ todos: [...state.todos, res] }));
这是"乐观更新"------不等重新拉取列表,直接把新 Todo 追加到本地数组。被注释掉说明可能是调试中,但这行代码展示了 Zustand 函数式更新的写法:set((state) => ({ ... })),拿到当前 state,返回新 state。
请求走 Nginx 代理
注释里有一句容易被忽略:
"请求通过 Nginx 代理。"
这说明项目已经从 Vite 开发代理(解决开发环境跨域)进阶到了 Nginx 反向代理(解决生产环境跨域)。
yaml
开发环境: 浏览器 → Vite 5173 → NestJS 3000
生产环境: 浏览器 → Nginx 80 → NestJS 3000
config.ts 里 baseURL: '/api' 的注释也印证了:
"统一请求前缀,配合 Nginx 反向代理。"
不管开发还是生产,前端请求都是 /api/xxx。开发时 Vite 代理转发,生产时 Nginx 代理转发。前端代码一行都不用改,只改部署配置。
六、React 组件:Todo 到达终点站
数据走完了整条链路,最后到达 React 组件------渲染到屏幕上。
components/TodoList.tsx:
typescript
import React, { useEffect } from 'react';
import { useTodoStore } from '../store/todoStore';
const TodoList: React.FC = () => {
const { todos, fetchTodos, addTodo } = useTodoStore();
useEffect(() => {
// fetchTodos();
}, [fetchTodos]);
const handleAdd = () => {
const title = prompt('Enter todo title:');
if (title) addTodo(title);
};
return (
<div>
<h1>Todo List</h1>
<button onClick={handleAdd}>Add Todo</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
};
export default TodoList;
React.FC:组件也有类型
React.FC(Function Component)是 TypeScript 给函数组件贴的标签------声明这个函数是一个 React 组件,返回 JSX。
Zustand 取值:解构 vs 选择器
typescript
const { todos, fetchTodos, addTodo } = useTodoStore();
这里用了解构取值------一次取出 todos、fetchTodos、addTodo 三个东西。对比上一节课 JWT 鉴权里用的选择器写法:
typescript
const token = useAuthStore(state => state.token);
选择器写法是"精准订阅"------只有 token 变了才 re-render。解构写法是"全量订阅"------store 里任何一个值变了都会 re-render。
课堂在这里用了解构,因为 TodoList 组件就用到这三个值,store 也不复杂,全量订阅不会有性能问题。如果 store 变大(比如加 user、settings 等),就该换成选择器了。
todo.title 不会拼错
typescript
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
todo 的类型是 Todo------有 id、title、completed。你写 todo.titel?TypeScript 红线报错。你写 todo.name?报错。
没有 TypeScript 的时代,这种拼写错误要等到运行时才能发现------页面空白,打开控制台一看 undefined。 有了 TypeScript,代码还没运行,编辑器就帮你揪出来了。
useEffect 里的注释
typescript
useEffect(() => {
// fetchTodos();
}, [fetchTodos]);
fetchTodos() 被注释掉了------可能是调试阶段不想自动请求。但这行代码展示了标准模式:组件挂载时调用 fetchTodos 拉取列表数据。[fetchTodos] 是依赖数组,Zustand 的 action 引用稳定,不会无限触发。
七、App.tsx:极简的"总入口"
App.tsx 只有一层组件:
typescript
import TodoList from './components/TodoList';
function App() {
return (
<>
<TodoList />
</>
)
}
export default App;
没有路由(这节课聚焦数据流,不涉及路由),没有 Provider 包裹(Zustand 不需要 Context Provider),没有全局样式。一个 Fragment 包一个 TodoList------干净到极致。
对比 Redux 的写法------App 外面得包一层 <Provider store={store}>。Zustand 连这层都省了。create 创建的 store 是一个全局单例,任何组件 import 后直接 useTodoStore() 取值。零 Provider,零样板代码。
八、一张图看懂 Todo 的完整旅程
一个 Todo 从后端数据库到屏幕渲染,经历了什么:
kotlin
NestJS 后端
└── todos.service.ts → return { id: 1, title: '学习', completed: false }
↓ HTTP JSON 序列化
↓
Nginx / Vite 代理
└── /api/todos → localhost:3000/todos (跨域解决)
↓
↓
Axios 响应拦截器 ← 入境海关
└── response.data 拆包,剥离 HTTP 外壳
↓ return Todo[](实际类型)
↓
API 函数层 ← 报关行
└── service.get<Todo[]>('/todos') 贴类型标签
↓ return Promise<Todo[]>
↓
Zustand Store ← 仓库
└── set({ todos: res }) 存入全局状态
↓ todos: Todo[]
↓
React 组件 ← 终点站
└── todos.map(todo => <li>{todo.title}</li>) 渲染到屏幕
每一层都做了且只做了一件事:
| 层级 | 文件 | 职责 | TypeScript 角色 |
|---|---|---|---|
| 代理层 | Vite / Nginx | 解决跨域 | --- |
| 拦截器 | config.ts |
戴 token + 拆包 | --- |
| API 层 | todos.ts |
发请求 + 标类型 | <Todo[]> 泛型 |
| Store 层 | todoStore.ts |
管状态 + 调 API | create<TodoStore> |
| 组件层 | TodoList.tsx |
取状态 + 渲染 | React.FC + Todo 类型 |
| 模型层 | todo.ts |
定义数据结构 | interface Todo |
interface Todo 是整条链路的"通用护照"------后端签发,API 验证,Store 存储,组件消费。每一站查验的都是同一本护照,一个字段都不会丢。
九、全栈 TypeScript 的真正威力
今天最深的感受------TypeScript 不是前端的专利,是全栈的通用语言。
后端 NestJS 用 interface Todo 定义数据结构(上一节课的 todos.service.ts),前端 React 也用同一个 interface Todo 消费数据。后端改了字段?前端编译就报错。前端拼错了属性?编辑器红线。
这就是 readme 里没明说但代码全在展示的东西------全栈类型安全。
没有 TypeScript 的全栈项目,前后端靠"口头约定"传数据。有了 TypeScript,约定变成了编译器能检查的合同。
typescript
后端 NestJS 前端 React
interface Todo { ← 同一个 interface Todo {
id: number id: number
title: string title: string
completed: boolean completed: boolean
} }
↑ ↑
└──── 同一本护照,全栈通用 ──────┘
后端返回的 JSON、前端接收的数据、Store 存的状态、组件渲染的变量------同一个 Todo 接口贯穿始终,类型信息一个字符都没丢。
PS:一条 Todo 从后端游到屏幕,经过五道关卡。没有 TypeScript,它可能中途变了形、丢了字段、拼错了名字。有了 TypeScript,每个关卡都查护照------对不上就拦下来。全栈类型安全,就是这么简单粗暴。