ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3+TypeScript全栈项目实战:工程化、类型与状态管理指南

Vue3+TypeScript全栈项目实战:工程化、类型与状态管理指南

时下很多前端同学刚接触全栈项目时,第一个念头是“我用 Vue3 写页面,后端找一个框架,接口串起来不就行了”。结果真正动手,两天就发现事情没这么简单:项目结构怎么组织、类型要不要专门建一层、接口返回不一致怎么处理、状态放在 Pinia 还是组件里、刷新页面登录态突然没了……每个问题单拎出来都像小水坑,连在一起就能把人困住。

我陆续做过几个带后台管理性质的全栈项目,踩了不少坑之后沉淀出一套比较顺手的方案,核心就是 Vue3 + TypeScript,配套 Vite、Pinia、一个轻量级 Node 后端框架和关系型数据库。整个链路从工程初始化、类型设计、状态管理,到接口对接、部署排错,今天完整过一遍。这篇文章不是对着文档念概念,而是把我在真实项目里验证过、能直接复用的做法摊开讲,适合刚学会 Vue 基础想用真实项目练手的人,也适合团队想规范 TypeScript 落地、却一直被各种类型问题折磨的开发者。

1. 项目全貌与技术选型:先想清楚再动手

全栈项目最容易翻车的地方其实不是代码,而是开始之前没想清楚“这个系统到底在解决什么问题”。我最近做的内容管理后台就是一个很典型的参考:登录并校验操作者身份、维护一批内容数据的增删改查、提供运营看数据的简单面板。看起来好像没什么难度,但把权限模型、接口设计、前端状态共享、构建部署这些因素全加进去,复杂度一点都不比 C 端产品低。

所以在选技术栈之前,我会先把核心流程抽象出来,再用需求反推方案。大多数后台管理项目的瓶颈不在页面渲染,而在数据能不能稳定地、有类型约束地从后端流到前端,再在用户操作后回写。这个核心矛盾决定了整套技术选型的方向。

1.1 从核心需求反推技术栈

以内容管理后台为例,我需要三类能力:前端渲染与交互、接口数据可靠传递、后台数据存储与校验。对应到技术栈上就很清晰:前端用 Vue3 和 TypeScript,因为组合式 API 对逻辑复用友好,模板和脚本里的类型推导也比较成熟;构建工具用 Vite,冷启动快、配置透明、HMR 稳定;状态管理用 Pinia,相比旧方案更轻量,而且对 TypeScript 的友好度非常高;后端选 Node 生态里的轻量中间件框架,理由很实际——全栈项目只需要掌握一种语言,省去来回切换的成本。

数据库我倾向于关系型数据库加轻量 ORM。内容管理这种场景里,数据表之间有关系、有事务、有字段迁移需求,关系型数据库更稳。选 ORM 不是为了花哨,而是让建表、查询、字段变更都在代码里可追踪,不用再靠一堆 SQL 脚本在多个环境里手工执行。我把最终确定的主干整理成一张表:

层级选择核心理由
前端框架Vue 3 + TypeScript组合式 API 逻辑复用强,类型覆盖模板和脚本
构建工具Vite冷启动快,HMR 稳定,配置可直接控制
状态管理Pinia更轻量,TypeScript 推导好,贴合组合式思维
后端框架轻量中间件框架路由简单、接口层薄,方便维护前后端契约
数据库关系型数据库 + 轻量 ORM数据关系清晰,事务可控,迁移机制完善

这张表看起来普通,但每一项都是我实际验证过才留下的。JVM 系后端能胜任更大规模场景,可对一个人维护的全栈项目来说,让前后端语言统一、部署链路简单,性价比往往高出不少。

1.2 为什么这套组合在真实项目里最省心

Vue3 本身并不强制要求上 TypeScript,项目初期用 JavaScript 写起来确实更快。但一旦页面超过十几个、接口到了几十个,JavaScript 的类型短板就会浮现。一个后端改了字段名,前端没有类型约束时只能人肉搜索全局引用;有了类型,编译期就把错误报出来,改动的成本低很多。

不过我也不是建议所有人都去研究类型体操,业务型全栈项目里 TypeScript 的职责就三件事:约束组件的 props 和事件、约束 API 的请求参数和响应结构、约束全局状态。把这三件事做扎实了,项目基本不会出现数据流失控的问题。后端选轻量框架也是同样的逻辑,全栈项目的后端职责是提供稳定、可预测的接口,而不是把依赖注入、微服务那一套全套上,那反而让边界变得模糊。轻量框架把 API 层写薄,把业务校验做扎实,前后端协作反而顺畅。

