Vue 中的响应式
在 JavaScript 中有两种劫持 property 访问的方式:getter/setters 和 Proxies。
简单模拟 Vue2 中响应式
Vue2 出于支持旧版本浏览器的限制使用 getter/setters。
- 在 html 中添加一个 input,绑定一个变量,当输入时,将输入的值在 p 标签中显示出来。
<input id="input" type="text" />
<p id="output"></p>
- 定义一个
Dep
类,用来收集依赖。并提供一个notify
方法,用来通知更新。
class Dep {
static target = null // 静态属性,用来保存当前正在执行的观察者实例
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
notify() {
this.subs.forEach((sub) => sub.update())
}
}
- 定义一个观察者类
Watcher
。
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)
}
}
}
- 定义一个
defineReactive
方法,用来将一个属性定义为响应式属性。
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()
}
},
})
}
- 具体实现
// 数据对象
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
})
- 试一试
text is:
提示
在第 2 步定义的 Dep
类中,使用 subs数组
来收集依赖。如果改成 Set 是否也可以?而且 Set 可以避免重复收集依赖。这样的话在第 3 步的观察者类 Watcher
的构造函数中就不需要将 Dep.target
重置为 null
了。
class Dep {
static target = null
constructor() {
this.subs = []
this.subs = new Set()
}
addSub(sub) {
this.subs.push(sub)
this.subs.add(sub)
}
// ...
}
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
来实现响应式数据绑定。
- 在 html 中添加一个
p
标签用来显示计数器的数值,再添加一个button
按钮,点击的时候让计数器加一。
<p id="counter"></p>
<button id="increment">Increment</button>
- 定义一个 WeakMap 类型的全局依赖管理容器
bucket
,和用于收集依赖的Dep
类。
Dep
类中的track
函数用于在读取属性时记录依赖关系。Dep
类中的trigger
函数用于在写入属性时触发所有相关的副作用函数。
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())
}
}
- 定义一个收集依赖的函数
effect
。
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。这表示当前没有正在收集依赖的副作用函数。
- 创建一个响应式数据对象
reactive
。
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
},
})
}
- 具体实现。
// 定义一个响应式状态
const state = reactive({ count: 0 })
// 注册副作用函数
effect(() => {
document.getElementById('count').innerText = state.count
})
// 绑定事件监听器
document.getElementById('increment').addEventListener('click', () => {
state.count++
})
- 试一试
Count is:
扩展
理解 JavaScript 对象(属性的类型)
ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两种:数据属性和访问器属性。
1. 数据属性
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。
- [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
- [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。
要修改属性的默认特性,就必须使用 Object.defineProperty()
方法。这个方法接收 3 个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包 含:configurable
、enumerable
、writable
和 value
,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。
let person = {}
Object.defineProperty(person, 'name', {
writable: false,
value: 'Nicholas',
})
console.log(person.name) // "Nicholas"
person.name = 'Greg'
console.log(person.name) // "Nicholas"
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()
。
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,严格模式下会抛出错误。