Skip to content

Vue 中的响应式

在 JavaScript 中有两种劫持 property 访问的方式:getter/setters 和 Proxies。

简单模拟 Vue2 中响应式

Vue2 出于支持旧版本浏览器的限制使用 getter/setters

  1. 在 html 中添加一个 input,绑定一个变量,当输入时,将输入的值在 p 标签中显示出来。
html
<input id="input" type="text" />
<p id="output"></p>
  1. 定义一个 Dep 类,用来收集依赖。并提供一个 notify 方法,用来通知更新。
js
class Dep {
  static target = null // 静态属性,用来保存当前正在执行的观察者实例
  constructor() {
    this.subs = []
  }
  addSub(sub) {
    this.subs.push(sub)
  }
  notify() {
    this.subs.forEach((sub) => sub.update())
  }
}
  1. 定义一个观察者类 Watcher
js
class Watcher {
  constructor(obj, key, cb) {
    Dep.target = this // 在创建一个新的观察者实例时,会临时将 Dep.target 设置为该实例。这样,当响应式属性的 getter 被调用时,可以将这个观察者添加到依赖(订阅者)列表中。
    this.obj = obj
    this.key = key
    this.cb = cb
    this.value = obj[key] // 触发 getter,进行依赖收集
    Dep.target = null // 观察者创建完成后,重置 Dep.target,以避免后续的 getter 触发意外的依赖收集。
  }
  update() {
    // 当update方法被调用时,此处访问this.obj[this.key]会再次触发getter,但由于Dep.target被重置为null了,所以不会再次进行依赖收集。
    const newValue = this.obj[this.key]
    if (newValue !== this.value) {
      this.value = newValue
      this.cb(newValue)
    }
  }
}
  1. 定义一个 defineReactive 方法,用来将一个属性定义为响应式属性。
js
function defineReactive(obj, key) {
  let value = obj[key]
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.addSub(Dep.target)
      }
      return value
    },
    set(newValue) {
      if (newValue !== value) {
        value = newValue
        dep.notify()
      }
    },
  })
}
  1. 具体实现
js
// 数据对象
const data = { text: '' }
// 将 text 属性设置为响应式
defineReactive(data, 'text')
// 创建一个 Watcher 实例,实现数据更新时的回调
new Watcher(data, 'text', function (newVal) {
  document.getElementById('output').innerText = newVal
})
// 监听 input 输入事件,更新数据对象
document.getElementById('input').addEventListener('input', function (e) {
  data.text = e.target.value
})
  1. 试一试

text is:

提示

在第 2 步定义的 Dep 类中,使用 subs数组 来收集依赖。如果改成 Set 是否也可以?而且 Set 可以避免重复收集依赖。这样的话在第 3 步的观察者类 Watcher 的构造函数中就不需要将 Dep.target 重置为 null 了。

js
class Dep {
  static target = null
  constructor() {
    this.subs = [] 
    this.subs = new Set() 
  }
  addSub(sub) {
    this.subs.push(sub) 
    this.subs.add(sub) 
  }
  // ...
}
js
class Wathcer {
  constructor(obj, key, cb) {
    Dep.target = this
    this.obj = obj
    this.key = key
    this.cb = cb
    this.value = obj[key]
    Dep.target = null 
  }
}

通过上述代码,实现了一个简化版的 Vue 2 响应式系统,并使用事件监听实现简单的双向绑定效果。这个示例仅展示了基本原理,实际的 Vue 实现更加复杂和健壮。

简单模拟 Vue3 中响应式

通过一个简单的计数器示例,展示如何使用 Proxy 来实现响应式数据绑定。

  1. 在 html 中添加一个 p 标签用来显示计数器的数值,再添加一个 button 按钮,点击的时候让计数器加一。
html
<p id="counter"></p>
<button id="increment">Increment</button>
  1. 定义一个 WeakMap 类型的全局依赖管理容器 bucket,和用于收集依赖的 Dep 类。
  • Dep 类中的 track 函数用于在读取属性时记录依赖关系。
  • Dep 类中的 trigger 函数用于在写入属性时触发所有相关的副作用函数。
js
const bucket = new WeakMap()

class Dep {
  static target = null // 静态属性,用来保存当前的依赖。
  track(target, key) {
    if (!Dep.target) return
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
      depsMap.set(key, (deps = new Set()))
    }
    deps.add(Dep.target)
  }
  trigger(target, key) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach((fn) => fn())
  }
}
  1. 定义一个收集依赖的函数 effect
js
function effect(fn) {
  Dep.target = fn
  fn()
  Dep.target = null
}

effect 函数中,做了以下 3 件事情:

  • 设置 Dep.target 在执行 fn() 之前,设置 Dep.target 为传入的 fn 函数。这使得在 fn 函数执行期间,每当访问响应式数据时,能够将当前的 fn 函数记录为该数据的依赖。
  • 执行 fn()Dep.target 被设置后,立即执行 fn() 函数。在执行期间,所有访问的响应式数据都会调用依赖收集函数 Dep.track(),并将当前的 Dep.target(即 fn 函数)添加到依赖集合中。
  • 重置 Dep.target 在执行完 fn() 后,将 Dep.target 重置为 null。这表示当前没有正在收集依赖的副作用函数。
  1. 创建一个响应式数据对象 reactive
js
function reactive(target) {
  const dep = new Dep()
  return new Proxy(target, {
    get(target, key) {
      dep.track(target, key)
      return Reflect.get(...arguments)
    },
    set(target, key, value) {
      const result = Reflect.set(...arguments)
      dep.trigger(target, key)
      return result
    },
  })
}
  1. 具体实现。
js
// 定义一个响应式状态
const state = reactive({ count: 0 })

// 注册副作用函数
effect(() => {
  document.getElementById('count').innerText = state.count
})

// 绑定事件监听器
document.getElementById('increment').addEventListener('click', () => {
  state.count++
})
  1. 试一试

Count is:

扩展

理解 JavaScript 对象(属性的类型)

ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。

属性分两种:数据属性和访问器属性。

1. 数据属性

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。

要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包 含:configurableenumerablewritablevalue,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。

js
let person = {}
Object.defineProperty(person, 'name', {
  writable: false,
  value: 'Nicholas',
})
console.log(person.name) // "Nicholas"
person.name = 'Greg'
console.log(person.name) // "Nicholas"
js
let person = {}
Object.defineProperty(person, 'name', {
  configurable: false,
  value: 'Nicholas',
})
console.log(person.name) // "Nicholas"
delete person.name
console.log(person.name) // "Nicholas"

2. 访问器属性

访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有 4 个特性描述它们的行为。

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
  • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。

访问器属性是不能直接定义的,必须使用 Object.defineProperty()

js
let book = {
  _year: 2017,
  edition: 1,
}
Object.defineProperty(book, 'year', {
  get() {
    return this._year
  },
  set(newValue) {
    if (newValue > 2017) {
      this._year = newValue
      this.edition += newValue - 2017
    }
  },
})
book.year = 2018
console.log(book.edition) // 2

获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。