2. 前端工程:目录、配置与基础能力

动手写业务之前,先把工程化的地基打好。我见过很多项目翻车,不是因为业务逻辑有多难,而是“能跑就行”的配置和乱糟糟的目录在后期不断制造额外成本。这里以我实际踩平的一套流程来做说明,新项目直接照搬,老项目对照着改也合适。

2.1 用官方脚手架完成初始化

创建 Vue3 项目不需要手动去拼配置,官方脚手架 create-vue 是最省心的方案。一条命令就能拿到包含 Vite、TypeScript、路由、状态管理、代码检查的工程骨架。相比网上各种个人模板,官方脚手架最大的优势是它不夹带作者的个人习惯,默认规范就是社区的主流实践。

npm create vue@latest

执行后会询问是否启用 TypeScript、JSX、Vue Router、Pinia、ESLint、Prettier 等选项。针对全栈后台项目,我通常这样选:

选项我选理由
TypeScript是全类型约束的基础
JSX否Vue 模板场景用不上 JSX,选了纯属多余
Vue Router是菜单、权限路由、页面跳转都依赖它
Pinia是全局状态和会话信息必须集中管理
单元测试否第一版先跑通业务,测试后面再补

有人担心官方脚手架生成的代码含着一堆模板示例,到时候还得清理一遍。其实无妨,脚手架生成的代码结构比大部分手写配置规范得多,把示例页面删掉,在它基础上扩展是最舒服的。

2.2 目录结构按职责展开

脚手架默认会把组件、视图等分好,但全栈项目再多一层按职责划分更清晰。我用得最顺手的一套结构是这样:

src/ api/ # 接口请求和 DTO 类型 assets/ # 静态资源 components/ # 通用组件 composables/ # 组合式函数 layouts/ # 布局组件 router/ # 路由配置 stores/ # Pinia 状态 types/ # 全局类型 utils/ # 工具函数 views/ # 页面组件

这个结构的关键是按职责分,而不是按技术名词分。请求方法和响应类型放在 api 里,页面内部的小组件留在对应 view 目录下,别硬塞到 components,否则 components 很快就变成杂物间。composables 放的是可复用的业务逻辑,像 useTable、useAuth 这类,和 utils 里纯函数、纯工具不一样,这两个目录最好分开,不然团队协作时找代码的成本会越来越高。

2.3 Vite 配置与 TypeScript 路径别名

工程里最基础也最容易被忽略的一步,是路径别名。全用相对路径的话,层级一深就容易出错。把@指向 src,然后同步配置 tsconfig,这样编辑器跳转和类型提示才会正常工作。很多人在这一步只改了 Vite,没改 TypeScript 配置,结果命令行跑得通,编辑器里却全程标红。

// vite.config.ts import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })

同时要在 tsconfig 里补上。官方脚手架生成的项目往往有好几个 tsconfig 文件,记得在 app 那一个里面补充:

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }

这里有个经验之谈:如果你发现 Vite 配置是对的、命令也能跑,但 IDE 仍然不认识别名,多半是baseUrl没配,或者是 tsconfig 文件选错了。Vite 用哪一个、IDE 默认加载哪一个,两个如果对不上,就会出现“编译通过但编辑器全红”的奇怪局面。

2.4 代码规范与格式化的一点点建议

全栈项目里多人协作时,代码风格问题最容易引发无谓争议。我的做法很直接:ESLint 管规则,Prettier 管格式,两者配合,不要功能重叠。ESLint 负责代码正确性和 TypeScript 相关的规范,Prettier 只负责统一分号、引号、换行。脚手架生成的时候已经配好了基础,你要做的是把团队常用的规则补充进去,比如禁止any、强制显式返回类型、组件命名一致性。Commit 前能跑一遍 lint 就已经能挡掉很多问题,不必一开始就上太重的工具链,等团队习惯后再逐步加。

3. 类型系统与数据层:全栈协作的黏合剂

我在很多项目里看到一种通病:类型定义写了,但只是简单把后端字段抄一遍,组件里依然到处as强转,时间格式化散落各处,错误处理全靠 try/catch。这就是把类型当摆设。真正想让它发挥作用,就得把数据层当成设计核心,而不是顺手加几个 interface。

