Vue3 核心基石 —— 组合式 API 深度解密

0 阅读11分钟

第一章: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 是怎么解决的?

答:

  1. 拦截颗粒度不同: defineProperty 只能拦截对象上已存在的具体属性,所以在初始化后新增或删除的属性,没有拦截器(Getter/Setter),导致视图不更新。而 Vue3 的 Proxy 是对整个对象进行代理,任何新增、删除甚至遍历操作,都会经过代理对象,从而被完美捕获。

  2. 数组处理的妥协: 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 的响应式,请背诵以下三部曲:

  1. 核心架构: Vue3 的响应式底层采用 Proxy 拦截对象的读写操作,并强绑定 Reflect 来保证内部访问的 this 永远指向代理对象,解决了 Vue2 无法监听新增/删除和数组的问题,彻底终结了 this.$set 时代。

  2. 巧妙的数据结构: 在依赖收集中,Vue3 巧妙地设计了 WeakMap -> Map -> Set 的三层树状数据结构。WeakMap 以目标对象为键(防止内存泄漏),Map 以对象的属性为键,Set 里存放的则是那些因为用到了这个属性而需要被通知的 effect(副作用函数)。

  3. 闭环流程: 当代码运行遇到被代理的数据时,触发 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 生命周期映射表:

Vue2Vue3说明
beforeCreate / created被 setup 替代代码直接写在 <script setup> 里即可,它们是最先执行的
beforeMountonBeforeMount组件挂载前
mountedonMounted高频使用,Ajax 请求、操作 DOM、实例化图表库
beforeUpdateonBeforeUpdate组件更新前
updatedonUpdated组件更新后
beforeDestroyonBeforeUnmount名字改了!更符合语义
destroyedonUnmounted高频使用,清场收尾工作

⚔️ 吊打面试官:生命周期函数的底层注册机制

面试官问: "所有的 onMounted 都是从 vue 导入的,Vue 底层怎么知道这个 onMounted 是属于哪一个组件的?"

你的回答:

在 Vue3 运行 setup() 函数之前,会在全局维护一个变量(比如叫 currentInstance),用来记录当前正在初始化的组件实例。

当我们在 setup 里面调用 onMounted(fn) 时,底层其实是从全局变量中获取到了当前的组件实例,并将这个 fn 回调函数 push 到该实例专属的生命周期数组中(如 instance.m)。

等到组件的 DOM 真正挂载完毕时,Vue 的渲染器会遍历这个数组,依次执行里面的回调函数。这也是为什么生命周期钩子只能在 setup 的同步代码中执行的原因(如果在 setTimeout 里调用,currentInstance 早就变成 null 或者指代别的组件了)。