面试官:您能详细说说vue的双向绑定原理吗?
我:双向绑定是v-model的实现方式,我猜您问的不是那么简单的问题,也许您是想说vue的响应式原理?
面试官:对对,就是这个,您说一下Vue的双向绑定原理?
我:哈哈哈,我不会。
面试官:哈哈哈,您真幽默,和您聊得很开心,HR后续会通知您面试结果的。
正文
因为最近找工作,面试vue3相关的知识点多一些,所以这里更多看
本来想考虑自己看源码写一份文档的,但是同事推荐了几篇文档之后,我觉得自己还是不要出来丢人了。
看了市面上几位大佬的解析之后,我觉得自己的理解多少还是菜了些。
这里我就转载其中一篇文档,我个人倾向于vue响应式详解(重学前端-vue篇1) - 掘金 (juejin.cn)这篇文档,因为我个人基础相对来说还可以,而且看了vue3的源码,所以这里更喜欢笼统的看个大概得思路,这样不乱。
不过,如果是新人,我更推荐看0年前端的Vue响应式原理学习总结1:基本原理 - 掘金 (juejin.cn),这篇文档写的会连带着实现动机解释的很明白,如果你之前没有看过VUE的源码,那么这篇文档能让新手非常顺滑的理解vue实现的动机和过程。
vue的原理确实不麻烦,但是思路很精妙,我们平日大概率是用不上的,所以这里只是拓充知识。
毕竟,大多数人不是底层的源码实现者,所以只能学习思路,至于平日代码实现,如果有机会实现,那确实厉害。
什么是Vue响应式
数据发生变化后,会重新对页面渲染,这就是Vue响应式
想完成这个过程,我们需要做些什么
- 侦测数据的变化
- 收集视图依赖了哪些数据
- 数据变化时,自动“通知”需要更新的视图部分,并进行更新
「它们对应专业俗语分别是:」
- 数据劫持 / 数据代理
- 依赖收集
- 发布订阅模式
什么是发布订阅模式
- 观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的
- 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。
vue响应式采用的就是发布订阅模式,举个生活中的例子说明
1 | 比如小红最近在淘宝网上看上一双鞋子,但是联系到卖家后,才发现这双鞋卖光了。 |
如何实现发布–订阅模式?
- 首先要想好谁是发布者(比如上面的卖家)。
- 然后给发布者添加一个缓存列表,用于存放回调函数来通知订阅者(比如上面的买家收藏了卖家的店铺,卖家通过收藏了该店铺的一个列表名单)。
- 最后就是发布消息,发布者遍历这个缓存列表,依次触发里面存放的订阅者回调函数
如何侦测数据的变化
有两种办法可以侦测到变化:
使用Object.defineProperty
和ES6的Proxy
,这就是进行数据劫持或数据代理。
Object.defineProperty实现
Vue通过设定对象属性的 setter/getter
方法来监听数据的变化,通过getter
进行依赖收集,而每个setter
方法就是一个观察者
,在数据变更
的时候通知订阅者
更新视图。
1 | function render () { |
定义核心函数observe
1 | function observe (obj) { // 我们来用它使对象变成可观察的 |
改变data的属性,会触发set;然后获取data的属性,会触发get
1 | data.location = { |
上面这段代码的主要作用
observe这个函数传入一个 obj(需要被追踪变化的对象)
,通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive
处理,给每个属性加上set
和get
方法,以此来达到实现侦测对象变化。
值得注意的是,observe 会进行递归调用,因此我们需要设置一个递归出口。
那我们如何侦测Vue中data 中的数据,其实也很简单
1 | class Vue { |
这样我们只要 new 一个 Vue 对象,就会将 data 中的数据进行追踪变化。
但是我们发现一个问题,上面的代码无法检测到对象属性的添加或删除
(如data.location.a=1,增加一个a属性)。
这是因为 Vue 通过Object.defineProperty来将对象的key转换成getter/setter的形式来追踪变化,但getter/setter只能追踪一个数据是否被修改
,无法追踪新增属性和删除
属性。
如果是删除属性,我们可以用vm.$delete
实现,那如果是新增属性,该怎么办呢?
- 可以使用 Vue.set(location, a, 1) 方法向嵌套对象添加响应式属性;
- 也可以给这个对象重新赋值,比如data.location = {…data.location,a:1}
Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写
观察者 Watcher
为什么引入Watcher
Vue 中定义一个 Watcher 类来表示观察订阅依赖
。
至于为啥引入Watcher,《深入浅出vue.js》给出了很好的解释。
当属性
发生变化后,我们要通知用到数据
的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理
这些情况的类。
然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个,再由它负责通知其他地方。
「依赖收集的目的」 将观察者 Watcher 对象存放到当前闭包中的订阅者 Dep 的 subs 中,形成如下所示的这样一个关系(图参考《剖析 Vue.js 内部运行机制》)。
Watcher的简单实现
1 | class Watcher { |
以上就是 Watcher 的简单实现,在执行构造函数的时候将 Dep.target 指向自身,从而使得收集到了对应的 Watcher,在派发更新的时候取出对应的 Watcher ,然后执行 update 函数。
「依赖的本质」
所谓的依赖
,其实就是Watcher
。
至于如何收集依赖,总结起来就一句话:
在getter
中收集依赖(收集Watch当如Dep中),在setter
中触发依赖。
先收集依赖,即把用到该数据的地方收集起来
,然后等属性发生变化
时,把之前收集好的依赖循环触发
一遍就行了。
具体来说,当外界通过Watcher读取数据
时,便会触发getter
从而将Watcher添加到依赖中
,哪个Watcher触发了getter,就把哪个Watcher收集到Dep中。
当数据发生变化时,会循环依赖列表,把所有的Watcher都通知一遍。
最后我们对 defineReactive 函数进行改造,在自定义函数中添加依赖收集和派发更新相关的代码,实现了一个简易的数据响应式:
1 | function observe (obj) { |
当 render function 被渲染的时候,读取
所需对象的值,会触发 reactiveGetter
函数把当前的 Watcher 对象(存放在 Dep.target 中)收集到 Dep 类中去。
之后如果修改
对象的值,则会触发 reactiveSetter
方法,通知 Dep 类调用 notify
来触发所有 Watcher 对象的 update
方法更新对应视图。
「完整流程图」
- 在 new Vue() 后, Vue 会调用_init 函数进行初始化,也就是init 过程,在 这个过程Data通过Observer转换成了getter/setter的形式,来对数据追踪变化,当被设置的对象被读取的时候会执行getter 函数,而在当被赋值的时候会执行 setter函数。
- 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到依赖中。
- 在修改对象的值的时候,会触发对应的setter, setter通知之前依赖收集得到的 Dep 中的每一个 Watcher,告诉它们自己的值改变了,需要重新渲染视图。这时候这些 Watcher就会开始调用 update 来更新视图。
收集依赖
为什么要收集依赖
我们之所以要观察数据,其目的在于当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。比如例子中,模板中使用了location 数据,当它发生变化时,要向使用了它的地方发送通知。
1 | let globalData = { |
如果我们执行下面这条语句:
1 | globalData.text = '前端工匠'; |
此时我们需要通知 test1
以及 test2
这两个Vue实例进行视图的更新
,我们只有通过收集依赖
才能知道哪些地方依赖我的数据,以及数据更新时派发更新
。那依赖收集是如何实现的?其中的核心思想就是“事件发布订阅模式”。接下来我们先介绍两个重要角色– 订阅者 Dep
和观察者 Watcher
,然后阐述收集依赖的如何实现的。
Dep,Watcher的关系
Observer负责将数据转换成getter/setter形式; Dep负责管理数据的依赖列表;是一个发布订阅模式,上游对接Observer,下游对接Watcher Watcher是实际上的数据依赖,负责将数据的变化转发到外界(渲染、回调); 首先将data传入Observer转成getter/setter形式;当Watcher实例读取数据时,会触发getter,被收集到Dep仓库中;当数据更新时,触发setter,通知Dep仓库中的所有Watcher实例更新,Watcher实例负责通知外界
- Dep 负责收集所有相关的的订阅者 Watcher ,具体谁不用管,具体有多少也不用管,只需要根据 target 指向的计算去收集订阅其消息的 Watcher 即可,然后做好消息发布 notify 即可。
- Watcher 负责订阅 Dep ,并在订阅的时候让 Dep 进行收集,接收到 Dep 发布的消息时,做好其 update 操作即可。
Vue的操作就是加入了发布订阅模式,结合Object.defineProperty
的劫持能力,实现了可用性很高的双向绑定。
订阅器 Dep
「为什么引入 Dep」
收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了Dep,它用来收集依赖
、删除依赖
和向依赖发送
消息等。
于是我们先来实现一个订阅者 Dep 类
,用于解耦属性
的依赖收集和派发更新操作,「说得具体点」:它的主要作用是用来存放 Watcher 观察者
对象。我们可以把Watcher理解成一个中介
的角色,数据发生变化
时通知它,然后它再通知其他地方
。
「Dep的简单实现」
1 | class Dep { |
以上代码主要做两件事情:
- 用 addSub 方法可以在目前的
Dep
对象中增加一个Watcher
的订阅操作; - 用 notify 方法通知目前
Dep
对象的subs
中的所有Watcher
对象触发更新操作。 所以当需要依赖收集
的时候调用 addSub,当需要派发更新
的时候调用 notify。
也就说,所有的Watch最终会存放到Dep的subs中,并且视图更新的流程是,Dep触发了subs中的每个Watch,执行了Watch更新逻辑,Dep的操作是绕不过Watch的,这就验证了上面说的,Watch是一个中介的角色。
调用也很简单:
1 | let dp = new Dep() |
实现
先实现new Vue的数据劫持
1 | //遍历data,进行数据劫持 |
劫持以后,vue.mount()关联Watch
1 | js复制代码//遍历data,进行数据拦截 |
Vue mount函数的功能是去读取data中的属性
,渲染
到页面上。
这里的关键一步是读数据
。
那么,读数据最终是要进入数据劫持get方法的。这时我们可以创建一个Watch(观察者)作为中介,让它去进入get。
而mount起到的作用是渲染,自然,在构造Watch的时候需要传入渲染函数。
回到前面我们说的,Watch充当的是中介角色,那么,它是谁和谁之前的中介?实际上它是Observer和我们后面要讲的Dep的中介。
既然是中介,那么它就需要拿到双方的数据。又因为Observer需要观察data,所以这里我们需要将整个Vue构造函数挂载到Watch自身。
整理下流程:
执行vue.mount()
渲染页面,需要在Vue定义渲染函数render,然后在mount通过中介Watch去执行定义在Vue的render函数。
创建中介Watch,执行传入的渲染函数,渲染肯定是需要根据页面绑定了哪些data属性去读取data的,这个时候就会触发get方法,
进入get拿到对应的值。
添加dep后大概结构
1 | //defineReactive是对Observer的抽离 |
详细代码
参考这个图,会了看的更清晰,注意看执行顺序,在new Vue的时候,就会按顺序,初始化各个构造函数
1 | /** |
「解析:」
- 一开始new Vue ,会走到46行执行Vue构造函数,打印6,然后判断传入的是否是个函数,从new Vue可以看出,传入的是个data()函数,然后将函数执行后返回的整个data对象挂载到vue的
_data
属性上(真实的vue也是这么操作的) - 接着依次挂载
mount
和render
函数。 - 然后 new Observer(this._data);
- new Observer 的时候会走到第一行
Observer(关键函数)
,打印1。我们发现Observer实际就是给data数据都添加上get和set
方法,只不过不添加的方法defineReactive给抽离出去了。 - 然后走到第9行,执行defineReactive,打印2。 defineReactive的作用:
- 劫持
data
的各个属性,挂载set
和get
方法 - 在
get
中将Watch
添加到dep
的subs
中 - 在
set
中触发dep.notify
更新视图
- 劫持
- 然后走到12行,new Dep的时候,会走到95行执行Dep,打印13。每次new Dep 的时候,都会置空target,this.target = null,Dep函数剩下的代码都只是定义函数depend和addSub,notify,都不会执行,会跳出Dep函数。然后会到defineReactive函数第13行。
- 然后15行给每个
属性
加上get和set
方法。注意:此时只是在挂载,还没有执行,因此不会进入get,set方法内部。也就是说defineReactive剩下的代码中的函数也不会执行,所以会回到Observer,再回到67行new Observer,即new Vue的过程走完了。
new Vue执行完毕
开始执行vue.mount()
- 然后走到135行的vue.mount(),走到56行,打印8。执行new Watcher,进入Watcher构造函数打印10,然后
this.vm = vm;
,将vue实例挂载到Watch的vm属性上了。Dep.target = this,将watch实例挂载到了Dep的target属性上,从而关联起来。然后挂载addDep和update方法,只是定义,没有执行。 - 接着89行this.value = fn():fn实际是传进来的
render
函数(Vue=>new Watcher(self, self.render)=>vm, fn),由于后面有(),所以会立即执行。然后走到60行的render函数,打印9,返回self._data.text
,然后回到Watch中this.value
就是返回值data.text。然后,关键的来了:self._data.text
这里读取了data中的text,那么,这一步就会触发get
方法。 - 然后走到21行的get,打印3。
- 然后走到25行,执行dep.depend(),再走到104行,打印14。
- 这时候判断
Dep.target
,由于第8步将watch
挂载到了Dep.target,这时候为true,所以打印15。然后执行Dep.target.addDep(self)
,其实就是执行Watch.addDep(self)
,然后执行self.addSub(Dep.target)
。 - 然后进入
this.addSub
打印16,完成了依赖收集,subs中就有了Watch了。然后会回到get,执行最后一行,退出get,接着回到this.render,退出render函数,接着回到Watch中的this.value = fn()
,继续往后走,Dep.target = null,避免陷入死循环,然后Watch执行完了。
vue.mount()也执行完毕
开始执行修改操作
- 136行赋值操作了
vue._data.text = '123'
,这时候会走到28行的set,打印4,然后设置值为新值
。 - 继续向下走,到36行,
dep.notify()
,然后走到119行,打印17。 - 会走到122行,
遍历Dep
中subs
数组里面所有的Watch
,触发Watch的update
方法,走到82行,打印12。 - 执行
Watcher
中的fn()
,即Vue
中的render
函数(new Watcher(self, self.render)时传入的render函数),走到60行,打印9。render函数中其实就可以做一些渲染的操作,例如:获取某个节点,将他的内容变成获取的值,就完成了页面的渲染。 - 走到63行,取data.text,会走get,走到21,打印3。
- 执行
dep.depend()
,走到Dep中的this.depend
,打印14。但是由于Dep.target为null,15不会打印,也就是说到了这里,所有的流程执行完了。
全部执行完毕
结语
响应式原理,大龄前端确实应该掌握的必需品,如今我才看确实有点晚。
不过,学习任何时候都不算晚,只是当初看着晦涩难懂的代码如今看起来居然如鱼得水,也真是奇怪。
而且,这并非工程化的核心,如果涉及到基础开发才会需要了解,平时我个人是仅作为拓展知识了解的。