
去年我开始意识到一个尴尬的事实我用 Vue 写了快两年的业务代码能独立维护三四个中后台项目但一旦被问到“v-if 和 v-show 的底层区别”“为什么 v-for 不推荐用 index 做 key”这类基础问题时我的回答总是支支吾吾。说白了我是那种“会用、但不完全懂”的前端。为了把这个窟窿补上我给自己排了一个系统学习计划把一套 22 集的 Vue 教程从头到尾过一遍每看一集就跟着敲一遍代码再写一篇学习记录。这篇文章就是这个学习过程的总结覆盖了 Vue 安装及环境配置、模板语法、组件化、路由、axios 联调、响应式原理和高频面试题。如果你也处于“能跑业务但讲不清原理”的阶段这篇内容应该能帮你少走不少弯路。1. 环境搭建Vue 安装及环境配置里的那些暗坑1.1 版本选择先学会用 nvm 管理 Node学 Vue 的第一步不是写代码而是把环境搞好。很多人一上来就npm init vuelatest结果报错一堆原因基本都是 Node 版本不对。这里强烈建议先装nvmNode Version Manager用它来灵活切换 Node 版本。你电脑上可能同时存在几个项目老项目要用 Node 14新项目要用 Node 20如果不做版本隔离单是 node_modules 的兼容问题就能折腾一整天。我的实际顺序是# 先确认当前 Node 版本太老就直接换 node -v # 用 nvm 安装指定的 LTS 版本 nvm install 20 nvm use 20 node -vVue 3 官方推荐 Vite 作为构建工具而 Vite 4 开始对 Node 版本有硬性要求至少要 14.18推荐 16 以上。如果版本太低Vite 会直接拒绝启动开发服务器。用 nvm 的好处是切到项目目录之后可以写一个.nvmrc文件固定版本团队其他人拉代码后执行nvm use就能保持一致环境这类问题基本不会再犯。1.2 用 Vite 还是 Vue CLI我给了自己一个明确答案学习过程中肯定绕不开一个选择Vite还是Vue CLI我的建议是新项目无脑选 Vite但如果你要接手的是老项目还是得会 Vue CLI。Vite 的底层思路是依赖预构建 原生 ES Module 按需加载开发模式启动速度比 Webpack 快非常多。我印象很深第一次用 Vite 跑项目冷启动大概是 300ms 级别而同配置的 Vue CLI 往往要等 3 到 5 秒。创建项目的命令很简单npm create vuelatest my-vue-app # 或者直接创建 Vite 模板 npm create vitelatest my-vue-app -- --template vue按提示选择是否需要 Vue Router、Pinia、ESLint 等脚手架会把基础工程搭好。有一个我踩过的坑如果项目名叫vue或者包含中文路径npm 会报错最好统一用英文小写加短横线命名比如my-admin-app。1.3 Vue Devtools 必须装但装完有几个细节调试 Vue 项目离不开浏览器插件 Vue Devtools。这里有一个新手容易困惑的点Vue 2 用的是旧版 DevtoolsVue 3 必须用新版。如果你两个项目都维护最好把两个扩展都装上然后根据当前项目手动启用或禁用避免混淆。我在 Chrome 上装完后遇到一个问题本地开发地址是http://localhost:5173打开插件面板却一直显示“No Vue application detected”。排查了半天发现是浏览器在无痕模式下默认禁用了扩展需要在扩展详情页里把“允许在无痕模式下运行”打开。另一个常见原因是项目跑在 iframe 里插件默认只识别顶层页面需要调整Devtools设置里的“允许访问文件网址”。这些细节不亲自踩一遍光看官方文档根本意识不到。2. 模板语法把“操作 DOM”的思维换成“描述状态”的思维2.1 插值表达式和 v-bind 的基础规则学 Vue 最大的思维转变就是以后不再手动document.getElementById取元素而是专心维护数据状态。模板中{{ }}里的内容支持任意 JavaScript 表达式比如{{ count * 2 }}、{{ user.name || 匿名 }}但不支持完整的语句比如if判断就不能写在里面只能写三目表达式或者计算属性。v-bind用于动态绑定属性最常用的是缩写成:href、:class、:style。这里有一个细节class除了传字符串还能传对象或数组。template div :class[card, { active: isActive }].../div /template学习时很多人会忽略:style中的驼峰规则比如font-size写成fontSizeVue 会自动转换。如果涉及 CSS 自定义属性要用--color这种写法而且得配合cssVars相关的绑定方式否则会不生效。2.2 v-model双向绑定只是一个语法糖v-model是高频指令很多人都知道它能在input、select、textarea上实现数据双向绑定但真正理解它本质的人并不多。其实它就是value绑定和input事件监听的组合写法等价于input :valuemessage inputmessage $event.target.value知道这一点有什么好处当你在自定义组件上用v-model时发现不生效就不会一脸懵。自定义组件里要定义modelValue的 prop并且通过emit(update:modelValue, newValue)实现更新这才是组件库里的完整协议。我学到这里才明白 Element Plus 的el-input为什么能直接v-model因为人家组件内部就是这样约定的。v-model还有几个常用修饰符.trim自动去首尾空格.number把用户输入转成数字.lazy把input事件改成change事件触发。.number这个修饰符我后来在车牌号输入页面、金额输入页面上都用到过非常实用。2.3 v-if 和 v-show 的取舍是我学习中的一个重要拐点这个问题几乎出现在每一篇 Vue 面试题里也是我学习路上的第一个“原来如此”时刻。v-if是真正的条件渲染条件为假时元素不会出现在 DOM 中v-show则只是切换 CSS 的display属性元素始终存在。两者的初始开销和切换开销正好相反对比项v-ifv-showDOM 存在性不存在始终存在初始渲染成本低只在为真时渲染高一开始就渲染切换成本高需要增删节点低只改 display适用场景低频切换条件少变高频切换例如 Tab 切换实际业务中我做过一个表单页里面有个“是否显示高级配置”的折叠区点开频率很高用v-show更顺手而像“登录后显示用户面板”这种状态切换频率很低的同时又牵扯权限用v-if更安全可以避免未登录状态下将节点暴露在 DOM 里。因为v-if为假时组件实例会被销毁条件变真时会重新创建组件所以如果你的子组件里有定时器切换频繁时很容易出现“多个定时器叠加”的问题这点要特别小心。2.4 v-for 的 key 为什么不能乱写v-for配合key是列表渲染的核心。很多初学者为了不报错直接写:keyindex这其实隐藏着很大的 bug 风险。当你只需要静态渲染一个不会变化的列表时index问题不大但一旦列表允许增删、排序或筛选用 index 作为 key 会导致 Vue 的复用机制把“原来位置的元素”误认为“新位置的元素”从而出现状态错乱。比如一个可勾选列表删除第一项后第二项会“继承”第一项的勾选状态因为它在 DOM 中的位置被复用了但组件状态却还是旧的。正确做法是用业务唯一 ID没有唯一 ID 时至少用内容组合生成一个稳定标识。对于v-for配合自定义组件的情况key 显得更加重要——组件实例的复用和销毁完全依赖 key 判断。做列表拖拽排序时这个体会尤为强烈排序一变index 全乱页面上的输入框内容就全串了。3. 组件化开发props、emit、slot 和生命周期3.1 props 的单向数据流和事件机制组件化开发的核心就是“数据从哪来事件往哪发”。props是从父组件传进来的数据Vue 强制单向数据流子组件不允许直接修改 props。一开始我很不理解改一个 prop 有什么危险后来维护项目才发现如果子组件随意修改 props数据流就变得不可预测多个子组件共用同一个父数据时排查问题会非常痛苦。所以正确做法是子组件把 props 复制到本地 data或者直接用 computed 基于 props 派生新值再通过emit通知父组件修改。script setup defineProps([title]) const emit defineEmits([update]) /script这里有个细节defineProps和defineEmits在script setup中都是编译器宏不需要手动 import。在组件上使用方法和 prop 的命名时模板里推荐 kebab-case比如my-prop而定义时用 camelCaseVue 会自动转换这个约定绑定 ESLint 后通常会强制提示。3.2 插槽让组件从“写死”变成“组装”如果说 props 解决的是数据传递那slot解决的是结构复用。一个组件如果只能接收 props 和 emit那它只能做参数化渲染有了插槽它就可以做布局容器由使用方来填充内容。slot分三种默认插槽、具名插槽、作用域插槽。具名插槽我常用于布局组件比如卡片组件的头部、主体、底部template div header slot nameheader/slot /header main slot/slot /main /div /template作用域插槽则是在插槽里拿到子组件的数据父组件收到后再自定义渲染方式。这个模式在做表格列自定义、下拉项自定义的时候几乎是刚需。Vue 3 的语法是用#default{ row }这种简写我一开始经常忘记#导致插槽没有生效还找不到原因所以如果模板渲染不出来先检查一下插槽名称和#写法。3.3 生命周期钩子的实际场景生命周期是所有组件化框架绕不开的内容。Vue 3 在script setup下最常用的是这几个组合式 APIonMounted组件挂载后执行我一般在这里发请求、初始化图表onBeforeUnmount组件卸载前执行用来清理定时器、取消事件监听onUpdated数据更新导致界面重新渲染后执行可以用来做“依赖某个响应式数据变化的副作用”。一个非常经典的问题在onMounted里发请求组件卸载后响应才回来这时候 setData 会触发警告或者在旧组件上更新状态。解决方法是结合onBeforeUnmount把请求取消或者用一个isUnmounted标记做判断。后来我用 axios 时统一在拦截器层面做请求取消这部分后面再细说。还有一个很容易被忽略的知识点v-if会导致组件的“挂载”和“卸载”反复发生而v-show只是隐藏不会触发这两个钩子。如果你在组件里用onMounted初始化了一次图表结果被v-if隐藏再显示图表实例可能就已经销毁了。遇到这种情况要么改成v-show要么在显示时重新初始化。3.4 组件拆分的粒度是一种经验积累学习到组件化这里我最大的收获不只是 API而是一种设计感。组件拆得太细props 和 emit 满天飞拆得太粗一个组件几百行没法维护。我踩过不少坑后的一个建议是先让功能跑通再回头看“哪一段内容可以复用”而不是一开始就抽象半天。判断标准很简单——同一个结构是否在页面里出现两次以上如果是它就有抽组件的价值如果只是临时用一次就没必要强行拆分。4. vue-router动态路由、参数传递和导航守卫4.1 基础路由配置的优化点学了 vue-router 之后我才算真正理解什么是单页应用。核心就是把 URL 和组件一一对应点击路由跳转时页面不刷新只替换路由对应的组件区域。Vue 3 下的基础配置很简单import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue const routes [ { path: /, component: Home }, { path: /about, component: () import(../views/About.vue) } ] const router createRouter({ history: createWebHistory(), routes })这里值得注意的两点第一createWebHistory对应 HTML5 History 模式URL 很干净但部署到服务器上需要额外配置 fallback否则刷新某个子路径就 404如果你不想折腾服务器可以改用createWebHashHistory也就是 hash 模式。第二组件按需加载的方式是用() import()这样在打包时会自动做代码分割首屏只加载必要的 JS而不是一次性全量加载整个应用。4.2 三种传参方式query、params 和动态路由路由传参是跳转页面的核心问题。Vue 3 中常见三种方式传参方式写法特点query/detail?fromhome参数暴露在 URL刷新不丢失params/detail/42需要配合动态路由声明参数不暴露为 querystate 属性history.state参数不进 URL适合大量数据传递动态路由的声明方式是在路由 path 里写:id组件里用route.params.id获取。这里有一个比较别扭的细节当从/detail/1跳转到/detail/2时组件会被复用不会重新触发created或onMounted。你需要手动监听route.params的变化或者给组件加:keyroute.params.id来强制重建。我第一次做商品列表进详情页时就被这个坑到过切换商品后详情还是上一个排查了老半天才反应过来是组件复用导致的。4.3 beforeEach 做登录拦截是后台项目刚需导航守卫中最典型的就是全局前置守卫beforeEach。后台管理系统几乎都会有这样一个逻辑判断用户是否登录没登录就跳转到登录页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })这个写法里有两个细节值得记下来一是通过meta标记哪些路由需要登录权限而不是用路径名去 if 判断后者的维护成本很高二是登录成功之后最好带上redirect参数跳回原来想访问的页面而不是傻傻地回到首页。我自己的项目中还加了一层动态路由逻辑根据用户角色从后端拉菜单权限再通过router.addRoute动态注册做权限管理系统的时候特别顺手。5. axios 封装与跨域前后端联调的真实场景5.1 一个二次封装的 axios 示例学 axios 的部分我最大的收获不是学会get和post而是学会封装。直接裸用 axios一旦项目里有几十个请求每个请求都要手动带 token、手动处理报错就非常痛苦。封装的核心就是统一“请求前”和“响应后”的逻辑。import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000 }) service.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( (response) { return response.data }, (error) { if (error.response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } ) export default service这里有两个非常实用的点一是 baseURL 放到环境变量里开发环境和生产环境可以配置不同的值不需要改代码二是响应拦截器统一拆包把response.data直接返回给业务代码页面里就不用每次都写.data.data。至于 401 跳登录这是后台系统的常规操作一定要写在拦截器层面统一处理而不是每个请求业务代码里单独判断。5.2 开发环境的跨域配置和生产环境部署前后端分离开发时跨域问题几乎是必遇的。浏览器出于同源策略限制localhost:5173访问localhost:8080的接口会受到限制。开发环境最简单的解决办法是在 Vite 配置里设 proxyexport default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })这段配置的意思是前端请求/api/userVite 代理到http://localhost:8080/user并自动修改 Origin 头从而绕过跨域限制。生产环境一般不会靠 Vite 代理而是由 Nginx 反向代理或者后端开启 CORS 来解决。这个原理搞清楚之后再看到某些报错信息就不会手足无措。5.3 数据交互中的异常处理要放在全局统一做很多初学者在业务代码里到处写try/catch不仅啰嗦还容易把报错信息吞掉。我的做法是全局拦截器统一弹出报错提示业务代码里只捕获自己需要关心的场景比如上传失败后的用户提示。定时器、轮询、防重提交这些逻辑的边界条件在真实项目里多得很只靠误打误撞解决不了问题还是要回归到“理解请求生命周期”这个根上。6. 学完 22 集后这些问题我可以用自己的话回答了6.1 computed 和 watch我终于知道它们的分工了以前在工作中我用 computed 和 watch 基本全凭感觉学完之后才彻底理清computed 适合“根据已有响应式数据派生新数据”它有缓存机制依赖不变就不会重算watch 适合“在数据变化时执行副作用”比如调接口、写日志、联动表单。对比项computedwatch使用目的派生计算侦听副作用缓存有依赖不变不重算无写法声明式命令式典型场景筛选后列表、总金额搜索、路由变化监听、表单联动面试官常问“computed 为什么有缓存”我的理解是Vue 在访问 computed 时会检查依赖是否发生变化如果没变化就直接复用上一次的计算结果。这也是我喜欢 computed 胜过写一大堆 watch 的原因代码更简单性能也更好。如果两者都行优先用 computed。6.2 响应式原理从 Object.defineProperty 到 ProxyVue 2 的响应式是基于Object.defineProperty对对象的每个属性做劫持所以它有两个缺点新增属性不是响应式的删除属性也不是响应式的必须用$set和$delete才能实现。Vue 3 改用Proxy后新增属性、删除属性、甚至数组下标操作都能被代理拦截这就从底层消除了这类问题。这个原理在面试里通常会从“为什么 Vue 3 用 Proxy”延伸出来。回答时最好加上一个细节Proxy是懒代理的只有访问到某个属性时才对该属性的值做深层代理而 Vue 2 在初始化时就要递归遍历整个对象这也解释了为什么 Vue 3 处理深层对象时性能更好。实际开发中如果你在 Vue 3 里遇到数据变了页面不更新的问题最可能的诱因是ref或reactive用得不对比如用普通对象承接接口数据而不是用ref包裹而不是响应式系统本身的限制。6.3 虚拟 DOM 和 key 的关系虚拟 DOM 这个概念听起来很高级但说白了就是用 JavaScript 对象描述真实 DOM 结构页面更新时先对比新旧虚拟 DOM 的差异再批量操作真实 DOM。为什么要引入这一层因为直接操作真实 DOM 的开销很大而 JS 对象对比相对廉价得多。diff 算法在遍历列表时会依赖 key 做“移动”和“复用”的判定。前面说过不能用 index 当 key这时候就可以把 key 和 diff 联系起来了key 的作用是告诉 Vue “这个节点是原来那个节点移到了新位置”而不是“位置相同就是同一个节点”。理解了这一点前面 2.4 中用 index 导致的 bug 也就有了理论根源。6.4 关于 Vue 是否只用于网页学完后发现也有不少人在问 Vue 能不能做桌面应用、移动 App。答案是能但借助的是周边生态桌面端用 Electron 集成 Vue移动端可以用 uni-app 或 Capacitor 打包。这套体系里主进程和渲染进程通过 ipc 通信Vue 只负责渲染进程这一侧主进程逻辑还是 Node 的活。理解了这个边界就不会再对一个前端框架提出超纲的要求。最后聊一点个人体会22 集课程学完后我又去翻了一次那些之前回答不好的面试题发现每个问题都能用自己的话展开讲了。这种变化不是记忆力提升了而是因为每集都亲手敲了代码、记录过踩坑过程。如果你也准备系统学一遍 Vue我建议给自己定一个“学完多少集”的目标而不是“每天看两小时视频”。目标越具体越不容易中途放弃。另外每看完一集就写几行笔记、复现一遍代码哪怕只是一个按钮的点击效果也比纯看视频有效十倍。学完之后你会发现最大的收获不是背下了 API而是真正建立了“先想清楚原理再动写代码”的习惯。之后再看任何新框架都能更快抓住本质。