
1. 从“前后端分离”到“前后端融合”的架构演进如果你和我一样是从传统的 ASP.NET Web Forms 或者早期的 ASP.NET MVC 时代一路走过来的开发者那么对于“前后端分离”这个概念一定经历了从抗拒到拥抱再到如今重新审视的过程。早些年我们习惯于在 Razor 视图里写 C# 代码用Html.TextBoxFor生成表单用foreach循环渲染列表后端控制器Controller不仅负责业务逻辑还通过ViewBag或ViewData把数据“喂”给视图View。这种模式开发效率高上手快但前端交互一旦复杂起来JQuery 满天飞代码就变得难以维护。后来以 React、Vue、Angular 为代表的现代前端框架崛起“前后端分离”成了绝对的政治正确。后端.NET Web API只提供纯净的 JSON 接口前端Vue.js通过 Axios 获取数据并独立渲染整个页面。这种架构清晰、职责分明特别适合大型团队协作。但随之而来的问题也很明显项目复杂度陡增。你需要维护两个独立的项目一个 .NET API一个 Vue SPA部署流程变复杂SEO 不友好虽然 SSR 能解决但成本高而且对于中小型项目或内部管理系统这种“重型分离”有时显得杀鸡用牛刀。于是一种更务实、更灵活的架构模式开始被更多 .NET 开发者采纳在 ASP.NET MVC 项目中集成 Vue.js。这不是简单的“在页面里引入 Vue.js 文件”而是将 Vue 3 的 Composition API、组件化、响应式等先进特性深度融入到 MVC 的页面生命周期中。我们不再把 MVC 的 View 仅仅当作一个静态模板而是将其升级为一个承载 Vue 应用的“容器”或“宿主”。后端 MVC 框架负责服务端路由、基础页面框架、身份认证、权限校验等“粗粒度”控制而页面内复杂的、动态的交互逻辑则完全交给 Vue 3 来处理。这种“融合”架构的好处是显而易见的它保留了 MVC 在服务端渲染、SEO 友好、快速输出首屏内容方面的优势同时又赋予了前端极致的交互体验和开发效率。你可以利用 .NET 强大的后端生态如 Entity Framework, Identity, SignalR又能享受 Vue 3 现代化的开发范式。对于从传统 .NET 技术栈升级而来的团队这种渐进式、低风险的迁移路径尤其友好。接下来我将结合我最近在一个内部数据仪表盘项目中的实战经验详细拆解如何实现 C# MVC 与 Vue 3 的进阶融合。2. 项目环境搭建与工程化配置要实现深度集成第一步就是搭建一个既能跑 .NET 又能顺畅开发 Vue 3 的工程环境。很多人第一步就踩坑直接在wwwroot里写.vue文件然后发现没有热重载、没有类型提示、打包构建一团糟。我们的目标是建立一个虽在同一个解决方案内但前端部分拥有独立、现代化工程能力的架构。2.1 后端项目创建与基础结构首先使用 Visual Studio 2022 或dotnet new命令创建一个标准的 ASP.NET Core MVC 项目。这里我强烈建议选择 .NET 6 或更高版本其对前端生态的支持更好。dotnet new mvc -n MyHybridApp cd MyHybridApp创建完成后你的项目结构大概是这样的MyHybridApp/ ├── Controllers/ ├── Models/ ├── Views/ ├── wwwroot/ └── Program.cs传统的做法是把 Vue 代码扔进wwwroot/js或wwwroot/lib。我们不这样做。我们在项目根目录下新建一个名为ClientApp的文件夹。这个文件夹将作为我们前端 Vue 3 项目的“独立王国”。MyHybridApp/ ├── ClientApp/ -- 前端 Vue 3 项目根目录 ├── Controllers/ ├── Views/ ├── wwwroot/ └── Program.cs2.2 前端工程初始化与关键配置进入ClientApp目录我们使用 Vite 来初始化 Vue 3 项目。Vite 的启动速度和热更新体验远超 Webpack与 .NET 开发的热重载dotnet watch run搭配起来相得益彰。cd ClientApp npm create vuelatest . # 或使用更简洁的 Vite 模板 npm create vitelatest . -- --template vue-ts在初始化过程中选择 TypeScript、Vue Router 和 Pinia状态管理。完成后ClientApp目录下就会生成标准的 Vue 3 项目结构src/,public/,index.html,vite.config.ts等。现在是最关键的一步修改 Vite 的构建输出目录。默认情况下Vite 会打包到dist文件夹。我们需要让它输出到后端项目的wwwroot目录下这样 ASP.NET Core 才能直接提供这些静态文件。打开ClientApp/vite.config.ts进行如下配置import { defineConfig } from vite import vue from vitejs/plugin-vue import { resolve } from path // https://vitejs.dev/config/ export default defineConfig({ plugins: [vue()], resolve: { alias: { : resolve(__dirname, src), }, }, // 核心配置修改构建输出目录 build: { outDir: resolve(__dirname, ../wwwroot), // 输出到上一级的 wwwroot emptyOutDir: true, // 构建前清空输出目录 // 生成资源的命名规则避免缓存问题 rollupOptions: { output: { chunkFileNames: js/[name]-[hash].js, entryFileNames: js/[name]-[hash].js, assetFileNames: assets/[name]-[hash].[ext] } } }, // 开发服务器配置避免与后端 Kestrel 端口冲突 server: { port: 3000, // 前端开发服务器端口 proxy: { // 将所有以 /api 开头的请求代理到后端服务器 /api: { target: http://localhost:5000, // 后端 .NET 运行地址 changeOrigin: true, }, }, }, })这个配置做了几件重要的事build.outDir: 将打包后的文件HTML, JS, CSS直接输出到后端的wwwroot文件夹。这意味着一次npm run build前端产物就自动部署到了后端静态文件目录。server.proxy: 在开发时Vite 服务器运行在3000端口而 .NET Kestrel 运行在5000或5001端口。这个代理配置将所有/api请求转发到后端完美解决了开发时的跨域问题你可以在 Vue 组件里直接使用相对路径/api/values调用接口。emptyOutDir: 确保每次构建时清空wwwroot防止旧文件残留。踩坑提示如果你在wwwroot中有其他静态资源如图片、字体emptyOutDir: true会将其一并删除。稳妥的做法是在wwwroot下建立子目录如wwwroot/dist专门存放 Vue 构建产物然后调整outDir路径和 ASP.NET Core 的静态文件中间件配置。但为了简化很多项目包括我这次直接使用根目录前提是确认wwwroot下没有需要保留的、非前端构建产生的文件。2.3 开发与构建脚本优化为了方便我们在后端项目的根目录MyHybridApp/下也放一个package.json用于统一执行脚本。但更常见的做法是只在ClientApp下管理前端依赖然后通过修改.csproj文件在构建 .NET 项目时自动触发前端构建。在MyHybridApp.csproj文件中添加以下 TargetProject SdkMicrosoft.NET.Sdk.Web ... Target NameBuildVueApp BeforeTargetsBuild Exec Commandnpm install WorkingDirectoryClientApp Condition!Exists(ClientApp/node_modules) / Exec Commandnpm run build WorkingDirectoryClientApp / /Target /Project这样每次在 Visual Studio 中点击“生成”解决方案或者运行dotnet build时都会自动先安装前端依赖如果不存在并执行npm run build。这保证了后端代码和前端产物的同步性。对于开发过程我们需要同时运行两个服务器在ClientApp目录下运行npm run dev启动 Vite 开发服务器端口3000提供热重载。在项目根目录运行dotnet watch run启动 .NET 开发服务器端口5000。你可以使用终端分屏工具或者配置一个launchSettings.json配合一些 VS Code 插件来同时启动。我个人习惯使用一个简单的dev.ps1(PowerShell) 脚本# dev.ps1 Start-Process powershell -ArgumentList -NoExit, -Command, cd ClientApp; npm run dev dotnet watch run3. MVC 视图与 Vue 3 应用的桥接策略环境搭好了接下来要解决核心问题MVC 的视图.cshtml如何加载并启动 Vue 3 应用这里有几种策略从简单到复杂适应不同场景。3.1 策略一单页面容器SPA within MVC这是最常用、最直接的方式。我们将整个网站的主体交互区域交给一个 Vue 应用而 MVC 只负责输出一个“外壳”页面。首先修改Views/Home/Index.cshtml将其内容大幅简化{ ViewData[Title] Home Page; Layout _Layout; // 仍然使用共享的 _Layout.cshtml } !-- 这个 div 是 Vue 应用的挂载点 -- div idapp/div !-- 引入由 Vite 构建生成的入口 JS 文件 -- section Scripts { script typemodule src~/js/main-xxxxx.js/script }对应的在ClientApp/src/main.ts中我们创建并挂载 Vue 应用import { createApp } from vue import { createPinia } from pinia import App from ./App.vue import router from ./router const app createApp(App) app.use(createPinia()) app.use(router) // 挂载到 #app 元素上 app.mount(#app)此时App.vue就是整个单页应用的核心。你可以在里面使用 Vue Router 管理路由整个页面的切换、组件的渲染都由 Vue 控制。MVC 的_Layout.cshtml仍然可以提供网站顶部的导航栏、侧边栏和页脚这些部分可以是静态的或者通过 Razor 语法动态生成例如显示登录用户名。而div idapp中间的区域就是 Vue 应用的天下。这种策略的优势开发体验统一前端完全使用 Vue 3 TypeScript Vite 的现代化开发流。前后端职责清晰后端 API 只提供数据前端负责所有渲染和交互。利于复用如果未来想彻底分离这个ClientApp可以几乎不改动地移植成一个独立的 SPA 项目。需要注意的坑路由冲突Vue Router 通常使用history模式其路由如/dashboard,/user/profile会与 ASP.NET Core 的路由冲突。你需要在后端Program.cs中配置一个 Fallback 路由将所有非 API 且不匹配静态文件的请求都重定向到Home/Index由这个“外壳”页面来接管。app.MapControllerRoute( name: default, pattern: {controllerHome}/{actionIndex}/{id?}); // 在 MapControllers() 之后MapFallbackToFile 之前 // 确保 API 路由和其他静态文件中间件先被处理 app.MapFallbackToController(Index, Home);状态同步如果顶部导航栏Razor 渲染需要根据 Vue 应用内的状态变化如购物车数量而更新就需要一些跨框架的状态同步机制。一个简单的方法是使用全局事件window.dispatchEvent或者将状态存储在localStorage中。3.2 策略二多页面组件化嵌入MPA Vue Components这种策略更适合传统的多页面应用MPA每个 MVC 的 View 都是一个独立的页面但每个页面内又有一个或多个复杂的、交互丰富的区域我们用 Vue 组件来实现。假设我们有一个产品列表页Views/Product/Index.cshtml列表本身用 Razor 渲染但有一个复杂的“价格筛选器”组件我们希望用 Vue 实现。首先在ClientApp/src/components下创建一个 Vue 组件PriceFilter.vue。!-- ClientApp/src/components/PriceFilter.vue -- template div classprice-filter input typerange v-modelpriceRange[0] :minmin :maxmax / input typerange v-modelpriceRange[1] :minmin :maxmax / span价格区间: {{ priceRange[0] }} - {{ priceRange[1] }}/span button clickapplyFilter筛选/button /div /template script setup langts import { ref } from vue; const props defineProps{ min: number; max: number }(); const emit defineEmits{ (e: filter, min: number, max: number): void }(); const priceRange ref([props.min, props.max]); const applyFilter () { emit(filter, priceRange.value[0], priceRange.value[1]); }; /script然后我们不能像 SPA 那样在main.ts里全局挂载。我们需要为每个需要嵌入 Vue 组件的页面独立地创建和挂载一个微型 Vue 应用。在ClientApp/src下创建一个entry文件夹为产品列表页创建一个入口文件product-index.ts// ClientApp/src/entry/product-index.ts import { createApp } from vue import PriceFilter from ../components/PriceFilter.vue // 查找页面上所有具有特定>// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import { resolve } from path export default defineConfig({ // ... 其他配置同上 ... build: { outDir: resolve(__dirname, ../wwwroot), emptyOutDir: true, rollupOptions: { // 多入口配置 input: { main: resolve(__dirname, index.html), // 主 SPA 入口如果还用的话 product-index: resolve(__dirname, src/entry/product-index.ts), // 可以添加更多入口如 user-profile: resolve(__dirname, src/entry/user-profile.ts), }, output: { // 根据入口名称生成对应的 JS 文件 entryFileNames: js/[name]-[hash].js, chunkFileNames: js/[name]-[hash].js, assetFileNames: assets/[name]-[hash].[ext] } } }, })最后在 MVC 的 Razor 视图Product/Index.cshtml中我们嵌入这个组件model ListProduct { ViewData[Title] 产品列表; } !-- Razor 渲染产品列表 -- ul foreach(var p in Model) { lip.Name - p.Price/li } /ul !-- 嵌入 Vue 价格筛选器组件 -- div>// Controllers/Api/ProductsController.cs using Microsoft.AspNetCore.Mvc; using MyHybridApp.Models; namespace MyHybridApp.Controllers.Api { [Route(api/[controller])] [ApiController] // 使用 ApiController 特性启用自动模型验证等特性 public class ProductsController : ControllerBase { private readonly IProductRepository _repository; public ProductsController(IProductRepository repository) { _repository repository; } // GET: api/products [HttpGet] public async TaskActionResultIEnumerableProductDto GetProducts([FromQuery] ProductQueryParameters queryParams) { var products await _repository.GetProductsAsync(queryParams); var productDtos products.Select(p new ProductDto(p)); return Ok(productDtos); // 返回 200 OK 和 JSON 数据 } // GET: api/products/5 [HttpGet({id})] public async TaskActionResultProductDto GetProduct(int id) { var product await _repository.GetProductByIdAsync(id); if (product null) { return NotFound(); // 返回 404 } return new ProductDto(product); } // POST: api/products [HttpPost] public async TaskActionResultProductDto PostProduct([FromBody] CreateProductDto createDto) { // ApiController 特性会自动进行 ModelState.IsValid 检查 var product createDto.ToEntity(); await _repository.AddProductAsync(product); // 返回 201 Created并在 Location 头部包含新资源的 URI return CreatedAtAction(nameof(GetProduct), new { id product.Id }, new ProductDto(product)); } // 其他 PUT, DELETE 方法... } }注意这里使用了ProductDto和CreateProductDto这样的数据传输对象而不是直接暴露领域模型Product。这是 API 设计的最佳实践可以控制输出/输入的字段避免过度暴露或循环引用等问题。4.2 前端请求层封装 Axios 与错误处理在前端ClientApp中我们使用 Axios 进行 HTTP 请求。一个好的实践是创建一个统一的请求实例并配置拦截器来处理通用逻辑。// ClientApp/src/utils/request.ts import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from axios; import { useUserStore } from /stores/user; // 假设使用 Pinia 管理用户状态 import { ElMessage } from element-plus; // 假设使用 Element Plus 做UI // 创建 axios 实例 const service: AxiosInstance axios.create({ baseURL: import.meta.env.VITE_APP_API_BASE_URL || /api, // 从环境变量读取 timeout: 10000, }); // 请求拦截器 service.interceptors.request.use( (config: AxiosRequestConfig) { const userStore useUserStore(); // 如果存在 token将其添加到请求头 if (userStore.token) { config.headers config.headers || {}; config.headers.Authorization Bearer ${userStore.token}; } return config; }, (error: AxiosError) { console.error(Request Error:, error); return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( (response: AxiosResponse) { // 如果后端有统一的响应格式如 { code: 200, data: {}, message: success } const res response.data; if (res.code res.code ! 200) { // 业务逻辑错误 ElMessage.error(res.message || Error); return Promise.reject(new Error(res.message || Error)); } else { // 返回真正的数据 return res.data || res; } }, (error: AxiosError) { // HTTP 状态码错误处理 if (error.response) { switch (error.response.status) { case 401: // 未授权跳转到登录页 ElMessage.error(用户未认证或登录已过期); const userStore useUserStore(); userStore.logout(); window.location.href /Account/Login; // 跳转到 MVC 的登录页 break; case 403: ElMessage.error(权限不足); break; case 404: ElMessage.error(请求的资源不存在); break; case 500: ElMessage.error(服务器内部错误); break; default: ElMessage.error(请求错误: ${error.response.status}); } } else if (error.request) { // 请求发出但没有收到响应 ElMessage.error(网络错误请检查网络连接); } else { // 请求配置出错 ElMessage.error(请求配置错误); } return Promise.reject(error); } ); export default service;然后在具体的业务模块中我们可以封装 API 调用// ClientApp/src/api/product.ts import request from /utils/request; export interface ProductQueryParams { page?: number; pageSize?: number; keyword?: string; minPrice?: number; maxPrice?: number; } export interface ProductDto { id: number; name: string; price: number; description?: string; } export function getProducts(params: ProductQueryParams) { return request.getProductDto[](/products, { params }); } export function createProduct(data: PartialProductDto) { return request.postProductDto(/products, data); }4.3 状态管理Pinia 与后端状态的同步对于复杂应用前端状态管理必不可少。Vue 3 生态首推 Pinia。我们需要考虑的是哪些状态应该放在前端管理哪些需要与后端同步。场景一全局用户状态用户登录信息、权限列表等在登录后从后端获取一次存入 Pinia Store并持久化到localStorage或sessionStorage。整个 Vue 应用的生命周期内都可以访问。// ClientApp/src/stores/user.ts import { defineStore } from pinia import { ref, computed } from vue import { login as apiLogin, getUserInfo } from /api/auth export const useUserStore defineStore(user, () { const token ref(localStorage.getItem(token) || ) const userInfo refany(null) const isLoggedIn computed(() !!token.value) function setToken(newToken: string) { token.value newToken localStorage.setItem(token, newToken) } function clearToken() { token.value localStorage.removeItem(token) userInfo.value null } async function login(credentials: { username: string; password: string }) { const res await apiLogin(credentials) setToken(res.token) // 获取用户详情 await fetchUserInfo() } async function fetchUserInfo() { if (token.value) { userInfo.value await getUserInfo() } } function logout() { clearToken() // 可以调用后端退出接口 window.location.href /Account/Logout // 跳转到 MVC 的退出动作 } return { token, userInfo, isLoggedIn, login, logout, fetchUserInfo, } })场景二页面级数据状态比如产品列表数据。通常我们在组件内使用useFetch来自 VueUse或直接在onMounted中调用 API 获取。对于需要在多个组件间共享的页面状态可以创建一个专门的 Store。// ClientApp/src/stores/product.ts import { defineStore } from pinia import { ref } from vue import { getProducts, ProductQueryParams, ProductDto } from /api/product export const useProductStore defineStore(product, () { const productList refProductDto[]([]) const loading ref(false) const error refstring | null(null) const totalCount ref(0) async function fetchProducts(params: ProductQueryParams) { loading.value true error.value null try { const res await getProducts(params) // 假设后端返回 { items: [], totalCount: 100 } productList.value res.items totalCount.value res.totalCount } catch (err: any) { error.value err.message || 获取产品列表失败 productList.value [] totalCount.value 0 } finally { loading.value false } } return { productList, loading, error, totalCount, fetchProducts, } })在组件中使用!-- ClientApp/src/views/ProductListView.vue -- template div div v-ifloading加载中.../div div v-else-iferror{{ error }}/div ul v-else li v-forproduct in productList :keyproduct.id {{ product.name }} - {{ product.price }} /li /ul button clickloadMore加载更多/button /div /template script setup langts import { storeToRefs } from pinia; import { useProductStore } from /stores/product; import { onMounted } from vue; const productStore useProductStore(); const { productList, loading, error, totalCount } storeToRefs(productStore); const queryParams ref({ page: 1, pageSize: 20 }); onMounted(() { productStore.fetchProducts(queryParams.value); }); const loadMore () { queryParams.value.page; productStore.fetchProducts(queryParams.value); }; /script5. 部署、优化与常见问题排查当开发完成进入部署阶段时这种融合架构需要一些特别的处理。5.1 部署流程与 CI/CD 集成我们的目标是一次构建同时产出包含前端资源的后端可部署包。构建前端在 CI/CD 流水线如 GitHub Actions, Azure DevOps或本地运行cd ClientApp npm ci npm run build。这会将优化后的静态文件输出到wwwroot目录。构建后端运行dotnet publish -c Release -o ./publish。publish命令会自动包含wwwroot下的所有文件。部署产物将publish文件夹下的所有内容拷贝到服务器如 IIS, Linux with Kestrel, Azure App Service即可。关键点在于要确保前端构建先于后端发布执行。在.csproj中配置的BeforeTargetsBuild在dotnet publish时同样有效。但更稳妥的 CI/CD 脚本是显式地执行这两个步骤。# GitHub Actions 示例片段 jobs: build-and-deploy: steps: - uses: actions/checkoutv3 - name: Setup Node.js uses: actions/setup-nodev3 with: node-version: 18 - name: Install frontend dependencies run: npm ci working-directory: ./ClientApp - name: Build frontend run: npm run build working-directory: ./ClientApp env: VITE_APP_API_BASE_URL: ${{ secrets.PROD_API_BASE_URL }} - name: Setup .NET uses: actions/setup-dotnetv3 with: dotnet-version: 8.x - name: Restore backend dependencies run: dotnet restore - name: Build and publish backend run: dotnet publish -c Release -o ./publish - name: Deploy to Azure Web App # ... 部署步骤 ...5.2 性能优化要点前端代码分割Code Splitting在 SPA 模式下利用 Vue Router 的懒加载和 Vite 的动态导入自动分割代码。// router/index.ts const routes [ { path: /dashboard, component: () import(/views/Dashboard.vue) // 懒加载 }, { path: /user/:id, component: () import(/views/UserDetail.vue) } ]后端输出缓存对于 MVC 框架输出的“外壳”页面如Home/Index如果内容基本静态可以使用[ResponseCache]特性进行缓存减少服务器压力。[ResponseCache(Duration 3600)] // 缓存1小时 public IActionResult Index() { return View(); }静态资源缓存与版本控制Vite 构建生成的 JS/CSS 文件带有哈希值完美解决了缓存更新问题。确保服务器如 IIS, Nginx为静态文件设置合适的缓存头如Cache-Control: public, max-age31536000用于哈希文件。5.3 常见问题与排查指南问题1前端路由刷新后显示 404在 IIS 或生产环境原因Vue Router 使用history模式其路由路径如/dashboard是前端虚拟的。当用户直接访问或刷新该 URL 时请求会发送到服务器服务器找不到对应的 MVC 控制器或静态文件。解决必须在服务器上配置 URL 重写将所有非文件和非 API 的请求重定向到Home/Index或你的 SPA 外壳页面。IIS安装 URL Rewrite 模块在web.config中添加规则。Nginx在配置中添加try_files $uri $uri/ /index.html;。Kestrel (Program.cs)我们已经通过MapFallbackToController或MapFallbackToFile处理了。问题2开发时热更新HMR不工作原因Vite 的 HMR 依赖于 WebSocket 连接可能被代理或防火墙阻止。解决确保vite.config.ts中的server.hmr配置正确通常默认即可。检查浏览器控制台是否有 WebSocket 连接错误。如果使用自定义代理确保其支持 WebSocket 代理。问题3构建后页面引用 JS/CSS 文件路径错误原因Vite 构建输出的资源路径与 MVC 视图中的引用路径不匹配。解决检查vite.config.ts中的base配置。如果你的应用部署在子路径如https://example.com/myapp需要设置base: /myapp/。在 Razor 视图中使用~/前缀会被 ASP.NET Core 的 Tag Helper 解析为应用根路径或Url.Content(~/)。确保_Layout.cshtml或_ViewStart.cshtml中正确设置了base href~/ /标签对于 SPA 模式很重要。问题4VS Code 或 Visual Studio 对.vue文件没有智能提示解决安装 Volar 扩展VS Code或 Vue.js 工具包Visual Studio。在ClientApp根目录创建env.d.ts文件声明.vue模块类型。// env.d.ts declare module *.vue { import type { DefineComponent } from vue const component: DefineComponent{}, {}, any export default component }确保tsconfig.json中包含了vue类型。问题5如何共享后端模型类型到前端这是一个高级需求可以确保前后端数据类型一致。有几种方案手动同步前后端各自定义 DTO手动保持同步。简单但容易出错。使用 NSwag 或 Swashbuckle后端通过 Swagger/OpenAPI 生成 API 文档然后使用nswag或openapi-generator工具根据这个文档自动生成前端的 TypeScript 接口和 API 客户端代码。这是最推荐的方式。共享项目创建一个.NET Standard类库项目定义公共的 DTO 和枚举。然后使用TypeScriptBuilder之类的工具将 C# 类编译成 TypeScript 接口。这种方式耦合较紧但类型安全级别最高。我个人在项目中更倾向于第二种NSwag它在保证类型安全的同时保持了前后端的松耦合。你只需要在后端给 API 添加清晰的 XML 注释NSwag 就能生成非常友好的前端客户端代码大大提升了开发效率。融合架构的精髓在于“因地制宜”它不是要取代纯粹的前后端分离而是在特定场景下尤其是需要兼顾开发效率、SEO、渐进式迁移和 .NET 全栈优势时提供的一个更优解。希望这篇从环境搭建到实战部署的长文能为你实践 C# MVC 与 Vue 3 的进阶融合提供一份可靠的路线图。