简介:这份资源面向具备一定前端基础、希望深入掌握 Vue3 与 TypeScript 协同开发的进阶学习者,围绕 Composition API、静态类型检查、状态管理与路由管理等核心主题展开,帮助读者理解现代 Vue 工程的组织方式与源码实现思路。压缩包共收录 2002 个文件,以 1042 个 md 文档、803 个 js 脚本、147 个 json 配置为主,另含少量 html 与 txt,整体约 63MB,文档侧重知识梳理与源码注释,脚本与配置则对应可运行的示例工程。内容覆盖 Vue3 基础、TypeScript 接口与泛型、Vuex 与 Pinia 状态管理、Vue Router 路由以及 Ant Design Vue 等组件库的集成实践,并涉及响应式原理与微任务、polyfill 等底层机制。目前已有 575 人学习,适合作为系统查阅与动手实践的参考材料,帮助读者在真实项目中落地类型安全与组件化开发。
1. 从一份能跑起来的 Vue3 + TypeScript 源码说起
很多人学 Vue3 + TypeScript 的方式是打开官方文档从头读到尾,结果组合式 API 看懂了,一到真实项目里defineProps泛型怎么写、ref和reactive在复杂表单里怎么选、computed的类型推导为什么老是报红,还是卡壳。这份源码包解决的就是这个问题:它不是零散的知识点,而是一个用 Vue3 + TypeScript 完整搭起来的工程,目录结构、类型定义、组件通信、状态管理、路由守卫、请求封装全都在里面,你可以直接跑起来,也可以顺着文件一层层拆。适合两类人:一类是刚学完 Vue3 基础、想找一个真实项目对照练手的开发者;另一类是用 Vue3 写过几个页面、但 TypeScript 类型总是靠any糊过去的熟手。接下来我按「这项目怎么组织 → 怎么跑起来 → 核心模块怎么实现 → 怎么改」的顺序,把这份源码拆开讲。
2. 工程骨架拆解:目录结构、构建配置与类型体系
2.1 目录结构:按职责分层,不按文件类型堆
拿到一份源码,我第一件事不是看main.ts,而是先看目录怎么分的。这份项目的结构是典型的「按职责分层」,而不是把所有组件丢一个components文件夹里。常见做法是这样:
src/ ├── api/ # 接口请求封装,按业务模块拆分 ├── assets/ # 静态资源,图片、全局样式 ├── components/ # 通用组件,无业务耦合 ├── composables/ # 组合式函数,可复用的逻辑 ├── router/ # 路由配置与守卫 ├── stores/ # Pinia 状态管理 ├── types/ # 全局类型定义与接口声明 ├── utils/ # 工具函数,请求实例、格式化等 ├── views/ # 页面级组件,和路由一一对应 ├── App.vue └── main.ts这个分法的好处是:当你要改一个接口,你知道去api/;要改一个可复用的逻辑,你知道去composables/;要改类型,你知道去types/。新手最容易犯的错是把所有东西塞进components/,结果项目一大就找不到北。这份源码从目录层面就把边界划清了,值得直接抄。
composables/这个目录是 Vue3 相比 Vue2 最大的结构变化。Vue2 时代逻辑复用靠 mixin,命名冲突和来源不清晰是血泪经验;Vue3 用组合式函数,一个useXxx就是一个独立逻辑单元,来源清晰、类型也好推导。这份源码里凡是「多个页面都要用、但又不是全局状态」的逻辑,基本都放在这里。
2.2 构建配置:Vite + TypeScript 的关键几项
项目用 Vite 做构建,vite.config.ts里有几个配置直接决定开发体验,值得逐项看:
// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { // 路径别名,避免 ../../.. 这种相对路径地狱 '@': resolve(__dirname, 'src') } }, server: { port: 5173, proxy: { // 开发环境代理,解决跨域 '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })alias里的@指向src,这是绝大多数 Vue3 项目的标配。没有它,深层组件里写../../../utils/request会让人崩溃。注意配了alias之后,tsconfig.json里也要同步配paths,否则编辑器会报「找不到模块」,但项目实际能跑——这个「能跑但报红」的现象坑过不少人,后面避坑章节会细说。
server.proxy是开发阶段解决跨域的常见做法。前端请求/api/user,Vite 转发到后端http://localhost:3000/user。changeOrigin: true表示把请求头的 host 改成目标地址,rewrite把/api前缀去掉。生产环境不走这个代理,靠 Nginx 或网关转发,所以接口封装里baseURL一般写成/api,两边都能对上。
2.3 类型体系:全局声明与接口约定
TypeScript 项目里,类型放哪儿是个容易乱的地方。这份源码的做法是:全局通用的类型放types/,比如后端返回的统一结构、分页参数;组件私有的类型就写在组件文件里,不往外提。types/下常见有这么几个文件:
// types/api.d.ts // 后端统一返回结构 export interface ApiResponse<T = unknown> { code: number // 业务状态码,0 表示成功 message: string // 提示信息 data: T // 泛型数据体,由调用方指定 } // 分页请求参数 export interface PageParams { page: number pageSize: number } // 分页返回结构 export interface PageResult<T> { list: T[] total: number }ApiResponse<T>用了泛型,调用方传什么类型,data就是什么类型。比如ApiResponse<UserInfo>,data就是UserInfo。这是 TypeScript 在接口层最实用的一个模式,比到处写any强太多。PageParams和PageResult是列表页的通用约定,几乎所有后台项目都会用到,提前抽出来能省很多重复定义。
提示:
types/下的文件用.d.ts还是.ts有讲究。纯类型声明用.d.ts,编译后不产生 JS;如果里面有运行时代码(比如枚举、常量),必须用.ts,否则编译后代码会丢。
3. 跑起来:环境准备、依赖安装与启动排错
3.1 环境与依赖:Node 版本是第一道坎
跑这份源码之前,先确认 Node 版本。Vite 对 Node 版本有要求,太老的版本会直接报错。常见做法是用nvm或fnm管理 Node,切到 18 或 20 这两个 LTS 版本最稳。
# 查看当前 Node 版本 node -v # 如果版本太低,用 nvm 切到 18 nvm install 18 nvm use 18 # 安装依赖,推荐用 pnpm,速度快、磁盘占用小 pnpm install # 启动开发服务器 pnpm devpnpm install会读package.json里的依赖清单。这份项目的核心依赖大致是:vue3.x、vue-router4.x、pinia2.x、typescript5.x、vite5.x,外加axios做请求、element-plus或类似 UI 库做组件。如果你用npm install也能跑,但pnpm在依赖多的项目里明显更快,而且不会出现「幽灵依赖」——就是你没在package.json里声明、却因为扁平化安装能直接 import 的包,这种依赖一旦上游改了版本,项目就玄学崩了。
3.2 启动脚本与常见报错
package.json里的scripts一般长这样:
{ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "type-check": "vue-tsc --noEmit" } }build里先跑vue-tsc --noEmit再做vite build,意思是构建前先做一次全量类型检查,类型不过就不打包。这个顺序很重要:Vite 本身用 esbuild 转译,不做类型检查,所以如果你只跑vite build,类型错误会被悄悄放过,上线后才炸。--noEmit表示只检查、不输出文件。
启动时最常见的几个报错:
Cannot find module 'xxx':依赖没装全,删掉node_modules和 lock 文件重装。Failed to resolve import "@/xxx":vite.config.ts的 alias 没配,或者tsconfig.json的paths没同步。- 端口被占用:
vite.config.ts里改server.port,或者启动时加--port 5174。 vue-tsc报一堆类型错误但页面能跑:说明类型定义和实际代码有出入,先跑pnpm type-check看完整列表,别急着改,很多是同一个根因。
3.3 环境变量:区分开发与生产
项目里通常有.env.development和.env.production两个文件,Vite 只暴露以VITE_开头的变量给客户端:
# .env.development VITE_API_BASE_URL=/api VITE_APP_TITLE=开发环境 # .env.production VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=生产环境代码里用import.meta.env.VITE_API_BASE_URL读取。注意只有VITE_前缀的才会被打进客户端包,其他变量(比如数据库密码)不会暴露,这是 Vite 的安全设计。请求封装里的baseURL就从这个变量取,开发环境走代理,生产环境走真实域名,一套代码两边通用。
4. 核心模块实现:请求封装、状态管理与组合式函数
4.1 请求封装:拦截器 + 泛型,一次封装处处受益
utils/request.ts是整个项目的数据入口,封装得好不好直接决定后面写业务顺不顺。常见做法是基于 axios 建一个实例,挂上请求拦截器和响应拦截器:
// utils/request.ts import axios, { type AxiosInstance, type AxiosResponse } from 'axios' import type { ApiResponse } from '@/types/api' const service: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) // 请求拦截器:统一带 token service.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) // 响应拦截器:统一剥壳 + 错误处理 service.interceptors.response.use( (response: AxiosResponse<ApiResponse>) => { const res = response.data if (res.code !== 0) { // 业务错误,统一提示 console.error(res.message) return Promise.reject(new Error(res.message)) } return res.data // 直接返回 data,业务层不用再 .data }, (error) => { // HTTP 层错误,401 跳登录等 if (error.response?.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } ) // 泛型请求方法,调用方指定返回类型 export function request<T>(config: Parameters<AxiosInstance['request']>[0]) { return service.request<ApiResponse<T>>(config).then((res) => res as unknown as T) } export default service这段代码有几个关键点。第一,响应拦截器里判断res.code !== 0,把业务错误和 HTTP 错误分开处理——HTTP 200 不代表业务成功,这是后端接口的常见约定。第二,拦截器里直接return res.data,把外层壳剥掉,业务层拿到的就是纯数据,不用每次写res.data.data。第三,request<T>这个泛型函数让调用方指定返回类型,比如request<UserInfo>({ url: '/user' }),返回的就是Promise<UserInfo>,类型一路贯通。
api/目录下按业务模块再包一层:
// api/user.ts import { request } from '@/utils/request' import type { UserInfo, PageParams, PageResult } from '@/types/api' export function getUserInfo() { return request<UserInfo>({ url: '/user/info', method: 'get' }) } export function getUserList(params: PageParams) { return request<PageResult<UserInfo>>({ url: '/user/list', method: 'get', params }) }业务组件里直接const info = await getUserInfo(),info的类型就是UserInfo,编辑器能自动补全字段。这就是「一次封装、处处受益」——类型从接口定义一路推到组件,中间不用手动断言。
4.2 状态管理:Pinia 的 setup 写法与持久化
这份源码用 Pinia 做状态管理,而且用的是 setup 写法,和组合式 API 风格统一:
// stores/user.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { getUserInfo } from '@/api/user' import type { UserInfo } from '@/types/api' export const useUserStore = defineStore('user', () => { // state const token = ref<string>(localStorage.getItem('token') || '') const userInfo = ref<UserInfo | null>(null) // getters const isLogin = computed(() => !!token.value) const nickname = computed(() => userInfo.value?.nickname ?? '未登录') // actions async function login(newToken: string) { token.value = newToken localStorage.setItem('token', newToken) } async function fetchUserInfo() { userInfo.value = await getUserInfo() } function logout() { token.value = '' userInfo.value = null localStorage.removeItem('token') } return { token, userInfo, isLogin, nickname, login, fetchUserInfo, logout } })setup 写法的好处是:ref、computed、函数混在一起写,逻辑内聚,不像 options 写法那样 state、getters、actions 分三块。isLogin和nickname是计算属性,依赖变化自动更新。token同步写localStorage,刷新页面不丢登录态——这是最常见的持久化做法,简单直接。如果状态多、想统一管理持久化,可以上pinia-plugin-persistedstate,配置一下persist: true就行,不用每个字段手写localStorage。
组件里用的时候:
import { useUserStore } from '@/stores/user' import { storeToRefs } from 'pinia' const userStore = useUserStore() // 解构响应式状态要用 storeToRefs,否则丢失响应性 const { isLogin, nickname } = storeToRefs(userStore) // 方法直接解构,不需要 storeToRefs const { logout } = userStore这里有个高频翻车点:直接const { isLogin } = userStore会丢失响应性,因为解构出来的是普通值。必须用storeToRefs包一层,它只对 state 和 getters 生效,方法不用包。这个坑几乎每个用 Pinia 的人都踩过。
4.3 组合式函数:把可复用逻辑抽成 useXxx
composables/里的组合式函数是 Vue3 逻辑复用的核心。举个最常见的例子,列表页的分页逻辑:
// composables/usePagination.ts import { ref } from 'vue' import type { PageParams } from '@/types/api' export function usePagination<T>( fetcher: (params: PageParams) => Promise<{ list: T[]; total: number }> ) { const list = ref<T[]>([]) as Ref<T[]> const total = ref(0) const page = ref(1) const pageSize = ref(10) const loading = ref(false) async function load() { loading.value = true try { const res = await fetcher({ page: page.value, pageSize: pageSize.value }) list.value = res.list total.value = res.total } finally { loading.value = false } } function changePage(newPage: number) { page.value = newPage load() } return { list, total, page, pageSize, loading, load, changePage } }这个函数接收一个fetcher,返回列表、分页参数、加载状态和操作方法。任何列表页只要传入自己的请求函数,就能拿到完整的分页能力,不用每个页面重写一遍。loading用try/finally保证请求失败也能关掉,避免转圈卡死。ref<T[]>([]) as Ref<T[]>这个断言是因为空数组推导不出元素类型,需要手动指定。
组件里用:
const { list, total, loading, load, changePage } = usePagination(getUserList) onMounted(load)三行代码搞定一个列表页的数据层。这就是组合式函数的价值——逻辑和 UI 解耦,测试也方便,单独测usePagination就行,不用挂载组件。
5. 避坑与排查:类型报红、响应性丢失与构建失败
5.1 编辑器报红但项目能跑
现象:import '@/utils/request'在编辑器里标红「找不到模块」,但pnpm dev启动后页面正常。
原因:vite.config.ts里配了 alias,但tsconfig.json里没配对应的paths。Vite 用 alias 解析路径,TypeScript 用paths解析,两套配置各管各的。
解决:在tsconfig.json的compilerOptions里加:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }改完重启编辑器(不是重启 dev server),让 TS 服务重新加载配置。
5.2 解构 store 后页面不更新
现象:从 Pinia store 里解构出isLogin,登录后页面上的登录状态没变。
原因:直接解构丢失了响应性,拿到的是快照值。
解决:用storeToRefs包一层,只对 state 和 getters 用,方法直接解构。这个坑的隐蔽性在于:不报错,只是不更新,新手容易怀疑是 Pinia 本身的问题。
5.3 构建时类型检查失败但开发时正常
现象:pnpm dev一切正常,pnpm build报一堆类型错误。
原因:Vite 开发时用 esbuild 转译,跳过类型检查;build脚本里跑了vue-tsc --noEmit,才做全量检查。
解决:别绕过它。先跑pnpm type-check看完整错误列表,按文件归类,很多错误是同一个类型定义引起的。常见根因是接口返回类型和实际不符,改types/里的定义即可,不要用any糊。如果确实要临时跳过,把build脚本里的vue-tsc --noEmit去掉,但这是后悔药,上线前一定补回来。
5.4 响应式对象整体替换后视图不更新
现象:reactive定义的对象,整体赋值state = newObj后视图没变。
原因:reactive返回的是代理对象,整体替换会丢掉代理引用。
解决:要么用Object.assign(state, newObj)逐字段赋值,要么直接用ref定义对象,赋值时state.value = newObj。我一般复杂对象用ref,简单表单用reactive,看场景选。
5.5 环境变量读取为 undefined
现象:import.meta.env.VITE_API_BASE_URL拿到undefined。
原因:变量名没加VITE_前缀,或者.env文件没放在项目根目录,或者改完没重启 dev server。
解决:确认前缀、确认文件位置、重启。Vite 只在启动时读环境变量,热更新不会重新加载.env。
6. 二次开发:从改一个接口到加一个完整模块
拿到这份源码做二次开发,最稳的路径是「先跑通、再小改、最后加模块」。小改可以从改一个接口开始:找到api/下对应的文件,改 URL 和返回类型,然后顺着类型报错一路修到组件——TypeScript 的好处就在这里,改一个类型,所有用到的地方都会提示你哪里要跟着改,不会漏。
加一个完整模块,我一般按这个顺序走,每一步都能验证:
| 步骤 | 动作 | 验证方式 |
|---|---|---|
| 1 | 在types/加模块的数据类型 | pnpm type-check无报错 |
| 2 | 在api/加接口函数 | 单独调用能拿到数据 |
| 3 | 在stores/加状态(如果需要跨页面共享) | 组件里能读写 |
| 4 | 在views/加页面组件 | 页面能渲染 |
| 5 | 在router/注册路由 | 能跳转 |
| 6 | 抽公共逻辑到composables/ | 复用无重复代码 |
这里有个技巧:第 3 步不是必须的。很多人一上来就把所有数据塞 store,结果 store 越来越臃肿。判断标准是「这个状态是否多个不相关页面都要用」,是才放 store,否则放组件本地或组合式函数里就够了。我见过一个项目把表单的临时输入都放 store,最后调试时根本分不清哪个页面改了它,这是典型的过度设计。
再讲一个具体改造场景:把请求封装从 axios 换成 fetch。很多人觉得换请求库要动很多地方,其实只要utils/request.ts的对外接口不变,业务层完全不用改。做法是保持request<T>的签名,内部用 fetch 实现:
// 用 fetch 重写 request,对外签名不变 export async function request<T>(config: { url: string method?: string params?: Record<string, unknown> data?: unknown }): Promise<T> { const base = import.meta.env.VITE_API_BASE_URL const query = config.params ? '?' + new URLSearchParams(config.params as Record<string, string>) : '' const token = localStorage.getItem('token') const res = await fetch(`${base}${config.url}${query}`, { method: config.method?.toUpperCase() ?? 'GET', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: config.data ? JSON.stringify(config.data) : undefined }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const json = (await res.json()) as ApiResponse<T> if (json.code !== 0) throw new Error(json.message) return json.data }业务层的getUserInfo一行都不用改,因为它们只依赖request<T>这个签名。这就是封装的价值——把变化挡在一个文件里。当然,axios 的拦截器、取消请求、超时重试这些能力 fetch 要自己实现,换之前想清楚是不是真有必要。
最后一个习惯:每次改完一个模块,我都会跑一遍pnpm type-check再跑pnpm build,两个都过才算完。类型检查能抓出大部分低级错误,构建能抓出环境变量、动态导入路径这类运行时才暴露的问题。从那以后我每次动types/或api/都强制走一遍这两个命令,宁可多花两分钟,也不想上线后收到「页面白屏」的消息。希望这份源码拆解能帮你少走点弯路,跑起来、改起来,比读十遍文档都管用。
本文还有配套的精品资源,点击获取