手把手搭建Vue轮子从0到1:3. 响应系统的核心设计原则

上一章:手把手搭建Vue轮子从0到1:2. 搭建框架雏形

仓库地址:gitee.com/carrierxia/...

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>

可以先想想两次打印输出的结果是什么?

代码分析:

  1. 首先定义了一个学生对象student,包含姓名name和年龄age
  2. 然后通过当前年份(假设为 2025 年)减去学生的年龄计算出学生的出生年份birthYear
  3. 第一次打印输出学生的姓名和计算出的出生年份。
  4. 接着修改学生的年龄为 20。
  5. 第二次打印输出学生的姓名和之前计算出的出生年份。

结果分析:

在这段代码中,第一次打印的值是 "学生张三的出生年份是 2007",第二次打印的值同样是 "学生张三的出生年份是 2007"。这是因为在 JavaScript 中,birthYear是在修改student.age之前计算的,它不会随着student.age的改变而自动更新,这体现了 JavaScript 的程序性,即代码按照顺序执行,变量一旦赋值,在后续代码未对其重新赋值的情况下,其值保持不变。

但是这真的是我们期望的结果吗?在修改学生年龄之后我们是想要的是更新后的值(2005),而 响应性 就是帮我们拿到这个结果的策略。

2. 设计响应性的步骤

2.1. 初始想法,调用方法重新获取

  1. 创建一个 effect 函数,在其内部封装计算出生年份的方法。
  2. 在第一/二次打印之前,调用 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 方法 来实现响应式更新。

具体局限性:

  1. 对象变化检测的限制:JS 的 Object.defineProperty () API 限制。
  • 无法检测属性的添加或删除:因为 Vue 在初始化时只会遍历对象现有的属性并转换为 getter/setter,新增的属性不会自动拥有响应式能力。
  • 深层对象需要递归转换:Vue 会递归遍历对象的所有属性,但如果在运行时动态添加深层属性,Vue 无法自动追踪。
  1. 数组变化检测的限制:对于数组,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 响应式系统的核心,但由于其设计限制:

  1. 只能劫持对象的属性,不能劫持整个对象
  2. 无法检测属性的添加和删除
  3. 对数组的支持有限

这些限制导致 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响应性设计对比

  1. Vue2 的响应性实现
    a. Vue2 使用 Object.defineProperty 来劫持对象属性的 getter 和 setter。
    b. 当需要修改对象的指定属性时,可以直接操作原对象。
    c. 通过 Object.defineProperty,只有在初始化时被监听的指定属性,才能够触发 getter 和 setter。这就导致如果在 Vue2 中给对象新增属性,这些新增属性不会自动具备响应性。
  2. Vue3 的响应性实现
    a. Vue3 采用 Proxy 来代理一个对象(被代理对象),从而得到一个新的对象(代理对象),并且这个代理对象会拥有被代理对象中所有的属性。
    b. 当想要修改对象的指定属性时,需要使用代理对象进行修改。
    c. 代理对象的任何一个属性的访问和修改都可以触发 handler 中的 getter 和 setter,包括新增的属性。
    所以,当 Vue3 通过 Proxy 实现响应性核心 API 之后,Vue 不再存在新增属性时失去响应性的问题。
特性 Object.defineProperty() Proxy
检测属性添加 / 删除 ❌ 无法检测 ✅ 可以检测
检测数组索引修改 ❌ 无法检测 ✅ 可以检测
深层对象监听 ❌ 需要递归处理 ✅ 自动深层监听
性能 ❌ 初始化时需要遍历所有属性 ✅ 按需监听

2.5. Proxy的"伴生对象"------ Reflect

开始之前先思考:为什么使用 reflect?

Reflect - JavaScript | MDN

Proxy的"伴生对象"------ 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.valuethis 会指向 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)

相关推荐
Jackson_Mseven13 分钟前
🧺 Monorepo 是什么?一锅端的大杂烩式开发幸福生活
前端·javascript·架构
我想说一句21 分钟前
JavaScript数组:轻松愉快地玩透它
前端·javascript
binggg23 分钟前
AI 编程不靠运气,Kiro Spec 工作流复刻全攻略
前端·claude·cursor
ye空也晴朗32 分钟前
基于eggjs+mongodb实现后端服务
前端
慕尘_35 分钟前
对于未来技术的猜想:Manus as a Service
前端·后端
爱学习的茄子40 分钟前
JS数组高级指北:从V8底层到API骚操作,一次性讲透!
前端·javascript·深度学习
小玉子42 分钟前
webpack未转译第三方依赖axios为es5导致低端机型功能异常
前端
爱编程的喵43 分钟前
React状态管理:从useState到useReducer的完美进阶
前端·react.js
markyankee1011 小时前
Vue-Router:构建现代化单页面应用的路由引擎
前端·vue.js
Java水解1 小时前
Spring WebFlux 与 WebClient 使用指南
前端