3.1 接口响应类型不是翻译器

很多人的习惯是后端返回什么字段就原样定义成什么类型,这其实是把类型当翻译器用,好处是省事,坏处是后端一改字段、一改结构,前端所有相关页面都要跟着改。我的建议是在 api 层维护两层类型:第一层是原始响应类型,描述接口的真实返回;第二层是页面模型类型,描述视图真正需要的内容。两层之间通过一个映射函数转换,页面组件感知不到接口细节。

举例来说,后端返回的 createdAt 是时间戳,页面需要展示yyyy-MM-dd字符串。如果只用一个类型,组件里每个用到时间的地方都要做格式化,改一次格式就要全局搜索。在 api 层用一个函数统一转换,组件拿到的就是格式化好的数据,这对业务代码的整洁度提升非常明显。

export interface RawContentRecord { id: number title: string created_at: number status: 'draft' | 'published' | 'archived' } export interface ContentListItem { id: number title: string createTime: string status: ContentStatus } export function toContentListItem(raw: RawContentRecord): ContentListItem { return { id: raw.id, title: raw.title, createTime: new Date(raw.created_at * 1000).toLocaleDateString('zh-CN'), status: raw.status } }

后端改字段时,前端只需要动这一个函数,页面不用跟着伤筋动骨。这个习惯一旦养成,重构和维护的效率会高出很多。

3.2 请求模块封装:统一响应与统一异常

请求模块是比 axios 封装本身更重要的一点:统一鉴权、统一响应结构、统一异常处理。我的做法是在拦截器里注入 token,在响应拦截器里做 401 处理,然后通过一个泛型方法直接返回业务数据。

import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' import type { ApiEnvelope } from '@/api/types' const http: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) http.interceptors.request.use((config) => { const token = authStore.getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) http.interceptors.response.use( (response) => response, (error) => { const status = error.response?.status if (status === 401) { authStore.clearSession() router.push('/login') } return Promise.reject(error) } ) export function request<T>(config: AxiosRequestConfig): Promise<T> { return http.request<ApiEnvelope<T>>(config).then((res) => { if (res.data.code !== 0) { throw new Error(res.data.message) } return res.data.data }) }

这段代码的价值在于,所有页面拿到T的时候已经自动解开了后端统一包裹层,不会出现res.data.data.list这种嵌套路径满天飞的情况。业务组件里只需要关心真正的业务对象,错误处理和 401 跳转也只在拦截器里写一次。你在组件里做的事就是调用 request 方法并处理可能抛出的业务异常。

3.3 环境变量也要有类型

import.meta.env.VITE_*默认可以读,但没有类型接口的话,任何环境变量名都被当成字符串,拼错了编译也不吭声。给环境变量补一个模块声明,是提高代码准确度成本最低的操作之一。

interface ImportMetaEnv { readonly VITE_API_BASE_URL: string readonly VITE_API_TIMEOUT: string } interface ImportMeta { readonly env: ImportMetaEnv }

这样以后新增一个环境变量,如果在声明里没有注册,编辑器会直接提示。多环境环境下这个价值更明显,dev、test、prod 的接口地址本来就不一样,如果连基础的类型检查都没有,配置错到上线前才被发现一点也不奇怪。

3.4 映射函数让页面层更干净

上一节讲到了原始响应类型和页面模型类型,实际操作中,映射函数的数量会随着接口数目一起涨。我通常会为每个资源建一个 mapper 文件,比如api/content.ts里既放请求方法又放转换函数。这样页面组件和 api 层之间就形成一道明确的边界:页面只和页面模型打交道,api 层自行消化后端的一切变化。很多人问这样是不是绕远了,其实恰恰相反。项目一进入迭代期,接口调整会越来越频繁,有一个单独的映射层,能挡住相当大一部分后端变更对 UI 代码的冲击。

4. 状态管理与逻辑复用

状态管理的核心是把业务规则从组件里抽出来,而不是让每个页面自己保存一份全局信息。全栈项目里最典型的状态是登录态、用户资料、侧边栏开合、列表页查询条件这些,放在组件内部靠 props 一层层传,会很快地把项目变成一团乱麻。Vue3 的响应式系统天然支持把状态抽出来共用,但具体怎么组织,还是有不少学问。

