上手 Vue3 和 TypeScript,最尴尬的处境是什么?教程刷了十来遍,文档翻了几大页,网上 demo 也都跑通了,可一旦关掉教程自己想从头搭一个项目,立刻不知道该从哪下手。尤其是 TypeScript,单独学一遍觉得还行,一放进 Vue3 组件里各种类型报错直接把心态搞崩。这篇文章是我用一个 Vue3 + TypeScript 练手项目从零到完整跑通的全程记录,包括项目该做什么、工具链怎么搭、TS 在组件里到底怎么用、实际开发踩了哪些坑,以及练完之后怎么往真实项目升级。内容基本是我实际敲过的代码和踩过的坑,适合刚学完 Vue3 基础、想用 TS 完整做一个项目的新手,也适合写过几个 demo 但没系统梳理过类型写法的同学参考。
1. 练手项目先想清楚:做什么才不算白练
1.1 一个合格的练手项目,要覆盖哪些技术点
练手项目最常见的误区是求大求全。我见过有人第一次上手就直接模仿中后台管理系统,又是权限模块又是多租户,最后写了两个多月还没完成首页。练手项目的核心不是功能多,而是把一条正常的业务闭环跑通。什么叫正常业务闭环?用户进来能登录、能看到数据列表、能搜索、能新增编辑、能删除,退出登录之后路由能拦住未授权访问。这几件事串起来,你对 Vue3 组合式 API、TS 类型约束、Pinia 状态流、路由守卫的理解才是"用过",而不是停留在"看过"。
具体到我这个练手项目,功能清单砍到最后就剩六块:用户登录、路由守卫、列表页搜索分页、新增编辑表单、删除与详情、全局状态管理。另加一个不算复杂的前置业务场景——我选的是"团队待办",因为它的数据模型简单,只有用户、任务、状态、时间,不会像商城那样陷入商品规格、购物车、支付回调的泥潭。这个选择很重要:练手项目的业务复杂度必须压到刚好能体现技术要点,而不是让业务本身成为学习障碍。
我一般建议,项目周期控制在三到四周。第一周做环境搭建和登录模块,第二周做列表、搜索、分页和表单,第三周做状态管理和细节打磨,第四周复盘、补注释、尝试加一个你没写过的小功能,比如"任务拖拽排序"或"数据导出"。按每天两到三小时算,这个节奏比较现实。一开始就把范围定死,每天都能看见进度,这是坚持写完一个练手项目的关键。
1.2 技术栈选型:Vue3 与 TypeScript 为什么适合组合
前端圈一直有 React 和 Vue 的口水战,但对练手来说,选 Vue3 + TS 有几个实打实的好处。第一,Vue3 的组合式 API 天然是函数式组织逻辑,这正好落在 TypeScript 的强项上。选项式 API 里 this 的类型推断在复杂组件里经常绕弯子,而 setup 函数里一切都是变量和函数,props、emit、返回值全走签名,TS 的推导基本不费劲。我自己从 Vue2 加 class component 转过来之后,最大的感受就是"原来 TS 和 Vue 可以这么顺"。
第二,Vue3 官方的 defineProps、defineEmits、ref、computed 都对类型做了第一等支持,不需要一大堆装饰器或者第三方辅助库。第三,生态层面 Element Plus、Naive UI、Vant 这些主流组件库全是 TS 编写,类型是第一公民;相比之下 Vue2 时代想要在组件库里拿到完整类型,很多时候依赖社区补丁,坑相当多。
这里也要说句实在话:如果你连 JavaScript 的数组方法、对象解构、Promise 都还没太搞明白,先别急着上 TypeScript。TS 是 JS 的超集,不是替代品,它的类型标注本身会引入大量新概念,基础不稳的时候会加倍痛苦。我遇到过不少新人,类型报错其实是业务代码写错了,但他以为是 TS 太难。所以推荐顺序是:先能熟练写简单 JS 业务,再上 Vue3 + TS 练手。
1.3 项目目录设计:一开始就把结构立好
很多新手拿到官方脚手架之后,第一反应是把所有东西都塞进 App.vue 或者 views 底下的一个大文件里。这个我特别理解,因为初期写代码最快。但练手项目一定要从第一天就按"功能模块 + 文件职责"来组织目录,不然后面加功能的时候会非常痛苦。
我用的目录结构是这样的:
src/ api/ # 接口定义与请求函数 assets/ # 静态资源 components/ # 通用组件 composables/ # 组合式函数 layouts/ # 布局组件 router/ # 路由配置与守卫 stores/ # Pinia 状态 types/ # 全局类型定义 utils/ # 工具函数 views/ # 页面级组件这个结构不是标准答案,但它的原则是对的:状态放 stores,接口放 api,类型定义独立放 types,通用逻辑抽成 composables,页面只负责组装。练手阶段就养成这个习惯,等做真实项目的时候会发现收益巨大。反过来如果前期全堆在一起,重构成本会让你直接放弃。
2. 环境准备与项目初始化:把工具链吃透
2.1 Node、包管理器与编辑器选择
开始之前先把环境校准。Vue3 + Vite 项目建议 Node 18 以上,我当前用的 Node 20 LTS,npm 10;你可以用 nvm 管理多个 Node 版本,避免不同项目互相干扰。包管理器方面我推荐 pnpm,理由很直接:Vite 官方脚手架默认支持 pnpm,而且 pnpm 的依赖隔离机制能帮你少踩不少依赖版本错乱的坑。如果你之前只用过 npm,也完全可以从 pnpm 开始,命令几乎没有学习成本,npm install 变成 pnpm install,npm run dev 变成 pnpm dev。
编辑器方面自然是 VS Code,装两个扩展就够了:Volar(现在叫 Vue Language Features)和 TypeScript Vue Plugin。装完之后需要在 VS Code 里把默认格式化工具设置成 Prettier,否则一会儿走 ESLint 一会儿走 Prettier 会很乱。这里有个小坑:如果你同时开了 Volar 的 Takeover 模式,记得把内置 TypeScript 扩展禁用,否则类型提示可能重复或者行为怪怪的。
我先给一条验证环境的命令:
node -v pnpm -v能正常打印版本号就说明基础环境没问题。如果这里就报错,大概率是没装 Node 或者环境变量没配好,先用搜索引擎查一下对应系统怎么配置环境变量,再继续后面的步骤。
2.2 create-vue 脚手架:每个选项都代表一个决策
初始化项目我直接用官方脚手架,命令是:
pnpm create vue@latest为什么不用老的 vue-cli?Vue3 官方已经把精力全部放到了 Vite 工具链上,vue-cli 对 Vue3 的支持基本是维护模式,新手没必要学一套即将过时的工具。create-vue 的优点是在交互式命令行里把所有工程化选项问清楚:TypeScript、JSX、Router、Pinia、Vitest、ESLint、Prettier、Vue DevTools。很多人一看到这么多选项就全选,或者全不选,这两个极端都不对。
我的建议是:TS 必选,Router 和 Pinia 选上,ESLint 和 Prettier 选上,Vitest 如果短期内不会写测试可以先不选,JSX 先不选。为什么不选 JSX?因为 Vue3 的模板语法和 TS 配合已经足够好,JSX 是另一套心智模型,练手阶段叠加太多写法反而是负担。注意,这里的选择不是终身的,后面想加测试随时可以装 vitest,但一开始保持精简能让入口不至于太复杂。
创建完项目进入目录,安装依赖,跑起来:
cd vue3-ts-todo pnpm install pnpm dev浏览器打开 http://localhost:5173,能看到 Vue 官方 logo 页面就算成了。这个时候你会注意到一个细节:脚手架默认生成的内容里带了一些类型声明的示例文件,比如 HelloWorld.vue 里有 defineProps 的类型写法,把它读一遍比看十篇文章都有用。
2.3 初始化后的目录与工具链分工
初始化完成后,工程目录大概是这样的,我整理成了示意:
.vscode/ # 编辑器统一配置 src/ assets/ components/ router/ stores/ views/ App.vue main.ts .env.development # 环境变量示例 .gitignore index.html package.json README.md tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts这个工程里几个文件的分工要搞清楚:入口是 index.html,它通过 script 标签加载 src/main.ts;main.ts 负责创建 Vue 实例、挂载 Router 和 Pinia;Vite 负责开发服务器和打包,配置在 vite.config.ts;TypeScript 的编译配置拆成了三个 tsconfig,其中 tsconfig.app.json 管 src 下的业务代码,tsconfig.node.json 管 vite.config.ts 这类构建脚本。这个拆分是官方推荐的,不要随便删。
我特别提醒一下 tsconfig 里的 paths 配置。很多人初始化之后想用 @/ 这种别名,结果发现不生效,因为 vite.config.ts 里的 alias 和 tsconfig.json 里的 paths 必须同时配置,而且路径要对应。比如:
// vite.config.ts resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } // tsconfig.json 或 tsconfig.app.json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }不过这里有个新变化,我放到后面踩坑章节详细讲,因为 TypeScript 新版对 baseUrl 已经提出弃用警告了。
3. Vue3 + TypeScript 的类型实践:不是装饰,是护栏
3.1 script setup 下的组件类型:props、emit 与暴露
我在练手项目里最常用的组件写法是<script setup lang="ts">,它会自动把顶层的变量和方法暴露给模板,配合 TS 的类型推导非常舒服。先看 props 的类型定义,官方推荐直接使用泛型语法:
<script setup lang="ts"> interface Props { title: string count?: number status: 'todo' | 'done' | 'doing' } const props = defineProps<Props>() // 需要默认值时用 withDefaults withDefaults(defineProps<Props>(), { count: 0 }) </script>defineProps 的泛型写法有编译期检查,模板里用到不存在的 prop 会直接报错,这比运行时检查前置太多了。defineEmits 同理:
const emit = defineEmits<{ (e: 'update:title', value: string): void (e: 'delete', id: number): void }>()这里给事件参数加了类型,父组件监听事件时也能拿到类型提示。defineExpose 则用于子组件向父组件暴露方法,配合模板 ref 获取子组件实例的时候类型是完整的,不会再像以前那样拿到一个 any。
3.2 ref、reactive、computed:类型推导与显式标注的边界
组合式 API 的三个核心响应式 API 在 TS 下表现不太一样,理解它们的推断规则能少写很多多余的标注。ref 接收基础值时会自动推导,比如const name = ref('')得到Ref<string>;但如果初始值是空数组或者空对象,你会得到一个不太理想的类型:
// 不推荐:可能被推断成 never[],后面 push 会报错 const list = ref([]) // 推荐:显式标注泛型 interface TodoItem { id: number title: string done: boolean } const list = ref<TodoItem[]>([])reactive 则用在对象类型上,它会递归地把所有嵌套属性变成响应式,TS 的推导一般可以直接用,但如果你需要定义接口,还是显式标注更清晰。computed 是三者里最省心的,返回值类型会自动推断,基本不需要手动写,除非你要导出一个复杂类型的纯函数。
这里还有一个高频坑:当你从接口拿数据,返回结构是ApiResponse<T>,但你只需要 data 字段,新手经常写const data = res.data as any,这等于把类型护栏拆了。正确做法是先定义接口:
interface ApiResponse<T> { code: number message: string data: T } interface LoginResult { token: string userInfo: UserInfo }然后请求函数返回Promise<ApiResponse<LoginResult>>,在使用处直接拿到res.data就是LoginResult类型。这个设计能让你从后端数据到页面渲染全链路都有类型,这也是我接下来要专门讲接口封装的原因。
3.3 接口请求封装里的 TS:泛型才是灵魂
练手项目里如果每个页面都直接调用 axios,你会很快发现两件事:一是错误处理代码重复,二是类型全靠手写。正确姿势是做一层薄封装,把所有请求的返回结构统一约束。我的封装思路是这样:
// src/utils/request.ts import axios from 'axios' import type { AxiosInstance, AxiosRequestConfig } from 'axios' export interface ApiResponse<T = unknown> { code: number message: string data: T } const service: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) service.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( (response) => { const res = response.data as ApiResponse if (res.code !== 200) { return Promise.reject(new Error(res.message)) } return response }, (error) => { return Promise.reject(error) } ) export function request<T>(config: AxiosRequestConfig): Promise<T> { return service.request<ApiResponse<T>>(config).then((response) => response.data.data) }然后每个接口文件都走这个 request:
// src/api/auth.ts import { request } from '@/utils/request' import type { LoginParams, LoginResult } from '@/types/auth' export function login(data: LoginParams) { return request<LoginResult>({ url: '/auth/login', method: 'post', data }) }这么做的好处是:业务代码里调用 login 后拿到的返回值天然是 LoginResult,不需要再手写类型断言,也不会因为接口改了字段产生静默错误。
3.4 全局类型声明:env.d.ts 与 types 目录的边界
很多新手会把所有类型都写在 main.ts 或者组件里,这会导致类型文件膨胀且难维护。我的习惯是:业务实体的类型放在 src/types 目录下,按模块拆文件,比如 auth.ts、todo.ts;面向全局的类型声明放在 d.ts 文件里,用 declare global 扩展。
比如 Vite 环境变量默认不被 TS 识别,你直接写import.meta.env.VITE_API_BASE_URL会报错,需要在src/vite-env.d.ts里声明:
/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_API_BASE_URL: string } interface ImportMeta { readonly env: ImportMetaEnv }还有一类是给后端返回的数据模型声明,这部分不建议全用 any,哪怕刚开始写不准类型,也可以先建一个界面大致约束字段。至于什么时候用 any、什么时候应该精确类型,我的原则是:数据边界处尽量精确,内部临时变量可以用自动推导,但不要主动写 any。主动写 any 等于告诉 TS"这里不用你管",一旦数量多了,类型系统就形同虚设。
4. 练手项目核心功能实录:从登录页到 CRUD
4.1 登录页:表单校验、调用接口、保存状态
到了这个阶段,我假设你已经完成了环境和类型基础,接下来就真正开始写业务。登录页虽然简单,但它把表单、接口、状态管理、路由跳转几个环节串起来了,非常适合作为第一个完整功能。
我的登录页结构是:模板部分放表单,script setup 部分负责校验和提交逻辑。校验可以用 Element Plus 自带的 Form 规则,也可以用简单的 if 判断,练手阶段重要的是提交函数里的类型和状态流转:
const loginForm = reactive<LoginForm>({ username: '', password: '' }) const loading = ref(false) async function handleLogin() { if (!loginForm.username || !loginForm.password) { ElMessage.warning('请输入用户名和密码') return } loading.value = true try { const res = await loginApi(loginForm) userStore.setToken(res.token) userStore.setUserInfo(res.userInfo) router.push('/') } catch (e) { // 统一错误提示已经在请求拦截器里做了 } finally { loading.value = false } }这里有一个很重要的细节:登录成功后 token 是放到 Pinia 还是 localStorage?我的答案是都放——Pinia 管应用内状态,localStorage 做持久化。刷新页面后,在入口重新从 localStorage 恢复 token。如果你只放内存,一刷新登录态就没了,路由守卫立刻把你弹回登录页。
4.2 路由守卫:未登录拦截与动态元信息
路由守卫是登录功能的另一半。练手项目至少要有全局前置守卫,逻辑是:没有 token、目标页需要认证就跳转登录页;有 token 但访问的是登录页就跳回首页。注意这里不要每次都从 localStorage 读 token,最好封装一个工具函数,或者在 store 里写 getter。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else if (to.path === '/login' && token) { next({ path: '/' }) } else { next() } })我建议把 redirect 参数用上,登录成功后再跳回来,这对用户体验很重要。后面如果做权限系统,再扩展到"按角色动态生成路由",练手阶段先不要碰动态路由,那个复杂度不是新手该承受的。
4.3 列表页:搜索、分页、loading 状态机
列表页是练手项目中代码量最大的部分,因为要处理状态、请求、渲染三层。我需要强调一个我犯过很多次的错误:把 loading 只当成一个 boolean,结果接口报错的时候整个页面没有反馈。更好的做法是把请求状态设计成'idle' | 'loading' | 'success' | 'error'这样一个状态机,或者至少区分 loading 和 error。
搜索参数和分页参数建议用一个响应式对象统一管理:
const query = reactive({ keyword: '', status: '' as '' | 'todo' | 'done', page: 1, pageSize: 10, total: 0 }) async function fetchList() { loading.value = true try { const res = await getTodoList({ keyword: query.keyword, status: query.status || undefined, page: query.page, pageSize: query.pageSize }) list.value = res.items query.total = res.total } finally { loading.value = false } }搜索按钮和分页组件的事件都调用 fetchList,注意分页时要把 page 和 pageSize 回写进 query。这里还有一个容易漏掉的细节:切换分页大小时应该把 page 重置为 1,否则你在第 5 页切成每页 50 条,接口可能会返回空列表。这个逻辑虽然小,但很影响体验。
4.4 表单页:v-model 类型、回显与提交
表单页的核心是 v-model 和类型对齐。Element Plus 的表单组件接收的 value 类型和你提交给接口的类型不一定完全一致,比如日期选择器返回的是数组,但你接口需要的是字符串范围。练手项目我建议在表单页维护一个独立的表单模型:
interface TodoFormModel { title: string description: string priority: 'low' | 'medium' | 'high' dueDate: string | null }编辑场景还有"回显"问题:从列表页跳过来时带着 id,需要先请求详情再填充表单。这个时候要注意时序,页面 onMounted 里是异步的,模板不能一开始就访问 form.dueDate,否则会报错。最简单的方法是加一个 loaded 标志,数据回来后再渲染表单组件。很多新手遇到"编辑页空白"就是这个问题。
const loaded = ref(false) const form = reactive<TodoFormModel>({ ... }) onMounted(async () => { const id = route.params.id if (id) { const data = await getTodoDetail(Number(id)) Object.assign(form, data) } loaded.value = true })提交函数则复用登录页的 loading 模式,成功后跳回列表并刷新。到这里,一个完整的登录加 CRUD 闭环就跑通了。
5. 新手必踩的坑:我整理了一份排查清单
5.1 TypeScript 版本升级带来的弃用警告
如果你用的是最新版 create-vue,并且 TypeScript 已经到 5.5 或更高,可能会在终端看到两条黄色警告:选项 baseUrl 已弃用并将停止在 TypeScript 7.0 中运行,以及选项 moduleResolution=node10 已弃用并将停止在 TypeScript 7.0 中运行。这不是你的代码有问题,而是老 tsconfig 写法正处在版本过渡期。
应对方法分两步。第一,moduleResolution 改成 bundler,因为 Vite 项目本身走 esbuild 打包,用 node10 这种老式解析方式是历史包袱。第二,去掉 baseUrl,直接依赖 paths 配合相对路径。我最后稳定下来的 tsconfig 核心配置长这样:
{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "bundler", "strict": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }如果你仍然看到警告,先看一下是不是 create-vue 生成的配置模板还没更新,可以手动把 moduleResolution 调成 "bundler" 试试。注意,只有打包工具真正支持 bundler 语义才能这样改,Vite 项目完全没问题。
5.2 类型断言:能不用就不用,但数据边界例外
我在练手项目里看到最多的类型问题就是乱用 as any。比如从 localStorage 读出 JSON 再转对象,新手习惯写JSON.parse(str) as any,后面的所有代码都失守了。我的建议是:数据边界处定义好接口,让类型错误提早暴露。比如:
interface StoredUser { userId: number name: string avatar: string role: string } function getUserFromStorage(): StoredUser | null { const raw = localStorage.getItem('user') if (!raw) return null try { return JSON.parse(raw) as StoredUser } catch { return null } }这里的 as StoredUser 是合理的,因为 JSON.parse 在 TS 里只能返回 any,边界处收窄一次是必要的。但不要在业务代码里到处 as,尤其是把类型 A 直接断言成类型 B,那几乎都是在掩盖设计问题。
5.3 响应式丢失、组件不更新与时序问题
练手阶段大家喜欢把接口请求直接写在 setup 顶层,期望页面加载就发请求。但 setup 执行时组件还没挂载,有些 DOM 相关操作会报错。正确做法是把请求放进 onMounted,或者在 setup 顶层请求但不要立即访问 DOM 相关引用。
另一个非常典型的坑是响应式丢失。从 reactive 对象或 Pinia store 里直接解构出来的属性,在 Vue3 里不会保持响应式。很多人写了const { userInfo } = userStore,然后页面怎么都不更新。正确做法是:
// 错误:解构后丢失响应式 const { userInfo } = userStore // 正确:用 storeToRefs 包裹 const { userInfo } = storeToRefs(userStore)或者直接用userStore.userInfo,这也是响应式的。组件里如果用了 reactive 对象,同样不要直接解构,必要的时候用 toRefs 转换。
还有一类"组件不更新"问题,常见于文件上传组件的成功回调。比如你配置了 on-success 但始终监听不到,这通常不是组件库的 bug,而是你的请求拦截器把响应数据结构改了,成功回调拿到的参数结构和组件库预期不一致。排查思路是打日志看回调参数到底是什么,而不是怀疑组件库有问题。
5.4 第三方类型缺失与组件库类型导入
练手项目一般都会引入一个组件库,比如 Element Plus。正常来说它的类型是齐全的,但偶尔你会遇到"类型上不存在属性 xxx"这样的报错。这时候先检查是不是组件库版本和 Vue 版本不匹配,再看是不是导入路径写错了。Element Plus 的全局类型可以通过在 d.ts 里配置实现按需引入,不需要手动导入每个组件的类型。
另外一个常见困扰:某个老包没有提供 TypeScript 类型定义,import 进来直接 any 报错。处理方式是在项目里新建一个 d.ts 文件用 declare module 补上类型:
declare module 'some-js-lib' { export function init(options: { debug?: boolean }): void }这属于"接口隔离"的思路,我不建议直接 suppress 或 ignore,因为你后面升级依赖时没有类型会很难受。
5.5 问题排查速查表
我把练手阶段最常见的几类问题汇总成一张表,方便你对照排查:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 终端出现 baseUrl 弃用警告 | TypeScript 5.5+ 开始标记旧配置 | 移除 baseUrl,只保留 paths,并改成相对路径 |
| 终端出现 moduleResolution=node10 弃用警告 | 老模板默认配置 | 改成 node16 / nodenext,Vite 项目用 bundler |
| 模板里读取不到 props | 没有用 defineProps 声明 | 检查是否在 script setup 中声明了 props 类型 |
| 调用接口返回值没有类型提示 | 请求函数缺少泛型 | 用 request 泛型包装,定义响应接口 |
| 解构 store 后页面不更新 | 响应式状态被抽离 | 使用 storeToRefs 或直接访问 store 属性 |
| 编辑页回显空白 | 异步回填时序问题 | 加 loaded 标志,数据返回后再渲染表单 |
| 数组 push 报错 | ref 初始值为空数组导致 never[] | 显式标注 ref<TodoItem[]>([]) |
| on-success 回调不到 | 请求拦截器改了响应结构 | 打印回调参数,调整与组件库的契约 |
这张表不是万能药,但它覆盖了新手练手项目里频率最高的几个点。遇到问题先对号入座,能省下不少排查时间。
6. 练手完成后,下一步往哪走
6.1 从练手项目到真实项目的差距在哪里
很多新人练完一个项目,觉得下一步就是接外包或者投简历,实际上中间还有不小的距离。练手项目的特点是需求明确、接口自造、没有团队协作,而真实项目至少要面对几个新问题:多人合并代码冲突、接口联调时的字段变动、产品修改需求导致的组件设计变化、旧浏览器兼容、性能优化、错误监控。这些能力不是靠写一个 demo 能获得的,但练手阶段养成的好习惯——类型完整、目录清晰、代码可读——恰恰是解决这些真实问题的底层能力。
如果你想更接近真实项目,可以把练手项目扩展:加一个 mock server 模拟真实接口延迟和失败,加一个单元测试覆盖核心逻辑,把它部署到服务器上用一个真实域名访问。这个过程会迫使你考虑环境变量、跨域、静态资源部署,这些在本地开发环境完全不会出现。
6.2 值得继续深入的方向:测试、工程化与组合式函数
练手项目跑通之后,我个人强烈建议先补 Vitest 单元测试,因为测试能反过来强迫你把代码拆得更合理。一个组件如果很难写测试,往往说明它做的事情太多,这是个很好的重构信号。其次是写自己的组合式函数,比如 useAuth、usePagination,把之前写在组件里的逻辑抽出来,你会发现组件代码一下子瘦身很多,而且对 Vue3 的"逻辑复用"会有更深的体会。
再往后,可以接触 VueUse 这个社区组合式函数库,看看别人怎么设计通用 hooks,但不要照抄,要理解每个函数背后的响应式原理。最后才是 SSR、微前端这些大工程方向,练手阶段不需要碰。
6.3 说点我自己的真实体会
最后分享一点个人经验。我用 Vue3 + TS 练手的时候,最大的瓶颈不是找不到资料,而是资料太多太散,今天看这个教程明天看那个 demo,结果一周下来一个完整功能都没写出来。后来我给自己定了一条规矩:一个练手项目只固定一套技术方案,遇到坑就查这一套方案的官方文档,不看其他分支方案的比较文章。事实证明这个做法非常高效,因为 Vue3 + TS 的方案在官方文档里足够完整,你缺的只是把时间花在写代码上。
写这个项目的过程中还有一个体会:类型报错不可怕,可怕的是不敢开 strict 模式。我一开始也是把严格模式关掉来绕过报错,后来发现那些报错恰恰是代码质量的提示。把 strict 打开,把每个报错都当作一次学习机会,练完一个项目之后你会发现自己对 TS 的理解上了一层。这个练手项目的代码我后来又重构过两遍,每次都有新收获,希望你能比我更快地走到"能用自己的项目去验证想法"那一步。