先说结论:如果你以前只写过Vue 2,拿到一个Vue 3项目的时候,第一天大概率会经历三个阶段——先是觉得"这有什么难的,不就是改个写法吗",然后被setup、ref、reactive一顿组合拳打懵,最后栽在一个响应式更新不生效的细节上,反复改代码就是看不到页面变化。这篇文章就是我亲历这三阶段后的完整记录,把我第一天的目标拆解、环境搭建、组合式 API 上手过程、响应式原理理解、踩过的坑,以及最后怎么验收学习成果,全部摊开来讲。
不管你是完全没碰过 Vue 的新手,还是 Vue 2 老手准备迁移,或者跟我一样是"项目不等人,被迫一天上手"的状态,这篇文章里说的都是可以照着做的具体路径。我不会让你一天学完整个框架,那既不现实也没必要。你只需要在 24 小时内跑通一个最小闭环:装好环境、写一个组件、理解响应式、跑出一个能交互的页面。只要这条路通了,后面的深入学习只是时间问题。
1. 第一天的目标定在哪里:先跑通,别贪多
学习 Vue 3 最大的误区,就是一上来想把ref、reactive、computed、watch、生命周期、路由、状态管理全学完。我不是没这么干过,结果就是笔记记了一大堆,真正打开编辑器写代码的时候,连一个按钮的点击事件都写得犹豫。所以第一天的目标必须非常明确:让一个能交互的 Vue 3 页面从无到有跑起来。
围绕这个目标,我给自己定了一份当天任务清单:
| 阶段 | 具体任务 | 验收标准 |
|---|---|---|
| 环境准备 | 安装/检查 Node 版本,用脚手架创建项目 | npm run dev能打开页面 |
| 基础概念 | 理解script setup与组合式 API 的基本定位 | 能说清和 Vue 2 选项式的差异 |
| 核心 API | 会使用ref、reactive声明响应式数据 | 数据变化,页面跟着变 |
| 业务联动 | 写一个带按钮交互的简单组件 | 点击事件能修改数据并触发更新 |
| 收尾复盘 | 记录卡住的问题和明天的学习方向 | 有一份自己的问题清单 |
这里有个经验:第一天最忌讳的是"每个知识点都去看一遍文档"。Vue 3 的文档写得很好,但内容量很大,如果你从快速上手一直看到渲染函数,一天根本看不完,而且看完也忘得差不多。我建议只读"快速开始"和"核心概念"两个章节里和ref、reactive、computed相关的部分,其他内容比如 Teleport、Suspense、自定义指令,知道有这个东西存在就行,别展开。
1.1 第一天的"不做什么"清单
和"要做什么"同样重要的是"不做什么"。我第一天踩过最大的软坑,就是浪费了整整两个小时在研究 Vite 的配置项,想搞清楚process.env和import.meta.env的区别。这对一个第一天学框架的人来说,完全不是重点。以下这些东西,第一天一律不做:
- 不深入 TypeScript,先用 JavaScript 把业务逻辑跑通。Vue 3 对 TS 支持虽好,但第一天两线作战很容易哪条线都断。
- 不上 Vue Router 和状态管理库。组件之间传值用最原始的
props和defineProps就够了。 - 不研究打包优化。什么按需加载、代码分割、tree-shaking,都等你的项目真正上线前再说。
- 不对比各脚手架差异。认准官方推荐的
create-vue,别把时间花在 webpack、rollup、esbuild 的争论上。
把不做什么写清楚,你会发现自己第一天的负担瞬间小了一半。时间花在刀刃上,这个"刀刃"就是组合式 API 的写法和响应式的直觉。
1.2 第一天真正需要记住的 6 个关键词
我第一天结束的时候,复盘发现真正高频用到的其实就 6 个东西:createApp、script setup、ref、reactive、computed、watch。其他所有看上去花哨的语法,都是这 6 个基础之上的扩展。
createApp是应用入口,Vue 2 里new Vue()的替代;script setup是把组件逻辑集中到一段<script setup>里的语法糖;ref和reactive都用于声明响应式数据,一个适合基础类型,一个适合对象;computed是派生数据的计算属性;watch是监听数据变化做副作用处理。
把这 6 个词的用法和适用场景弄明白,你已经能写出一个结构完整的 Vue 3 组件了。接下来要考虑的就是怎么把环境搭起来,这是第一天最容易卡住又最不值得卡住的环节。
2. 环境与工程搭建:第一道坎藏在 Node 和包管理器里
Vue 3 的中文文档其实在环境要求上写得很清楚:要使用相关构建工具,Node.js 需要 18 或者 20 以上的版本。但很多人第一天的第一个报错,就出在这里——机器上的 Node 还是 12 或 14,一执行脚手架命令不是报语法错误,就是提示版本过低。这种问题最气人,因为它和你的代码水平完全无关,纯粹是工具链版本不对。
2.1 先花 10 分钟检查环境,别急着执行命令
我第一天第一件事,就是在终端跑了一句node -v。看到版本号是 v14.17.0 的时候,我内心是很崩溃的。不是说 v14 一定不能运行,而是拿它去跑最新脚手架,大概率会遇到各种莫名其妙的问题。这里我的建议是:如果你机器上有多个项目依赖不同 Node 版本,就装一个版本管理工具(很多开发者用的 nvm 就能做这事),随时切换到较高的 Node 版本再继续操作。
环境确认没问题之后,创建项目用官方提供的create-vue脚手架,命令很简单:
npm create vue@latest执行之后,终端会问你几个交互式问题:项目名字叫什么?要不要 TypeScript?要不要路由?要不要 Pinia?要不要 Eslint 和 Prettier?第一次接触 Vue 3 的人,这里特别容易纠结,因为每个选项后面都有一堆英文说明,看着挺吓人。我的建议就一句话:除了项目名称,其它全都选 No。我第一天选了个"全都要",结果项目生成之后一堆配置文件,我光是看tsconfig.json和vite.config.ts就耗掉了大半天,完全没有精力去写自己的业务代码。
全部选 No 生成的项目非常干净,依赖少,启动快,什么东西都是一眼就能看明白的,非常适合作为新手起步的底子。后续这个项目学会了,再想要 TypeScript 或 Pinia,官方的扩展文档会告诉你怎么加,完全不影响。
2.2 生成后的目录结构,只要盯住 4 个文件
项目生成之后,会有一个标准结构,但第一天不用全都看。盯住这几个地方就够:
src/ ├── main.js ├── App.vue ├── components/ └── assets/main.js是整个应用的入口,里面最核心的代码是createApp(App).mount('#app')。这行代码的意思是:创建一个 Vue 应用实例,然后把它挂载到index.html里那个<div id="app">上。如果你之前写过 Vue 2,肯定会觉得特别熟悉,Vue 2 是用new Vue({ render: h => h(App) }).$mount('#app'),默认配置项是全局的;Vue 3 则引入了"应用实例"的概念,配置项变成了app.config之类的局部属性,多个应用之间不会互相污染。
App.vue是根组件。Vue 单文件组件一般分三块:<template>里写页面结构,<script setup>里写逻辑,<style>里写样式。script setup看起来和普通<script>没什么两样,但最大的区别是:顶层变量、函数、导入的组件,都不需要显式return,模板里就能直接用。第一天写代码,你会觉得这个设计非常舒服,少写很多样板代码。
assets目录一般放静态资源,全局样式通常也会在这里。第一天的重点还是components目录,因为你马上要在里面创建自己的第一个组件。我给第一个组件命名为Counter.vue,名字直白,逻辑也简单。
2.3 启动与调试的"就近原则"
执行npm run dev之后,终端会给你一串本地访问地址,一般是http://localhost:5173。在浏览器里打开它,看到官方默认的初始页面,环境就算跑通了。
这里有一个细节:尽量不要开着多个编辑器窗口和多个终端来回切换。第一天写代码,经验太少,报错出现的时候你需要快速定位是编译报错还是运行时错误。编译报错直接看终端,运行时错误更多要看浏览器 console。我用一个终端跑npm run dev,一个浏览器开页面,编辑器只保留一个文件窗口,把这个习惯保持住,排查问题的效率会高很多。
环境这一关过了,接下来是今天最难但也最核心的内容:Composition API。这是 Vue 3 和 Vue 2 分道扬镳的分水岭。
3. Composition API 使用体验:从选项堆到一起,到逻辑按需组织
我用一个最小例子来说明 Vue 2 和 Vue 3 在组织代码上的区别。假设我们现在要写一个计数器组件,Vue 2 选项式的经典写法是这样的:
export default { data() { return { count: 0 }; }, methods: { add() { this.count++; } }, computed: { double() { return this.count * 2; } } };同样的逻辑,在 Vue 3 的组合式 API 里这样写:
<script setup> import { ref, computed } from 'vue'; const count = ref(0); const add = () => { count.value++; }; const double = computed(() => count.value * 2); </script> <template> <p>当前计数:{{ count }}</p> <p>双倍值:{{ double }}</p> <button @click="add">加一</button> </template>我第一次看到这段代码的时候,最大的困惑不是ref到底是什么,而是:为什么这个函数组件这么像普通 JavaScript?是的,这正是组合式 API 的设计意图——它让逻辑复用变得更像"我们平时写函数的方法"。Vue 2 里data、methods、computed是按类型区分的,比如三个业务功能的数据全堆在data里,三个功能的点击事件全堆在methods里,当组件功能一多,你就得上下跳着找代码。而组合式 API 允许你把某个业务的相关数据、函数、计算属性写在一起,想抽出去复用,直接封装成自定义函数就行。
3.1script setup语法糖的隐蔽功效
script setup是 Vue 3.2 以后推荐的写法。它在编译阶段做了一件很关键的事:把所有顶层绑定都自动暴露给模板。这意味着你在<script setup>里用const count = ref(0)定义的数据,模板里直接写count就能显示;你引入的组件,模板里直接放标签就能用,连注册都不需要。
这一点和 Vue 2 的差别特别大。Vue 2 里要用一个子组件,必须先components: { Child }注册;Vue 3 的script setup里import Child from './Child.vue'之后,模板<Child />直接用。第一天如果是从 Vue 2 转过来的,很容易在"为什么我引用了组件但模板里不显示"这种问题上卡住,大概率就是忘了写注册;换成script setup之后,这个坑直接消失了。
不过script setup也有一个"转译"上的小坑:在模板里自动解包,但在逻辑代码里不会。什么意思?你看上面的代码,模板里写的是count,而脚本里用的是count.value。这是第一天特别容易精神分裂的地方——我一开始总记不住什么时候要加.value,写模板时不加,写逻辑时又漏加,最后页面显示正常但点击按钮报错。我的记忆口诀是:"模板是自动解包的,脚本里要亲手拆包。"
3.2ref和reactive:用得最多的一对兄弟
ref和reactive都能让数据变成响应式的,但适用场景不同。ref更通用,它能把一个基础类型值(字符串、数字、布尔值)包成一个响应式引用;reactive只能接受对象、数组这些引用类型,并且返回的对象本身就是响应式的。
import { ref, reactive } from 'vue'; // ref 的使用 const name = ref('张三'); const changeName = () => { name.value = '李四'; }; // reactive 的使用 const user = reactive({ age: 18, job: '前端开发' }); const growUp = () => { user.age++; };第一天我的建议是:先死记ref,再顺带了解reactive。为什么这么说?因为reactive有一个很容易踩的坑——如果你把一个reactive对象解构出来,比如const { age } = user,然后直接在逻辑里改age,它已经不再是响应式的了,因为解构时你拿到的只是那个属性的普通值。要解构又保持响应式,得用toRefs,这又多学一个 API,第一天完全没必要增加这个负担。
用ref则没有这个烦恼:不管你解构不解构,只要你操作的是ref对象本身(即用.value方式读写),响应式就不会丢。所以如果我只能传一个口诀出去,那就是:基础类型用ref,对象考虑reactive;想稳妥就用ref包一层箱子里放整个世界。
3.3computed和watch:够用就好
computed是依赖响应式数据自动变化的计算属性。比如输入一个价格,页面同时显示含税价格,就可以用computed:
const price = ref(100); const priceWithTax = computed(() => price.value * 1.13);注意computed的回调里有一个返回值,这个返回值会实时跟随依赖项更新。watch则是监听某个数据变化,然后执行副作用,比如监听price变化后调用某个接口模拟保存:
watch(price, (newVal, oldVal) => { console.log('价格从', oldVal, '变成', newVal); });第一天如果遇到"为什么不更新"这类问题,多半是搞混了computed和watch的使用前提:computed是拿来生成新数据的,watch是拿来"做事"的。你写一个const x = watch(...)想拿到返回值,那基本就废了,因为watch的返回值是停止监听的函数,不是数据。
4. 响应式原理没那么神秘:Proxy 替换 defineProperty 之后发生了什么
Vue 3 第一天学习里最值得花时间理解的一个底层机制,就是响应式。它也是很多 Vue 2 老手遇到"为什么我新增一个属性页面没反应"时的历史遗留问题。要理解这个变化,得先说清楚 Vue 2 的响应式是怎么做的。
4.1 Vue 2 的"给抽屉装报警器"与 Vue 3 的"门口装监控"
Vue 2 的响应式,核心是Object.defineProperty。它干的事情是遍历你data里返回的对象,把每个属性都变成 getter/setter,并在 getter 和 setter 里收集依赖、触发更新。但这个方案有个天然局限:它只能监听到已经存在的属性。如果你在初始化之后往对象里新加一个属性,这个新属性是没有任何 getter/setter 的,所以你不会触发更新。于是 Vue 2 提供了一个补救方案$set,专门给这种场景用。
Vue 3 换了个方向。它不再给对象里的每个属性逐个做手脚,而是用 ES6 的Proxy对整个对象进行代理。相当于 Vue 2 是在每个抽屉上装报警器,事先装好的才响;Vue 3 是在门口装了一个监控,无论你往屋里塞进什么新抽屉,都能被看到。所以 Vue 3 里,新增属性、删除属性、甚至数组下标变化,都不需要再调什么$set,天然就是响应式的。
这个对比可以用表格来记:
| 对比维度 | Vue 2 响应式 | Vue 3 响应式 |
|---|---|---|
| 核心机制 | Object.defineProperty | Proxy |
| 监听范围 | 初始化时已有的属性 | 整个对象的所有读写操作 |
| 新增属性 | 需要 $set 手动处理 | 自动响应式 |
| 数组下标修改 | 有兼容性问题 | 可正常触发更新 |
| 性能 | 递归遍历所有属性,开销大 | 按需拦截,更高效 |
4.2 第一天的我就踩了"替换对象"的坑
光看原理不会踩坑,写代码才会。我第一天写了一个编辑用户信息的组件,一开始用的是reactive存数据:
const form = reactive({ name: '张三', age: 18 }); function loadData(data) { // 想直接替换整个表单数据 form = data; }结果页面完全没有反应,控制台也没有报错。我排查了好一会儿,最后才意识到一个问题:form被声明成const,你要是直接给它赋值一个普通对象,那这个赋值操作根本不会触发Proxy的 set 拦截,因为它是在改变form这个变量的指向,而不是在修改form对象内部的属性。
Vue 3 的响应式,针对的是对象内部的变化,而不是变量引用的变化。这个区别非常关键。正确做法是把新数据展开后再赋值,比如说:
Object.assign(form, data);或者干脆一上来就用ref装这个对象,替换的时候用form.value = data。用ref包裹的好处是:form.value = data相当于把整个抽屉整体换掉,报警器装在抽屉外壳上,所以一定能触发更新。这个经验我会建议每一个第一天学 Vue 3 的人都记住,算是组合式 API 里最常见的暗坑之一。
4.3 深层响应式:默认帮你递归到底
还有一个容易忽视的细节,就是 Vue 3 的reactive是默认为你递归包裹内部所有嵌套对象的。也就是说,你一个对象里有对象,有数组,数组里面还有对象,这些数据全都是响应式的。这跟 Vue 2 的"深度递归监听"不一样的点在于:Vue 3 用了Proxy,它可以做成"惰性"的,你真正访问到哪一层,才在那一次读取时按需去建立代理,因此性能上更有优势。
但我一般不建议第一天就深入源码去研究它究竟在什么时机触发代理。你需要形成的第一天直觉是:别把响应式数据复杂嵌套层次当成心理负担,你写普通let对象那样写就对了;唯一要心虚的是"引用指向替换"这个动作。
5. 第一天踩坑实录:与旧习惯短兵相接的几个回合
第一天学习 Vue 3,最消耗心智的不是陌生语法,而是旧习惯带来的"想当然"。我在写组件、做数据展示、处理样式的时候,一共踩了好几个坑,有一些甚至不是报错,只是页面表现不符合预期。我把这些整理成一张问题表和解决思路,你遇到了可以直接对号入座。
| 问题现象 | 根本原因 | 解决方式 |
|---|---|---|
| 子组件使用 v-model 无效或者更新异常 | Vue 3 的 v-model 默认 prop 名变成了 modelValue,事件名变成了 update:modelValue | 按 Vue 3 的命名规则给子组件声明 props,并派发对应更新事件 |
| 自定义组件里 $emit 不生效或控制台提示未声明 | Vue 3 建议在 defineEmits 里显式声明事件名 | 用const emit = defineEmits(['update'])声明 |
| 深度样式选择器不生效 | >>>写法在 Vue 3 中不再被支持 | 使用:deep()语法 |
给<style scoped>里的第三方组件改样式无效 | scoped 限制导致后代选择器失效 | 结构调整为:deep(选择器) |
| script 里数据改变了,页面却不渲染 | 很可能是直接替换了 reactive 对象,或解构了响应式对象 | 用Object.assign或ref用法修正 |
| 数组用 index 作为 key,顺序乱了之后出 bug | 虚拟 DOM diff 时 key 不稳定 | key 使用唯一标识,如业务 id |
5.1 报错信息明显是少数,软坑才是大头
上面表格里的坑,大部分不是当场给你终端报错,而是页面要么不更新,要么显示的内容和你预期不一样。这种"软坑"难排查,因为浏览器 console 往往干干净净。我的排查顺序是:
- 先看数据本身是否改了。在逻辑里把修改后的值
console.log出来,确认数据源对不对。 - 再看模板里引用的变量名是否拼错。
script setup自动暴露,拼写错了不报错,只是数据显示undefined。 - 再确认是不是响应式写法的问题。比如用了普通
let声明数据,或者在reactive对象上做了解构、替换。
这套排查链路第一天至少救了我三次,尤其是"先确认数据源是否变更"这一步,很多所谓的"响应式失效"其实是数据处理流程的问题,和数据绑定机制本身没关系。
5.2 v-model 在 Vue 3 中的新命名,是旧代码最大的违建
我第一天把一个封装好的 Vue 2 风格的输入组件搬过来,却在父组件里怎么都拿不到子组件的输入值。后来打开子组件才发现,问题出在 v-model 的 prop 名变化上。
Vue 2 里,v-model="value"在子组件中默认对应valueprop 和input事件。Vue 3 里,默认对应modelValueprop 和update:modelValue事件。如果你还在子组件里写props: ['value'],父组件的 v-model 自然对不上。正确的子组件写法可以这样:
<script setup> const props = defineProps(['modelValue']); const emit = defineEmits(['update:modelValue']); const handleChange = (event) => { emit('update:modelValue', event.target.value); }; </script> <template> <input :value="modelValue" @input="handleChange" /> </template>第一天如果没时间细看组件库源码,至少要把这个规则背下来。它属于"不知道就会花一小时找 bug,知道了就永不踩坑"的典型。
5.3 scoped 样式加不上,先想后代选择器和 :deep
还有一个很现实的场景:你引用了某个 UI 组件,比如一个弹窗或者一个日期选择器,想调整它内部的某个元素样式,于是在<style scoped>里写了类似.modal .confirm-button的选择器,结果完全没生效。我第一次遇到时以为是权重问题,加了!important还是没用。
后来才发现,scoped通过给元素加特殊属性来实现样式隔离,它只能命中"当前组件模板里直接渲染的元素",第三方组件内部渲染的那些 DOM 元素不在当前组件模板里,自然不会被你的选择器命中。Vue 3 里推荐用:deep()穿透:
.modal .confirm-button, :deep(.confirm-button) { background-color: red; }:deep()的作用是让选择器匹配到子组件内部元素,而不再受 scoped 属性隔离的约束。这个知识点,我喜欢把它归在"第一天不学不影响跑通,但早晚会遇到"的范畴里,遇到了再回来看就好。
6. 第一天的收尾方式:用一个有交互的小项目来验收
光说不练,等于白学。第一天最后两个小时,我给自己留了一个实际任务:做一个"简易待办清单"页面。为什么会选这个而不是更炫酷的东西?因为待办清单的功能刚好能覆盖第一天所有知识点:数据声明、列表渲染、input 输入、按钮事件、computed 统计、条件渲染。一个小页面做出来,整个 Vue 3 的基础使用就算跑通了。
页面功能很简单:
- 输入框输入待办事项,点击添加按钮,列表新增一项;
- 每个列表项前面有一个 checkbox,点击后标记完成;
- 底部显示"已办数量/总数量",用
computed计算; - 当列表为空时,显示一条"暂无数据"提示。
这个任务看起来朴素,但它真正检验的是你对ref、v-model、v-for、computed的综合运用。我在写这个页面的过程中,至少经历了三次对 Vue 2 写法的惯性反应,然后又三次自我纠正。当你发现自己已经能不再犹豫地写出const list = ref([]),并且能用list.value.push(...)去更新数据时,这第一天的学习就真的到位了。
6.1 验收标准只有三条
项目做完之后,我没有急着去学下一个新特性,而是先做了三件事:
- 第一,重新从零创建一个小项目,不用脚手架模板,一步步手动配料写清单组件,确认自己不看笔记也能成文。
- 第二,把今天所有踩过的坑和解决方案,整理在一个文档里,用"问题描述+解决方式"的格式记录,方便日后检索。
- 第三,给自己明天的学习定了三个方向:组件通信(
defineProps和emit)、生命周期钩子的变化、以及 Vue Router 的入门。
这三件事做完,第一天才算真正闭环。因为知道"自己不知道什么"比"学到了什么"在第二天更重要。
6.2 明天的学习计划,是根据今天的痛点长出来的
我第一天的痛点集中在几个点:自定义组件里defineProps的用法还不够熟练、生命周期函数和 Vue 2 的对应关系模糊、多个组件之间的状态共享还不知道怎么处理。这些痛点会自动生成第二天的学习清单:组件通信、组合式函数、Pinia 的状态管理。
所以我会强烈建议:第一天不要按文档目录顺序硬啃,而是按今天写代码时"卡住过的那些地方"来决定明天学什么。这样第二天的学习是带着问题去的,效率会高出很多。
学习 Vue 3 的这一天下来,我最大的体会是:它并没有把 Vue 2 推倒重来,而是把"代码组织方式"和"响应式底层"这两个核心重新设计了。只要你肯花一天时间把环境、组合式 API 和响应式这三个关键路径走通,后面的所有功能都只是往外扩展分支而已。第一天的体验就分享到这里,说到底,框架学习没有捷径,最快的路径从来都是"今天立刻打开编辑器,亲手把一个会变化的页面跑出来"。