前言
MVVM时代到来,各个前端框架大放光彩,其中Vue以其自身学习成本低,渲染效率高,开发文档齐全,issue修复及时等特点获得了很不错的口碑,成为了业内前端项目的主要开发框架。我们的项目目前也是主要以Vue来进行开发的,双向绑定是整个框架底层实现的核心,今天就来对双向绑定这个概念进行刨根问底,了解目前常见的绑定方法,以及对Vue双向绑定原理实现进行解析。
1. 常见数据绑定方法
了解Vue具体实现之前,先介绍一下当前常见的数据绑定方法。
- 订阅者–发布者模式:订阅者通过订阅消息的方式,将自身添加到发布者的订阅列表当中(subs),并且保存自身的发布者列表(pubs),来实现数据与视图的绑定监听(类似自定义事件的实现,事件触发后,逐个执行subs中的通知方法);
- 脏检查:设置监听数据的watcher方法,数据改变后,进入digest阶段(特殊事件自动触发或者用户手动触发),循环执行所有watcher来更新数据,直到数据全部更新完毕;
- 数据劫持:通过Object.defineProperty方法进行实现,将指定数据进行setter和getter方法的重定义,为该数据设置值的时候,会调用setter方法,在setter方法中调用其绑定的回调方法。
脏检查会随着项目的增多、数据复杂度增加而变得效率低下,因为某个视图的更新,一次要执行完所有的watcher进行数据更新,所耗费的时间是大量且不易优化的。单独使用订阅者–发布者模式或是数据劫持都会让开发者在开发当中书写大量的代码,虽然灵活性更高,但学习成本和使用体验上都会增加一定难度。
Vue结合了订阅者–发布者模式和数据劫持的方式来实现其数据的双向绑定,并提供规范的开发方式,各类便订阅捷的API,为开发者的使用提供了不少的便利,下面来具体分析其实现。
2. Vue双向绑定分析
2.1 整体流程分析
首先看整体流程图来理解一下其中的相关概念。

