三年前我第一次把线上项目从 Vue2 迁到 Vue3,setup里满屏的ref和.value让我一度怀疑这是不是同一个框架。后来陆续带过几个刚入行的同学,发现大家卡住的位置出奇地一致:不是语法写不出来,而是脑子里还留着 Vue2 那套this、data、methods的模型,一看到组合式 API 就本能地发怵。这篇东西就是把我踩过的、看别人踩过的坑按学习顺序捋一遍,从装环境一直讲到能独立接一个后台管理项目。如果你是完全没碰过 Vue 的小白,照着顺序看能少走不少弯路;如果你已经有 Vue2 基础,那重点看响应式重构、组件通信和工程化那几节就够了。前端开发这件事,框架只是工具,真正值钱的是你对数据流和组件边界的理解,Vue3 恰好是练这个的好载体。
1. 在写第一行代码前,先搞明白 Vue3 到底改了什么
很多人学 Vue3 效率低,根源在于把 Vue3 当成"Vue2 加了个 setup"来学。实际上这是一次底层到写法都比较彻底的迭代,先建立整体认知,后面写代码时才不会拧巴。
1.1 组合式 API 不是语法糖,是逻辑组织方式的转变
Vue2 的选项式 API 把代码按"类型"切块:data放数据、methods放方法、computed放计算属性。项目小的时候很清晰,但一个组件写到 500 行以后,你会发现"修改用户列表"这个业务逻辑被拆散在四五个不同的选项里,改一个功能要在文件里来回翻。这就是业界常说的"逻辑关注点分散"。
Vue3 的组合式 API 换了个切法:按"业务功能"切块。跟用户列表相关的状态、方法、监听全写在一起,一个功能一块,抽出去就是 composable。我自己的判断标准很简单——如果一个逻辑需要在多个组件复用,就写成useXxx函数;如果只是本组件用,就老老实实待在setup里,别为了"看起来高级"过度抽象。
1.2 响应式系统从 defineProperty 换成了 Proxy
这是 Vue3 最本质的改动。Vue2 用Object.defineProperty递归给每个属性加 getter/setter,带来两个老毛病:一是没法监听对象新增、删除属性(当年得靠Vue.set),二是数组下标赋值和长度修改监听不到。Vue3 改用Proxy代理整个对象,从根上解决了这两类问题。
对写业务的人来说,最直接的体感是:不用再记Vue.set这个 API 了,给对象动态加字段天然就是响应式的。但要注意一个反直觉的点——Proxy代理的是对象引用,所以你把一个响应式对象整体替换成新对象时,原来那份代理就失效了,这也是后面讲ref和reactive取舍时会重点说的。
1.3 性能数字背后的真实含义
官方给的数据是渲染性能提升、打包体积减小,这些数字在实际项目里能不能感知到,取决于项目规模。小项目基本无感,但大列表、复杂表单场景下提升是实打实的。更重要的是 Vue3 对 Tree-shaking 友好,用不到的 API 不会进最终产物。
| 对比维度 | Vue2 | Vue3 |
|---|---|---|
| 响应式实现 | Object.defineProperty | Proxy |
| 逻辑组织 | 选项式 API | 选项式 + 组合式 API |
| 动态增删属性 | 需要 Vue.set | 天然支持 |
| 类型支持 | 对 TS 支持较弱 | 原生为 TS 设计 |
| 打包体积 | 全量引入 | 支持 Tree-shaking |
提示:不要因为"Vue3 更快"就无脑迁移。真正的迁移成本在于生态库兼容性、团队熟悉度和第三方组件的支持情况,性能只是其中一项。
2. 把开发环境一次性配到位,省下后面反复折腾的时间
环境这块看着简单,但我见过太多人卡在 Node 版本、包管理器混用、镜像源配置上,一卡就是半天。这一节按最小可用原则走一遍。
2.1 Node 版本和包管理器的选择
Vue3 的构建工具链对 Node 版本有要求,建议直接用 Node 18 LTS 或 20 LTS,别用太老的版本。装 Node 我推荐用版本管理工具(Windows 上用 nvm-windows,macOS/Linux 上用 nvm),原因是不同项目对 Node 版本要求不一样,来回切版本是常态,用系统级安装迟早会打架。
包管理器方面,npm、yarn、pnpm 都能用。我现在的默认选择是 pnpm,因为它用硬链接共享依赖,装得快、占空间小,而且对 monorepo 天然友好。但有个坑要提醒:如果团队里有人用 npm、有人用 pnpm,容易出现node_modules结构不一致导致的诡异报错,所以一个项目里统一用一个就行,可以在package.json里加"packageManager"字段锁死。
2.2 用 Vite 起项目,别再用 vue-cli 了
新建项目主流做法是 Vite。命令很固定:
# 用 pnpm 创建 pnpm create vue@latest my-vue-app # 或者用 npm npm create vue@latest my-vue-app执行后会有一系列交互式选项,问你要不要 TypeScript、要不要 Router、要不要 Pinia、要不要 ESLint 等。新手建议第一次全选"否",先跑通最纯净的模板,理解清楚每个东西是干嘛的,再回头按需加。上来就全选,出了问题你都不知道是哪块引起的。
创建完进目录、装依赖、起服务:
cd my-vue-app pnpm install pnpm dev看到本地地址能在浏览器打开,环境就算通了。Vite 启动是秒级的,改代码热更新也快,这种即时反馈对新手特别重要。
2.3 VSCode 插件和"怎么查看网页代码构成"
新手常问"怎么看到页面的代码是怎么拼出来的",这里说清楚:浏览器里右键"检查"打开的是编译后的 DOM 结构,不是你的.vue源文件。想看源码对应关系,靠的是Vue DevTools 浏览器扩展,它能按组件树展示每个组件的 props、data、事件,点进去就能定位到对应组件。编辑器这边必装的插件是Vue - Official(Volar 的继任者),它提供模板内的类型提示和语法高亮,比老插件体验好一大截。
我一般还会配几个辅助插件:ESLint、Prettier 用于代码规范,Path Intellisense 用于路径补全。插件装多了会拖慢编辑器,按需来就行。
注意:如果你之前装过 Veture 老插件,务必卸载,它和新的 Vue - Official 会冲突,表现为模板里疯狂报红。
3. 响应式是 Vue 的灵魂,ref 和 reactive 用错了后面全是坑
这一节是新手最容易翻车的地方,也是面试高频考点。搞懂它,你对 Vue3 的理解就过了一半。
3.1 从 data 到 ref/reactive 的思维切换
Vue2 里所有响应式数据都塞进data()返回的对象,用this.xxx访问。Vue3 把这件事拆成了两个 API:
ref:可以包裹任意类型(基本类型、对象、数组都行),访问时要用.value。reactive:只能包裹对象或数组,访问时不用.value。
为什么ref要加.value?因为ref内部会把值包成一个带value属性的对象,模板里 Vue 帮你自动解包了,所以模板里写count而不是count.value,但 JS 逻辑里必须写.value。这个不一致是新手最大的困惑来源,记住一句话:模板自动解包,脚本里手动点。
我个人的实践原则是:统一用ref。原因是reactive有两个致命缺点——整体替换会丢响应式,解构会丢响应式。而ref没这些问题,虽然多敲.value,但换来的是心智负担低。只有在明确需要一组相关状态且不做替换和解构时,我才会用reactive。
import { ref, reactive } from 'vue' // 推荐:统一用 ref const count = ref(0) const userInfo = ref({ name: '小明', age: 18 }) function increment() { count.value++ userInfo.value.age++ } // reactive 的陷阱示范 let state = reactive({ count: 0 }) // 整体替换后,原来的响应式引用失效 state = reactive({ count: 1 })3.2 computed 和 watch 的分工不能乱
这两个都是"根据其他数据推导东西",但定位完全不同。
computed是派生值:它本身是个结果,依赖变了自动重算,且有缓存——依赖没变时不会重复执行。典型场景是全名、总价、过滤后的列表。
watch是副作用:它不产生值,而是在数据变化时去做某件事,比如发请求、写 localStorage、操作 DOM。用watch能做的,computed做不了;但能用computed表达的逻辑,千万别用watch去绕。
import { ref, computed, watch } from 'vue' const firstName = ref('张') const lastName = ref('三') // computed:派生值,有缓存 const fullName = computed(() => firstName.value + lastName.value) // watch:副作用,数据变了执行动作 watch(fullName, (newVal, oldVal) => { console.log(`名字从 ${oldVal} 变成了 ${newVal}`) }) // watch 监听对象需要 deep watch(userInfo, (val) => { console.log('对象变了', val) }, { deep: true })一个高频坑:watch监听reactive对象默认是深度的,而监听ref包裹的对象默认只看引用变化,对象内部属性变不会触发,必须加{ deep: true }。
3.3 模板里那些看着没问题实则出错的地方
新手写模板容易在这几个地方翻车:
第一,v-for和v-if不要写在同一元素上。v-if优先级更高,会导致每次循环都判断一次,性能差且逻辑混乱。正确做法是在外层套<template v-for>或者用 computed 先过滤数据。
第二,v-for的key不要用数组下标。下标在列表增删时会错位,导致组件状态复用错误(比如输入框里的内容串行)。用数据的唯一 id。
第三,模板里不要写复杂表达式。{{ a + b + c + d }}这种看着能用,但逻辑一复杂就难维护,抽成 computed。
提示:
vue3 props 赋值给 data是很多人问的问题。在组合式 API 里没有"赋值给 data"这个概念,正确姿势是用 computed 派生 props,或者用toRefs保持响应式,直接赋值会丢失响应性。
4. 组件通信是 Vue3 的分水岭,理顺了项目才不散架
一个人能不能从"会写 Vue"进阶到"会用 Vue 做项目",看他怎么处理组件通信就知道了。这一节把父子、跨层、插槽三条线讲清楚。
4.1 props 和 emit 的父子约定
父传子用props,子传父用emit,这是基础。Vue3 里在<script setup>中这两个都要用编译器宏声明:
// 子组件 Child.vue const props = defineProps({ title: { type: String, default: '' }, list: { type: Array, default: () => [] } }) const emit = defineEmits(['update', 'delete']) function handleClick() { emit('update', { id: 1, name: '新值' }) }有个重要的单向数据流原则:子组件绝对不能直接修改 props。新手常犯的错是想改props.list,一改就报错或警告。正确做法是 emit 事件让父组件改,或者用v-model语法糖做双向绑定。
v-model在 Vue3 里可以绑多个,通过参数区分:
<!-- 父组件 --> <Child v-model:name="nameVal" v-model:age="ageVal" /> <!-- 子组件 --> <script setup> const name = defineModel('name') const age = defineModel('age') // 直接 name.value = 'xxx' 就能同步给父组件 </script>4.2 插槽的三种用法与实战场景
插槽是组件的"可定制区域",分三种:
- 默认插槽:直接
<slot />,最简单。 - 具名插槽:
<slot name="header" />,用于多区域定制。 - 作用域插槽:子组件把数据传给插槽内容,是最强大的。
作用域插槽典型场景是做通用列表组件,让使用者决定每行怎么渲染:
<!-- 子组件 MyList.vue --> <ul> <li v-for="item in list" :key="item.id"> <slot :item="item" :index="index" /> </li> </ul> <!-- 父组件使用 --> <MyList :list="users"> <template #default="{ item }"> <span>{{ item.name }}</span> </template> </MyList>我之前做后台管理系统时,表格组件、下拉选择组件全靠作用域插槽实现定制化渲染,这是组件库设计的核心手法。
4.3 provide/inject 与生命周期钩子的现代写法
跨层级传数据(爷爷传孙子,中间层不关心)用provide和inject,避免 props 一层层透传的"props drilling"。
// 祖先组件 import { provide, ref } from 'vue' const theme = ref('dark') provide('theme', theme) // 后代组件(任意层级) import { inject } from 'vue' const theme = inject('theme', 'light') // 第二个参数是默认值生命周期方面,<script setup>里不再是选项,而是一组onXxx函数:onMounted、onUpdated、onUnmounted等。最常用的是onMounted里发起初始请求,onUnmounted里清理定时器、事件监听、websocket 连接,这一步不写就是内存泄漏。
有个常见误解:以为setup执行时机等于created,其实setup比beforeCreate还早,此时 DOM 还没挂载,所以任何操作 DOM 的代码都必须放在onMounted里。
5. 路由和状态管理,把项目骨架搭起来
单页应用离不开路由和状态管理。Vue3 对应的是 Vue Router 4 和 Pinia,这一节讲落地姿势和常见故障。
5.1 Vue Router 4 的路由表与跳转
路由配置集中在router/index.js,用createRouter和createWebHistory:
import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/user/:id', component: () => import('@/views/User.vue') }, { path: '/:pathMatch(.*)*', component: () => import('@/views/NotFound.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) export default router路由懒加载用() => import(...)是标配,能显著减小首屏体积。跳转方式有两类:声明式<router-link to="/user/1">,编程式router.push('/user/1')。
5.2 路由跳转后组件不渲染的完整排查链路
这是热词里高频出现的问题——"router vue3 路由跳转,组件内容渲染不显示"。我自己踩过,也帮别人排查过多次,按下面顺序走基本能定位:
第一步,检查<router-view />是否存在。新手常把 App.vue 写了个空壳,忘了放出口,路由切换当然没反应。
第二步,检查路由配置的path和跳转的路径是否完全匹配,特别是带参数的路由,/user/:id跳/user/1是对的,跳/user?id=1就不匹配。
第三步,检查createWebHistory的 base 配置和服务器端是否支持 history 模式。用 hash 模式能规避,但 URL 难看。
第四步,检查组件本身是否有报错。打开控制台看有没有Uncaught SyntaxError: invalid or unexpected token这类错误——这通常是复制代码时混入了不可见字符或中英文标点,导致模块解析失败,组件自然渲染不出来。
第五步,检查是否有全局守卫beforeEach没调用next(),把导航卡死了。
我一般会先看控制台有没有红色报错,再看网络请求,最后才逐行看路由配置。先看现象再猜原因,比一上来就翻代码高效得多。
5.3 用 Pinia 替代 Vuex 的理由和写法
Pinia 现在是官方推荐的状态管理方案,比 Vuex 简单太多:没有 mutation,没有嵌套模块,直接定义 store 即可。
// stores/user.js import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { const token = ref('') const userInfo = ref(null) const isLogin = computed(() => !!token.value) function setToken(val) { token.value = val } function logout() { token.value = '' userInfo.value = null } return { token, userInfo, isLogin, setToken, logout } })这种"组合式"写法叫 setup store,和组件里写逻辑完全一致,心智负担极低。我选 Pinia 的核心原因是它对 TypeScript 友好,且有 devtools 支持、支持热更新、支持服务端渲染。
注意:Pinia 里存的状态是内存态,刷新页面就没了。需要持久化的话,用
pinia-plugin-persistedstate插件,别手写 localStorage 监听,容易漏。
6. 工程化落地,把零散知识点串成能交付的项目
会用 API 和能交付项目是两码事。工程化这块决定你的代码能不能维护、能不能上线。
6.1 axios 封装与统一请求处理
几乎每个项目都要封装请求层。推荐统一拦截器 + 统一错误处理 + 环境变量 baseURL 的组合:
import axios from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE, 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( res => { const { code, data, message } = res.data if (code === 0) return data // 统一错误提示 return Promise.reject(new Error(message)) }, err => Promise.reject(err) ) export default service这样业务代码里只关心await api.getUser(),不用每次判断状态码。统一收口是工程化的核心思想,任何"每个页面都写一遍"的逻辑,都值得抽出来。
6.2 环境变量与构建部署
Vite 用.env文件管理环境变量,只有以VITE_开头的变量才会暴露给客户端。这点很重要,别把密钥写进去,客户端能看到的都是公开的。
# .env.development VITE_API_BASE=/api # .env.production VITE_API_BASE=https://api.example.com构建用pnpm build,产物在dist目录。部署到 Nginx 时要配 history 模式的 fallback,否则刷新页面 404:
location / { try_files $uri $uri/ /index.html; }6.3 那些报错信息背后的真实原因
把热词里几个高频报错按根因归一下类,遇到时可以直接对照:
| 报错/现象 | 常见根因 | 处理方向 |
|---|---|---|
| Uncaught SyntaxError: invalid or unexpected token | 复制代码带入不可见字符、中文标点 | 重打或规范化文件编码 |
| pxtorem 对 echarts 不生效 | echarts 用 canvas 绘制,不走 CSS 单位换算 | 在 JS 层手动换算字号 |
| 若依 vue3 ts 报错 | 类型定义缺失或版本不匹配 | 检查 tsconfig 与依赖版本 |
| 引入飞书 sdk 报错 | 脚本加载时机早于 sdk 就绪 | 动态加载并等待 ready |
| 打印组件无样式 | 打印样式未内联或被全局样式覆盖 | 单独维护打印专用样式 |
关于 pxtorem 对 echarts 失效这个问题多说一句:pxtorem 的本质是转换 CSS 里的 px 单位,而 echarts 的图形是 canvas 渲染,字号是 JS 参数传给渲染引擎的,根本不过 CSS。解决办法是在设置fontSize时用rem转 px 的计算函数,或者用自适应方案动态计算。
打印这块,如果用vue-print-nb这类方案,核心是准备一份独立的打印模板,把不打印的元素用no-print类隐藏,打印区域单独写样式,别指望复用页面样式。
7. 从"会用"到"熟练",中间差的是这些
写到这里,基础的东西基本齐了。但"入门到熟练"中间还有一层,是很多人容易忽略的:调试能力、底层理解、性能意识。
7.1 调试不是打 console.log 那么简单
新手用 console.log 排错,老手用断点。VSCode 可以配 launch.json 直接在编辑器里给浏览器代码打断点,比来回切窗口高效得多。Vue DevTools 则能看组件树、数据快照、事件流、路由状态。改一个响应式数据没生效?打开 DevTools 看这个数据的实时值,是不是根本没变,比猜快一百倍。
7.2 理解 diff 算法,才算摸到 Vue 的底层
热词里"vue3 diff算法""mini-vue"这类内容,说明有相当一部分人想往里钻。Vue3 的 diff 用了最长递增子序列做移动优化,配合编译期生成的静态标记,能跳过大量不必要的比较。真要理解它,最好的方式是手写一个 mini-vue,把响应式、渲染器、diff 三块自己实现一遍。这个过程走下来,你对"为什么 ref 要 .value""为什么 key 重要"会有完全不同的认识。
7.3 性能优化清单,能对照检查的那种
Vue3 项目的性能优化有几个固定动作,我列成清单方便自查:
- 路由和大型组件用懒加载,减小首屏体积。
- 长列表用虚拟滚动,别一次性渲染几千个 DOM。
- 大数据过滤、排序放 computed,利用缓存。
- 需要频繁触发的输入用防抖节流。
- 图片懒加载加合适的尺寸,避免加载原图。
- 用
v-once标记永不变更的静态内容。 - 打包分析用 rollup-plugin-visualizer,找出体积异常的大包。
可视化大屏、mapbox 地图这类重渲染场景尤其要注意,地图实例一定在onUnmounted里 destroy,否则切路由回来越积越多,页面越来越卡。
我个人在实际操作中的体会是,Vue3 真正的门槛不在 API,而在你是否建立了"数据驱动视图"和"组件即函数"这两套心智模型。把响应式、组件通信、工程化这三条线吃透,剩下的都是查文档就能解决的细节。