Vue3 全栈实战第九周:Node.js + Express 后端从零搭建实战记录

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:5173mail-server 跑在 localhost:3000------端口不同就算不同的"源",浏览器同源策略会默认拦截前端 JS 向不同源发起的请求。

修改文件:src/app.tsmail-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.tsindex.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-utilsmount 模拟组件渲染是同一个思路,不需要真实的运行环境,直接对着程序实例操作。

命名提醒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.tsgetList/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前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,遇到的问题和解决过程都会记录下来。

相关推荐
故作春风1 小时前
elpis-core 核心从入门到理解
后端·架构·node.js
雪芽蓝域zzs1 小时前
第四十七节:驾驶舱大屏 ECharts 图表集成
前端·javascript·vue.js
白雾茫茫丶3 小时前
Vibecoding 一个主题切换动画库:13 种揭幕方式
前端·vue.js·react.js
雪芽蓝域zzs3 小时前
第四十九节:TagsView 右键菜单(带三角箭头)给每个 tag 增加**鼠标右键菜单**(右键标签弹出:关闭、关闭其他、关闭全部)
前端·javascript·vue.js
MingQi394 小时前
DevFlow Harness 全栈实践 · M1.5:用户体系、多对话与聊天分享
全栈
志尊宝4 小时前
Vue3 零基础每日笔记(024):组件的创建与使用——从 import 到自动导入
前端·vue.js·笔记·前端框架·html5
爱吃红星柚6 小时前
【学习】Elpis 抽离与发布 npm 包
前端·前端框架·node.js
梦帮科技7 小时前
Next.js 16 模块化单体实战:App Router、领域模块与 Route Handler 分层
css·数据结构·链表·正则表达式·node.js·json·html5
梦帮科技7 小时前
从 Guest 到 Platinum:Prompt 配额、API Key 与访问控制的安全闭环
javascript·git·架构·node.js·reactjs·html5·visual studio