1. JS 的程序性
JS 的程序性是指:一套固定的,不会发生变化的执行流程(代码按照顺序执行)。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS的程序性示例</title>
</head>
<body>
<script>
// 定义一个学生对象,包含姓名和年龄
let student = {
name: "张三",
age: 18
}
// 计算学生的出生年份(假设当前年份为2025年)
let birthYear = 2025 - student.age;
// 第一次打印
console.log(`学生${student.name}的出生年份是${birthYear}`);
// 修改学生的年龄
student.age = 20;
// 第二次打印
console.log(`学生${student.name}的出生年份是${birthYear}`);
</script>
</body>
</html>
可以先想想两次打印输出的结果是什么?
代码分析:
- 首先定义了一个学生对象
student
,包含姓名name
和年龄age
。 - 然后通过当前年份(假设为 2025 年)减去学生的年龄计算出学生的出生年份
birthYear
。 - 第一次打印输出学生的姓名和计算出的出生年份。
- 接着修改学生的年龄为 20。
- 第二次打印输出学生的姓名和之前计算出的出生年份。
结果分析:
在这段代码中,第一次打印的值是 "学生张三的出生年份是 2007",第二次打印的值同样是 "学生张三的出生年份是 2007"。这是因为在 JavaScript 中,birthYear是在修改student.age之前计算的,它不会随着student.age的改变而自动更新,这体现了 JavaScript 的程序性,即代码按照顺序执行,变量一旦赋值,在后续代码未对其重新赋值的情况下,其值保持不变。
但是这真的是我们期望的结果吗?在修改学生年龄之后我们是想要的是更新后的值(2005),而 响应性 就是帮我们拿到这个结果的策略。
2. 设计响应性的步骤
2.1. 初始想法,调用方法重新获取
- 创建一个 effect 函数,在其内部封装计算出生年份的方法。
- 在第一/二次打印之前,调用 effect 方法。
html
<script>
// 定义一个学生对象,包含姓名和年龄
let student = {
name: "张三",
age: 18
}
let birthYear = null;
let effect = () => {
// 计算学生的出生年份(假设当前年份为2025年)
birthYear = 2025 - student.age;
}
// 第一次打印
effect();
console.log(`学生${student.name}的出生年份是${birthYear}`); // 2007
// 修改学生的年龄
student.age = 20;
// 第二次打印
effect();
console.log(`学生${student.name}的出生年份是${birthYear}`); // 2005
</script>
存在的问题:必须主动在数量变化之后,重新主动执行 effect,太麻烦了。
2.2. Vue2 中的响应性设计:Object.defineProperty
Vue2使用 Object.defineProperty API 作为响应性的核心。该 API 可以监听指定对象的指定属性的 getter 和 setter。详细可以看红宝书第4版的第8章对象一节 或 MDN | Object.defineProperty()。
该 API 接收三个参数:指定对象、指定属性、属性描述符对象
html
<script>
// 定义一个学生对象,包含姓名和年龄
let student = {
name: '张三',
age: 18
}
// 出生日期
let birthYear = null
// 计算学生的出生年份的匿名函数(假设当前年份为2025年)
let effect = () => {
birthYear = 2025 - student.age
}
// 第一次打印
effect()
console.log(`学生${student.name}的出生年份是${birthYear}`) // 2007
// 监听 student 的 age 的 setter
Object.defineProperty(student, 'age', {
// 监听 student.age = newVal 的行为,当 age 被赋值时,重新调用 effect 函数
set(newVal) {
// 将新值赋值给 student.age。注意这里没有使用 student.age = newVal 的方式,因为这样会触发 set 方法的无限递归
age = newVal
// 重新调用 effect 函数
effect()
},
// 监听 student.age,在触发该行为时,以 age 变量的值作为 student.age 的属性值
get() {
return age
}
})
// 修改学生的年龄
student.age = 20
// 第二次打印
console.log(`学生${student.name}的出生年份是${birthYear}`) // 2005
</script>
缺陷:由于 JS 的限制,不能检测数组和对象的变化。在 Vue2 中需要使用 Vue.set 方法 来实现响应式更新。
具体局限性:
- 对象变化检测的限制:JS 的 Object.defineProperty () API 限制。
- 无法检测属性的添加或删除:因为 Vue 在初始化时只会遍历对象现有的属性并转换为 getter/setter,新增的属性不会自动拥有响应式能力。
- 深层对象需要递归转换:Vue 会递归遍历对象的所有属性,但如果在运行时动态添加深层属性,Vue 无法自动追踪。
- 数组变化检测的限制:对于数组,Vue 2 重写了数组的变异方法(如 push/pop/splice 等),但仍有以下限制:
- 无法检测通过索引直接修改数组元素:例如 arr[0] = newValue 不会触发更新。
- 无法检测数组长度的变化:例如 arr.length = 0 不会触发更新。
Vue.set方法作用:
Vue.set (target, propertyName/index, value) 方法用于解决上述限制:
- 对象:为对象添加一个新属性,并确保这个新属性也是响应式的。
- 数组:通过索引修改数组元素,并触发更新。
从源码角度看,Vue.set 本质上会:
- 检查目标是否是响应式对象
- 如果是数组,调用变异方法触发更新
- 如果是对象,使用 defineReactive () 为新属性创建 getter/setter
例子:
js
// 初始化 Vue 实例
const vm = new Vue({
data: {
user: {
name: '张三',
age: 20
},
fruits: ['苹果', '香蕉']
}
});
// 问题1:直接添加新属性,不会触发更新
vm.user.gender = '男'; // 非响应式,界面不会更新
// 解决方案1:使用 Vue.set
Vue.set(vm.user, 'gender', '男'); // 响应式更新
// 问题2:通过索引修改数组,不会触发更新
vm.fruits[0] = '橙子'; // 非响应式,界面不会更新
// 解决方案2:使用 Vue.set
Vue.set(vm.fruits, 0, '橙子'); // 响应式更新
// 问题3:修改数组长度,不会触发更新
vm.fruits.length = 1; // 非响应式,界面不会更新
// 解决方案3:使用变异方法
vm.fruits.splice(1); // 响应式更新
总结:
Object.defineProperty () 是 Vue 2 响应式系统的核心,但由于其设计限制:
- 只能劫持对象的属性,不能劫持整个对象
- 无法检测属性的添加和删除
- 对数组的支持有限
这些限制导致 Vue 2 需要提供额外的 API(如 Vue.set)来处理特殊情况。
那么Vue3如何解决这些缺陷呢?
2.3. Vue3中的响应性设计:Proxy
js
// 定义一个对象,包含名称和年龄
let person = {
name: '张三',
age: 20
}
// new Proxy 接收两个参数: 第一个参数是目标对象,第二个参数是代理对象
// 生成代理对象,代理对象中可以定义一些拦截器,当访问目标对象的属性时,会触发拦截器
// 拦截器中可以定义一些拦截行为,比如读取属性(get)、设置属性(set)、删除属性(deleteProperty)等
// 此时,person 被称为"被代理对象",proxyPerson 被称为"代理对象"
const proxyPerson = new Proxy(person, {
// 监听 proxyPerson 的 get 方法,在 proxyPerson.xxx 时,会触发。
// 接收三个参数:target 是目标对象(被代理对象),key 是指定属性名,receiver 是代理对象(最初被调用的对象)
// 返回值是目标对象的属性值 proxyPerson.xxx
get(target, key, receiver) {
console.log(`读取属性 ${key}`)
return target[key] // 返回目标对象的属性值
},
// 监听 proxyPerson 的 set 方法,在 proxyPerson.xxx = value 时,会触发。
// 接收四个参数:target 是目标对象(被代理对象),key 是指定属性名,value 是设置的属性值(新值),receiver 是代理对象(最初被调用的对象)
// 返回值是设置是否成功, boolean 类型,true 表示设置成功
set(target, key, value, receiver) {
console.log(`设置属性 ${key} 为 ${value}`)
target[key] = value // 设置目标对象的属性值
// 触发 effect 重新计算
effect()
return true
},
deleteProperty(target, key) {
console.log(`删除属性 ${key}`)
delete target[key]
return true
}
})
let birthYear = null
let effect = () => {
birthYear = 2025 - proxyPerson.age
console.log(
`学生${proxyPerson.name || '=未知='}的出生年份是${birthYear}`
)
}
// 第一次打印
effect()
// 第二次打印,设置属性 age 为 21
proxyPerson.age = 21
// 删除属性 name
delete proxyPerson.name // 输出: 删除属性 name
2.4. Vue2、Vue3响应性设计对比
- Vue2 的响应性实现
a. Vue2 使用 Object.defineProperty 来劫持对象属性的 getter 和 setter。
b. 当需要修改对象的指定属性时,可以直接操作原对象。
c. 通过 Object.defineProperty,只有在初始化时被监听的指定属性,才能够触发 getter 和 setter。这就导致如果在 Vue2 中给对象新增属性,这些新增属性不会自动具备响应性。 - Vue3 的响应性实现
a. Vue3 采用 Proxy 来代理一个对象(被代理对象),从而得到一个新的对象(代理对象),并且这个代理对象会拥有被代理对象中所有的属性。
b. 当想要修改对象的指定属性时,需要使用代理对象进行修改。
c. 代理对象的任何一个属性的访问和修改都可以触发 handler 中的 getter 和 setter,包括新增的属性。
所以,当 Vue3 通过 Proxy 实现响应性核心 API 之后,Vue 不再存在新增属性时失去响应性的问题。
特性 | Object.defineProperty() | Proxy |
---|---|---|
检测属性添加 / 删除 | ❌ 无法检测 | ✅ 可以检测 |
检测数组索引修改 | ❌ 无法检测 | ✅ 可以检测 |
深层对象监听 | ❌ 需要递归处理 | ✅ 自动深层监听 |
性能 | ❌ 初始化时需要遍历所有属性 | ✅ 按需监听 |
2.5. Proxy的"伴生对象"------ Reflect
开始之前先思考:为什么使用 reflect?

