vue+c#的前后端联调~
接着上一篇后端复盘,这次轮到前端。技术栈 Vue3 + TypeScript + Vite,目标是把后端那 6 门课渲染到页面上。这篇主要记录下一些前后端习惯不一致的乌龙,还有就是给小登参考一下
一、前端也有分层,而且能和后端对上
上一篇我说「代码一定要解耦」。前端可能会习惯性以为「不就发个请求渲染一下吗」,结果真写起来发现请求逻辑一旦散进组件里就收不住了——每个组件里都有baseURL、超时、错误处理。
所以!代码一定要分层!
endpoints.ts 常量:后端有哪些地址 ↓http.ts axios 实例 + 拦截器(横切逻辑,全局一份) ↓courses.ts service(熟悉不?):一个后端 action = 一个函数students.ts ↓App.vue 组件:调用service+渲染和后端的分层对照一下,几乎是同构的:
| 后端 | 前端 | 职责 |
|---|---|---|
appsettings.json | api/endpoints.ts | 常量配置 |
Program.cs 的中间件管线 | api/http.ts | 横切关注点,全局唯一 |
CourseService | api/courses.ts | 业务,一个操作一个方法 |
DTOs/ | types/*.ts | 前后端契约 |
Controllers/ | views/*.vue | 对外入口 |
二、各层具体职责
1. endpoints.ts:端点表
export const ENDPOINTS = { COURSES: { LIST: "/Courses" }, STUDENTS: { PROFILE: "/Students/{studentNo}/profile" },}就是一个纯字符串字典。定义路由
它凭什么存在? 因为 URL 字符串只能有这一个来源。倒是后你后端要是改了,这里只改一处。传统开发,要讲究松耦合(
至于对应名都哪来的,直接看后端
| 后 | 前 |
|---|---|
[Route("api/[controller]")] + 类名 CoursesController | 前缀 /Courses |
[HttpGet] | 后缀空 → LIST: "/Courses" |
[HttpGet("{studentNo}/profile")] | 后缀 /{studentNo}/profile |
叶子常量的数量 = 后端 action 的数量。
2. http.ts:axios 实例
import axios from "axios";
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '/api', timeout: 10000,})
export default api;关于 baseURL 为什么是 '/api':
- 开发:Vite dev server 把
/api代理到后端https://localhost:7039 - 生产:前端产物由后端
wwwroot托管,同源
两种情况都是 /api,所以不需要搞环境判断。?? 后面那个兜底是留给将来的——哪天要指向一个真实部署的后端,在 .env.local 里写一行 VITE_API_URL=https://xxx/api 就行,不用改代码。
⚠️ 自定义环境变量必须以 VITE_ 开头才会暴露给前端代码,不然只在 vite.config.ts 里可见。改了 .env 要重启 dev server。
3. courses.ts:service
import api from './http';import type { CourseDto } from '../types/course';import { ENDPOINTS } from './endpoints';
export const CourseApi = { async getCourses(): Promise<CourseDto[]> { const { data } = await api.get<CourseDto[]>(ENDPOINTS.COURSES.LIST); return data; }}把「URL 字符串」和「类型」绑在一起。绑完之后,组件里只会看到 CourseApi.getCourses(),没url。
4. types/:契约
export interface CourseDto { id: number; code: string; name: string; // 后端 CourseDto.Name credit: number; // 后端 CourseDto.Credit capacity: number; selectedCount: number;}字段名必须是「C# 属性名的 camelCase」:ASP.NET Core 默认用 System.Text.Json 的 camelCase 策略,Name 序列化成 name、Credit 序列化成 credit。
名字不对不会报错,但变量编程undefined,你就查去吧
三、typescript(anyscript)
ts和js略有区别。暑假的纳新系统图省事用了js
api.interceptors.response.use( (response) => { if (response.config.responseType === "blob") return response; return response.data; // ← 拆包 }, (error) => { /* 错误归一化 */ });看图:收到响应后先把 .data 抽出来返回,这样调用方拿到的就是业务数据。
ts不行。看 axios.get 的签名:
get<T = any, R = AxiosResponse<T>, D = any>(url: string, config?): Promise<R>// T 泛型进到这里 ↑ 返回的是这个关键在于:泛型参数 T 决定的是 response.data 的类型,不是返回值的类型。 api.get<CourseDto[]>() 返回的永远是 AxiosResponse<CourseDto[]>。
所以如果你在拦截器里 return response.data,运行时确实是数据,但 TS 还以为你在返回 AxiosResponse——类型和现实就对不上了。
这里直接显式拆包:
async getCourses(): Promise<CourseDto[]> { const { data } = await api.get<CourseDto[]>(ENDPOINTS.COURSES.LIST); return data;}多写这一行,但类型没问题。对调用方来说两种方案完全一样(都是拿到 CourseDto[]),区别只在 service 内部。
不拆包的后果:调用方看到的是 AxiosResponse<any>,.data 是 any。于是 res.data[0].naem 这种拼写错误编译期抓不到,运行时变 undefined,然后你对着空白页面查半天。辛苦写的 types/ 目录一点忙都帮不上。
四、前后端习惯不一致
1. ref
后端没有「界面」这个东西,改了字段就改了,没人需要知道:
int count = 0;count = 1; // ── 完事前端不行:
let count = 0count = 1 // vue没跟踪
const count = ref(0)count.value = 1 // 自动更新vue新手不得不品的一环:
| 位置 | 写法 |
|---|---|
<script> 里 | 必须写 .value |
<template> 里 | 不能写 .value(Vue 自动解包) |
2. 组件里 API 调用的终点必须是一次 ref.value = ...
踩坑了(
const courses = ref<CourseDto[]>([])
async function loadCourses() { try { const response = await CourseApi.getCourses(); // ← 数据拿到了…… } catch (err) { error.value = 'Failed to fetch courses'; } finally { loading.value = false; }}response 是个局部变量,函数一结束它就没了。courses.value 从头到尾没被碰过,还是初始的 []。界面盯着 courses,courses 永远是空的 —— 于是显示「No courses available」。
请求没问题,但拿完数据没干活
写这行的时候脑子还在后端:调用一个方法 → 拿到返回值 → 赋给局部变量 → 往下算。C# 里这是对的。但前端不是。
数据不是「流经函数」的,是「存进响应式状态」的。 所以你用ref就对了(
所以规矩是:组件里一个 API 调用的终点,必须是一次 ref.value = ... 赋值。 写成 const x = await ...,x 就是条死胡同。
回头看我的代码,courses.value 声明了但从没被写过,response 拿到了真数据但界面看不见 —— 它们本该是同一个东西,中间那次赋值就是漏掉的一环。
3. 三个状态一个都不能少
const courses = ref<CourseDto[]>([])const loading = ref(false)const error = ref('')后端返回一个 union,调用方 switch 出 NotFound / Loaded。前端一模一样 —— 页面必须能表达「正在加载」和「失败了」,否则用户看到白屏,分不清是在加载还是已经寄掉了。
<p v-if="loading">Loading courses...</p><p v-else-if="error" class="error">{{ error }}</p><p v-else-if="courses.length === 0">No courses available.</p><ul v-else> <li v-for="c in courses" :key="c.id"> ... </li></ul>四个分支依次判断,就是穷尽 switch 的模板版。
注意 v-else-if / v-else 必须紧跟在前面那个元素后面,中间不能插别的元素,插了会编译报错。
4. v-for 的 :key
<li v-for="c in courses" :key="c.id">Vue 更新列表时需要知道「新列表的这一项,对应旧列表的哪一项」,才能正确复用 DOM 节点。key 就是给每项发的身份证。
:key="c.id"用业务主键,稳定唯一:key="index"数组下标。列表一排序/删除,下标全错位,Vue 认错人,会出现输入框内容串行、勾选状态跳到别的行这种极难查的 bug
5. catch 里的 e 是 unknown
} catch (e) { error.value = e.message // 炸了}TS 里 catch 的变量类型是 unknown,因为 JS 允许 throw 任何东西——字符串、数字都行:
throw '炸了' // 合法throw 42 // 也合法所以必须先收窄类型再访问成员:
} catch (e) { error.value = e instanceof Error ? e.message : '加载失败'}这和后端 union + 穷尽 switch 是同一个思路:先确定类型,再访问字段。
五、踩坑清单
-
const response = await ...,前后端习惯不一致,记得用ref跟踪。用pinia用久了基础确实忘了 -
路径依赖:
getAPIBaseURL():return import.meta.env.PROD ? PRODUCTION_API_URL : DEVELOPMENT_API_URL这是纳新系统里的一行,主要是为了区分生产环境和开发环境的url路径,但本地自己跑没必要
-
路径少个字母 → 404:
endpoints.ts里写成了/Student/{studentNo}/profile,少个s。后端是StudentsController→/api/Students。类型检查查不到这个,纯粗心 -
TS 类型字段名和后端对不上:
types/course.ts里写的是description/credits,后端是Name/Credit。camelCase 序列化后应该是name/credit。 -
依赖装错目录:在仓库根目录
npm install axios,结果package.json和node_modules落在了根目录,客户端项目里反而没有。表现是import axios from 'axios'解析不了。装依赖前先确认自己在哪个目录。 -
引用后缀问题:Vue/Vite 生态惯例是无后缀。import时不用加上文件扩展名
-
命名拼写:
studentsNo→ 应该是studentNo,和后端保持一致。
六、排查流程
排查的时候不要直接运行。
三个步骤:
| 顺序 | 命令 | 抓什么 | 成本 |
|---|---|---|---|
| 1 | npm run lint | 没用的变量、漏掉的 await、可疑写法 | 秒级 |
| 2 | npm run type-check | 类型不匹配 | 秒级 |
| 3 | 浏览器 | 逻辑错、接口错 | 手动点半天 |
第 1 个坑(const response = await ...)就是 ESLint 一秒能抓的:
src/App.vue 16:11 error 'response' is assigned a value but never used @typescript-eslint/no-unused-vars它连行号和变量名都给你了。
善用f12开发者工具,直接抓网络请求看
| 状态码 | 往哪查 |
|---|---|
200 但页面空白 | 请求通了,问题在渲染。看 v-if 条件、看 Console 报错 |
404 | 路径错了,去对着后端路由改 |
500 | 后端炸了,看 dotnet run 那个终端的异常栈 |
(failed) / 红色 | 请求根本没发出去,后端没启动或代理没配对 |
| 请求压根没出现 | onMounted 没跑,Console 里应该有报错 |
还有一个非浏览器的手段:开发时打开 https://localhost:7039/openapi/v1.json endpoints.ts ,这是后端
总结
阶段一完成。目前课程列表出来了,后面把流程走完,抗一下高并发
技术上最有价值的两个认知:
- TS 的类型检查不是自动。
api.get<CourseDto[]>()里的泛型不会自动落到调用方手里,你得显式拆包、显式标注返回类型,类型才会一路传下去。图省事少写那一行,整条链路的类型安全就归零了。 - 前后端的思维冲突 后端思维是数据流经函数,前端是ref跟踪。我有时候脑子还没转过来。
下一篇开始写选课逻辑——那才是这个项目真正的主题。会故意制造超卖 bug,然后压测复现,再一步步上并发控制。前端这边暂时够用了,Pinia 和 vue-router 都先不装,等真有两个页面要共享数据了再说。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时