图中包含了3个大的部分,分别是Observer、Compiler以及Watcher。下面详细介绍一下其具体扮演的角色:
- Observer:承担发布者的角色,同时也完成了数据劫持的功能。利用Object.defineProperty方法,劫持我们传入的data数据对象,为其设置Vue自定义的setter和getter方法。调用setter方法时,先更新数据,然后通过调度中心调度订阅者的处理函数;调用getter方法时,会先调度订阅者的登记方法,将其登记到自身专属的调度中心中,然后返回数据;
- Watcher:承担订阅者的角色,参数接受订阅目标以及目标更新的回调通知函数。在获取数据时,会将全局的唯一指针指向自己,以供发布者收集自身的信息以及进行调度中心的登记等其他操作,同时也会记录下自己所订阅的那些发布者的列表数据以进行排重;
- Compiler:提供Html标签、数据以及指令模板编译功能。将组件数据拼凑,读取data对象数据,整合成完整的虚拟DOM树以供后续处理。
分析可知Observer主要是Model数据模块的处理,Compiler主要是View层的编译处理,Watcher则是连接Compiler和Observer的核心。图中还可以看到Dep以及Updater这两个概念,Dep是连接Observer和Watcher的核心,可以叫做调度中心,存放的是某数据对应的订阅者列表数据,用于通知变化。Updater则是虚拟DOM差异比较,DOM更新的模块。
结合以上分析以及图中所示,整体流程为:创建Vue组件,劫持Data数据封装setter和getter方法,Compiler编译模板数据并创建编译Watcher,设置回调函数为更新视图方法,进入初始化视图方法,编译虚拟DOM的时候,由于通过Data数据的getter对象获取了数据,编译Watcher实例被添加到了对应数据的Dep数组当中,然后完成实际DOM的修改,显示页面。等到Data数据被修改的时候,setter方法通过Dep调度中心通知具体的Watcher更新,编译Watcher收到了这个更新,就会调用更新视图方法的回调函数,从而利用新的Data数据重新编译虚拟DOM,通过Updater进行页面的实际更新,完成Model到View层的绑定。
2.2 模块实现分析
上面分别分析了各个模块的作用以及整体的流程,接下来分模块进行分析,看看其具体的代码实现。由于Observer模块的功能实现基于Object.defineProperty方法,那么首先来简单了解一下这个方法。
2.2.1 Object.defineProperty
官方的说明非常详细,还有很多例子进行介绍,这里主要引用基础概念作为了解,详细地址点击这里进行查看。
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
调用方式为:
1
2
3
4
5
6
7
8
9 > /**
> * 对象属性定义或修改方法.
> * @param {Object} obj - 需要被操作的目标对象
> * @param {String} prop - 目标对象需要定义或修改的属性的名称。
> * @param {Object} descriptor - 将被定义或修改的属性的描述符。
> */
>
> Object.defineProperty(obj, prop, descriptor)
>
对象里目前存在的属性描述符有两种主要形式(descriptor):数据描述符和存取描述符。数据描述符是一个拥有可写或不可写值的属性。存取描述符是由一对 getter-setter 函数功能来描述的属性。描述符必须是两种形式之一;不能同时是两者。
数据描述符和存取描述符均具有以下可选键值:
configurable
当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
enumerable
当且仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false。
数据描述符同时具有以下可选键值:
value
该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
writable
当且仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变。默认为 false。
存取描述符同时具有以下可选键值:
get
一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined。
set
一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为 undefined。
从上述说明可以了解到Object.defineProperty方法是一个用于给对象定义属性描述符的方法,Vue借此来实现了数据劫持的功能。
2.2.2 Observer发布者
下面直接从代码的角度来分析Observer。
1 | export class Observer { |
Vue在初始化获取到Data数据时,就会调用Observer构造方法构建实例,判断目标对象类型,然后调用原型上的walk方法进行数据处理。
1 | ... |
walk方法获取目标对象自身所有可遍历的属性,对其执行defineReactive方法来进行劫持的最终实现。
1 | ... |
数据处理完成后,获取和设置Data对象的数据时就会调用Vue定义的通知调度中心的方法了,下面来看下调度中心的代码,了解dep.depend方法和dep.notify方法做了哪些工作。
2.2.3 Dep调度中心
每个Dep调度中心都有一个ID属性,由0自增以进行区分在watcher当中会被用来排重,还有subs属性数组,用来存储需要被通知的那些订阅者(watcher),并且提供了一系列的方法来提供给发布者和订阅者使用以作为连接枢纽,下面看下Dep类的实现代码。
1 | let uid = 0 |
在调度中心的实现当中,识别当前订阅者是基于Dep.target这个类属性来实现的,充当全局唯一的一个订阅者对象变量指针,看下具体的实现方法。
1 | Dep.target = null // |
pushTarget功能是存储当前的target到内部属性targetStack数组当中,然后将Dep.target类属性更新以供当前的调度中心操作;操作完毕后调用popTarget方法从targetStack数组当中还原之前的Dep.target值。以上两个方法都属于操作target变量以供调度中心使用,所以应该是订阅者实例watcher来使用的,下面进行Watcher的实现分析。
2.2.4 Watcher订阅者
Watcher作为订阅者,同时作为$watcher(API)的底层实现,主要提供了属性的计算更新功能以及和调度中心的交互功能(用于接收更新通知)。wather示例在初始化获取监控属性值的时候会调用其get方法,在该方法中启动了与调度中心的连接,先看一下简单的绑定图。

接下来看下watcher的实现代码。
1 | ... |
get方法调用调度中心提供的pushTarget设置全局Dep.targe值为自身,然后调用getter方法,转到observer的getter方法中,由于Dep.target有值,调度中心能够执行dep.depend方法,然后调用当前watcher实例的addDep方法,addDep方法获取到调度中心后,保存到dep依赖列表中,然后调用dep.addSub方法使调度中心保留自身,以供通知,看下watcher的addDep实现。
1 | ... |
上面从watcher的getter方法调用出发,到调度中心,再到watcher自身,最后回到调度中心的流程就是一次依赖添加的完整过程。get方法当中还实现了对value属性的深度观察,通过循环调用其子属性的值,利用上述getter到dep的流程巧妙实现,看下traverse方法的流程。
1 | ... |
依赖添加完成后,当目标属性值进行了变更后,调度中心调用update方法,来完成watcher实例值的更新,最终调用创建时传入的callback函数,并将新旧value传入。考虑到性能问题,update方法实现兼容了3种更新方案,update及其调用的run方法代码如下。
1 | ... |
代码流程看完之后,看一下绑定和更新的执行流程图来加深理解。

经过上面的分析,我们了解了Observer数据劫持,Watcher通知接收及其Object深度观察,以及连接两者的枢纽观察中心Dep等的实现。还了解了一个从收听者发起的完整的一次双向绑定的流程,最后来看看Compiler当中如何利用Watcher来实现视图和数据绑定的。
2.2.5 Compiler巧绑Model
Compiler主要实现了对template的编译功能,完成对各类directives的支持,由于本文主要讨论双向绑定的原理,所以不去分析Compiler的指令解析。上小节提到了Compiler利用Watcher来完成跟model的绑定,这里的实现是在lifecycle.js中进行巧妙实现的,方法代码如下。
1 | ... |
在mountComponent方法中,通过绑定updateComponent到watcher的getter上实现绑定。执行原理是渲染watcher在初始化的时候会调用updateComponent方法(此时Dep.target指向当前渲染watcher),该方法中调用了vm._render()方法,会将编译完成的html模板与data中的数据结合渲染为虚拟DOM,在此步骤当中,读取data数据,其getter会将该watcher绑定到对应data数据的调度中心dep数组当中作为通知对象,然后继续完成后续的对比和实际DOM渲染工作,此时html渲染完成,compiler中的view也通过watcher绑定到了model当中。后续model更新,dep数组逐个执行watcher.update方法,然后updateComponent又被执行了,编译出新的虚拟DOM,然后对比,更新实际DOM,完成从model到view的自动更新。
2.3 实例结合
看完上述原理分析,你或许会非常的懵逼,那么我们现在用一个最简单的官方实例来分析,代码如下。
1 | <!-- html代码片段 --> |
html页面渲染如下图。

以上实例实现的功能就是input框和p标签初始化显示Hello Vue!,用户修改input框内的值,p标签内容跟随着变化。了解了之前的代码和解析后,下面我们用底层实现的流程(仅双向绑定,不涉及其他模块)来分析这一功能。
首先Vue接收到option后,Observer分析data对象,调用walk方法遍历其属性key,分别对其执行defineReactive方法,进行数据劫持。这里数据简单,执行后的message有了如下的属性描述。
1 | { |
除此之外,在message的setter和getter当中还能够对dep的实例进行访问操作,所以生成了一个专属于message属性的dep调度中心。在message的setter当中会更新其value操作,并通知当前dep调度中心的deps依赖数组中的订阅者对象(watcher)。在message的getter当中能得到当前访问该message的watcher,并通知dep进行保存。以上是对data对象进行数据劫持的步骤。
接着Vue分析我们传入的el元素中的template代码 <p></p><input v-model="message">,进行编译,生成一个可执行函数,执行这个函数,就会对其中的变量message进行访问获取value值,拼装出完整的html代码,触发虚拟DOM比较以及实际DOM的更新操作。这个函数被封装在了updateComponent函数之内。以上是对传入的template进行编译的步骤,将其封装为可执行函数。
compiler和observer是通过watcher进行连接的。所以在mountComponent方法当中,创建了一个watcher,实例化的时候将updateComponent方法作为其getter传入,那么初始化该watcher的时候,getter方法被执行,上一步的编译生成html代码的渲染方法就会被执行,其中访问了message变量,那么message的getter就会捕捉到当前这个watcher,其专属调度中心deps数组就会有此watcher的引用。以上是compiler将编译流程作为watcher的getter方法,并利用该watcher将编译流程绑定到数据上的步骤。
执行完上述步骤,初始化以及渲染流程就结束了,来分简短步骤看一下:
- observer劫持
data:{ message: 'Hello Vue!'},生成setter、getter及其专属dep调度中心; - compiler获取template,编译其为可执行函数,包含了整个编译和虚拟DOM比较,实际DOM操作的流程,封装在updateComponent方法内部;
- 在compiler流程最后,会创建一个watcher对象,将本次代码的编译等流程方法updateComponent绑定到这个watcher对象中,通过执行updateComponent方法,watcher会被绑定到data对象对应访问的属性上(deps数组内),然后在updateComponent方法内完成本次代码的初始化渲染,我们就可以在浏览器中看到实际的渲染效果了。
在页面当中我们修改message的值,会发生如下步骤:
- message的setter被调用,值被更新,其调度中心dep循环更新deps数组,那么编译watcher的update方法会被调用;
- watcher方法调用get方法进行value的更新,get方法内部会调用getter方法,也就是我们传入的updateComponent方法,该方法将模板编译输出html,此时编译的时候就拿到了新的message的值,编译成新的虚拟DOM,然后进行比较,完成实际DOM的更新;
- 页面上的p标签内部message显示的值更新了。
看完了以上的实例分析,其实可以发现vue做的事情很简单:将代码的渲染流程委托给watcher,watcher将自己放在数据更新通知队列中,数据更新,watcher收到通知,执行代码渲染流程,完成更新。
3. 知识扩展–计算属性Computed
经过上一节的模块实现及其之间关系的分析,大家已经了解Vue双向绑定的原理。由于Vue提供的Computed(api)利用Watcher模块进行了实现,所以这里再扩展了解一下该api的实现,先看一下整体的结构图帮助后面的代码实现解读。

computed的创建其实就是一个watcher实例的创建,computed的key为watcher实例的key,value是一个function(用户自定义,其中读取其他data属性值,进行操作后返回),会作为watcher的getter方法被其调用,下面结合代码实现进行分析。
1 | ... |
上面的代码在初始化state时被调用,为所有的computed属性创建对应watcher并且在过滤操作后,调用defineComputed方法进行进一步处理,看下defineComputed方法。
1 | ... |
defineComputed方法执行的是将computed属性设置到vm对象上以供访问的操作,其中的get方法是由createComputedGetter执行返回的函数,看下其中的具体操作。
1 | ... |
从以上代码可以了解到createComputedGetter执行后返回的computedGetter才是真正实现computed功能的函数,其执行了evaluate方法进行值的更新,还传递了当前取值的其他watcher到dep当中,以帮助其获取更新通知。至于更新流程,data更新后,dep循环调用所有watcher.update方法通知更新,在computed-watcher当中,因为为lazy模式,所以仅标记当前watcher为dirty,不做其他更新处理;在compiler-watcher当中,新建虚拟DOM获取值的时候,读取computed属性,从而访问到了computed-watcher,此时更新该watcher的value值,并返回最新的值,最后完成页面的更新。以上为computed(api)的实现原理和更新流程。
4. 总结
本文主要围绕Vue的MVVM整体结构模块、模块的实现细节对其双向绑定原理进行了分析,最后还加入了个人对计算属性computed实现的分析理解。旨在能够在使用Vue的时候能够更加清晰的了解其底层实现的思路,帮助分析代码的逻辑流程,排除bug等情况。如果发现文中的解析思路有误,或者其他错误,欢迎大家给我指出改正,一起交流。