在日常开发中,经常遇到这种场合:
先定义一个空对象(或数组),然后调后端接口,拿到数据之后赋值给这个对象(或数组),然后拿着这些数据去页面上展示:
new ViewComponent({
el: '#app',
data: function () {
return {
good: {}
}
},
methods: {
getGoodData () {
let _this = this
setTimeout(function () {
_this.good = {
id: '8faw8cs4fw9760zt7tnesini4qup5hid',
name: 'iPhoneX',
cpuNum: 1,
memory: 1073741824,
brand: 'iPhone',
category: 'phone',
color: 'black'
}
_this.good.region = '北京'
}, 1000)
},
changeName () {
this.good.name = 'new iPhonex'
}
},
render: function () {
// 生成新的DOM
let children = [
this.createElement('div', { class: 'item' }, this.good.name),
this.createElement('div', { class: 'item' }, this.good.brand),
this.createElement('div', { class: 'item' }, this.good.cpuNum),
this.createElement('div', { class: 'item' }, this.good.memory),
this.createElement('div', { class: 'item' }, this.good.category),
this.createElement('div', { class: 'item' }, this.good.color),
this.createElement('div', { class: 'item' }, this.good.region),
]
return this.createElement('div', { class: 'good-detail' },
[
this.createElement('button', {
on: {
click: this.changeName
}
}, '重命名'),
this.createElement('div', { class: 'detail' }, children)
]
)
},
mounted: function () {
this.getGoodData()
}
})
此外,还会修改这个对象上的某些属性,例如我们可能有重命名的功能,在回调中会重新赋值good.name,例如上面代码中的changeName
有时,我们也会在这个对象上自己扩展一些属性,比如上面代码中methods -> getGoodData里面的
_this.good.region = '北京'
不过把上面的代码跑一遍之后发现,我们发现,最开始时页面上什么也没有,setTimeout过了1秒钟之后,good上本来就有的属性渲染出来了,但在getGoodData中扩展的region没有展示出来,点击按钮触发changeName修改good.name也没改成功
因为,我们现在还没有支持到这一步,所以本节我们进一步完善代码,支持对象扩展属性和修改属性时,触发组件重新渲染
我们再回想组件初始化以及后来触发渲染再次更新的流程: 初始化:遍历data、props等里面的数据,通过defineReactive,将其改为响应式的数据,然后再通过renderAndCollectDependencies收集依赖
注意:开始时,我们的data中只有一个值为空对象的good,所以初始化的时候只有good有对应的updateFns,但由于good是空对象,good里面没有任何key value,所以再递归进到里面遍历属性添加响应式时,什么都遍历不出来
接下来执行render,准备收集依赖,同样也是只有通过this.good这种方式访问good时才会收集到依赖,通过this.good.name访问name时,是收集不到name对应的依赖的
为了验证good是响应式的,我们可以在render中把good创建出来放到页面上看一下:
return this.createElement('div', { class: 'good-detail' },
[
this.createElement('div', { class: 'detail' }, children),
this.good
]
)
效果如下:
最下面的[object Object]就是good在页面上的渲染
render执行完后,接下来组件就执行到mounted钩子里了,mounted里面调用getGoodData拿到数据之后重新给good赋值
由于good是响应式的,所以给good赋值,就可以触发good对应的依赖,即组件渲染函数update的执行,组件在重新渲染时,在render中可以得到重新赋过值的新的good对象,自然也就能将它的值放到DOM中展示出来
但是,这些值只是赋给了good而已,good里面的属性并不是响应式的,所以我们点击按钮触发changeName,虽然修改了name:this.good.name = 'new iPhonex',但good.name并没有被defineReactive处理过,所以它只是good的普通属性,并不是响应式属性,自然也没有收集过什么依赖,所以也没有对应的updateFns
既然问题出在了这里,自然地,我们在属性set的时候做一下判断,如果发现重新赋的值是对象类型(或数组类型)的,那就要深度遍历对象里所有的属性(深度遍历数组下的各项)将其重新定义为响应式的 注:数组的情况我们下一节会讨论
function defineReactive(data, key, val) {
let updateFns = []
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
updateFns.push(curExecUpdate)
}
return val
},
set: function (newVal) {
val = newVal
// 如果发现重新赋的值是对象类型,就要深度遍历对象里所有的属性
if (isObject(newVal) || isArray(newVal)) {
setDataReactive(newVal)
}
for (let i = 0; i < updateFns.length; i++) {
updateFns[i]()
}
}
})
}
注意,在set钩子中,我们新增了将newVal中所有属性变成响应式的操作setDataReactive(newVal)
这么搞完,刷新之后再看效果,似乎还是不行
我们的目的是期望点击重命名,触发:
changeName () {
this.good.name = 'new iPhonex'
}
进而触发name的set钩子,然后去找name对应的updateFns,然后遍历更新
但是这也是出问题的地方,我们可以想一想,updateFns里面有我们想要的组件渲染函数吗?
答案是没有
因为我们往updateFns里收集渲染函数只是在组件调用init初始化的时候做的,之后即使再访问name触发它的get钩子,curExecUpdate也是null,啥也收集不进来
所以,我们收集依赖的时机有问题
其实在Vue中,每次执行render的时候都会收集依赖的渲染函数(其实就是watcher),每个渲染函数都标有id,内部会判断,发现重复的watcher,就不加进去了,对应的代码:
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
// 如果在添加dep的时候,发现要添加的watcher之前和dep有关联关系,这个判断就是true
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
};
我们也跟着Vue的思路来吧:
ViewComponent.prototype.renderAndCollectDependencies = function () {
this.update = this.update.bind(this)
this.update()
let fn = this.$options.mounted
if (fn && isFunction(fn)) {
fn.call(this)
}
}
ViewComponent.prototype.update = function () {
// 清除旧的DOM
let oldRenderedElement = this.renderedElement || this.el
this.renderedElement = this._render()
if (oldRenderedElement) {
let parent = oldRenderedElement.parentNode
let sibling = oldRenderedElement.nextElementSibling
parent.removeChild(oldRenderedElement)
if (!this.renderedElement) return
if (sibling) {
parent.insertBefore(this.renderedElement, sibling)
} else {
parent.appendChild(this.renderedElement)
}
}
}
ViewComponent.prototype._render = function () {
pushCurExecUpdateToStack(this.update)
let renderedElement = this.render()
popCurExecUpdateFromStack()
return renderedElement
}
上面的代码主要改了3个地方:
- renderAndCollectDependencies中去掉了pushCurExecUpdateToStack和popCurExecUpdateFromStack
- ViewComponent.prototype.update中render改成了_render
- 新增了_render方法,里面调用开发人员自己定义的render,并用pushCurExecUpdateToStack和popCurExecUpdateFromStack包围起来,这样就做到了每次渲染时,都收集依赖,我们也有判断依赖是否重复的逻辑,所以也不会重复收集依赖
改完代码后,我们刷新页面再点重命名按钮,就得到我们预期的效果了(我查了一下,掘金似乎没法插入视频,遗憾),效果只能靠大家脑补了,或者最后查看一下我给出的源码也可以
题外话:在调试代码的过程中,发现this.good.name = 'new iPhonex'执行的时候,依次执行了good的get钩子,good.name的set钩子
不过也可以看到,除了name被改变了,region也神奇的出来了,这个region出现的时机明显不对,我们是在getGoodData里拿到数据之后,给对象赋值region属性的,应该赋完值后立马出来才对,而不是点重命名触发changeName时再出,这也是我们接下来要解决的问题
Vue中解决这个问题是提供了一个$set的方法,如果需要给data中初始化之后的数据中的对象添加新的属性,就需要调用这个方法
不过,需要注意,在执行$set添加属性的时候,也要触发组件渲染才可以,但现在有一个矛盾点,我们添加的是新属性,新属性原来根本都没有在对象上,更不可能收集过依赖,所以要去哪触发依赖的执行呢?
上面这段话不太好理解,我们以此处的this.good.region为例来具体说明一下
good上每个属性都有对应的updateFns(里面其实就是App组件的update),如果改变这个属性,就会触发它的set钩子,然后set钩子遍历updateFns里面的函数挨个执行,进而实现DOM更新
但如果要添加一个good上原来没有的属性,例如region,在添加region的时候我们也想遍历某个updateFns,然后更新DOM
不过,以我们目前的实现情况,属性和updateFns是一一对应的,good原来连region这个属性都没有,更不可能有它对应的updateFns
为了解决这个问题,根据Vue的解决思路,还需要设计另外一种updateFns,这种updateFns不是和属性一一对应,而是和对象一一对应,什么意思呢?
假设组件定义中data写成了下面这样:
let myComp = {
data: function () {
return {
basicTypeAttr: '基本类型',
referenceTypeAttr: {
key1: '对象类型value1',
key2: '对象类型value2'
}
}
}
}
在我们现在的代码中,对于这个myComp组件,在initData执行完后,会有4个updateFns,分别和basicTypeAttr、referenceTypeAttr、key1、key2 这4个属性一一对应
所谓和对象一一对应,其实就是对于referenceTypeAttr这类值是引用类型的data属性,专门创建一个updateFns和它对应,这个updateFns将会作为referenceTypeAttr对象上的一个属性存到对象上:
referenceTypeAttr: {
key1: '对象类型value1',
key2: '对象类型value2',
_updateFns: updateFns
}
当然,这个属性只是为了我们方便之后从对象中拿到updateFns来更新组件,不应该提供给开发人员使用,所以它的enumerable应该设置为false
除此之外,还额外增加了一个名为set里面拿到要添加属性的对象,再通过该对象拿到_updateFns,然后再执行,从而达到更新DOM的效果
这个原理我之前看的时候感觉,恩,挺合理
不过前几天当我在某一次断点调试时,尝试把一个组件上所有的dep都console出来,看一下它都和哪些data中的key对应,和哪些对象对应:
上图中,后面有数字的console都是dep相关的,这个数字就是它的id
我发现其实这个对象对应的updateFns和这个对象所属的上一层对象的key(此处就是referenceTypeAttr)对应的updateFns应该是起同一作用的,都应该是修改这个属性时需要触发的update,例如id为8的dep就是对象对应的dep,id为7的dep和id为8的dep都是作用在同一对象上的同一个key的,这个key为testObject
如果从节省空间的角度来讲,假如有一种方法能把id为7和id为8的dep合在一起,其实是最好的,因为这两个dep分别对应直接重新给testObject赋值和增删改testObject上的某些属性,总之这些操作都是改testObject,所以都应该触发testObject对应的依赖进行更新
不过作者并没有这么做,可能是考虑到语言层面的语法支持度?或者是其他一些不好处理的地方? 比如说,当改了对象里面很深层次的某个属性时,逐层通知到父级各个对象,这个不好实现? 这个疑问纯属我个人猜测,如果有误,欢迎大神指出,也欢迎大家一起讨论
废话了这么多,我们还是按照Vue的思路来,看下怎么实现
首先需要改造的是setDataReactive这个函数,由于我们之前只考虑了对每个属性添加对应的updateFns,所以这个函数的功能是深度遍历每个属性,遍历到每个属性时,每调用一次defineReactive,就在这次调用的作用域内专门为这个属性创建一个属于自己的updateFns,其实这就是闭包的一个经典应用
那么沿着这种思路,如果我们要为data中的每个对象也建一个对应的updateFns,那么也必然需要遍历到一个对象,就调用一次某个函数,在这个函数的作用域内专门为这个对象建一个属于它的updateFns,其实还是闭包的使用
接下来我们把setDataReactive做一下改造,使其作为上面我们提到的“某个函数”
要怎么改造呢?可以从setDataReactive和defineReactive的调用关系中寻找答案
在之前只支持每个属性对应有一个updateFns的情况下,setDataReactive深度遍历对象的每个属性,每遍历到一个属性,就调用defineReactive 那么将setDataReactive遍历的维度降低,遍历到对象时便不再往里面深度遍历,然后直接将遍历到的value传给setDataReactive,setDataReactive内部判断到value是对象时,就再调用defineReactive
这样一来的好处就是,defineReactive的每次调用其实就是data里面遇到各级对象时的调用,也就抓到了这个时机,可以给每个对象建立一个自己的updateFns
function setDataReactive (data) {
let updateFns = []
Object.defineProperty(data, '_updateFns', {
value: updateFns,
enumerable: false,
writable: true,
configurable: true
})
let keys = Object.keys(data)
for (let i = 0; i < keys.length; i++) {
let v = data[keys[i]]
defineReactive(data, keys[i], v)
}
return updateFns
}
上面代码中setDataReactive里面的updateFns就是和遍历到的对象一一对应的,在for循环里面,我们可以看到去掉了之前的判断:
if (isObject(val)) {
setDataReactive(val)
}
一会儿我们会看到,这段被挪到了defineReactive中
还有一个值得注意的地方,就是我们把updateFns返回了回去,返回去干啥呢?我们赶紧看defineReactive的改造,就知道返回去干什么了:
let curExecUpdate = null
function defineReactive(data, key, val) {
let updateFns = []
let objectUpdateFns
// 对判断遍历到的value为对象时情况的处理
if (isObject(val)) {
objectUpdateFns = setDataReactive(val)
}
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
updateFns.push(curExecUpdate)
}
if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate)) {
objectUpdateFns.push(curExecUpdate)
}
return val
},
set: function (newVal) {
val = newVal
if (isObject(newVal) || isArray(newVal)) {
objectUpdateFns = setDataReactive(newVal)
}
for (let i = 0; i < updateFns.length; i++) {
updateFns[i]()
}
}
})
}
可以看到在defineProperty的上方,就是我们对判断遍历到的value为对象时情况的处理
还有,我们把setDataReactive的返回值赋给了一个名叫objectUpdateFns的变量,从变量名字中也可以看出来,它其实就是对象本身对应的updateFns,我们用上面举到的myComp的例子来说明一下objectUpdateFns和updateFns之间的关系:
let myComp = {
data: function () {
return {
basicTypeAttr: '基本类型',
referenceTypeAttr: { // referenceTypeAttr有自己的"属性对应的updateFns"
key1: '对象类型value1',
key2: '对象类型value2',
_updateFns: []
}
}
}
}
referenceTypeAttr中_updateFns其实就对应我们的objectUpdateFns,set时就会很清晰
这里我起名叫objectUpdateFns的这个东西,在Vue中其实对应的就是childOb.dep
此外,我还在defineProperty的get钩子中加了这么一段:
if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate)) {
objectUpdateFns.push(curExecUpdate)
}
还以myComp的例子来说,当它的render函数通过this.referenceTypeAttr访问这个属性时首先会走这个判断:
if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate))
将myComp的update放到了updateFns中去 接着便来到了
if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate))
这个判断,objectUpdateFns这个值只有在value为对象时才会接收setDataReactive的返回值,否则就是undefined,所以对于referenceTypeAttr这样值为对象的key,这个条件就会是true,从而将依赖添加到objectUpdateFns中去,这将来会在$set中触发
这个地方,最后一点需要提的就是set钩子的改造,我们注意到在set钩子里面,我们同样用objectUpdateFns接收了一下setDataReactive的返回值,用于给一个属性赋值一个新的对象时,这个新的对象又生成了新的objectUpdateFns的情况
先小结一下写这段代码的感受:
当我只是去看Vue源码时,通过
observe -> new Observer -> walk -> defineReactive
一步一步跟进来时,只知道按着作者的思路走,是体会不到其中的设计思想的,而当自己真正实现这么一个功能并发现这个问题时,不得不说在setDataReactive中,定义一个和对象映射的变量objectUpdateFns使其形成闭包,这个设计十分巧妙
到此,关于setDataReactive和defineReactive的修改就告一段落了,在这个过程中我们提了若干次$set,现在可以把它拿出来了,因为是添加一个新方法,所以这是一段纯新增的代码:
ViewComponent.prototype.$set = function (target, key, val) {
if (key in target) {
target[key] = val
return
}
let updateFns = target._updateFns
if (!updateFns) {
target[key] = val
return
}
defineReactive(target, key, val)
for (let i = 0; i < updateFns.length; i++) {
updateFns[i]()
}
}
整段代码中,最重要的部分就是最后的defineReactive和updateFns的遍历执行,defineReactive就是给目标对象添加名为key、值为value的响应式属性,updateFns则是为了更新DOM,target._updateFns其实就是defineReactive中的objectUpdateFns
当然,$set中还做了其他一些处理,比如说如果target对象中原来就有key属性,那就不需要做响应式处理了,target[key] = val就会触发key的set钩子,进而遍历执行key属性对应的updateFns完成DOM的更新
到此为止,对象类型的值,响应式支持到这一步功能基本差不多了,我们可以再回顾一下文章最开头的那个例子中getGoodData的执行流程
getGoodData () {
let _this = this
setTimeout(function () {
_this.good = {
id: '8faw8cs4fw9760zt7tnesini4qup5hid',
name: 'iPhoneX',
cpuNum: 1,
memory: 1073741824,
brand: 'iPhone',
category: 'phone',
color: 'black'
}
_this.$set(_this.good, 'region', '北京')
}, 1000)
}
我们重点关注setTimeout里面的流程
首先给good属性赋值,触发good的set 由于赋值给good的是一个对象类型,所以需要递归遍历里面的每个属性,对其添加set和get钩子,同时也会给good对象本身添加_updateFns,这个_updateFns是这个新的good对象的updateFns的引用,这个时候,它还是空数组 接下来继续执行good的set钩子里的updateFns钩子函数,注意,这个updateFns对应了good属性,里面放的是之前遍历data时放进去的app组件的update,updateFns执行到render的时候遇到这样的代码时:
this.good.name
就会触发它们的get钩子,收集依赖,这里会先触发good的get钩子:
if (curExecUpdate && !updateFns.find(fn => fn === curExecUpdate)) {
updateFns.push(curExecUpdate)
}
if (objectUpdateFns && curExecUpdate && !objectUpdateFns.find(fn => fn === curExecUpdate)) {
objectUpdateFns.push(curExecUpdate)
}
对于good来说,它的updateFns里已经有了App的update,所以第1个if判断是false
在第二个if判断中,objectUpdateFns其实就是good对象的updateFns了,不过要注意,这个objectUpdateFns已经不是组件初始化时:
data: function () {
return {
good: {}
}
},
good对象的那个objectUpdateFns了,而是新赋值的good对象:
_this.good = {
id: '8faw8cs4fw9760zt7tnesini4qup5hid',
name: 'iPhoneX',
cpuNum: 1,
memory: 1073741824,
brand: 'iPhone',
category: 'phone',
color: 'black'
}
对应的objectUpdateFns,这是set钩子中这行代码起的作用:
objectUpdateFns = setDataReactive(newVal)
即objectUpdateFns会随着给属性赋值不同的对象而跟着改变
this.good的get钩子执行完之后,就会执行this.good.name的钩子,这个过程比较简单就不分析了
之后render函数执行完,继续执行update剩下的部分,删去老的DOM,加上新的DOM,注意此时新的DOM中还没有this.good.region
接下来继续执行:
_this.$set(_this.good, 'region', '北京')
首先通过target._updateFns拿到target对象的updateFns,此处就是拿到_this.good对象的updateFns
再通过defineReactive(target, key, val)将region属性加到good对象上,而且这个region是响应式属性
再遍历调用updateFns中的this.update时,就开始创建DOM,然后顺带收集依赖
其实,对于good以及good的子属性来说,依赖(即this.update)在上一轮执行中已经收集完毕,所以这次update的执行,主要是收集region属性对应的依赖,以备之后修改region时触发更新
至此,App组件的getGoodData中的过程就都分析完了
最后,我们可以发现,虽然目前功能可以实现,但是代码非常冗余,而且有些地方很不好理解,例如updateFns和objectUpdateFns之间的关系就会让人很晕,向updateFns、objectUpdateFns中添加依赖,遍历执行updateFns的代码都是重复的
所以,在Vue中,作者封装了一个对象叫Dep来抽象,我们已经在前面多次提到过依赖这个术语了,其实Vue中的Dep指的就是依赖,下一节我们将把这部分抽离出来,这样让代码更加清晰
除此之外,还有一个问题,在getGoodData这个方法中,我们给good赋值一次,又给good设置了一个新的属性region,这导致了2次连续的DOM更新,但实际上完全没必要,我们经常在拿到请求之后通过$set设置大量的自定义属性,如果每设置一个就更新一次DOM(往往是以组件为粒度更新),非常不好,是性能的极大损耗,所以待响应式部分所有内容结束后,我们会实现异步更新的功能来解决这个问题