
最近把练手项目用 Vue3 TypeScript 从零搭了一遍前后花了大半个月踩了不少坑也把“只会复制粘贴模板代码”的老毛病治好了。这个项目本身不复杂——一个带登录、列表、表单、路由权限的小型后台页面但所有东西都是自己动手配的Vite、tsconfig、路由、状态管理、请求封装、组件封装全链路走通之后再看 vue3面试题很多以前只能硬背的东西突然就能说通了。这篇文章就围绕这个练手项目把“新手从零搭建 Vue3 TypeScript 项目”这件事拆开讲透。我会按真实操作顺序来写为什么选这套组合、环境怎么配、每一步做了什么、遇到了哪些经典问题、怎么排查。里面提到的坑都是我实际踩过的能帮你少走不少弯路。不管你是刚学完 JavaScript 基础还是用过 Vue2 想转 Vue3这篇都可以直接拿来当操作目录。1. 为什么从零搭一个 Vue3 TypeScript 项目1.1 Vue3 相比 Vue2 到底变在哪很多人一开始学 Vue3关注点全在setup、ref、reactive这些 API 上觉得只是写法变了。但真正从零搭过一次项目就会发现变化远不止这些。响应式原理从 Object.defineProperty 换成了 Proxy组件的逻辑复用从 mixin 变成了组合式函数体积和性能也有不少优化。这些不是“进阶知识”而是你用 Vue3 写每一行代码时底层都在发生的事情。用 Proxy 做响应式带来的一个直接好处是新增属性、数组按下标赋值、删除属性这些操作都能被侦测到。以前 Vue2 里this.obj.newProp 1不响应要用$set在 Vue3 里直接赋值就行不需要那些补救手段。这个变化对新手尤其友好写出“改了数据但不生效”这种 bug 的概率低了很多。组合式 API 的价值要等真正拆分代码的时候才体现得出来。同一个功能相关的数据、方法、计算属性、生命周期逻辑以前要按选项类型分散到 data、methods、watch 里现在可以全部收在一个useXxx函数里。项目里两个页面都用到了表格加载和搜索抽出一个useTable组合式函数之后两个页面各用几行就接上了不用再复制粘贴一大堆 options。1.2 TypeScript 对新手是负担还是机会我刚开始写 TypeScript 也有点发怵觉得类型定义、泛型、接口这些概念太抽象。但实际做下来发现TypeScript 给新手带来的安全感远大于学习成本。最直观的感受是编辑器提示路由传参、组件 props、接口返回值全部有类型约束写错了立刻就能看到红色波浪线而不是运行到浏览器里才报错。拿接口定义举个例子。开发一个后台项目后端返回的数据结构往往是嵌套的{ code: number, data: { list: [], total: number } }。用 TypeScript我可以先写好ApiResponseT这种泛型接口再定义具体业务类型比如用户信息UserInfo。请求函数返回什么类型、页面里拿到的数据长什么样都清清楚楚。以前用 Vue2 JS 的时候接口返回的数据类型全靠看 console 猜或者运气好遇到文档写全的否则经常data.list[0].name写错字段名才知道。对新手来说TypeScript 更像是一个“在自己身边盯着代码的老师”它会通过类型系统提前告诉你哪些地方可能出现运行时报错比如访问了可能为空的数组元素、给方法传了错误类型的参数。这些错误在用纯 JavaScript 时往往要等运行到那一行才会暴露而 TS 在编译阶段就拦下来了。1.3 为什么不用脚手架一键生成用npm create vitelatest生成一个空白模板这和“从零搭建”并不矛盾。Vite 脚手架解决的是工程化基础配置生成的是最小可运行骨架真正项目的目录结构、代码规范、请求封装、环境变量、路由组织方式都得自己设计。我用脚手架把项目壳子拉起来之后自己动手调整了目录结构、加了路径别名、配了环境变量、写了请求封装这些才是项目的灵魂。同时理解脚手架生成的每一份配置也很重要。比如vite.config.ts里 server 代理是什么意思、tsconfig.json里每个编译选项是干嘛的这些如果不自己过一遍后面遇到“局域网打不开页面”“TS 一直报错”就会一头雾水。用现成的后台管理模板看起来很省事但出现问题的时候根本不知道从哪排查因为里面集成了太多你不了解的东西。自己从空白搭建等于把知识脉络重新理了一遍。2. 环境准备与项目初始化实操2.1 Node 版本和包管理器怎么选搭建的第一步不是写代码而是确认环境。Node 的版本直接影响 Vite 能不能正常运行。Vite 5 和 Vite 6 要求 Node 18 或 20如果电脑里还是 Node 14 或 16启动项目时会直接报错提示版本不支持。我个人建议装 Node 20 LTS稳定性好生态兼容性也最好。检查版本用node -vnpm 一般会跟着 Node 一起装上用npm -v确认就行。包管理器我试过三种npm、pnpm、yarn。npm 是 Node 自带的最省事但安装速度慢一点依赖多了之后 node_modules 体积也大。pnpm 用硬链接共享依赖装包速度快磁盘占用也低但要注意它默认的依赖结构比较严格个别包在 pnpm 下会有兼容问题。yarn 现在用得少了个人建议新手直接用 npm等熟悉之后再换 pnpm 也不迟。换源这个事也要做。国内网络环境下直接用默认 npm registry 装包速度慢到怀疑人生还经常超时。配置淘宝镜像源命令很简单npm config set registry https://registry.npmmirror.com执行后可以用npm config get registry确认是否生效。这个步骤做完后面安装依赖会快很多也算是一次环境预检。2.2 创建项目与基础配置创建项目我用的是 Vite 官方提供的命令npm create vitelatest命令行会交互式询问项目名称和模板类型选择 Vue TypeScript 就行。生成的项目结构很精简核心文件就这几个index.html是页面入口、vite.config.ts是构建配置、src/main.ts是应用入口、src/App.vue是根组件。首次接触时先理解这四块就够了。基础配置第一步是路径别名。开发时我们会把组件、页面、工具函数分门别类放进 src 下的不同目录如果导入路径写成../../components/Button.vue这种相对路径层级一深就分不清到底跑到哪个目录里去了。配置路径别名可以把指向src目录导入时写成/components/Button.vue直观又不容易出错。Vite 里配置如下// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, src) } } })光配 Vite 还不够tsconfig.json里也要同步声明路径映射否则 TypeScript 编辑器提示会报找不到模块{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }2.3 tsconfig 配置细节与新手易踩的坑新版本 TypeScript 在 tsconfig 里会有一些弃用警告。装完依赖之后第一次运行npm run dev或者tsc --noEmit控制台可能就会显示类似这样的提示选项 baseUrl 已弃用并将停止在 TypeScript 7.0 中运行。请使用 paths 的替代方案。这不是你的配置写错了是 TypeScript 5.x 之后对这个选项的规划发生了变化。baseUrl以前是用来解析非相对路径导入的根基目录在新版本里paths不再依赖baseUrl也能工作所以编译器建议你删掉baseUrl只保留paths和相对位置。我在实际项目里的做法是直接删掉baseUrl把paths写成指向./src/*警告就消失了。还有一个常见警告是选项 moduleResolutionnode10 已弃用并将停止在 TypeScript 7.0 中运行。这个是因为 Vite 项目默认的模块解析方式更适合设为bundler。node10是老时代 Node 的解析策略面对现在的前端生态export条件导出、TS 的paths映射、npm 包的exports字段已经不够用了。改成moduleResolution: bundler是 Vite Vue3 项目的标准配置。新的 Vue3 TS 项目可以直接在 tsconfig 里写{ compilerOptions: { target: ESNext, module: ESNext, moduleResolution: bundler, strict: true, jsx: preserve, resolveJsonModule: true, esModuleInterop: true, skipLibCheck: true } }strict: true这个选项我特意说一下。新手容易觉得严格模式太麻烦动不动就报“可能为 null”“类型不匹配”。但严格模式才是 TypeScript 真正发挥保护作用的地方。比如document.getElementById(app)返回的类型可能是HTMLElement | null如果不处理空值直接操作运行时真的可能在页面上炸掉。严格模式逼你先处理这个情况写出来的代码自然更稳。2.4 引入 UI 组件库的两种方式和注意点练手项目里我选了 Element Plus这也是 vue3后台管理系统里最常用的 UI 库之一。引入方式有两种全量引入和按需引入。全量引入最简单main.ts里import ElementPlus from element-plus然后app.use(ElementPlus)再引入样式文件就行。优点是什么都不用想缺点是打包体积很大首屏加载慢。按需引入需要额外装一个插件unplugin-vue-components和unplugin-auto-import它在编译时自动把用到的组件和 API 引入进来打包后只包含实际使用的组件。很多新手遇到“vue3引入所有的UI框架都不生效”这个问题多半是缺了一个关键步骤vitejs/plugin-vue没安装或没配置。Vite 脚手架生成的模板已经带了 Vue 插件但如果你是自己手动创建的vite.config.ts忘了加这行插件配置.vue文件根本编译不了组件自然全部不生效。检查vite.config.ts里有没有import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()] })这个坑我在清理配置文件的时候遇到过删掉几行之后启动页面全白了控制台报“Failed to resolve component”排查半天才发现是插件丢了。还有样式文件问题Element Plus 的样式需要单独引入否则组件虽然渲染了但没有任何样式看起来就像“没生效”。3. 核心功能模块的实战实现3.1 路由配置与页面骨架练手项目要有一个基本的页面骨架侧边栏菜单、顶部导航、内容区。用 vue-router 4 来搭。先安装npm install vue-router4在src/router/index.ts里定义路由。因为要做登录拦截设置一个/login页面还有登录后的主布局页面以及首页、列表页和表单页。路由配置可以用懒加载写法在访问时才加载对应组件减少首屏体积const routes [ { path: /login, component: () import(/views/LoginView.vue) }, { path: /, component: () import(/layout/MainLayout.vue), redirect: /dashboard, children: [ { path: dashboard, component: () import(/views/DashboardView.vue) }, { path: list, component: () import(/views/ListView.vue) }, { path: form, component: () import(/views/FormView.vue) } ] } ]然后main.ts里app.use(router)。路由跳转用router.push(/list)模板里用router-link或者useRouter()的编程式导航都行。需要注意的是 vue-router 4 中用createRouter和createWebHistory不再像 vue-router 3 直接用new VueRouter。页面骨架我用了 Element Plus 的el-containerel-asideel-headerel-main侧边栏菜单用el-menu通过router属性开启路由模式。每个菜单项的index直接写成路由路径点击菜单自动跳转。3.2 数据请求封装与拦截器项目里所有数据请求我都封装在一个统一的模块里用 axios 作为底层。为什么不用多个页面各自调用axios.get(/api/list)因为要统一处理 token、错误提示、加载状态不然每个页面写一遍代码逻辑重复而且非常难维护。src/utils/request.ts的核心结构是这样的import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } // 统一提示错误 return Promise.reject(new Error(res.message || 请求失败)) }, error { if (error.response?.status 401) { // 跳转登录页 } return Promise.reject(error) } )封装好之后每个 API 模块只需要定义接口类型和请求函数页面直接调用就行。比如用户模块export interface UserInfo { id: number name: string email: string } export function fetchUserInfo(id: number) { return request.get{ code: number; data: UserInfo }(/user/${id}) }这样页面上拿到返回值后类型提示齐全字段名称不会写错。这是 Vue3 TS 项目里最值得花时间的封装。3.3 状态管理怎么选练手项目用不用 Pinia我的建议是只要有一个全局状态需要被多个页面共享就该上。我用 Pinia 管理用户登录信息和侧边栏展开状态。安装npm install piniamain.ts里app.use(pinia)。创建一个 storeimport { defineStore } from pinia import { ref } from vue export const useUserStore defineStore(user, () { const token refstring() const userInfo refUserInfo | null(null) function setToken(value: string) { token.value value localStorage.setItem(token, value) } function clearUser() { token.value userInfo.value null localStorage.removeItem(token) } return { token, userInfo, setToken, clearUser } })Pinia 的 setup 写法配合 TypeScript 很顺手状态、获取器、action 都是普通变量和函数类型推断基本不用手动标注。相比 Vuex 要写一堆 mutation、action、getter 样板代码Pinia 清爽多了。3.4 组合式 API 与 TypeScript 结合在 Vue3 组件里脚本部分使用script setup语法。一个带类型约束的列表页长这样script setup langts import { ref, onMounted } from vue import { fetchList, type ListItem } from /api/list const loading ref(false) const list refListItem[]([]) const total ref(0) async function loadData() { loading.value true try { const res await fetchList({ page: 1, pageSize: 10 }) list.value res.data.list total.value res.data.total } finally { loading.value false } } onMounted(() { loadData() }) /scriptrefListItem[]([])里面写了类型参数这样list.value[0].name这种访问就有提示了拼错字段名会直接标红。function loadData()定义的函数也能被模板直接使用不用像 Options API 那样挂在 methods 里。组件之间通信也用类型约束。父组件传 props 给子组件时子组件用defineProps接口声明const props defineProps{ title: string data: ListItem[] }()这样父组件传错类型会立刻报错子组件接收到的 props 在模板里也有类型提示。事件用defineEmits声明名称和参数类型。4. 实战中的常见问题与排查口诀4.1 UI 组件不生效页面空白出现“引入所有 UI 框架都不生效”这种问题第一步先看控制台有没有报错。常见有三种情况第一Vite 插件缺失。vitejs/plugin-vue没配好.vue文件编译失败页面空白。检查vite.config.ts。第二样式没有引入。Element Plus 全量引入时要import element-plus/dist/index.css按需引入时要在 vite 配置里把样式相关的插件也加上比如unplugin-element-plus。第三组件名称拼写错误比如el-button写成了el-buton模板不会报错但组件渲染不出来。这时候打开浏览器 F12找到那个区域看 DOM 结构如果只是一个普通元素没有渲染成组件那就是名称或引入问题。4.2 on-success 监听不到回调Element Plus 的el-upload组件里上传成功后的回调是on-success。我在练手项目里遇到回调不触发排查后发现了两个原因。一是action属性指向的上传地址返回了非预期格式。on-success回调能触发但参数里的 response 是后端原始返回。如果后端返回错误状态码可能走了on-error。要分清楚上传请求确实是成功结束还是 HTTP 层就失败了。二是上传组件放在el-form里此时触发上传前会用 form rules 先校验如果表单里有未通过校验的其他字段on-success虽然不会受影响但整个提交按钮的逻辑可能会被 form 的校验拦下来让人误以为组件没回调。这个排查思路建议记住表单和上传组件混在一起时的报错先拆开定位。4.3 “baseUrl 已弃用”和“moduleResolutionnode10 已弃用”这两个是当前 TypeScript 新版本最常见的配置警告不算代码 bug但不清除的话控制台一直有黄色警告严谨的团队会视为配置不规范。解决办法在 2.3 小节说过删掉baseUrl把moduleResolution改成bundler。如果你用的还是旧版配置模板看到这两个警告不用紧张按照编译器提示去改就行。注意不要把bundler乱改成node或者node16它们面对现在的 Vite 项目不一定合适。改完重启一下编辑器和 dev server让配置重新加载。4.4 vite dev 局域网访问空白这个典型场景是手机或者另一台电脑访问http://192.168.x.x:5173页面一片空白但本机访问 localhost 正常。原因很简单Vite dev server 默认只绑定 localhost不允许外部设备访问。解决方法是在vite.config.ts里设置 server.host server: { host: true }设置为true表示监听0.0.0.0所有可用的网络地址都可以访问。另外如果项目里用了路由的 history 模式createWebHistory后端服务器如果没有配置 fallback刷新子路径页面会 404。dev 阶段出现的“局域网能打开首页但刷新子路由就空白”多半是这个原因可以在 dev 配置里加historyApiFallback类似的处理具体要看运行环境。4.5 页面中文乱码和请求跨域前端请求后端接口时Vite dev 阶段最常见的处理方式是用 server.proxy 代理跨域。配置在vite.config.ts里server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }意思是以/api开头的请求会被转发到http://localhost:8080并且改写掉前缀。这样做的好处是开发时前端的请求路径都是相对地址/api/...和后端环境无关以后部署时只需要调整链路方式不用改动业务代码。乱码问题一般是响应头的 charset 设置不过后端接口常见的是 utf-8前端也保持统一即可遇到 GBK 返回的接口选项有限直连时可以解码本地代理转发不会改变字节内容但实际项目里这种乱码主要靠后端统一编码解决。5. 练手项目的面试价值与“再进一步”的建议5.1 新手面试中经典的 Vue3/TS 问题热搜词里有一条“vue3面试最经典6个问题”几乎每个面试官都会从这里挑几个问第一个Vue3 的响应式是怎么实现的很多人只回答“用了 Proxy”但深一层要能解释 Proxy 相比 Object.defineProperty 的优势可以监听新增属性、删除属性、数组下标修改而 defineProperty 只能劫持已有属性的 getter 和 setter。还可以说一下 Vue3 为了兼容会把 reactive 对象递归代理以及 ref 内部也是通过 reactive 实现的。第二个ref和reactive的区别。ref支持基本类型通过.value访问模板中自动解包reactive只支持对象类型直接返回代理对象。本质上ref内部也会把对象用 reactive 包装。这个考点要能现场说出来并举例。第三个Composition API 相比 Options API 的优势。核心不是写法炫酷而是逻辑组织方式的改变。同一功能的代码可以聚在一起抽离成组合式函数多页面复用逻辑时不用依赖 mixin 的命名冲突和隐式共享。第四个nextTick的作用。Vue 的 DOM 更新是异步的修改数据后不能立刻拿到更新后的 DOMnextTick和flush-promises就是等 DOM 更新完再执行回调的工具。面试中问这个主要是看你对 Vue 更新机制的理解。第五个组件通信方式。父传子用 props子传父用 emit跨层级用 provide/inject复杂状态用 Pinia。面试时要能说清楚每种方案的适用场景。第六个v-for中key的作用。简单说就是 DOM 复用用稳定唯一的值标识虚拟节点帮助 diff 算法高效更新。不建议用数组 index 当作 key当数组顺序变化时可能造成错误的复用。TypeScript 维度常见的问题则是“interface和type的区别”“泛型的实际使用场景”“any和unknown的区别”。新手能把这些问题结合自己做过的项目讲一遍会比背书强很多。5.2 TypeScript 在项目里的几个高频考点接着 5.1 把 TS 这块也梳理一下。面试时如果简历写了 TypeScript大概率会被问这几个any和unknown的区别。any直接绕过了类型检查等于让 TS 失效unknown表示未知值你需要先收窄类型才能操作。项目里遇到老代码或第三方库类型不全时我一般用unknown加断言来处理而不是无脑any。interface和type我现在的习惯是定义对象结构优先用interface因为它支持继承、声明合并联合类型、交叉类型、工具类型推导这类用type。Vue3 组件里定义 props 和 emit 类型时用interface显得更规范。泛型也会问最基本的场景就是请求函数封装request.getT(url)的T就是响应数据类型。能说出“泛型是为了不事先指定类型调用时再确定类型”这种话加上自己封装的例子就够了。5.3 把这个练手项目继续扩展的思路搭完这个项目后我在想还能做什么。可以参考热词里的 vue3商城、vue3后台管理系统、vue3 three.js 机房这类方向但不要一上来就整大的。我建议从小功能入手一是加一个“全局搜索”组件基于computed对列表数据做过滤练习计算属性的响应式依赖。二是加一个“用户详情抽屉”打通路由传参和详情接口请求练习动态路由参数的使用。三是把列表页的搜索条件抽成一个自定义组件通过 v-model 双向绑定实现条件保留和重置这个思路在后台管理系统里非常常见。四是尝试做一个支持动态增删表单行的页面这几乎是 vue3动态添加删除form表单一行数据的标准场景能练到数组操作和表单校验的配合。如果还想深入了解框架本身可以去看 vue3源码解析类的文章比如createApp内部做了什么、setup的执行时机、组件挂载流程。不过这一步对新手略早先把业务场景吃透再啃源码不迟。6. 我最后想说的几句实在话从零搭一个 Vue3 TypeScript 项目最宝贵的不是最后做出来的那个页面而是过程中被迫搞懂的那一堆“为什么”。为什么需要插件列表为什么 TS 严格模式会报错为什么打包体积那么大这些都会在配置、运行、排错的过程中形成真实记忆比看十篇教程都深刻。如果让我给新手一个顺序建议先把项目的初始化、路径配置、请求封装做完再加一个简单的列表页和表单页然后补一个登录拦截流程。做到这里Vue3 TS 的核心链路就全走了一遍剩下的都是量变。后面遇到奇怪的问题记住一套排查逻辑先看控制台报错再拆功能模块然后查配置项最后才考虑是不是框架 bug。大多数时候都是自己的小疏忽。这个项目我还会继续迭代下一步想加基于角色的路由权限控制把不同登录身份看到的菜单区分开。到时候如果碰到新坑再来分享。提示新手做完项目后记得把.gitignore配置好避免把node_modules提交到仓库。我吃过这个亏仓库一拉下来几十万的node_modules文件又卡又慢。