如果target对象中指定了getter,receiver则为getter调用时的this值。
xml
<script>
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get
// 如果target对象中指定了getter,receiver则为getter调用时的this值。
const target = {
_value: 42,
get value() {
return this._value
}
}
const receiver = {
_value: 99
}
const receiver2 = {
_value: 0
}
// 使用 Reflect.get 访问 target.value,并指定 receiver 作为 this
console.log(target.value) // 42
console.log(Reflect.get(target, 'value', receiver)) // 输出: 99
// 第三个参数 receiver2 在对象指定了 getter 调用的this值
console.log(Reflect.get(target, 'value', receiver2)) // 输出: 0
</script>
对比普通属性,如果直接访问 target.value
,this
会指向 target
自身:
arduino
console.log(target.value); // 输出: 42
而通过 Reflect.get 并指定 receiver,可以动态改变 getter 的上下文,这在代理(Proxy)和元编程中非常有用。
可以让方法的调用像属性的调用一样
html
<script>
const person = {
name: 'myName',
age: 10,
get introduce() {
return `${this.name}${this.age}岁了`
}
}
const proxy = new Proxy(person, {
// target: 被代理对象
// receiver: 代理对象
get(target, key, receiver) {
console.log('触发了getter')
return target[key]
}
})
console.log(proxy.introduce)
</script>

在这里触发了一次 proxy.introduce,然后 introduce 又触发了 this.name 和 this.age,是应该触发了3次吗?不是,实际只触发了1次。
因为我们这里的 this 是 person,而非 proxy,所以 name 和 age 不会再次触发getter。
如何让 getter 触发3次呢?这时就要结合 Reflect.get 了。
html
<script>
const person = {
name: 'myName',
age: 10,
get introduce() {
return `${this.name}${this.age}岁了`
}
}
const proxy = new Proxy(person, {
// target: 被代理对象
// receiver: 代理对象
get(target, key, receiver) {
console.log('触发了getter')
// return target[key]
return Reflect.get(target,key,receiver)
}
})
console.log(proxy.introduce)
</script>

总结:
期望监听代理对象的 getter 和 setter(被代理对象的内部,通过 this 触发 getter 和 setter 时,也需要被监听到),所以不应该使用 target[key],因为它在某些时刻下是不可靠的(比如 introduce)。而应该使用 Reflect,借助它的 get 和 set 方法,使用 receiver 作为 this,达到期望的结果(触发三次 getter)