相信大家在面试的时候没被面试官少问vue的响应式原理,大家可能都会说通过发布订阅模式+数据劫持(Object.defineProperty)把对象里的属性转化为get和set,当属性被修改或访问就通知变化
,然而,大多数人可能只是知道这一层面,并没有完全理解。本文将从一个简单的例子出发,一步步深入响应式原理。
举一个简单的例子,我们先定义一个对象:
const hero = {
hp: 1000,
ad: 100
}
这里定义了一个英雄,hp为1000,ad为100。
现在我们可以通过hero.hp
和hero.ad
来读写对应的属性值,但是这个英雄的属性被读写时,我们并不知道。
这时候通过Object.defineProperty
就可以在对应的get
和set
来实现了。
let hero = {}
let val = 1000
Object.defineProperty(hero, 'hp', {
get() {
console.log('hp属性被读取了!')
return val
},
set(newVal) {
console.log('hp属性被修改了!')
val = newVal
}
})
通过Object.defineProperty
方法,给hero
定义了一个hp
属性,这个属性在被读写的时候都会触发一段console.log
。现在来尝试一下:
hero.hp
// -> 1000
// -> hp属性被读取了!
hero.hp = 4000
// -> hp属性被修改了!
可以看到,英雄已经可以主动告诉我们其属性的读写情况了,这也意味着,这个英雄的数据对象已经是“可观测”的了。为了把英雄的所有属性都变得可观测,我们可以想一个办法:
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function reactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`我的${key}属性被读取了!`)
return val
},
set(newVal) {
console.log(`我的${key}属性被修改了!`)
val = newVal
}
})
}
/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable(obj) {
const keys = Object.keys(obj)
keys.forEach((key) => { reactive(obj, key, obj[key]) })
return obj
}
现在可以使用上面的方法来定义一个响应式的英雄对象。
const hero = observable({
hp: 1000,
ad: 100
})
大家可以在控制台自行尝试读写英雄的属性,看看它是不是已经变得可观测的。
现在,对象已经可观测,任何读写操作他都会主动告诉我们,如果我们希望在修改完对象的属性值之后,他能主动告诉他的其他信息该怎么做?假设有一个watcher
方法
watcher(hero, 'type', () => {
return hero.hp <= 1000 ? '后排' : '坦克'
})
我们定义了一个watcher
作为监听器,它监听了hero
的type
属性。这个type
属性的值取决于hero.hp
,换句话来说,当hero.hp
发生变化时,hero.type
也应该发生变化,前者是后者的依赖。我们可以把这个hero.type
称为计算属性。
watcher
的三个参数分别是被监听的对象、被监听的属性以及回调函数。回调函数返回一个该被监听属性的值。顺着这个思路,我们尝试着编写一段代码:
/**
* 当计算属性的值被更新时调用
* @param { Any } val 计算属性的值
*/
function computed(val) {
console.log(`我的类型是:${val}`);
}
/**
* 观测者
* @param { Object } obj 被观测对象
* @param { String } key 被观测对象的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher(obj, key, cb) {
Object.defineProperty(obj, key, {
get() {
const val = cb()
computed(val)
return val
},
set() {
console.error('计算属性无法被赋值!')
}
})
}
现在我们可以把英雄放在监听器里面,尝试跑一下上面的代码:
watcher(hero, 'type', () => {
return hero.hp <= 1000 ? '后排' : '坦克'
})
hero.type
hero.hp = 4000
hero.type
// -> 我的hp属性被读取了!
// -> 我的类型是:后排
// -> 我的hp属性被修改了!
// -> 我的hp属性被读取了!
// -> 我的类型是:坦克
这样看起来确实不错,但是我们现在是通过hero.type
来获取这个英雄的类型,并不是他主动告诉我们的,如果希望他的hp修改后可以立即告诉我们该怎么做? ----依赖收集
当一个可观测的对象被读取后,会触发对应的get
和set
,如果在这里面执行监听器的computed
方法,可以让对象发出通知吗?
由于computed
方法需要接受回调函数,而可观测对象内并无这个函数,所以需要建立一个“中介”把可观测对象和监听器连接起来。
中介用来收集监听器的回调函数的值一级computed()
方法
这个中介就叫“依赖收集器”:
const Dep = {
target: null
}
target
用来存放监听器里的computed
方法。
回到监听器,看看在什么地方把computed
赋值给Dep.target
/**
* 观测者
* @param { Object } obj 被观测对象
* @param { String } key 被观测对象的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher(obj, key, cb) {
// 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
const onDepUpdated = () => {
const val = cb()
computed(val)
}
Object.defineProperty(obj, key, {
get () {
Dep.target = onDepUpdated
// 执行cb()的过程中会用到Dep.target,
// 当cb()执行完了就重置Dep.target为null
const val = cb()
Dep.target = null
return val
},
set () {
console.error('计算属性无法被赋值!')
}
})
}
我们在监听器内部定义了一个新的onDepUpdated()
方法,这个方法很简单,就是把监听器回调函数的值以及computed()
给打包到一块,然后赋值给Dep.target
。这一步非常关键,通过这样的操作,依赖收集器就获得了监听器的回调值以及computed()
方法。作为全局变量,Dep.target
理所当然的能够被可观测对象的getter/setter
所使用。
重新看一下我们的watcher实例:
watcher(hero, 'type', () => {
return hero.hp <= 1000 ? '后排' : '坦克'
})
在它的回调函数中,调用了英雄的hp
属性,也就是触发了对应的get
函数。理清楚这一点很重要,因为接下来我们需要回到定义可观测对象的reactive()
方法当中,对它进行改写:
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function reactive(obj, key, val) {
const deps = []
Object.defineProperty(obj, key, {
get() {
if (Dep.target && deps.indexOf(Dep.target) === -1) {
deps.push(Dep.target)
}
return val
},
set(newVal) {
val = newVal
deps.forEach((dep) => {
dep()
})
}
})
}
可以看到,在这个方法里面我们定义了一个空数组deps
,当get
被触发的时候,就会往里面添加一个Dep.target
。回到关键知识点Dep.target
等于监听器的computed()
方法,这个时候可观测对象已经和监听器捆绑到一块。任何时候当可观测对象的set
被触发时,就会调用数组中所保存的Dep.target
方法,也就是自动触发监听器内部的computed()
方法。
至于为什么这里的deps
是一个数组而不是一个变量,是因为可能同一个属性会被多个计算属性所依赖,也就是存在多个Dep.target
。定义deps
为数组,若当前属性的set
被触发,就可以批量调用多个计算属性的computed()
方法了。
完成了这些步骤,基本上我们整个响应式系统就已经搭建完成,下面贴上完整的代码:
/**
* 定义一个“依赖收集器”
*/
const Dep = {
target: null
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function reactive(obj, key, val) {
const deps = []
Object.defineProperty(obj, key, {
get() {
console.log(`我的${key}属性被读取了!`)
if (Dep.target && deps.indexOf(Dep.target) === -1) {
deps.push(Dep.target)
}
return val
},
set(newVal) {
console.log(`我的${key}属性被修改了!`)
val = newVal
deps.forEach((dep) => {
dep()
})
}
})
}
/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable(obj) {
const keys = Object.keys(obj)
keys.forEach((key) => { reactive(obj, key, obj[key]) })
return obj
}
/**
* 当计算属性的值被更新时调用
* @param { Any } val 计算属性的值
*/
function computed(val) {
console.log(`我的类型是:${val}`);
}
/**
* 观测者
* @param { Object } obj 被观测对象
* @param { String } key 被观测对象的key
* @param { Function } cb 回调函数,返回“计算属性”的值
*/
function watcher(obj, key, cb) {
// 定义一个被动触发函数,当这个“被观测对象”的依赖更新时调用
const onDepUpdated = () => {
const val = cb()
computed(val)
}
Object.defineProperty(obj, key, {
get() {
Dep.target = onDepUpdated
// 执行cb()的过程中会用到Dep.target,
// 当cb()执行完了就重置Dep.target为null
const val = cb()
Dep.target = null
return val
},
set() {
console.error('计算属性无法被赋值!')
}
})
}
const hero = observable({
hp: 1000,
ad: 100
})
watcher(hero, 'type', () => {
return hero.hp <= 1000 ? '后排' : '坦克'
})
console.log(`英雄初始类型:${hero.type}`)
hero.hp = 4000
// -> 我的hp属性被读取了!
// -> 英雄初始类型:后排
// -> 我的hp属性被修改了!
// -> 我的hp属性被读取了!
// -> 我的类型是:坦克
上述代码在浏览器控制台可直接执行
在上面的例子中,依赖收集器只是一个简单的对象,其实在reactive()
内部的deps
数组等和依赖收集有关的功能,都应该集成在Dep
实例当中,所以我们可以把依赖收集器改写一下:
class Dep{
constructor() {
this.deps = []
}
depend() {
if (Dep.target && this.deps.indexOf(Dep.target) === -1) {
this.deps.push(Dep.target)
}
}
notify() {
this.deps.forEach((dep) => {
dep()
})
}
}
Dep.target = null
同样的道理,我们对observable和watcher都进行一定的封装与优化,使这个响应式系统变得模块化:
class Observable{
constructor(obj) {
return this.walk(obj)
}
walk(obj) {
const keys = Object.keys(obj)
keys.forEach((key) => {
this.reactive(obj, key, obj[key])
})
return obj
}
reactive(obj, key, val) {
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
dep.depend()
return val
},
set(newVal) {
val = newVal
dep.notify()
}
})
}
}
class Watcher{
constructor(obj, key, cb, computed) {
this.obj = obj
this.key = key
this.cb = cb
this.computed = computed
return this.defineComputed()
}
defineComputed() {
const self = this
const onDepUpdated = () => {
const val = self.cb()
this.computed(val)
}
Object.defineProperty(self.obj, self.key, {
get() {
Dep.target = onDepUpdated
const val = self.cb()
Dep.target = null
return val
},
set() {
console.error('计算属性无法被赋值!')
}
})
}
}
尝试运作一下:
const hero = new Observable({
hp: 1000,
ad: 100
})
new Watcher(hero, 'type', () => {
return hero.hp <= 1000 ? '后排' : '坦克'
}, (val) => {
console.log(`我的类型是:${hero.type}`)
})
console.log(`英雄初始类型:${hero.type}`)
hero.hp = 4000
// -> 英雄初始类型:后排
// -> 我的类型是:坦克
// -> 4000
上述代码在浏览器控制台可直接执行
上述代码,是不是和vue
里的源码很相似?其实思路是一样的,本文把核心部分挑出供大家食用。如果大家在学习vue
源码时,不知如何下手,希望这篇文章能给你提供帮助。作者也是参考了许多他人的思想和不断的尝试才掌握。
本文是作者蛮早以前的笔记重新整理了一篇供大家食用,如有意见或其他问题欢迎大家指出,如果对你有帮助请记得点赞关注收藏三连击。