-
Notifications
You must be signed in to change notification settings - Fork 316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
深入浅出 - vue变化侦测原理 #17
Comments
思考题
文章的代码中会有这样一个问题,当数据改变触发 有兴趣的小伙伴自己想方案或者看vue源码 欢迎把解决办法写在下方留言~ |
文章写的很好懂!!赞! ps 其实为什么源码里面defineReactive函数new了一个Dep之后,Observer再new一次Dep ? |
@SSShooter 哈哈哈哈哈,这个问题相当有技术含量,不错不错,,,,,😄😄😄 defineReactive 这个函数只有 Object 类型的数据才能进的去,Array是进不去的,所以 Observer 上的 Dep 是给Array类型的数据用的。 而且在 defineReactive 函数中有这样几行代码 let childOb = !shallow && observe(val)
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
} 就是要把 做了同步之后呢,就可以直接通过 val 来访问依赖,比如 有一种场景是 |
😂原来是这样 谢谢大佬!! |
看递归还是看得有点晕...请问key 上的 dep 和 val 上的 dep有什么不同...:sob: |
@SSShooter 哈哈,这是个好问题,可能很多小伙伴都会有这样的问题, 用数组举例子,如果是这样的数据 也就是说数组的拦截器是访问不到 也就是说正因为每个数据都有 |
了解! |
我有一点疑惑的地方,dep要通过addSub收集watcher,那为什么watcher要通过addDep去收集dep,希望能说明一下,谢谢 |
@JSupot 原因有很多, teardown 中有一部分代码是这样写的: // this 就是 watcher 实例
// deps 就是你说的watcher收集到的 dep 列表
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
} 我知道的另一个原因是和 为什么会和 首先 看下面的伪代码: // getter 就是我们写的 Computed 函数
function computedGetter () {
const watcher = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
} 你肯定会问为什么这么做,为什么要在中间加上 其实也是一个优化策略,作用是如果Computed中依赖的其他数据没有发生改变的话,那么就不需要执行 Computed 去计算结果,因为如果依赖的数据不发生变化,那么 Computed的结果也不会发生变化。 为了这个优化所以需要加一个 而加了这个 举个🌰: 我们写了这样一个 Computed: computed: {
age () {
return this.n
}
} 这个Computed中使用了 如果日后这个 这里解释一下为什么 其实没有这个优化也不影响功能,但如果Computed中做了大量的计算,并且没有这个优化的话,那么每次读数据都会做大量计算比较消耗性能,如果有很多地方都使用到了这个属性,那么程序会变得非常卡,但有了这个watcher后其实只有在依赖的数据发生了变化后才重新计算,这样就可以降低一些消耗。 所以上面的伪代码中有这样几行代码: if (watcher.dirty) {
watcher.evaluate()
} 但是到目前为止都还没有涉及到你问的问题,Computed 为什么和 我先问个问题,你有没有发现,在 vue 中你写了 Computed,然后Computed依赖了其他数据,你把其他数据改了,DOM却发生了变化,way? 比如上面的例子,模板中只用了 之所以这么神奇,是因为模板在使用watcher读 Computed这个数据时,Computed从上面我们用来优化的 watcher 中把 deps 掏了出来!!!! 然后把模板的那个 所以虽然模板中使用了一个 所以现在回答你的问题,为什么watcher要通过addDep去收集dep?因为 |
你好,有个问题想问你一下,使用v-on来监听事件,DOM事件比如click、change和自定义事件有什么区别吗,这个v-on和$on的事件机制有什么联系吗 |
作者可以讲一下 Observer Watch walk() 的执行方法和顺序吗 |
@classname 喔?什么顺序? |
嗯,目前看懂的是 Observer里面有Dep,dep里收集的watcher,walk方法是递归遍历所有的属性并使用defineProperty方法,这里有个疑问,是每个属性有自己的Observer还是说全局只有一个Observer,walk方法在哪个方法里执行的? 不知道上面说的对不对 |
@classname 每个属性有自己的 Observer 实例~ walk方法就是在 Observer 中执行的~~ |
好的,谢啦~ |
@JSupot 额。。你可以去看看文档。。。。里面会有答案。。 |
@classname 不客气~ 😁😁 |
@berwin 今天看了一下,好像vue对v-on的解析是分开处理的,如果是在DOM元素上就是绑定到对应的元素事件上,用在组件上的时候,会注册到事件系统里。 |
@JSupot 对,表现形式上是这样的~ |
大佬好~请问一个问题:
除了需要知道数组的变化外,为何要在子依赖收集中加入父的watcher呢? 试过注释掉代码,只发现了数组不响应的情况,其余的暂时还没发现哦。 |
@ljf0113 哈哈哈哈,主要就是用来收集数组的依赖的,数组的依赖只有这样收集,才能在数组的拦截器中访问到依赖。 |
@berwin 如果只为了数组的话,是否有点浪费内存?不能走个if else吗? |
@ljf0113 想了半天,,,,没想通在哪里写if else 🤣🤣 {a: []} 如果想监听this.a这个数组,那其实key是a,而value是 我没太想通会有什么浪费,if 和 else又是什么东东在哪里写~~ 😄😄 |
@berwin 嗯嗯~谢谢大佬解答~~ |
@ljf0113 不是大佬,哈哈哈,大佬没我这么菜,不用谢啦,可能也是我自己这文章写的不是特别的容易理解给你添麻烦了。 |
@berwin 整理了一下思路~可能我还是有点疑问,我的意思是这样的:
如果修改成这样呢? 其实不是数组的话,有必要在子依赖中收集父watcher吗? emmmmm~补充一下,我搜了一下代码,发现__ob__这个东东,在vnode与部分样式相关的代码中都有出现,还没仔细看,应该是有它的用途的。 |
侦测数组那里console.log(methods) methods多打了个s |
@Hazlank 哈哈哈哈,多谢多谢,已经改过来啦~ |
源码没看,从文档里看出来Vue的处理方法是数据变化后不立即更新,在下一个tick更新,调用这个 |
@berwin 不好意思,我naive了😂,我再仔细阅读下 |
在源码$set中, https://github.com/vuejs/vue/blob/dev/dist/vue.js 大概1054行的位置: var ob = (target).__ob__;
if (!ob) {
target[key] = val;
return val
} 没看到这段代码的意思,若target监听器不存在,则把值给到这个key即可?为什么会有这个 |
@wenzi0github 因为并不是所有数据都是响应式数据啊 😂,如果数据不是响应式数据就直接把新增的属性设置到数据上就行了,并且使用 defineReactive(ob.value, key, val); |
诶~ 转了一圈回来问大佬。目测你肯定看过 vue 在 github 最新的代码了~dev分支的。有没有发现它默默地改了 computed 的实现。原来的代码(包括现在用的最新版),computed 的实现是 lazy 的,在它依赖的属性值变化的时候标记为 dirty,只在这个 computed 属性被读取的时候才进行运算。 然而 github 的代码实现改为了只要它依赖的属性值产生了变化,那么 computed 函数必然运算一次,其实不是更低效吗? 期待你的解答哦~~ |
@ljf0113 不不不,其实我就是个小废材。 说正事: 我好像没看见你说的变化?我看到的逻辑还是将dirty设置为 你看到的代码地址能不能发给我看看,我研究一下 🤣🤣 |
@berwin 你贴的地址已经是新的代码了哦~ 然而官网的 2.5.17 稳定版,还是旧的实现。具体看 watcher 也就是你贴的那部分代码~针对 computed 的实现是不一致的。。。 |
@ljf0113 看了下,代码虽然不一样,但只是代码位置变了,逻辑是一样的~ 你就当做重构了一下代码,提升了可读性和扩展性。 |
@berwin 实现完全不同的。。。
你试下引入不同的 vue,一个是官网的2.5.17版本,一个是 dev 分支 dist 下面的 vue.js,当 |
@ljf0113 仔细看了下,你说的对,确实有问题,这个我之前还真没发现。哈哈哈哈 我看了下,发现是这样的: 之前的计算属性的逻辑是,Vue实例会观察computed函数内出现的的所有状态,也就是说只要出现在computed函数里的状态发生变化后,Vue实例都会收到通知并进行重新渲染。 这就导致一个问题,如果计算属性中用到的状态发生了变化,即便这个计算属性的返回值没变,组件也会走一遍渲染的流程,只不过最终由于VirtualDOM的diff发现没有变化所以在视觉上并不会发现有什么问题,但是渲染函数会被执行,看这个issue vuejs/vue#7767 demo地址: https://jsfiddle.net/72gzmayL/ 为了解决这个问题,所以把computed的逻辑改成了Vue实例不再观察computed函数中所使用到的状态,那么Vue实例如果不再观察computed函数中所使用到的状态,那它如何知道计算属性依赖的状态发生变化后是否需要重新渲染呢? 这就需要computed的watcher主动去向Vue实例发送通知好让Vue实例知道computed的返回值变了,它应该渲染视图了。所以发送这个通知的前提是computed的返回值有变化。可以看下这行代码:https://github.com/vuejs/vue/blob/dev/src/core/observer/watcher.js#L207 解决这个问题的pull request地址:vuejs/vue#7824 所以为什么会出现你说的 dev 分支的版本会打印出两次呢?因为click函数被执行后修改了两次状态,而这两次修改都会使computed的返回值不一样,所以会向Vue实例发送两次通知,从而Vue实例会读取两次computed的值,所以computed函数会触发两次。(这个应该是一个bug,或许是为了解决一个问题而不小心引发了另一个问题吧。) |
@berwin 推测合理,然而它的实现还是有问题~
在回调里面 退一步说,按照这个思路,我认为也是有待商榷,现在是靠运行多次 computed 去换取渲染函数不触发,然而 computed 很可能是很复杂的函数,这问题变为了到底牺牲哪个比较好~ 我是感觉如果组件化做得好,反而是渲染函数触发的成本低一点。。。 最优的实现应该是以前 lazy 的基础上,判断是否变化,再执行 |
@ljf0113 确实是,我也感觉这块实现可能有点问题。和你说的一样,getAndInvoke是同步的,所以并不会等全部依赖变完之后再去算一次。 但这个问题是可以解决的,可以使用异步调度一下,等当前 你可以去vue的issues里把这个问题提一下,他们就会fix掉这个issue了 |
@berwin 我再仔细看了一下代码,发现应该是死在 event loop 上。。。属性的 setter 触发 dep 的 notify 其实已经放在 micotask 上的,也就意味着 事实说,这是我遇到的第一个关于 event loop 引发的bug~ 虽然面试时候了然于胸,但实际中我真没被坑过,这是第一次。。。。 |
也许是鄙人愚笨...看完以后似懂非懂... |
先将watcher推到一个队列中,做了去重后在nexttick的时候再更新? |
@weimingxinas 不用先推到队列中,先判断下如果之前推进去过,这次就不推就行了。哈哈哈哈 |
看着头晕啊,再看几遍 😂 |
由浅到深 也同时清楚了一些概念 |
从“依赖收集在哪” 那里开始往下就已经不知所云了,好在这些字我都懂。 |
受教了。 |
@berwin 为什么这里的vue变化侦测中有的内容跟你写的深入浅出vue那本书中的有些内容不一样 |
@berwin能不能解释一下函数中的参数的意义 |
mark |
应该是考虑到IE对于__proto__的支持不好,貌似只有IE11支持 |
把我给讲明白了 写的真好 |
深入浅出 - vue变化侦测原理
其实在一年前我已经写过一篇关于 vue响应式原理的文章,但是最近我翻开看看发现讲的内容和我现在心里想的有些不太一样,所以我打算重新写一篇更通俗易懂的文章。
我的目标是能让读者读完我写的文章能学到知识,有一部分文章标题都以深入浅出开头,目的是把一个复杂的东西排除掉干扰学习的因素后剩下的核心原理通过很简单的描述来让读者学习到知识。
关于vue的内部原理其实有很多个重要的部分,变化侦测,模板编译,virtualDOM,整体运行流程等。
今天主要把变化侦测这部分单独拿出来讲一讲。
如何侦测变化?
关于变化侦测首先要问一个问题,在 js 中,如何侦测一个对象的变化,其实这个问题还是比较简单的,学过js的都能知道,js中有两种方法可以侦测到变化,
Object.defineProperty
和 ES6 的proxy
。到目前为止vue还是用的
Object.defineProperty
,所以我们拿Object.defineProperty
来举例子说明这个原理。这里我想说的是,不管以后vue是否会用
proxy
重写这部分,我讲的是原理,并不是api,所以不论以后vue会怎样改,这个原理是不会变的,哪怕vue用了其他完全不同的原理实现了变化侦测,但是本篇文章讲的原理一样可以实现变化侦测,原理这个东西是不会过时的。之前我写文章有一个毛病就是喜欢对着源码翻译,结果过了半年一年人家源码改了,我写的文章就一毛钱都不值了,而且对着源码翻译还有一个缺点是对读者的要求有点偏高,读者如果没看过源码或者看的和我不是一个版本,那根本就不知道我在说什么。
好了不说废话了,继续讲刚才的内容。
知道
Object.defineProperty
可以侦测到对象的变化,那么我们瞬间可以写出这样的代码:写一个函数封装一下
Object.defineProperty
,毕竟Object.defineProperty
的用法这么复杂,封装一下我只需要传递一个 data,和 key,val 就行了。现在封装好了之后每当
data
的key
读取数据get
这个函数可以被触发,设置数据的时候set
这个函数可以被触发,但是,,,,,,,,,,,,,,,,,,发现好像并没什么鸟用?怎么观察?
现在我要问第二个问题,“怎么观察?”
思考一下,我们之所以要观察一个数据,目的是为了当数据的属性发生变化时,可以通知那些使用了这个
key
的地方。举个🌰:
模板中有两处使用了
key
,所以当数据发生变化时,要把这两处都通知到。所以上面的问题,我的回答是,先收集依赖,把这些使用到
key
的地方先收集起来,然后等属性发生变化时,把收集好的依赖循环触发一遍就好了~总结起来其实就一句话,getter中,收集依赖,setter中,触发依赖。
依赖收集在哪?
现在我们已经有了很明确的目标,就是要在getter中收集依赖,那么我们的依赖收集到哪里去呢??
思考一下,首先想到的是每个
key
都有一个数组,用来存储当前key
的依赖,假设依赖是一个函数存在window.target
上,先把defineReactive
稍微改造一下:在
defineReactive
中新增了数组 dep,用来存储被收集的依赖。然后在触发 set 触发时,循环dep把收集到的依赖触发。
但是这样写有点耦合,我们把依赖收集这部分代码封装起来,写成下面的样子:
然后在改造一下
defineReactive
:这一次代码看起来清晰多了,顺便回答一下上面问的问题,依赖收集到哪?收集到Dep中,Dep是专门用来存储依赖的。
收集谁?
上面我们假装
window.target
是需要被收集的依赖,细心的同学可能已经看到,上面的代码window.target
已经改成了Dep.target
,那Dep.target
是什么?我们究竟要收集谁呢??收集谁,换句话说是当属性发生变化后,通知谁。
我们要通知那个使用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,有可能是模板,有可能是用户写的一个 watch,所以这个时候我们需要抽象出一个能集中处理这些不同情况的类,然后我们在依赖收集的阶段只收集这个封装好的类的实例进来,通知也只通知它一个,然后它在负责通知其他地方,所以我们要抽象的这个东西需要先起一个好听的名字,嗯,就叫它watcher吧~
所以现在可以回答上面的问题,收集谁??收集 Watcher。
什么是Watcher?
watcher 是一个中介的角色,数据发生变化通知给 watcher,然后watcher在通知给其他地方。
关于watcher我们先看一个经典的使用方式:
这段代码表示当
data.a.b.c
这个属性发生变化时,触发第二个参数这个函数。思考一下怎么实现这个功能呢?
好像只要把这个 watcher 实例添加到
data.a.b.c
这个属性的 Dep 中去就行了,然后data.a.b.c
触发时,会通知到watcher,然后watcher在执行参数中的这个回调函数。好,思考完毕,开工,写出如下代码:
这段代码可以把自己主动
push
到data.a.b.c
的 Dep 中去。因为我在
get
这个方法中,先把 Dep.traget 设置成了this
,也就是当前watcher实例,然后在读一下data.a.b.c
的值。因为读了
data.a.b.c
的值,所以肯定会触发getter
。触发了
getter
上面我们封装的defineReactive
函数中有一段逻辑就会从Dep.target
里读一个依赖push
到Dep
中。所以就导致,我只要先在 Dep.target 赋一个
this
,然后我在读一下值,去触发一下getter
,就可以把this
主动push
到keypath
的依赖中,有没有很神奇~依赖注入到
Dep
中去之后,当这个data.a.b.c
的值发生变化,就把所有的依赖循环触发 update 方法,也就是上面代码中 update 那个方法。update
方法会触发参数中的回调函数,将value 和 oldValue 传到参数中。所以其实不管是用户执行的
vm.$watch('a.b.c', (value, oldValue) => {})
还是模板中用到的data,都是通过 watcher 来通知自己是否需要发生变化的。递归侦测所有key
现在其实已经可以实现变化侦测的功能了,但是我们之前写的代码只能侦测数据中的一个 key,所以我们要加工一下
defineReactive
这个函数:这样我们就可以通过执行
walk(data)
,把data
中的所有key
都加工成可以被侦测的,因为是一个递归的过程,所以key
中的value
如果是一个对象,那这个对象的所有key也会被侦测。Array怎么进行变化侦测?
现在又发现了新的问题,
data
中不是所有的value
都是对象和基本类型,如果是一个数组怎么办??数组是没有办法通过Object.defineProperty
来侦测到行为的。vue 中对这个数组问题的解决方案非常的简单粗暴,我说说vue是如何实现的,大体上分三步:
第一步:先把原生
Array
的原型方法继承下来。第二步:对继承后的对象使用
Object.defineProperty
做一些拦截操作。第三步:把加工后可以被拦截的原型,赋值到需要被拦截的
Array
类型的数据的原型上。vue的实现
第一步:
第二步:
现在可以看到,每当被侦测的
array
执行方法操作数组时,我都可以知道他执行的方法是什么,并且打印到console
中。现在我要对这个数组方法类型进行判断,如果操作数组的方法是 push unshift splice (这种可以新增数组元素的方法),需要把新增的元素用上面封装的
walk
来进行变化检测。并且不论操作数组的是什么方法,我都要触发消息,通知依赖列表中的依赖数据发生了变化。
那现在怎么访问依赖列表呢,可能我们需要把上面封装的
walk
加工一下:我们定义了一个
Observer
类,他的职责是将data
转换成可以被侦测到变化的data
,并且新增了对类型的判断,如果是value
的类型是Array
循环 Array将每一个元素丢到 Observer 中。并且在 value 上做了一个标记
__ob__
,这样我们就可以通过value
的__ob__
拿到Observer实例,然后使用__ob__
上的dep.notify()
就可以发送通知啦。然后我们在改进一下Array原型的拦截器:
可以看到写了一个
switch
对method
进行判断,如果是push
,unshift
,splice
这种可以新增数组元素的方法就使用ob.observeArray(inserted)
把新增的元素也丢到Observer
中去转换成可以被侦测到变化的数据。在最后不论操作数组的方法是什么,都会调用
ob.dep.notify()
去通知watcher
数据发生了改变。arrayMethods 是怎么生效的?
现在我们有一个
arrayMenthods
是被加工后的Array.prototype
,那么怎么让这个对象应用到Array
上面呢?思考一下,我们不能直接修改
Array.prototype
因为这样会污染全局的Array,我们希望arrayMenthods
只对data
中的Array
生效。所以我们只需要把
arrayMenthods
赋值给value
的__proto__
上就好了。我们改造一下
Observer
:如果不能使用
__proto__
,就直接循环arrayMethods
把它身上的这些方法直接装到value
身上好了。什么情况不能使用
__proto__
我也不知道,各位大佬谁知道能否给我留个言?跪谢~所以我们的代码又要改造一下:
关于Array的问题
关于vue对Array的拦截实现上面刚说完,正因为这种实现方式,其实有些数组操作vue是拦截不到的,例如:
修改数组第一个元素的值,无法侦测到数组的变化,所以并不会触发
re-render
或watch
等。在例如:
清空数组操作,无法侦测到数组的变化,所以也不会触发
re-render
或watch
等。因为vue的实现方式就决定了无法对上面举得两个例子做拦截,也就没有办法做到响应,ES6是有能力做到的,在ES6之前是无法做到模拟数组的原生行为的,现在 ES6 的 Proxy 可以模拟数组的原生行为,也可以通过 ES6 的继承来继承数组原生行为,从而进行拦截。
总结
最后掏出vue官网上的一张图,这张图其实非常清晰,就是一个变化侦测的原理图。
getter
到watcher
有一条线,上面写着收集依赖,意思是说getter
里收集watcher
,也就是说当数据发生get
动作时开始收集watcher
。setter
到watcher
有一条线,写着Notify
意思是说在setter
中触发消息,也就是当数据发生set
动作时,通知watcher
。Watcher
到 ComponentRenderFunction 有一条线,写着Trigger re-render
意思很明显了。The text was updated successfully, but these errors were encountered: