Vue3 全栈实战第九周:Node.js + Express 后端从零搭建实战记录
「Vue前端转全栈实战」系列第十一篇,第二阶段(全栈项目)的第一周。第一阶段八周搭好的
mail-client前端项目,这周开始接入真正的后端------不是新起一个项目,就是给同一个mail-client装上一个真实存在的服务端。这周内容:Node.js + Express 基础、CORS 跨域联调、邮件列表接口、把mailStore.ts接到真实后端、补测试。
目录
- 本周项目结构
- [Day1:Node.js + Express 基础](#Day1:Node.js + Express 基础 "#day1nodejs--express-%E5%9F%BA%E7%A1%80")
- [Day2:CORS 与前后端联调](#Day2:CORS 与前后端联调 "#day2cors-%E4%B8%8E%E5%89%8D%E5%90%8E%E7%AB%AF%E8%81%94%E8%B0%83")
- Day3:邮件列表接口
- [Day4:把 mailStore.ts 接到真实后端](#Day4:把 mailStore.ts 接到真实后端 "#day4%E6%8A%8A-mailstorets-%E6%8E%A5%E5%88%B0%E7%9C%9F%E5%AE%9E%E5%90%8E%E7%AB%AF")
- [Day5:整合优化 + 补测试](#Day5:整合优化 + 补测试 "#day5%E6%95%B4%E5%90%88%E4%BC%98%E5%8C%96--%E8%A1%A5%E6%B5%8B%E8%AF%95")
- 前后端"路由"概念对比
- 本周总结
- 下周计划
本周项目结构
后端是一个和 mail-client 完全独立的新项目,两者平级:
scss
projects/
mail-client/ ← 前端项目(第1-8周)
mail-server/ ← 新增:后端项目
src/
app.ts ← 新增:构建 Express 应用实例
app.test.ts ← 新增:接口测试,4 条用例
index.ts ← 新增:只负责启动服务
data/
mails.ts ← 新增:内存模拟邮件数据
package.json
tsconfig.json
mail-client/src/api/
mailApi.ts ← 修改:getList/getById 增加 createdAt 类型转换
mail-client/src/stores/
mailStore.ts ← 修改:fetchMails 改为调用 mailApi.getList()
Day1:Node.js + Express 基础
为什么需要这两个东西
浏览器里的 JavaScript 出于安全限制,不能读写文件、不能开一个网络服务监听端口。Node.js 是一个能在浏览器之外运行 JavaScript 的环境 ,让 JS 拥有了这些能力。Express 是建立在 Node.js 之上的一层封装,把"怎么解析请求、怎么处理不同路径的请求"这些繁琐细节包装成了简单好用的 API。
项目初始化
后端和前端是完全独立的项目,不要塞进 mail-client 内部,建一个平级目录:
bash
mkdir mail-server
cd mail-server
pnpm init
pnpm add express
pnpm add -D typescript @types/express @types/node tsx
tsx 是这几个包里比较关键的一个:Node.js 原生不认识 TypeScript,正常流程要先 tsc 编译成 .js 再运行,tsx 把这两步合并成一步,运行时现场把 TS 转换成 JS 执行,tsx watch 还带文件改动自动重启的能力。
踩坑:pnpm init 默认生成的 package.json 带 "type": "module" ,这意味着项目走的是 ES Module(import/export),不是传统 Node.js 教程里常见的 CommonJS(require/module.exports)。这会影响 tsconfig.json 的配置:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
module 要写成 "ES2022" 而不是 "commonjs",才能匹配 "type": "module"。这个坑值得记住的原因是:Express 生态大量老教程默认按 CommonJS 风格写(require(...)),照抄到 ES Module 项目里会报错,遇到时要知道是"模块系统不匹配",不是代码本身写错了。
另一个安装期的提示:
csharp
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.28.2
tsx 依赖的 esbuild 需要跑一个安装后脚本(下载对应系统的二进制文件),pnpm 新版本默认不自动运行依赖包的安装脚本,需要手动运行 pnpm approve-builds,用空格选中 esbuild、回车确认,再输入 y 批准执行。
第一个接口:健康检查
typescript
// src/index.ts
import express from 'express'
import type { Request, Response } from 'express'
const app = express()
const PORT = 3000
/**
* 功能:健康检查接口
* 场景:验证服务是否正常运行,部署时常用这个接口做存活检测
*/
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.listen(PORT, () => {
console.log(`[server] 服务已启动,监听端口 ${PORT}`)
})
package.json 加脚本:"dev": "tsx watch src/index.ts",跑 pnpm dev,访问 http://localhost:3000/health 能看到 JSON 返回就算跑通。
知识点:app.listen、路由处理函数、tsx watch
app.listen(3000) 让服务开始"守在"3000 端口等待请求,这行代码执行后进程不会退出。app.get('/health', (req, res) => {...}) 里,req(request)代表收到的请求,res(response)代表要发回去的响应------这是和前端不一样的思维方式:前端写的是"用户点击了按钮要做什么",后端写的是"收到了一个请求要回应什么"。
tsx watch 的热更新和前端 Vite 的热更新原理不同:前端是"局部替换正在运行的模块",后端是"整个进程重启一遍"(服务端代码没有"页面"概念可以局部刷新)。
给服务写一个 /health 接口是后端工程的通用惯例------部署上线后,监控系统会定期请求这个接口判断服务是否存活。
Day2:CORS 与前后端联调
为什么会有跨域问题
mail-client 跑在 localhost:5173,mail-server 跑在 localhost:3000------端口不同就算不同的"源",浏览器同源策略会默认拦截前端 JS 向不同源发起的请求。
修改文件:src/app.ts(mail-server)
typescript
import express from 'express'
import cors from 'cors'
import type { Request, Response } from 'express'
const app = express()
/**
* 功能:允许跨域请求
* 场景:mail-client(localhost:5173)需要请求这个服务(localhost:3000)
* 开发阶段先允许所有来源,生产环境部署时会收紧
*/
app.use(cors())
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
export default app
bash
pnpm add cors
pnpm add -D @types/cors
中间件顺序很重要 :cors() 必须注册在具体路由之前,中间件按注册顺序依次执行,写在路由后面可能导致请求已经处理完、CORS 头还没加上。
修改文件:mail-client 的 .env
ini
VITE_API_BASE_URL=http://localhost:3000
第二周就配置好的环境变量,之前一直指向模拟数据,现在改成指向真实运行的 mail-server。
验证:两个真实系统第一次串联
两个服务同时跑起来,mail-client 页面里临时发一次请求,Console 应该能打印出后端的响应。顺带验证第七周做的错误处理 :故意关掉 mail-server,刷新页面,应该能看到全局提示弹出"网络连接异常,请检查网络"------这是第七周的错误处理链路第一次在真实网络环境下被验证,之前只在单元测试里用手写的假错误对象测过。
Day3:邮件列表接口
内存模拟数据
typescript
// src/data/mails.ts
export interface Mail {
id: number
subject: string
from: string
to: string[]
body: string
isRead: boolean
createdAt: string
}
export const mails: Mail[] = [
{ id: 1, subject: '周一例会通知', from: 'boss@company.com', to: ['you@company.com'], body: '明天上午 10 点开会,请准时参加', isRead: false, createdAt: '2025-01-06T09:00:00.000Z' },
{ id: 2, subject: '项目进度同步', from: 'pm@company.com', to: ['you@company.com'], body: '本周进度汇报...', isRead: true, createdAt: '2025-01-05T09:00:00.000Z' },
{ id: 3, subject: '团队建设活动', from: 'hr@company.com', to: ['you@company.com'], body: '本周五下午团建,请大家准时参加', isRead: false, createdAt: '2025-01-04T09:00:00.000Z' },
]
createdAt 用字符串,不是 Date 对象 ------JSON 格式本身没有"日期"类型,后端用 res.json(...) 发送数据时,Date 对象会被自动转换成 ISO 字符串(Date 内置的 toJSON() 方法,JSON.stringify 序列化时自动调用)。前端拿到的永远是字符串,这是前后端数据传输容易被忽略的隐性规则。
路由:列表 + 详情
typescript
app.get('/mails', (req: Request, res: Response) => {
res.json(mails)
})
app.get('/mails/:id', (req: Request, res: Response) => {
const id = Number(req.params.id)
const mail = mails.find(m => m.id === id)
if (!mail) {
return res.status(404).json({ message: '邮件不存在' })
}
res.json(mail)
})
:id 是路径参数 ,/mails/:id 能匹配 /mails/1、/mails/2 这类请求,解析出来放进 req.params.id。注意 req.params.id 永远是字符串类型 ,即使 URL 里是数字,也要手动 Number(...) 转换,直接拿字符串和数字比较(mail.id === req.params.id)永远不会相等。
res.status(404).json(...) 是链式写法:先设置 HTTP 状态码,再发送具体内容。
mail-client 这边不用改代码
mailApi.ts 第八周就按这个接口形状提前写好了:
typescript
async getList(): Promise<MailV2[]> {
return request.get('/mails')
},
async getById(id: number): Promise<MailV2> {
return request.get(`/mails/${id}`)
},
提前设计好接口形状的好处在这一步体现出来------后端接口写完,前端不用改一行就能对上。
验证:三种场景全部通过
临时验证代码分别调用列表、详情、一个不存在的 id,观察结果:列表返回 3 条数据、详情返回完整字段、404 场景里 normalizeError 正确把状态码转换成 { type: 'http', message: '请求的资源不存在', status: 404 }------这是第七周错误分类逻辑第一次在真实后端接口下验证通过。
Day4:把 mailStore.ts 接到真实后端
修改文件:mailStore.ts
typescript
import { mailApi } from '@/api/mailApi'
const { loading, error, execute: executeFetch } = useAsyncState(async () => {
return await mailApi.getList()
})
/**
* 功能:加载邮件列表
* 场景:进入收件箱页面时调用
*/
async function fetchMails() {
const result = await executeFetch()
mails.value = result
}
原来 useAsyncState 里那段手写的模拟数据整段删除,换成 return await mailApi.getList() 这一行。这是分层设计的价值体现 :替换数据来源,只改了"数据从哪来"这一行,管理 loading/error 状态的逻辑、调用方的用法一行都没动。
类型问题:编译期和运行时不一致
MailV2.createdAt 的类型是 Date,但后端实际返回的是字符串(Day3 提到的 JSON 序列化规则)。TS 编译期认为拿到的是 Date 对象,运行时实际是字符串------这是前后端联调时最常见的一类"类型骗过了编译器,但运行时不一致"的问题 ,vue-tsc 不会报错(它只检查类型标注对不对,不会真的跑代码验证运行时的值)。
修改文件:mailApi.ts
typescript
/**
* 功能:后端原始返回的邮件数据类型
* 场景:后端返回的 createdAt 是字符串,和前端 MailV2.createdAt: Date 不一致,
* 需要单独定义这个"原始类型"做转换的中转
*/
type RawMail = Omit<MailV2, 'createdAt'> & { createdAt: string }
export const mailApi = {
async getList(): Promise<MailV2[]> {
const result = await request.get<any, RawMail[]>('/mails')
return result.map(m => ({
...m,
createdAt: new Date(m.createdAt)
}))
},
async getById(id: number): Promise<MailV2> {
const result = await request.get<any, RawMail>(`/mails/${id}`)
return {
...result,
createdAt: new Date(result.createdAt)
}
},
// 其余方法不变
}
type RawMail = ... 不需要 export,只是这个文件内部做转换用的中转类型。
验证
收件箱正常显示后端返回的 3 条真实数据,createdAt 显示正常;标记已读、全部已读、删除这几个操作作为纯前端内存操作依然正常工作(这几个功能真正对接后端接口不在本周计划内,后面几周会陆续做)。
Day5:整合优化 + 补测试
拆分 app.ts 和 index.ts
typescript
// src/app.ts:只负责构建应用实例
import express from 'express'
import cors from 'cors'
import type { Request, Response } from 'express'
import { mails } from './data/mails'
const app = express()
app.use(cors())
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.get('/mails', (req, res) => {
res.json(mails)
})
app.get('/mails/:id', (req, res) => {
const id = Number(req.params.id)
const mail = mails.find(m => m.id === id)
if (!mail) return res.status(404).json({ message: '邮件不存在' })
res.json(mail)
})
export default app
typescript
// src/index.ts:只负责启动服务
import app from './app'
const PORT = 3000
app.listen(PORT, () => {
console.log(`[server] 服务已启动,监听端口 ${PORT}`)
})
为什么要拆 :测试的时候不想真的开一个端口(端口冲突、测试跑完进程不退出都是麻烦事)。拆分之后,app.ts 只是"构建了一个 Express 应用实例",测试文件可以直接引用它、模拟发请求,完全不涉及真实的端口监听。
新增文件:src/app.test.ts
bash
pnpm add -D vitest supertest @types/supertest
typescript
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import app from './app'
describe('GET /health', () => {
it('应该返回 200 和 ok 状态', async () => {
const res = await request(app).get('/health')
expect(res.status).toBe(200)
expect(res.body.status).toBe('ok')
})
})
describe('GET /mails', () => {
it('应该返回邮件列表', async () => {
const res = await request(app).get('/mails')
expect(res.status).toBe(200)
expect(res.body.length).toBe(3)
expect(res.body[0].subject).toBe('周一例会通知')
})
})
describe('GET /mails/:id', () => {
it('存在的 id 应该返回对应邮件', async () => {
const res = await request(app).get('/mails/1')
expect(res.status).toBe(200)
expect(res.body.id).toBe(1)
})
it('不存在的 id 应该返回 404', async () => {
const res = await request(app).get('/mails/999')
expect(res.status).toBe(404)
expect(res.body.message).toBe('邮件不存在')
})
})
supertest 是测试 Express 接口的标准工具 :不需要真的启动服务、监听端口,直接拿到 Express 的 app 实例模拟发一次 HTTP 请求,拿到响应结果断言------和前端用 @vue/test-utils 的 mount 模拟组件渲染是同一个思路,不需要真实的运行环境,直接对着程序实例操作。
命名提醒 :supertest 导出的名字也叫 request,容易和 mail-client 项目里 utils/request.ts 导出的 axios 实例(同样叫 request)搞混------两者是完全不同的东西,只是恰好同名,分属两个独立项目,不会互相冲突,但读代码时要认清楚是哪个 request。
跑 pnpm test,4 条用例全绿。
前后端"路由"概念对比
同一个词,前后端指代完全不同的东西,这周开始要习惯区分:
| Vue Router(前端路由) | Express 路由(后端路由) | |
|---|---|---|
| 管的是什么 | 浏览器地址栏变化时,展示哪个组件 | 服务器收到 HTTP 请求时,用哪个函数处理 |
| 触发方式 | 用户点击链接、浏览器前进后退 | 客户端发起网络请求 |
| 是否涉及网络请求 | 不涉及(纯前端行为) | 本身就是在处理网络请求 |
| 本周对应代码 | router/index.ts 里的 { path, name, component } |
app.get('/mails', handler) |
本周总结
最大的收获:分层设计在真实联调时开始兑现价值
第七周设计的 normalizeError/messageStore/useAsyncState 三层错误处理,第八周提前设计好的 mailApi.ts 接口形状,这周第一次接上真实的后端服务,几乎没有额外改动就跑通了------fetchMails 只改了一行数据来源,mailApi.ts 里 getList/getById 的方法签名一个字都没变。这印证了一件事:分层和提前设计接口形状不是"过度设计",是在为将来的接入成本买保险,成本在写的时候就已经付过了,接入的时候几乎是免费的。
掌握的知识点自检:
- Node.js 让 JS 拥有了浏览器里没有的能力(文件系统、网络服务)
- Express 路由:
app.get(path, (req, res) => {...}),req代表请求,res代表响应 -
tsx/tsx watch:不用手动编译 TS,文件改动自动重启(整个进程重启,不是局部热更新) -
package.json的"type": "module"决定项目走 ES Module 还是 CommonJS,会影响tsconfig.json配置 - CORS:跨域请求需要后端显式声明允许的来源,中间件要注册在路由之前
- JSON 没有 Date 类型,后端返回的日期字段永远是字符串,前端需要手动转换
-
req.params.id永远是字符串,需要手动Number(...)转换再比较 -
res.status(404).json(...)链式设置状态码和响应内容 -
supertest测试 Express 接口,不需要真实监听端口,直接对app实例操作 - 前后端"路由"是完全不同的概念,只是碰巧用了同一个词
下周计划
第10周的具体安排还没最终确认,大方向是继续给 mail-client 补后端能力(markAsRead/deleteMail 这类目前还是纯前端内存操作的功能,接入真实接口;以及引入 PostgreSQL + Prisma,让数据真正持久化,不再是重启服务就丢失的内存数组)。具体 Day1-5 安排会在下周开始前确认。
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。