第一章:Vue3 核心基石 —— 组合式 API 深度解密
如果说 Vue2 的 Options API(选项式 API)是"填空题"(在 data 里写数据,在 methods 里写方法),那么 Vue3 的 Composition API(组合式 API)就是"自由拼装的乐高"。
本章将带你彻底吃透 Vue3 的核心引擎,不仅让你会用,更让你在面试中能直接按在地上摩擦面试官。
1.1 绝对主流:<script setup> 语法糖
在 Vue3 刚发布时,我们要写一个 setup() 函数,并在最后把数据 return 出去,模板才能用到。这种写法非常臃肿。后来官方推出了 <script setup> 语法糖,现在它是企业开发的绝对标准。
💻 代码与效果示例
<template>
<div>
<h1>{{ title }}</h1>
<button @click="changeTitle">修改标题</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 1. 声明的变量,模板直接可用,无需 return
const title = ref('我是初始标题')
// 2. 声明的函数,模板直接可用,无需 return
const changeTitle = () => {
title.value = '标题被修改了!'
}
// 3. 引入的组件,直接在模板写标签即可,无需 components 注册
// import Child from './Child.vue'
</script>
⚔️ 吊打面试官:<script setup> 的底层原理是什么?
面试官问: "你知道 <script setup> 为什么不需要 return 吗?它是在浏览器里直接运行的吗?"
你的回答:
<script setup> 根本不是标准的 JS 语法,它是 Vue 编译器(Compiler)提供的一种编译时宏(Macro)。
在构建阶段(Vite/Webpack),Vue 的编译器会把 <script setup> 里面的代码提取出来,自动包裹在一个 setup() 函数中。
编译器会自动分析在顶层声明的变量、函数、import 导入,并自动生成 return 语句暴露给模板。
性能优势: 在 <script setup> 中,由于编译器确切知道哪些变量被模板使用了,它可以生成更高效的渲染函数代码,甚至直接在闭包中访问变量,比 Vue2 通过 this 查找属性快得多。
1.2 响应式双雄:ref 与 reactive (全场重点!)
Vue3 的灵魂在于它的响应式系统。Vue2 只能响应对象属性的修改,而 Vue3 支持各种类型。
1. reactive():对象的专属魔法
专门用来定义对象或数组类型的响应式数据。
import { reactive } from 'vue'
const user = reactive({
name: '尤雨溪',
age: 18,
skills: ['Vue', 'Vite']
})
// 修改数据,视图会自动更新
const addAge = () => {
user.age++
user.skills.push('TypeScript')
}
🚫 reactive 的致命缺陷(避坑指南)
reactive 有两个在开发中极易踩坑的缺点:
不能直接赋值整个对象,否则丢失响应式!
let state = reactive({ count: 0 })
// ❌ 错误:state 的引用地址变了,Proxy 代理断开,视图不会更新!
state = { count: 1 }
// ✅ 正确做法:Object.assign(state, { count: 1 })
解构会丢失响应式!
const state = reactive({ count: 0 })
// ❌ 此时 count 只是一个普通的数字 0,不再具有响应式
let { count } = state
2. ref():万能的响应式包裹器
为了解决基本数据类型(如 String, Number, Boolean)无法被拦截的问题,Vue3 引入了 ref。现在的社区最佳实践是:万物皆可 ref。
import { ref } from 'vue'
const count = ref(0)
const userInfo = ref({ name: '张三' })
const update = () => {
// 注意:在 JS 中修改 ref 必须加上 .value!
count.value++
userInfo.value.name = '李四'
}
提示: 在
<template>模板中使用 count 时,Vue 会自动解包,不需要写 count.value。
3. 解救 reactive 的神器:toRefs
当你有一个极大的 reactive 对象,想在模板里少写前缀时,必须用 toRefs 来解构。
import { reactive, toRefs } from 'vue'
const state = reactive({
x: 100,
y: 200
})
// ✅ 把 reactive 里的每个属性都转化成独立的 ref
const { x, y } = toRefs(state)
// 现在 x 和 y 都是 ref,修改它们依然会触发视图更新
x.value = 150
⚔️ 吊打面试官:ref 和 reactive 的底层原理与区别
面试官问: "Vue3 的响应式原理是什么?ref 和 reactive 底层有什么区别?"
你的回答:
Vue2 vs Vue3: Vue2 使用 Object.defineProperty 劫持对象的 getter/setter,这导致了它无法监听到对象属性的新增/删除,也无法完美监听数组。Vue3 彻底抛弃了它,采用了 ES6 的 Proxy(代理),可以拦截对象上的任何操作(读、写、删除、遍历等)。
🔍 深度原理解析:为什么 Object.defineProperty 有这么多缺陷?
这个问题问得太绝了!如果你在面试时被问到这个问题,很多普通程序员只能背诵:"因为 Object.defineProperty 监听不到新增和删除,也监听不到数组。"
如果面试官追问一句:"为什么它监听不到?底层原因是什么?" 90% 的人当场哑口无言。
今天我们就把它的"底裤"扒掉。你把这段内容写进你的书里,绝对是**"核弹级"的干货**。
第一重封印:为什么无法监听对象属性的"新增"和"删除"?
1. 它是怎么工作的?(给现有的门装报警器)
Object.defineProperty 的核心语法是这样的:
Object.defineProperty(目标对象, '指定的属性名', { get(), set() })
注意看它的参数!它必须精确指定某一个具体的属性名。
在 Vue2 初始化的阶段,Vue 会把你写在 data 里的对象拿出来,用一个 for...in 循环,遍历当时已经存在的所有属性,挨个给它们装上 get 和 set(报警器)。
底层原理解析(代码演示):
let obj = { a: 1 }; // 初始化时,只有一个属性 a
// Vue2 底层开始遍历,给 a 装报警器
Object.defineProperty(obj, 'a', {
get() { return 1 },
set(newVal) { console.log('视图更新啦!') }
})
// 此时你修改 a,报警器响了,视图更新:
obj.a = 2; // 👉 输出:视图更新啦!
// ❌ 灾难发生了:你新增了一个属性 b
obj.b = 100; // 👉 静悄悄的,什么都没发生
2. 为什么监听不到新增?
因为 defineProperty 不是拦截整个对象,而是拦截对象的某个具体属性。
Vue 初始化时,对象里没有 b 这个属性,Vue 也就没机会给 b 绑定 set 方法。你后来强行加了一个 b,它就是一个普普通通的 JS 属性,根本没有触发视图更新的能力。
(这也是为什么 Vue2 被迫搞出了一个恶心的 this.$set(obj, 'b', 100) API,它的底层就是强行再对新属性调用一次 defineProperty)。
3. 为什么监听不到删除?
同理,当你使用 delete obj.a 删除属性时,JS 引擎直接把这个属性从内存里抹掉了,它根本不会触发 set() 方法(set 只有在"赋值"时才会触发)。所以视图不会知道数据被删了。
第二重封印:为什么无法完美监听数组?
这是一个巨大的面试陷阱!
很多面试官会问:"Object.defineProperty 真的不能监听数组吗?"
如果你回答"不能",你就掉坑里了!
1. 惊天真相:其实它是能监听数组的!
数组在 JS 里本质上也是对象,数组的索引(0, 1, 2)其实就是对象的键(Key)。
理论上,Vue 完全可以像遍历对象一样,遍历数组的每一个索引去绑定 set:
let arr = [10, 20, 30]
Object.defineProperty(arr, '0', { set() { ... } }) // 强行给索引 0 绑定
2. 既然能,为什么 Vue2 没这么做?(性能妥协)
尤雨溪(Vue 作者)在 GitHub 的 Issue 中亲自回答过这个问题:出于性能考量。
对象通常只有几个、十几个属性。
但数组呢?随便一个列表数据可能就有 上千个、上万个元素!如果通过 for 循环给上万个数组元素挨个调用 Object.defineProperty,浏览器的内存和性能瞬间就爆炸了,页面会卡死。
3. 导致了什么残缺?
因为 Vue2 放弃了对数组索引的拦截,所以你写出这样的代码时,Vue2 是瞎的:
this.arr[0] = '新数据' // ❌ 视图不更新!(因为索引没被拦截)
this.arr.length = 0 // ❌ 视图不更新!(直接改长度也没被拦截)
4. Vue2 是怎么补救的?(猴子补丁 / Monkey Patching)
既然不能监听索引,那我们平时用 arr.push() 为什么能更新视图?
因为 Vue2 重写(劫持)了数组的 7 个原生方法!
Vue2 在底层把数组的 push, pop, shift, unshift, splice, sort, reverse 这 7 个方法偷偷换成了自己写的函数。
当你调用 arr.push() 时,其实调用的不是 JS 原生的 push,而是 Vue 写的假 push,Vue 会在里面手动通知视图更新,然后再调用原生 push 塞入数据。
第三重境界:为什么 Vue3 的 Proxy 是无敌的?
了解了 Vue2 的憋屈,你就知道 Vue3 换成 ES6 的 Proxy 有多爽了。
Proxy 的英文意思是"代理"。它和 defineProperty 最大的区别是:
- defineProperty 是给**具体的门(属性)**装报警器。
- Proxy 是在整座房子(对象)外围建了一圈高墙,门口放了一个保安。
let obj = { a: 1 }
// Proxy 拦截的是【整个对象】
let proxyObj = new Proxy(obj, {
// 不管你读什么属性,都归我管
get(target, key) { ... },
// 不管你改什么属性、新增什么属性,统统归我管
set(target, key, value) {
console.log(`你操作了属性:${key}`)
target[key] = value
return true
},
// 连删除属性都归我管!
deleteProperty(target, key) {
console.log(`你删除了属性:${key}`)
delete target[key]
return true
}
})
proxyObj.b = 100; // 👉 输出:你操作了属性:b (完美监听到新增!)
delete proxyObj.a; // 👉 输出:你删除了属性:a (完美监听到删除!)
对于数组也是一样的,Proxy 把整个数组包起来了,你无论是 arr[0] = 100 还是 arr.length = 0,全都在保安的监视之下,根本不需要什么重写数组方法的恶心操作!
👑 终极总结
问: 为什么 Vue2 (Object.defineProperty) 有那么多缺陷?Vue3 是怎么解决的?
答:
-
拦截颗粒度不同: defineProperty 只能拦截对象上已存在的具体属性,所以在初始化后新增或删除的属性,没有拦截器(Getter/Setter),导致视图不更新。而 Vue3 的 Proxy 是对整个对象进行代理,任何新增、删除甚至遍历操作,都会经过代理对象,从而被完美捕获。
-
数组处理的妥协: defineProperty 理论上可以拦截数组索引,但因为数组长度往往很大,遍历拦截会带来不可接受的性能开销。所以 Vue2 放弃了拦截数组索引,只能靠"重写数组的 7 个变异方法"来强行触发更新(导致
arr[0] = x这种写法失效)。而 Proxy 天然支持拦截数组的索引和长度变化,不需要任何黑魔法,性能和体验实现了质的飞跃。
🚀 Vue3 源码最深处:为什么必须是 Proxy + Reflect?
太棒了!你能问出这个问题,说明你已经触及到了 Vue3 源码的最深处。
在面试中,99% 的前端都能说出 "Vue3 用了 Proxy"。但如果你问他:"既然有了 Proxy,为什么 Vue3 的源码里还要配合 Reflect 一起用?不用 Reflect 行不行?" 绝大多数人都会立刻懵圈。
如果你能在你的书里把这段讲透,并且手写一个简易版的 Vue3 响应式,那就是真正的**"降维打击"**。
下面,我们将分为三个层次,带你徒手撕开 Vue3 响应式的底层逻辑。
第一层:代理守卫 Proxy 与 幕后黑手 Reflect
我们知道 Proxy 是门卫,能拦截对对象的所有操作。那么 Reflect 是什么?
Reflect(反射)是 ES6 内置的一个全局对象。它包含了一系列操作对象的方法,这些方法和 Proxy 的拦截器(Trap)名字一模一样(比如 Reflect.get, Reflect.set)。
很多新手写 Proxy 是这样写的:
const obj = { a: 1 }
const proxy = new Proxy(obj, {
get(target, key) {
console.log(`拦截到了读取:${key}`)
return target[key] // 👈 直接用 target[key] 返回值
},
set(target, key, value) {
console.log(`拦截到了设置:${key}`)
target[key] = value // 👈 直接给 target 赋值
return true
}
})
面试官杀手级问题来了: "上面的代码看起来挺完美的,为什么 Vue3 源码里偏偏不用 target[key],而必须写成 Reflect.get(target, key, receiver) ?"
💣 致命陷阱:this 的指向问题(必须用 Reflect 的原因)
假设我们有一个对象,里面不仅有普通属性,还有一个 getter 访问器属性(它的返回值依赖了对象内部的其他属性)。
const person = {
name: '张三',
get greeting() {
return '你好,' + this.name; // 注意这里的 this
}
}
// 我们用上面的老方法写 Proxy,不用 Reflect
const proxyPerson = new Proxy(person, {
get(target, key, receiver) {
console.log(`【拦截访问】去读取了属性:${key}`)
return target[key] // ❌ 这里的坑来了!
}
})
// 测试:
console.log(proxyPerson.greeting)
运行结果:
【拦截访问】去读取了属性:greeting
你好,张三
发现了什么不对劲吗?!
当我们通过 proxyPerson.greeting 访问时,触发了 Proxy 的 get 拦截。
但是在 greeting 函数内部,执行了 this.name。可是,控制台并没有打印"【拦截访问】去读取了属性:name"!
底层原因:
当我们 return target[key] 时,greeting 函数是被原对象 person 调用的!所以 greeting 内部的 this 指向的是 person 原对象,而不是代理对象 proxyPerson。
既然绕过了代理对象直接读了 name,Vue 的响应式系统就瞎了——它根本不知道你在这个时候用到了 name!以后 name 变了,视图就不会更新!
🛡️ 神器降临:Reflect 修正 this 指向
我们把代码换成 Reflect:
const proxyPerson = new Proxy(person, {
// receiver 实际上就是当前的 Proxy 实例 (proxyPerson)
get(target, key, receiver) {
console.log(`【拦截访问】去读取了属性:${key}`)
// ✅ Reflect.get 第三个参数 receiver 可以强制修改 this 的指向!
return Reflect.get(target, key, receiver)
}
})
console.log(proxyPerson.greeting)
完美的运行结果:
【拦截访问】去读取了属性:greeting
【拦截访问】去读取了属性:name <-- 完美!name 也被拦截到了!
你好,张三
总结
Vue3 响应式为什么必须是 Proxy + Reflect 组合?
因为 Proxy 负责在对象外层设下拦截网;而 Reflect 配合 receiver 参数,能够确保对象内部无论嵌套多深、无论内部如何通过 this 互相调用,this 永远指向外层的 Proxy 代理对象。这样才能保证所有的属性访问都能被精准拦截,哪怕是对象内部的自我调用(完美解决依赖收集漏掉的问题)。
第二层:Vue3 响应式的"依赖收集"与"触发更新"机制
有了完美的拦截器,接下来 Vue3 是怎么做到"数据一变,页面就变"的?
它的核心思想只有六个字:track(追踪) 和 trigger(触发)。
依赖收集(track):
当页面渲染(或者计算属性运行)时,会触发 Proxy 的 get。在这个 get 里,Vue 会记录下来:"当前是哪一个函数(副作用 effect)正在读取我?" 把这个函数塞进一个桶里。
触发更新(trigger):
当你修改数据时,会触发 Proxy 的 set。在这个 set 里,Vue 会去那个桶里找:"刚才谁读取过这个属性来着?" 然后把那些函数统统拿出来,重新执行一遍。页面就更新了。
👑 终极总结
如果在面试中让你详述 Vue3 的响应式,请背诵以下三部曲:
-
核心架构: Vue3 的响应式底层采用 Proxy 拦截对象的读写操作,并强绑定 Reflect 来保证内部访问的 this 永远指向代理对象,解决了 Vue2 无法监听新增/删除和数组的问题,彻底终结了 this.$set 时代。
-
巧妙的数据结构: 在依赖收集中,Vue3 巧妙地设计了 WeakMap -> Map -> Set 的三层树状数据结构。WeakMap 以目标对象为键(防止内存泄漏),Map 以对象的属性为键,Set 里存放的则是那些因为用到了这个属性而需要被通知的 effect(副作用函数)。
-
闭环流程: 当代码运行遇到被代理的数据时,触发 get,执行 track() 把当前函数丢进 Set 集合;当数据被修改时,触发 set,执行 trigger() 从 Set 集合里拿出所有函数重新执行一遍,从而完成数据驱动视图的闭环!
reactive 的底层: 直接基于 Proxy 实现。它接收一个普通对象,返回一个 Proxy 实例。所以上面提到解构或重新赋值会破坏响应式,本质上是因为破坏了指向 Proxy 实例的引用。
ref 的底层:
- 对于基本数据类型:Proxy 无法拦截基本类型(数字、字符串),所以 ref 底层创建了一个叫 RefImpl 的类,通过类的 get value() 和 set value() 属性访问器来实现响应式。这就是为什么必须要写 .value。
- 对于对象类型:如果在 ref 里传入一个对象,ref 底层会自动调用 reactive(),将其转换为 Proxy。
1.3 运筹帷幄:计算与监听 (computed & watch)
1. computed():带缓存的计算属性
用于根据现有数据派生出新的数据。
import { ref, computed } from 'vue'
const price = ref(10)
const count = ref(2)
// 基础用法:只读
const total = computed(() => price.value * count.value)
// 高级用法:可读可写(面试常考)
const discountTotal = computed({
get: () => price.value * count.value * 0.8,
set: (newValue) => {
// 逆向推导
count.value = newValue / 0.8 / price.value
}
})
底层要点: computed 具有缓存特性。只有当依赖的 price 或 count 发生变化时,它才会重新计算。如果依赖不变,多次访问 total 会直接返回上次计算的缓存结果,性能极佳。
2. watch():精确的狙击手
用于监听特定的数据源,并在数据变化时执行副作用(比如发 Ajax 请求)。
import { ref, reactive, watch } from 'vue'
const keyword = ref('')
const user = reactive({ info: { age: 18 } })
// 1. 监听单个 ref
watch(keyword, (newValue, oldValue) => {
console.log('搜索词变了:', newValue)
})
// 2. 监听 reactive 对象的某个属性(必须写成 getter 函数的形式!)
watch(
() => user.info.age,
(newAge, oldAge) => {
console.log('年龄变了:', newAge)
}
)
// 3. 配置项:immediate(立即执行一次)和 deep(深度监听)
watch(user, (val) => {
console.log('user变化了')
}, { deep: true, immediate: true })
// 注意:Vue3 中,如果 watch 监听的是整个 reactive 对象,deep 会隐式强制开启。
3. watchEffect():全自动的巡逻机
不需要指定监听谁,你在回调函数里用到了谁,它就自动监听谁!
import { ref, watchEffect } from 'vue'
const id = ref(1)
// 组件挂载时会自动执行一次,以后只要 id 变化,就会再次执行
watchEffect(() => {
// 自动收集依赖:因为用到了 id.value
console.log(`正在请求 ID 为 ${id.value} 的用户数据...`)
// fetchUserData(id.value)
})
⚔️ 吊打面试官:watch 和 watchEffect 有什么区别?
你的回答:
- 触发时机: watch 默认是懒执行的(数据变了才触发),而 watchEffect 在组件初始化时一定会立即执行一次(为了收集依赖)。
- 依赖追踪方式: watch 必须明确指定要监听的数据源;watchEffect 是自动推导的,函数里用到了什么响应式变量,它就监听什么。
- 获取旧值: watch 可以获取到修改前的值(oldValue),而 watchEffect 拿不到旧值。
- 底层机制: watchEffect 的底层类似于 computed 的依赖收集过程,执行函数时触发了响应式数据的 get 拦截器,从而将该副作用函数记录为依赖。
1.4 生命周期的蜕变
Vue3 的生命周期钩子以导入函数的形式使用,统一加上了 on 前缀。
import { onMounted, onUnmounted, ref } from 'vue'
const timer = ref(null)
onMounted(() => {
console.log('组件已经挂载到页面上了,可以操作 DOM 或发请求了')
timer.value = setInterval(() => {
console.log('心跳检测...')
}, 1000)
})
onUnmounted(() => {
console.log('组件即将被销毁')
// 最佳实践:必须在这里清理定时器、全局事件监听,否则会造成内存泄漏!
clearInterval(timer.value)
})
Vue2 到 Vue3 生命周期映射表:
| Vue2 | Vue3 | 说明 |
|---|---|---|
| beforeCreate / created | 被 setup 替代 | 代码直接写在 <script setup> 里即可,它们是最先执行的 |
| beforeMount | onBeforeMount | 组件挂载前 |
| mounted | onMounted | 高频使用,Ajax 请求、操作 DOM、实例化图表库 |
| beforeUpdate | onBeforeUpdate | 组件更新前 |
| updated | onUpdated | 组件更新后 |
| beforeDestroy | onBeforeUnmount | 名字改了!更符合语义 |
| destroyed | onUnmounted | 高频使用,清场收尾工作 |
⚔️ 吊打面试官:生命周期函数的底层注册机制
面试官问: "所有的 onMounted 都是从 vue 导入的,Vue 底层怎么知道这个 onMounted 是属于哪一个组件的?"
你的回答:
在 Vue3 运行 setup() 函数之前,会在全局维护一个变量(比如叫 currentInstance),用来记录当前正在初始化的组件实例。
当我们在 setup 里面调用 onMounted(fn) 时,底层其实是从全局变量中获取到了当前的组件实例,并将这个 fn 回调函数 push 到该实例专属的生命周期数组中(如 instance.m)。
等到组件的 DOM 真正挂载完毕时,Vue 的渲染器会遍历这个数组,依次执行里面的回调函数。这也是为什么生命周期钩子只能在 setup 的同步代码中执行的原因(如果在 setTimeout 里调用,currentInstance 早就变成 null 或者指代别的组件了)。