资讯动态

Vue 3 + TypeScript 全栈实战:从组合式 API 到前后端部署

发布时间:2026/10/15 7:22:37 来源:尧图企业网站定制
最近这一两年我身边的开发者在规划新专案时几乎都会先问一句要不要用 Vue 3问的人多了我渐渐发现答案其实不是“新框架比较潮”而是生态已经成熟到可以认真地把 TypeScript 当作团队协作的安全网而不是事后的补丁。我自己从 Vue 2 的经验型开发转到 Vue 3 组合式 API 那阵子确实踩过不少砂多半不是功能写不出来而是类型推不太动、全栈之间契约不一致到了联调阶段才被迫回头改数据结构。所以这次我想用一套完整可复现的全栈项目实录把 Vue 3、TypeScript、后端 API、状态管理、跨域、构建部署一次讲透。用到的案例是一个很常见的“任务管理工具”前端负责列表展示和状态编辑后端负责提供资料并写入 SQLite 数据库。整篇重点不是某个炫技特效而是“一套代码从头到尾能跑通、能打字检查、能部署上线”的完整经验。不管你是正在做毕业设计还是团队内要起一个内部管理系统又或者是单纯想把自学路线走完这篇应该都能给你省下不少查资料的时间。1. 技术选型背后的真实考量为什么是 Vue 3 TypeScript1.1 真正让 Vue 3 脱胎换骨的是组合式 API很多朋友第一次接触 Vue 3最先注意到的是setup这个新入口然后会问这不就是把 data、methods、computed 全塞进一个函数吗表面看确实如此但组合式 API 的价值并不在于“写法变短”而是让逻辑的复用单元从组件变成函数。以前在 Vue 2 里要把一段“下拉选单的远程搜索”逻辑搬到另一个组件你得抽 mixin 或高阶组件mixin 的来源和覆盖关系一多根本说不清某个 data 到底是从哪里来的。换成 Vue 3 的 composition function我通常把相关状态、请求、事件处理全部收在一个useSearch.ts里组件里用多少就取多少可读性立刻不一样。更关键的是组合式 API 在 TypeScript 推导上比旧版 options 写法友好很多。因为setup里你写的就是普通函数、普通变量TypeScript 不需要通过“魔法字符串”去推断this.$data.someValue的类型。尤其在使用ref、computed、reactive时类型几乎可以一路自动推导不需要到处写断言。1.2 全栈不是“前端代码 后端代码”的简单相加我刚接触全栈项目时有个很深的误区以为前端会发请求、后端能开个接口就算全栈了。真实做过一轮之后才发现全栈真正难的是前后端之间的契约要能在同一个上下文里被同时维护。前端用一个Task介面后端也有一份Taskmodel两边的字段顺序、是否可空、时间戳格式只要稍微不一致编译阶段完全不会报错跑起来后却会出现undefined满天飞。这也是我坚持“前后端共用一套 DTO 类型定义”的原因。实践上我会建立一个shared/types目录前端直接 import后端也直接 import。如果使用 npm workspace 或者 monorepo 结构这件事非常顺手如果只是一个小专案也可以在前后端各放一份同名的types.ts但要保证两边由同一个人或同一个约定来同步。1.3 为什么是 Vite、Pinia、Vue Router 这组搭配在做选型时我刻意没有引入太重的东西像大型微前端框架、GraphQL或者后端微服务。对一个 Vue 3 全栈实战来说刚需其实只有Vite开发服务器启动快热更新几乎无感构建产物也够精简。PiniaVue 官方生态里的状态管理方案没有 Vuex 那么多样板代码且对 TypeScript 推导非常自然。Vue Router单页应用路由的标准选择配合动态 import 做路由级代码拆分。Express better-sqlite3作为全栈后端够简单不需要额外开数据库服务很适合教学和内部工具。提示如果你的专案已经预期会变成大型商业专案后端可以换成 NestJS或者用 Prisma 管理数据库。但作为一次“把全链路跑通”的实战这套组合的维护成本最低踩坑时也最容易定位原因。2. 从空白目录开始搭骨架2.1 先初始化前端工程我习惯先创建前端目录再创建后端目录因为前端有一些预设模板能帮我们省掉很多手动配置。直接在终端执行npm create vitelatest task-manager -- --template vue-ts这个模板会生成一个带tsconfig.json、vite.config.ts、src目录的基础工程。接着安装日常开发会用到的主要依赖cd task-manager npm install vue-router4 pinia axios npm install -D eslint prettier这里的vue-ts模板很重要它默认开启了vue-tsc作为类型检查工具在build脚本里会先执行vue-tsc --noEmit再做打包。很多新手觉得这步很烦但正是它保证了只要npm run build能过组件里的 props 类型、emit 事件类型、store 取用类型就基本没有明显问题。2.2 后端服务初始化与数据层设计后端我选择用一个相对干净的 Express TypeScript 结构。先建立server目录再初始化mkdir server cd server npm init -y npm install express cors better-sqlite3 npm install -D typescript tsx types/express types/cors types/better-sqlite3在package.json里设定开发与编译脚本{ scripts: { dev: tsx watch src/index.ts, build: tsc -p tsconfig.json, start: node dist/index.js } }在src/db.ts中用 better-sqlite3 建一张最简单的tasks表import Database from better-sqlite3; const db new Database(dev.db); db.exec( CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); ); export interface TaskRow { id: number; title: string; done: number; created_at: string; } export const getAllTasks (): TaskRow[] { return db.prepare(SELECT * FROM tasks ORDER BY id DESC).all() as TaskRow[]; }; export const createTask (title: string): TaskRow { const createdAt new Date().toISOString(); const result db .prepare(INSERT INTO tasks (title, created_at) VALUES (?, ?)) .run(title, createdAt); return db .prepare(SELECT * FROM tasks WHERE id ?) .get(result.lastInsertRowid) as TaskRow; };注意这里的done用整数 0 / 1 来表示布尔值主要是因为 SQLite 没有原生布尔型。后端取出资料后记得统一转换成前端比较自然的done: boolean不要把这个转换义务丢给前端组件去处理。2.3 前端、后端目录结构长什么样我最终采用的目录结构大致如下拆得不复杂但每个目录责任都很清楚task-manager/ ├── server/ │ ├── src/ │ │ ├── index.ts │ │ ├── db.ts │ │ └── routes/ │ │ └── taskRoutes.ts ├── src/ │ ├── api/ │ │ ├── client.ts │ │ └── taskApi.ts │ ├── types/ │ │ └── task.ts │ ├── stores/ │ │ └── taskStore.ts │ ├── views/ │ │ ├── TaskList.vue │ │ └── TaskDetail.vue │ ├── App.vue │ └── main.ts ├── vite.config.ts └── tsconfig.json这样的好处是后端路由只负责 HTTP 处理和参数校验数据库操作集中在db.ts前端所有的 API 调用集中在src/api状态变更集中在stores组件里尽量只保留“展示和交互”的动作。等专案规模再变大一点这个结构也能顺势扩成 modules 或 features 维度不需要推翻重来。3. 前端核心细节组件类型、泛型与可维护状态3.1 用 defineProps 和 defineEmits 把组件边界讲清楚Vue 3 的script setup加上defineProps和defineEmits是我觉得类型体验提升最明显的地方。以前父组件传参传错类型要等执行到某个判断才会报错现在只要子组件声明了 props 型别编辑器会在父组件使用时就划线提示。我会把一个任务项组件写成这样script setup langts import type { Task } from ../types/task; const props defineProps{ task: Task; }(); /script template div classtask-item input typecheckbox :checkedprops.task.done change$emit(toggle, props.task.id) / span{{ props.task.title }}/span button click$emit(delete, props.task.id)删除/button /div /template如果要更严谨我会把 emit 事件也定义成具名的类型映射const emit defineEmits{ toggle: [id: number]; delete: [id: number]; }();这样一个组件对外暴露的接口就非常明确父组件必须给task子组件只会发出toggle和delete事件且参数都是数字 ID。哪怕这个组件被六个页面复用也不会因为“不知道事件里到底传了什么”而反复翻代码。3.2 别急着写 any几个缩小类型的实用手法全栈项目里最容易出现any的地方就是 API 响应。明明后端回传的是Task[]因为前端没有声明就被推断成any一路污染到 store 和模板。我在团队里立过一个很简单的规矩没有类型的数据不允许直接分配到 state 上。如果需要处理未知结构优先用unknown配合类型守卫const isTask (value: unknown): value is Task { if (!value || typeof value ! object) return false; const candidate value as Recordstring, unknown; return ( typeof candidate.id number typeof candidate.title string typeof candidate.done boolean ); };这样在后端回传格式还没完全稳定时前端至少能在入口处挡住明显不合法的资料而不是让脏数据进入组件树底层。有些朋友觉得类型守卫写起来繁琐但它其实是对运行时安全的低成本保险。3.3 用 Pinia 组织共享状态类型推导不用反复 importPinia 与 TypeScript 的组合非常自然。定义 store 时不需要额外写一堆类型 namespace因为 store 本身就是一个对象state、getters、actions的类型通通可以由 TS 推导。import { defineStore } from pinia; import { ref } from vue; import type { Task } from ../types/task; import { fetchTasks, createTask } from ../api/taskApi; export const useTaskStore defineStore(task, () { const tasks refTask[]([]); const loading ref(false); const loadTasks async () { loading.value true; try { tasks.value await fetchTasks(); } finally { loading.value false; } }; const addTask async (title: string) { const created await createTask(title); tasks.value.unshift(created); }; return { tasks, loading, loadTasks, addTask }; });在组件里使用时页面不用手动维护一份与 store 并行的类型副本store.tasks拿到的就是Task[]表格列、卡片状态的绑定都跟着类型走很大程度上降低了改字段时的连锁遗漏。4. 串起一条完整数据流4.1 统一 Task 资料模型与前后端转换要串起数据流第一步是让前后端对“一份 Task 长什么样”有共识。我在前端建了这样的型别export interface Task { id: number; title: string; done: boolean; createdAt: string; }后端从 SQLite 取出来的是TaskRow里面是done: number和created_atnaming。如果直接把TaskRow丢给前端组件里满脑子都要记得“done可能是 0/1、created_at要自己转”。所以我刻意在 API 层做一次转换后端回传时就已经是前端需要的形状const toTaskDTO (row: TaskRow): TaskDTO ({ id: row.id, title: row.title, done: row.done 1, createdAt: row.created_at, });这个转换在刚开始看起来多此一举但等到接第三方 client、做数据报表、或换数据库时你会发现“实体模型”和“传输模型”分开是让后端不至于被外部需求绑死的关键。4.2 请求封装与统一错误处理全栈项目一旦要处理登录态、错误提示、超时重试就会发现每个页面各自写 axios 非常危险。我把请求集中在一个client.tsimport axios from axios; export const client axios.create({ baseURL: /api, timeout: 10000, }); client.interceptors.response.use( (response) response.data, (error) { const message error?.response?.data?.message ?? 伺服器目前无法处理请求请稍后再试; console.error(message); return Promise.reject(new Error(message)); } );在上面的拦截器里我直接让成功响应的返回值变成data这样 API 函数不需要每写一个都拆一层response.data代码会清爽很多。错误处理统一在拦截器出口拦截避免每个页面重复写try/catch的样板。4.3 实际数据流从页面表单到列表刷新在TaskList.vue里整个资料流是这样走的页面挂载时调用store.loadTasks()。loadTasks触发taskApi.fetchTasks()由client送出 GET 请求。Node.js 后端收到/api/tasks呼叫db.ts的查询方法。查询结果经过 DTO 转换回传 JSON。前端拦截器回传Task[]存入 Pinia 的tasks。模板用v-for渲染TaskItem组件。用户勾选后触发toggle事件组件再把状态写入后端最后更新本地 store。附上一段页面层的核心逻辑const store useTaskStore(); const newTitle ref(); const handleAdd async () { const title newTitle.value.trim(); if (!title) return; await store.addTask(title); newTitle.value ; }; onMounted(() { void store.loadTasks(); });实际在浏览器里跑一遍时我通常会打开“网络面板”确认每个请求的状态码、payload、response 是否符合预期。这一招看起来简单却是排查全栈问题时最高效的手段比在代码里console.log堆一堆还强。5. 问题排查与性能调优备忘5.1 常见问题速查表我把自己在全栈实战和带新人时最常遇见的几个问题整理成一张表现象可能原因检查方向前端请求一直 404Vite 代理没配好或后端路由路径不一致查看vite.config.ts中/api是否被代理到后端端口接口回传能用但 TypeScript 一直报undefined后端字段是created_at前端型别写createdAt在 DTO 转换处统一字段命名done有时是 boolean 有时是 0/1后端没有统一转换边界检查数据库 row 到 API response 的 mapperbuild 成功但浏览器白屏路由模式用了 history但伺服器没做 SPA fallbackNginx 配置try_files指向index.html页面能跑但类型检查爆红模板里直接使用了未宣告的变量检查script setup中是否遗漏 import跨域能通但带不上 cookiecors未设置origin为具体来源且未开启 credentials开发环境下优先使用代理而非硬开 CORS5.2 构建体积与首屏性能优化任务管理工具不大但如果以后要继续扩展最值得做的是路由级拆包。现在的TaskList.vue和TaskDetail.vue完全可以等用户真正进入时才下载const routes [ { path: /, component: () import(../views/TaskList.vue), }, { path: /task/:id, component: () import(../views/TaskDetail.vue), }, ];这样打包后会产生多个 chunk首屏只加载首页必要的内容而不是把整个应用一次塞进bundle.js。另一个实用操作是使用rollup-plugin-visualizer查看各个依赖的体积分布很多时候你发现某个“顺手装的工具库”其实占了一大半体积换掉它收益非常明显。5.3 部署时一定要知道的几个环节部署并不神秘但有几个细节非常容易漏.env环境变量不要提交到 Git。前端变量用VITE_前缀才会被打包器读取。如果前后端都部署在同一台伺服器最省事的方式是让 Nginx 托管 Vue 的dist并把/api反向代理到 Node 服务。后端生产模式下记得用tsc编译后再启动而不是继续依赖tsx减少运行时的不确定性。一份可用的 Nginx 关键配置大概长这样location / { root /var/www/task-manager/dist; try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }提示如果部署后刷新页面出现 404几乎都是try_files没写好如果刷新后页面正常但接口怪怪的先看proxy_pass是否有斜杠尾随写法不同会影响 URL 路径拼接。6. 我个人在实操中的一些习惯总结整套实战跑下来我心里最深的体会是所谓全栈真正的加分项不是“会写前端也会写后端”而是能用一条清晰的数据契约把所有环节串起来让每一层都只需要处理自己该处理的事。前端组件不要到处发请求后端路由不要塞一堆业务逻辑数据库表格的命名要稳定这一套纪律比某个框架的冷知识重要得多。另外我强烈建议在做任何全栈专案时都先花一小段时间把前后端的类型结构定义出来哪怕后面会改也要先定义。这一步不会让编码变慢反而能让你在开发过程中减少大量“刚联调就卡住”的窘境。对于刚接触 Vue 3、TypeScript 的朋友我的建议是先把这个任务管理工具完整跑一遍改一改 UI、加一两个字段再尝试增加登录和权限逻辑。这样循序渐进地做下去你对整个项目掌控感会越来越强不会只是“别人写好的模板我们能启动”而已。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