4.1 用 setup store 组织领域状态

Pinia 支持选项式 store 和 setup store,我更推荐 setup store,因为它和组件的组合式写法一脉相承,ref 就是 state,函数就是 action,组件里用起来跟本地状态几乎没有区别。每个 store 文件只负责一个领域,不要因为“都要用”就全塞进同一个 store。

一个后台项目常用的 store 大致有这几个:useAuthStore 管登录态、useUserStore 管用户资料、usePermissionStore 管菜单与权限、useAppStore 管全局 UI 状态。领域分清楚,store 才不会越滚越肥。在写新 store 之前先想一个问题:“这个状态是全局需要的吗?”如果只是单个页面用,放在组件里或组合函数里就够了,没必要进 Pinia。

4.2 会话 store 的完整写法

登录态是每个全栈项目都绕不开的状态。我用 setup store 写了一个典型的会话管理,里面同时处理 token、用户资料和登录/登出动作。

import { ref, computed } from 'vue' import { defineStore } from 'pinia' import { loginApi, logoutApi, fetchCurrentUser } from '@/api/auth' export const useAuthStore = defineStore('auth', () => { const token = ref<string | null>(localStorage.getItem('access_token')) const user = ref<CurrentUser | null>(null) const isLoggedIn = computed(() => !!token.value && !!user.value) async function login(payload: LoginPayload) { const result = await loginApi(payload) token.value = result.token localStorage.setItem('access_token', result.token) user.value = result.user } async function fetchProfile() { if (!token.value) return user.value = await fetchCurrentUser() } async function logout() { await logoutApi() token.value = null user.value = null localStorage.removeItem('access_token') } return { token, user, isLoggedIn, login, fetchProfile, logout } })

这里有个容易被忽略的点:登录接口返回的 token 和本地缓存的 token 类型要保持一致,否则刷新页面之后会出现“token 在但 user 不在”的中间态。所以我在 store 里把初始化流程也包进去,组件只需要调用 fetchProfile,由 store 自己决定怎么把用户资料补全。请求拦截器读取 token 时也要通过 store 的 getter,避免在 api 层反过来 import store 造成循环依赖。

4.3 useTable 这种组合式函数值得写

列表页是后台项目的主角,几乎每个页面都在重复“拉数据、管理分页、处理加载状态”这件事。我封装了一个 useTable 的组合式函数,把这些样板逻辑收拢到一起。它的职责是:管理查询参数、请求状态、分页信息,对外暴露加载、刷新、翻页这些动作。

import { ref, reactive } from 'vue' import type { TablePagination, PageResult } from '@/types/table' export function useTable<T>( fetcher: (params: Record<string, unknown>) => Promise<PageResult<T>> ) { const loading = ref(false) const data = ref<T[]>([]) const pagination = reactive<TablePagination>({ page: 1, pageSize: 20, total: 0 }) const filters = ref<Record<string, unknown>>({}) async function load() { loading.value = true try { const result = await fetcher({ page: pagination.page, pageSize: pagination.pageSize, ...filters.value }) data.value = result.items pagination.total = result.total } finally { loading.value = false } } function search(newFilters: Record<string, unknown>) { filters.value = newFilters pagination.page = 1 load() } function handlePageChange(page: number) { pagination.page = page load() } return { loading, data, pagination, load, search, handlePageChange } }

页面组件里调用一次,查询条件和页面操作就全齐了。类似的还有 useForm,把校验、提交状态、错误提示封装起来,表单代码能从几十行缩到十几行。这类组合式函数对 TypeScript 也很友好:返回对象的类型会被自动推导,模板里直接v-model="form.name",字段名写错了编辑器立刻就能看到。泛型不要设计得太复杂,够用就行,两个泛型参数通常比一串嵌套泛型更容易被团队接受。

4.4 路由守卫与权限菜单的配合

全栈后台几乎都要做登录拦截和按权限渲染菜单。最常见的坑是“刷新之后菜单闪一下”或者“进错页面才被拦住”,根子在于路由守卫没有等待用户资料加载完成。我的方案是给 store 加一个initialized标志,让初始化动作返回 Promise,路由守卫在跳转前先等这个 Promise。

