Vue3 全栈实战:第一阶段复盘(第1-8周)
「Vue前端转全栈实战」系列第十篇,也是第一阶段的收官篇。前九篇分别记录了转型决策、技术栈选型,以及第 1-7 周的逐周知识点和踩坑(TypeScript、Pinia/Vite/Router/axios、组件化、响应式优化与 keep-alive、Vitest 单元测试、表单与校验、错误处理与请求层封装)。这一篇不再是"这周学了什么"的周报体,而是把八周拉通看一遍:技术脉络怎么演进的、哪些坑最值得记住、项目现在是什么状态,为第9周接入真实后端做一次收尾。
目录
- 八周技术脉络
- 一个反直觉的发现:难度感知和技术难度不对应
- 八周踩坑合集:最值得记住的几条
- 第八周:给第一阶段做了一次代码体检
- [mail-client 项目现状](#mail-client 项目现状 "#mail-client-%E9%A1%B9%E7%9B%AE%E7%8E%B0%E7%8A%B6")
- 第二阶段计划
八周技术脉络
八周不是八个孤立的知识点,是一条从"学语法"到"学怎么让项目在真实世界里正常工作"的曲线。
第 1-3 周:打基础 ------TypeScript 核心语法、Pinia 状态管理 + Vite 工程化 + Vue Router + axios 封装、组件化开发(拆分、Props/Emit、provide/inject、插槽、自定义指令)。这三周建立起 mail-client 的骨架:一个能展示邮件列表、能路由跳转、组件职责拆分清楚的前端应用。
第 4-5 周:从"会用"到"懂原理" ------这是八周里跨度最大的阶段。第四周钻进 Vue 响应式系统内部(shallowRef/markRaw/v-memo),第五周钻进"怎么验证代码是对的"这件事本身(Vitest 单元测试、组件测试、Mock)。这两周的知识点有个共同点:不是"多学一个 API",而是"理解一个机制的边界"------shallowRef 追踪到哪一层为止,v-memo 管得到哪些重新渲染、管不到哪些,watch 回调到底是同步还是异步执行的。
第 6-7 周:为真实场景做准备------表单与校验、错误处理与请求层封装。这两周开始不再是"学 Vue 语法",而是"学怎么让一个项目在真实、不完美的网络环境和用户输入下正常工作"。校验规则要处理用户乱填的数据,错误处理要应对网络超时、断网、后端 500------这些都是模拟数据阶段完全不会遇到、但第9周接真实后端后天天都会遇到的问题。
第 8 周:不学新东西,专门还债------类型定义走查、命名与目录结构统一、测试覆盖率复查、项目 README。相当于给第一阶段做了一次代码意义上的体检,把七周里陆续堆积的死代码、命名不一致、类型冗余清理掉,让第9周轻装上阵。
一个反直觉的发现:难度感知和技术难度不对应
复盘这八周,最有意思的一点是:TypeScript 类型体操(第一周)是主观感觉最难的一周,但现在回头看,是掌握得最扎实、最不容易忘的一周。
原因不是这部分内容后来变简单了,而是它被反复用到了:
javascript
第一周:学 Omit/Pick/Partial/keyof 这些工具类型
→ 纯抽象语法规则,没有具体场景支撑,理解起来吃力
第六周:写表单校验,用到 keyof typeof form 收窄 Object.keys 的返回类型
→ 第一次真正在业务代码里用到第一周学的抽象概念
第八周:清理 mail.ts 里的类型定义,判断哪些类型被真正引用
→ Omit<MailV2, 'id'|'createdAt'>(CreateMail)这类写法已经能一眼看懂
每次用到都是一次巩固,等到第八周,类型断言"为什么需要"已经成了直觉判断,不再需要现查语法。这和第四、五、七周那些"踩坑式"的学习完全不同 ------shallowRef 忘记同步改写法、watch 回调时机、useMessageStore() 在 Pinia 初始化前调用,这些坑大多是"当场卡住,排查完就彻底懂了",属于一次性的顿悟,不需要靠重复使用才能内化。
两种学习方式没有优劣之分,但值得记住这个区别:抽象的语法规则需要靠后续反复使用才能真正掌握,光在学的当下"看懂了"是不够的------这也是为什么把 TS 语法放在第一周、而不是单独抽出来学"深"了再往后走是合理的安排,语法先打个照面,后面在真实需求里反复撞见,自然就扎实了。
八周踩坑合集:最值得记住的几条
从每周文章里挑出跨周仍然有参考价值的几条(完整踩坑记录见各周文章):
响应式选型:改浅了要同步排查所有修改路径 ------mails 从 ref 改成 shallowRef 后,markAsRead/markAllAsRead 忘记从"直接改内部属性"同步成"整体替换"的写法,界面不更新。教训:换响应式类型不是改一行声明的事,要跟着排查所有修改这份数据的地方。
验证"有没有重新渲染",不能把日志写在 <script setup> 顶层 ------顶层代码只在组件创建时执行一次,要用 onUpdated 才能验证出真实的重新渲染次数;而且要选对验证场景,inject 依赖会绕过父组件层面的 v-memo。
响应式副作用是异步的,断言前要 await nextTick() ------不管是 DOM 更新还是 watch 回调,改完数据立刻断言副作用生效与否,都要留一次 nextTick 的余地。
Composition Store 内部访问不到 $patch,request.ts 里的 useMessageStore() 必须写在回调函数内部 ------这两条踩坑表象不同,根源相同:都是"某个东西还没准备好就去用它"。$patch 是"访问了还不存在的自身实例",useMessageStore() 是"在 Pinia 初始化之前就调用",都属于时机类问题。
jsdom 会把十六进制颜色转成 rgb() 格式------写颜色断言的测试要记得这个转换规律,不是代码错了。
手写 Mock 数据不会自动带上真实的错误转换逻辑 ------useAsyncState 包一个手动 reject({ response: { status: 500 } }) 的假请求,拿到的错误对象没有 message 属性,只会落到兜底文案。想验证 normalizeError 的分类逻辑,必须让错误真的经过 request.ts 的拦截器转换一遍。
第八周:给第一阶段做了一次代码体检
第八周没学新知识点,做的是四件"还债"的事:类型清理、命名统一、测试覆盖率复查、项目 README。下面把每一天的具体改动详细记录一下。
Day1:类型定义走查
清理 src/types/mail.ts ,删掉从未被引用的类型(Mail、MailCardProps、MailStatus、Priority、MailFolder、SortOrder、SortField、MailFilter、MailSorter、AsyncMailFetcher、MailPreview、ContactPreview、FolderUnreadCount),保留真正在用的:
typescript
// src/types/mail.ts
import type { ApiError } from '@/utils/request'
export interface BaseItem {
id: number
createdAt: Date
updatedAt?: Date
}
export interface MailV2 extends BaseItem {
subject: string
from: string
to: string[]
body: string
isRead: boolean
attachments?: string[]
}
export interface Contact extends BaseItem {
name: string
email: string
avatar?: string
isFavorite: boolean
}
export type CreateMail = Omit<MailV2, 'id' | 'createdAt' | 'updatedAt'>
export type EditMail = Partial<MailV2>
export type RequestStatus = 'idle' | 'loading' | 'success' | 'error'
/**
* 功能:通用 API 响应类型
* 场景:所有后端接口的返回数据结构
* error 字段统一用 ApiError,和 request.ts 里 normalizeError 转换后的格式保持一致
*/
export interface ApiResponse<T> {
data: T | null
error: ApiError | null
status: RequestStatus
}
踩坑:判断"有没有被用到",靠人工搜索几个文件是不够的 ------第一轮清理时把 Contact、CreateMail、EditMail 也一起删了,因为搜索过的 mailStore.ts/request.ts/validate.ts/几个 View 组件里都没有引用。结果跑 pnpm build 直接报错:
sql
src/api/contactApi.ts:2:15 - error TS2305: Module '"@/types/mail"' has no exported member 'Contact'.
src/api/mailApi.ts:2:23 - error TS2305: Module '"@/types/mail"' has no exported member 'CreateMail'.
src/api/mailApi.ts:2:35 - error TS2305: Module '"@/types/mail"' has no exported member 'EditMail'.
原因是 src/api/contactApi.ts、src/api/mailApi.ts 这两个第9周要用的接口封装文件,之前一直没被看到过,它们确实在用这三个类型。教训:判断一个类型有没有被引用,最终依据是编译器报错,不是人工翻了几个文件就下结论 ------人工搜索的范围永远可能有遗漏,tsc/vue-tsc 会把整个项目的引用关系都检查一遍,报错列表就是最准确的"还有谁在用"的清单。
清理 mailStore.ts 的死代码 :fetchMails、markAsRead、markAllAsRead 里第七周改造时留下的大段注释掉的旧代码全部删除,只保留现行逻辑;同时纠正一处写错的注释------原来写着"markAsRead 和 deleteMail 需要整体替换",但 deleteMail 用 filter 天然返回新数组,从来不需要改,真正需要改的是 markAsRead 和 markAllAsRead:
typescript
// ❌ 原来的注释
// 注意:markAsRead 和 deleteMail 需要整体替换,不能直接改内部属性
// ✅ 改成
// 注意:markAsRead 和 markAllAsRead 需要整体替换,不能直接改内部属性
// 因为 shallowRef 只追踪第一层(deleteMail 用 filter 天然返回新数组,不受影响)
这种"注释写错但代码本身是对的"的情况比代码写错更容易被忽略------代码跑起来一切正常,只有回头对照注释的人会被误导。
Day2:命名与目录结构统一
导航栏统一改用 RouterLink ,原来混用了 router.push({ name: 'xxx' })(收件箱、已发送)和 RouterLink to="/xxx"(编辑资料、写邮件,第六、七周加的),改成统一风格:
vue
<ul style="list-style:none;padding:0">
<li style="margin-bottom:8px">
<RouterLink to="/inbox" :style="{ display: 'block', padding: '8px 12px', background: route.name === 'inbox' ? '#eee' : 'transparent', borderRadius: '6px', textDecoration: 'none', color: 'inherit' }">
📥 收件箱
</RouterLink>
</li>
<li style="margin-bottom:8px">
<RouterLink to="/sent" :style="{ display: 'block', padding: '8px 12px', background: route.name === 'sent' ? '#eee' : 'transparent', borderRadius: '6px', textDecoration: 'none', color: 'inherit' }">
📤 已发送
</RouterLink>
</li>
<li style="margin-bottom:8px"><RouterLink to="/profile" style="display:block;padding:8px 12px">编辑资料</RouterLink></li>
<li style="margin-bottom:8px"><RouterLink to="/compose" style="display:block;padding:8px 12px">写邮件</RouterLink></li>
</ul>
路由 name 统一成 kebab-case ------原来六条路由出现了三种风格(全小写 inbox/sent,kebab-case mail-detail/not-found,PascalCase ProfileEdit/ComposeMail),统一成 kebab-case(改动量最小的方向):
typescript
// router/index.ts
{ path: '/profile', name: 'profile-edit', component: () => import('@/views/ProfileEditView.vue') },
{ path: '/compose', name: 'compose-mail', component: () => import('@/views/ComposeMailView.vue') },
修复通配符路由参数名 :/:pathMatch1(.*) 多打了一个数字,Vue Router 官方约定的参数名是 pathMatch:
typescript
// ❌ { path: '/:pathMatch1(.*)', name: 'not-found', ... }
// ✅
{ path: '/:pathMatch(.*)', name: 'not-found', component: () => import('@/views/NotFoundView.vue') }
(确认过 NotFoundView.vue 没有读取 route.params.pathMatch,改这个不影响任何现有逻辑。)
修复拼写错误 themeModeKry → themeModeKey,涉及四个文件:
typescript
// src/types/injectionKeys.ts
export const themeModeKey: InjectionKey<Ref<ThemeMode>> = Symbol('ThemeMode')
// App.vue
import { themeModeKey } from './types/injectionKeys'
provide(themeModeKey, themeMode)
// MailItem.vue
const themeMode = inject(themeModeKey, ref('light'))
// MailItem.test.ts
import { themeModeKey } from "@/types/injectionKeys"
// ... global: { provide: { [themeModeKey as symbol]: ref('dark') } }
确认 MailList.vue 和 MailListScoped.vue 不是重复组件 :两者在 InboxView.vue 里通过 useScoped 开关切换渲染(v-if="!useScoped" / v-else),是学作用域插槽时特意保留的两种实现方式对照,不需要清理。
Day3:测试覆盖率复查
跑 pnpm test:coverage,整体覆盖率 86.85%。补了两条真正有价值的测试:
typescript
// useAsyncState.test.ts 新增:验证 catch 里的兜底文案分支
it('错误对象没有 message 属性时应该使用兜底文案', async () => {
const { error, execute } = useAsyncState(async () => {
throw { response: { status: 500 } } // 没有 message 属性
})
await expect(execute()).rejects.toBeTruthy()
expect(error.value).toBe('操作失败')
})
// messageStore.test.ts 新增:success() 之前从没被测试调用过
it('success 应该新增一条 success 类型的消息', () => {
const store = useMessageStore()
store.success('操作成功')
expect(store.messages[0].type).toBe('success')
})
补完之后 useAsyncState.ts、messageStore.ts 都到了 100%,整体覆盖率提到 87.42%。request.ts 拦截器回调体(48.38%)评估后决定不补------normalizeError 本身已经用 6 条用例充分覆盖,拦截器回调只是"调用它"的入口逻辑,没有额外的分支判断风险,为了凑覆盖率去写绕过 axios 内部 interceptors.handlers 数组的变通测试不划算。
Day4:项目 README
写了一份面向"第9周接后端时的自己"的 README.md,重点是"接下来要干什么、现在项目是什么状态",不是事无巨细重复七周的所有细节(那些已经在各周文章里了)。完整内容:
markdown
# mail-client
Vue3 + TypeScript 全栈转型学习项目,围绕"邮件客户端"场景练习。与工作中的邮件客户端项目方向一致,但完全独立、从零搭建。
## 技术栈
- Vue 3(Composition API + `<script setup>`)
- TypeScript
- Vite
- Pinia(含 `pinia-plugin-persistedstate` 持久化插件)
- Vue Router
- Axios
- Vitest + Vue Test Utils(单元测试 + 组件测试)
## 目录结构
src/
api/ # 具体业务接口封装(mailApi、contactApi),内部调用 utils/request
components/ # 可复用组件
composables/ # 组合式函数(useAsyncState 等)
directives/ # 自定义指令(v-focus、v-click-outside、v-loading、v-highlight)
stores/ # Pinia Store(mailStore、messageStore)
types/ # 类型定义
utils/ # 工具函数(request 请求层封装、validate 校验规则)
views/ # 路由页面
router/ # 路由配置
## 已实现功能
- 收件箱:列表展示、搜索(防抖)、标记已读/全部已读、删除、`keep-alive` 缓存滚动位置
- 邮件详情页
- 写邮件:收件人多选(标签式输入)、表单校验、脏值检测(未保存离开提醒)
- 编辑资料:表单校验、脏值检测
- 已发送(页面已建,数据待接入)
- 全局错误提示(请求失败自动弹出,按 network/timeout/http/business 分类文案)
- 深色模式(`provide`/`inject` 全局主题状态)
## 测试
pnpm test # watch 模式
pnpm test:coverage # 跑一次并生成覆盖率报告
当前 57 条用例,整体覆盖率 87.42%。已知缺口:`utils/request.ts` 的拦截器回调体(48.38%)------`normalizeError` 本身已用 6 条用例充分覆盖,拦截器回调只是"调用它"的入口逻辑,评估后判断不值得为了覆盖率去写绕过 axios 内部 API 的变通测试,予以保留。
## 关键设计取舍
这几条是七周下来做过对比、有意选择的设计,不是随手写的:
- **`mails`/`selectedMail`/`mailStats` 用 `shallowRef` 而非 `ref`**:这几个数据始终是整体替换,不修改内部属性,`shallowRef` 减少不必要的深度代理开销。`viewHistory` 因为用 `push` 做内部追加,必须保留 `ref`。
- **错误处理分层**:`normalizeError`(转换)→ `messageStore`(展示)→ `useAsyncState`(状态管理)三层各自独立,互不知道对方的存在,靠"真实请求路径"上的顺序配合起来。
- **`$patch` 只能在组件里通过 Store 实例调用**,Composition 风格 Store 内部批量更新老实分别赋值即可。
## 第9周计划
接入真实后端:Node.js + Express + PostgreSQL + Prisma。`api/mailApi.ts`、`api/contactApi.ts` 已经按未来的接口形状提前搭好(`CreateMail`、`EditMail` 等类型已就位),后端接口跑起来之后:
- `mailStore.ts` 里的 `fetchMails` 换成调用 `mailApi.getList()`
- `ComposeMailView.vue`/`ProfileEditView.vue` 里模拟的 `setTimeout` 换成真实的 `mailApi.create()`/`userApi.updateProfile()`
- 验证 `normalizeError` 的分类逻辑在真实网络环境下表现是否符合预期
"已实现功能"这块特意按用户能感知到的功能点来写,不是按周罗列"学了 watch""学了 Vitest"------对第9周接后端时判断"要改哪块代码"更有用。"关键设计取舍"也只挑了三条,不是把七周所有踩坑都搬过来,这份文档的作用是快速参考,不是踩坑史全集。
mail-client 项目现状
- 技术栈:Vue3(Composition API)+ TypeScript + Vite + Pinia + Vue Router + Axios + Vitest
- 已实现功能 :收件箱(列表、搜索、已读/删除、
keep-alive缓存)、邮件详情、写邮件(收件人多选、校验、脏值检测)、编辑资料、全局错误提示、深色模式 - 测试:57 条用例,整体覆盖率 87.42%
- 已知的、经过判断后接受的技术债 :
request.ts拦截器回调体的测试覆盖率缺口;ApiResponse<T>的error: ApiError暂时依赖从utils/request.ts导入类型(更理想的做法是把ApiError挪到独立的类型层目录,等第9周设计后端响应格式时一起理清楚)
第二阶段计划
第9周开始接入真实后端:Node.js + Express + PostgreSQL + Prisma。api/mailApi.ts、api/contactApi.ts 已经按未来的接口形状提前搭好,mailStore.ts 里模拟的 fetchMails、ComposeMailView.vue/ProfileEditView.vue 里模拟的 setTimeout,都等着换成真实的接口调用------第一阶段搭好的错误处理和校验逻辑,理论上不需要大改,正好可以在真实网络环境下检验当初的设计够不够用。
「Vue前端转全栈实战」系列持续更新,第二阶段(全栈项目)开始后继续记录。