mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2258 字
6 分钟
从0到Vue3+c#联调
2026-09-21

vue+c#的前后端联调~#

接着上一篇后端复盘,这次轮到前端。技术栈 Vue3 + TypeScript + Vite,目标是把后端那 6 门课渲染到页面上。这篇主要记录下一些前后端习惯不一致的乌龙,还有就是给小登参考一下

一、前端也有分层,而且能和后端对上#

上一篇我说「代码一定要解耦」。前端可能会习惯性以为「不就发个请求渲染一下吗」,结果真写起来发现请求逻辑一旦散进组件里就收不住了——每个组件里都有baseURL、超时、错误处理。

所以!代码一定要分层!

endpoints.ts 常量:后端有哪些地址
↓
http.ts axios 实例 + 拦截器(横切逻辑,全局一份)
↓
courses.ts service(熟悉不?):一个后端 action = 一个函数
students.ts
↓
App.vue 组件:调用service+渲染

和后端的分层对照一下,几乎是同构的:

后端前端职责
appsettings.jsonapi/endpoints.ts常量配置
Program.cs 的中间件管线api/http.ts横切关注点,全局唯一
CourseServiceapi/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 = 0
count = 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 是同一个思路:先确定类型,再访问字段。


五、踩坑清单#

  1. const response = await ...,前后端习惯不一致,记得用ref跟踪。用pinia用久了基础确实忘了

  2. 路径依赖:getAPIBaseURL():

    return import.meta.env.PROD ? PRODUCTION_API_URL : DEVELOPMENT_API_URL

    这是纳新系统里的一行,主要是为了区分生产环境和开发环境的url路径,但本地自己跑没必要

  3. 路径少个字母 → 404:endpoints.ts 里写成了 /Student/{studentNo}/profile,少个 s。后端是 StudentsController → /api/Students。类型检查查不到这个,纯粗心

  4. TS 类型字段名和后端对不上:types/course.ts 里写的是 description / credits,后端是 Name / Credit。camelCase 序列化后应该是 name / credit。

  5. 依赖装错目录:在仓库根目录 npm install axios,结果 package.json 和 node_modules 落在了根目录,客户端项目里反而没有。表现是 import axios from 'axios' 解析不了。装依赖前先确认自己在哪个目录。

  6. 引用后缀问题:Vue/Vite 生态惯例是无后缀。import时不用加上文件扩展名

  7. 命名拼写:studentsNo → 应该是 studentNo,和后端保持一致。


六、排查流程#

排查的时候不要直接运行。

三个步骤:

顺序命令抓什么成本
1npm run lint没用的变量、漏掉的 await、可疑写法秒级
2npm 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 ,这是后端


总结#

阶段一完成。目前课程列表出来了,后面把流程走完,抗一下高并发

技术上最有价值的两个认知:

  1. TS 的类型检查不是自动。 api.get<CourseDto[]>() 里的泛型不会自动落到调用方手里,你得显式拆包、显式标注返回类型,类型才会一路传下去。图省事少写那一行,整条链路的类型安全就归零了。
  2. 前后端的思维冲突 后端思维是数据流经函数,前端是ref跟踪。我有时候脑子还没转过来。

下一篇开始写选课逻辑——那才是这个项目真正的主题。会故意制造超卖 bug,然后压测复现,再一步步上并发控制。前端这边暂时够用了,Pinia 和 vue-router 都先不装,等真有两个页面要共享数据了再说。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从0到Vue3+c#联调
https://sannohana.top/posts/post_9/
作者
鈴天衣
发布于
2026-09-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录