router.beforeEach(async (to) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.token) { return { name: 'login', query: { redirect: to.fullPath } } } if (auth.token && !auth.initialized) { await auth.fetchProfile() } if (to.meta.requiresAuth && !auth.isLoggedIn) { return { name: 'login', query: { redirect: to.fullPath } } } })

注意isLoggedIn要同时判断 token 和 user,而不是只看 token。token 只代表有过登录动作,不代表当前会话对后端依然有效。如果 token 已经过期,fetchProfile 会返回 401,此时拦截器把用户踢回登录页,等比进业务页面后再掉登录合理得多。菜单那里可以在布局组件中根据 permission store 的权限列表做过滤,凡是当前用户没有权限的菜单直接不渲染,后端接口再做一层校验,双重保护。

5. 后端接口设计与前后端对接

前端类型和安全问题理清之后,后半程就是后端接口的稳定性和整个链路的一致性问题。全栈项目里接口契约比文档更重要,类型加一套运行时校验基本能覆盖大部分需求。

5.1 接口规范,一开始就要稳定

前后端一起做时,最怕接口半路改来改去。想减少这种情况,最好的办法是在项目启动时就定好一套稳定规范:用 JSON 作为消息格式,用 REST 风格表达动作,响应统一包一层,错误码按大类划分,别把 HTTP 状态码用得过于花哨但没有说明。

比如内容模块的接口设计:GET/api/v1/contents拉列表,POST/api/v1/contents创建,PUT/api/v1/contents/:id更新,DELETE/api/v1/contents/:id删除。响应统一为{ code, message, data },前端请求模块就能用同一套逻辑处理。/api/v1前缀我从一开始就坚持保留,因为后面大概率会出现移动端或其他调用方,版本前缀是给自己留的扩展余地。

5.2 用 schema 做入口校验

前端已经用 TypeScript 做了约束,但它只在编译期有效。运行时如果有人传非法字段,后端必须做校验兜底。我用 zod 这类 schema 校验库在接口入口直接做验证,非法请求在进业务逻辑之前就被挡住。

import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { findContents, findContentById, createContent } from '../services/content' const contentRoutes = new Hono() const listQuerySchema = z.object({ page: z.coerce.number().default(1), pageSize: z.coerce.number().default(20), keyword: z.string().optional() }) const createBodySchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), status: z.enum(['draft', 'published', 'archived']) }) contentRoutes.get('/', zValidator('query', listQuerySchema), async (c) => { const { page, pageSize, keyword } = c.req.valid('query') const result = await findContents({ page, pageSize, keyword }) return c.json(result) }) contentRoutes.post('/', zValidator('json', createBodySchema), async (c) => { const body = c.req.valid('json') const created = await createContent(body) return c.json({ code: 0, data: created }) }) export { contentRoutes }

这段代码最有价值的一点是入参校验前置。前端类型和后端校验是一对互补关系,前者在编译期发现问题,后者在运行期兜住非法数据。不要只依赖数据库约束,那只能防止存储层面的错误,给不出清晰的业务提示。

5.3 开发阶段的代理和跨域问题

开发阶段最常见的坑是跨域。前端 Vite 开发服务器可以配置代理,把/api开头的请求转发到后端,这样浏览器看到的始终是同一个来源,跨域问题在开发阶段基本不会出现。

export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })

配置好之后,前端代码里所有请求路径都应该写成/api/...相对路径。很多人在这一步卡了一天:配置了代理但还是跨域,原因可能是 Vite 配置没重启,或者是后端目标地址写错。判断方法看浏览器网络面板:如果请求 URL 还是localhost:5173/api,说明代理没生效;如果已经是后端地址还报跨域,才是后端 CORS 的问题。把这两个方向分开排查,效率会高很多。

5.4 一次完整请求的链路演示

把前后端连起来看,一次列表请求的完整链路大概是这样的:用户点击列表页,组件触发 useTable 的 load;load 调用 api 层封装好的请求函数;请求函数用 request 方法打给/api/v1/contents;Vite 代理把请求转发到后端;后端先经过 zod 校验查询参数,再调 service 查询数据库;ORM 把行记录映射成对象;service 将原始数据转成统一响应格式;前端 request 拦截器解包 data;映射函数把原始响应转成页面模型;useTable 最终把数据写入响应式数组,页面重新渲染。这整条链路里任何一环的类型对不上,都应该在编译期或请求模块入口被发现,而不是等页面上出现 undefined 后再回头查。

