🚍 一条 Todo 的奇幻漂流:TypeScript 全栈类型安全的"护照检查"

写在前面:前几节课分别学了 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,而是一个有 idtitlecompleted 三个字段的 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

注意 fetchTodosaddTodo 都是 async 函数------Zustand 原生支持异步 action,不需要额外中间件(对比 Redux 的 redux-thunk / redux-saga,简洁得多)。

fetchTodos 的流程:

  1. getTodos()(即 API 层的 fetchTodos)发 HTTP 请求
  2. Axios 拦截器已经拆包了,res 直接就是 Todo[] 数据
  3. 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.tsbaseURL: '/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();

这里用了解构取值------一次取出 todosfetchTodosaddTodo 三个东西。对比上一节课 JWT 鉴权里用的选择器写法:

typescript 复制代码
const token = useAuthStore(state => state.token);

选择器写法是"精准订阅"------只有 token 变了才 re-render。解构写法是"全量订阅"------store 里任何一个值变了都会 re-render。

课堂在这里用了解构,因为 TodoList 组件就用到这三个值,store 也不复杂,全量订阅不会有性能问题。如果 store 变大(比如加 usersettings 等),就该换成选择器了。

todo.title 不会拼错

typescript 复制代码
{todos.map((todo) => (
  <li key={todo.id}>{todo.title}</li>
))}

todo 的类型是 Todo------有 idtitlecompleted。你写 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,每个关卡都查护照------对不上就拦下来。全栈类型安全,就是这么简单粗暴。

相关推荐
计算机魔术师1 小时前
NVIDIA 以 129.3 亿美元收购 Hugging Face
前端
平头哥技术团队1 小时前
Day 01 | 用 HTML 写第一个网页:双击就能在浏览器打开
前端
weixin_422555422 小时前
el-menu子菜单点击就被激活,恢复之前的激活状态
javascript·vue.js·elementui
计算机魔术师2 小时前
两年翻45倍!英伟达靠买买买建成千亿投资帝国
前端
paopaokaka_luck2 小时前
基于springboot3+vue3的精准扶贫管理系统(AI 问答、ECharts 图形化分析)
前端·人工智能·echarts
l1m0_3 小时前
智能电动自行车管理后台实战:AI生成页面与React组件化技巧
前端·react.js·ui·ai·设计
Patrick_Wilson3 小时前
为什么gitlab的MR会默认有一个merge commit
前端·git·gitlab
en.en..3 小时前
Linux mmap 内存映射深度解析:基于帧缓冲 /dev/fb0
java·服务器·前端
后台模板学习4 小时前
从0到1用Vite构建电商订单系统前端性能优化方案
前端