6. 问题排查与避坑纪录

全栈项目里的坑,数到最后往往就那么几类。我先给一个速查表,再展开讲定位方法,后面遇到类似问题时可以直接对照。

现象常见原因解决思路
TypeScript 报“对象可能为空”空值判断缺失用类型守卫、可选链或显式判空
模板里拿不到数据但接口有请求时机太早在 onMounted 里触发请求,别写在 setup 同步阶段
刷新后登录态丢失初始化流程没重新拉取用户资料store 初始化时根据 token 调用 fetchProfile
跨域问题代理没生效或后端 CORS 没配先看网络面板的请求 URL,再定位是哪一端
构建体积过大没做按需引入、路由懒加载路由动态 import,组件库按需引入
前端类型和后端校验不一致只有编译期类型,缺少运行时校验后端补齐 schema 校验

6.1 类型报错不要硬跳过

Object is possibly 'null'和xxx is possibly 'undefined'应该是最常见的两类 TS 报错。多数时候问题不是 TypeScript 太严格,而是你没告诉它某个值现在是存在的。定位方法不复杂:先看报错发生在哪一层。如果是组件 props,就用 withDefaults 给默认值;如果是接口返回的数据,回到 API 层的解析函数,对可能为空的字段做判断。千万别为了通过编译写@ts-ignore,那是把风险往后推,某天线上出现 undefined 时会更加难查。

组合式函数返回的数据尤其要注意,ref 自动解包后类型虽然对,但值可能还是初值。列表数据初始是空数组,模板直接用没问题;如果初始值是 null,就必须先判断再做加载。所有拿不到明确值的数据,建议都显式设计成可能为空,让 request 返回的泛型把不确定因素带出来。

6.2 刷新丢登录态这类中间态

全栈项目里一个隐蔽的坑,是页面刷新时 token 还在但用户资料没加载,界面先渲染一个“未登录”壳,再跳成已登录,闪一下。根因就是 store 初始化时没做用户资料拉取,或者路由守卫里没有等待。解法在前面已经给了:给 store 加 initialized 标志,fetchProfile 返回 Promise,路由守卫在决定去哪儿之前先 await。这个细节一旦不处理,上线之后白屏和菜单闪烁的问题就会频繁出现。

6.3 构建体积与部署回退

前端部署时最常踩的坑是把 history 路由忘了配回退。单页应用在服务端需要一份 try_files 之类的回退配置,否则刷新/dashboard直接 404。另一个容易被误解的点是VITE_前缀的环境变量在构建时会被固化进产物,不同环境要用不同的构建命令,不是运行时改一下地址就行的。构建体积上,优先看路由是不是都用了动态 import,然后给组件库配一个按需自动导入,通常首屏包能明显降下来。部署前把vue-tsc --noEmit和vite build一起跑,类型错误会直接中断发布,虽然麻烦,但比让坏版本上线再去救火强。

6.4 调试时能快速定位问题的工具用法

遇到问题别急着加日志,先用好开发工具。浏览器网络面板能看到请求是否发出、返回状态和耗时;Vue DevTools 里可以直接观察 Pinia 的状态变化和路由跳转,看 store 里的 token、user 是否按预期更新;组件树能帮你确认当前渲染的 props 是什么。大多数“页面数据不对”的问题,靠这三样就能快速定位:先看请求有没有发出去,再看 store 有没有更新,最后看组件拿到的 props 是什么,三步通常就能划定问题范围。

7. 写在最后的实战体会

全栈项目的难点,往往不在某个具体技术点,而在前后端之间的契约是否清晰、类型是否贯通、状态是否可控。我个人的体会是,把时间花在前期设计上,远比后期救火划算。类型层的拆分、请求模块的封装、接口响应格式的约定,这些都没有用到什么高深技巧,但正是这些平凡的基础工作,对交付稳定性的提升最明显。

最后再分享一个小习惯:每当后端调整字段时,先改 API 层的类型和映射函数,再改页面。顺序反过来的话,页面就会散落各种临时补救代码,越堆越乱。对于觉得 TypeScript 约束太多的人,我的建议是先只约束 props、接口响应和 store,跑完一个完整功能后,你会慢慢体会到编译期纠错远比人工排查舒服。这套玩法是一个稳妥的起步点,后面按业务形态慢慢演化出自己的规范就可以了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进