当我谈Vue的数据绑定时我谈些什么?
内标题取自某村姓文学大师的某书.如有雷同,纯属我闲的
本文基于Vue 2.0+写就,主要参考自Vue的源码.内容主要是我对
Vue
数据绑定这块儿的理解思路,其中并未涉及模版编译和虚拟DOM部分的具体代码,也没有具体实现Vue-like
的框架.具体内容包括
data
的reactive
,computed
和watch
的逻辑,以及简要的模版绑定原理.最后的是我比较好奇的Vue
数据绑定中一些细节实现.(本篇的代码均能在我的
gayhubGithub上找到)
楔子
接触过Vue的朋友都知道,Vue的重要功能之一就是响应式的数据绑定.即在我们变更数据后,依赖于这个数据的DOM结构会自动以变更后的数据去同步,而你不用再写下任何逻辑去更新Dom.
在Vue中数据绑定分两种:
- 单向绑定: 逻辑层变更引起视图层更新.更新变量后,DOM自动同步更新.
- 双向绑定: 在单向绑定的基础上,增加视图层引起逻辑层的同步.
下面来看两个来自Vue官网的例子:
单向绑定:
<div id="app">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
复制代码
new Vue({
el: '#app',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
复制代码
点击button
后,DOM会自动更新<p>..</p>
中的字符串会自动变为!sj.euV olleH
.
双向绑定:
<div id="app">
<p>{{ message }}</p>
<input v-model="message">
</div>
复制代码
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
复制代码
任何输入到input
框中的值,会立即同步到<p></p>
中.
早在三皇(ARV)威震江湖之前,jQuery独霸天下的时代,我们如果想做到同样的功能,就需要我们添加额外的逻辑代码直接去修改DOM结构.而在Vue中直接修改变量的值就可以直接做到,那就一定是因为Vue
帮我们完成了这些逻辑. 那么问题就来了,Vue
中是怎么实现的?
数据绑定的原理
现在,让我们一步步的思考Vue
是如何做到的.
我们先从单向绑定开始分析.
首先是数据劫持
很多情况下,我们在Vue
中是通过这样的方式修改数据的:this.someData = 'some data'
.这本是一个很常规的赋值操作.而在Vue
中却能够触发依赖这个数据的视图或代码更新,那么说明Vue在对赋值这一步做了一些处理.
这个处理就是数据劫持.即拦截到对数据的操作,取值或者赋值,在实际值操作前或后完成别的一些操作.
在Vue
中,数据劫持的核心方法是Object.defineProperty
.这个方法并不支持polyfill,所以这也是为什么Vue
最低只支持到IE9的原因.(不熟悉这个方法的朋友可以参考MDN).
Vue
利用这个方法拦截了对变量的get
和set
操作,像下面这样:
function defineReactive(
obj,
key
) {
let val = obj[key]
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
console.log('Someone is trying to get my value!')
return val
},
set: function(newVal) {
console.log(`Someone is trying to change my value!`)
val = newVal
}
})
}
let data = {
message: 'Im a ruthless message'
}
defineReactive(data, 'message')
let msg = data.message // log: Someone is trying to get my value!
data.message = 'Im a happy message' // log: Someone is trying to change my value!
复制代码
从上面可以看出,我们已经拦截到了对data.message的值获取或者修改的操作.同时这里还需要注意的一点是:Object.defineProperty
第一个参数是个Object
,它是基于一个对象去拦截对该对象属性的操作的.所以在vue中data这个选项必须是一个对象或者是一个返回对象的函数.
现在我们有了这个方法后,就可以知道什么时候有代码去set/get
某一个值了.而想要做到响应式更新,我们就还要知道“是谁”动了这个值.
然后是收集依赖
灵性的朋友应该直接想到了: “谁”获取了这个值就是谁依赖这个值.所以我们应该在get这个拦截方法中去做.像下面这样:
function defineReactive(
obj,
key
) {
let val = obj[key]
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
console.log('Someone is trying to get my value!')
dep.addSub(who?)
return val
},
set: function(newVal) {
console.log(`Someone is trying to change my value!`)
val = newVal
}
})
}
复制代码
从上面可以看出每次调用这个方法,我们都利用一个叫Dep
的构造函数用来管理依赖.在每一次调用defineReactive
的时候,我们会去new Dep()
.这样每个被defineReactive
的值都有自己的dep
. 同时细心的朋友应发现了:每次defineReactive
这里都形成了一个闭包,所以只要这个属性存在,这个dep
也就会一直存在.
虽然闭包解决了持久化的问题,但也带了另一个问题: 这是个闭包,那我们要如何确定addSub
添加的是谁?
大家都知道js是个单线程运行的语言,所以同一时间必定只会有一个"东西"在获取某个值,我们把去订阅这个值变化的东西叫做Subscriber
,那我们只需要保证全局共用一个变量保存当前的Subscriber
就能解决之前所说的问题.像下面这样:
function defineReactive(
obj,
key
) {
let val = obj[key]
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {
if(Dep.target) {
dep.addSub(Dep.target)
}
return val
},
set: function(newVal) {
val = newVal
}
})
}
function Dep() {
this.subs = []
}
Dep.prototype = {
// 添加
addSub(sub) {
if(subs.indexOf(sub) === -1) {
this.subs.push(sub)
}
},
// 删除
removeSub(sub) {
const subs = this.subs
if (subs.length) {
const index = subs.indexOf(sub)
if (index > -1) {
return subs.splice(index, 1)
}
}
}
}
复制代码
这里用Dep.target
保存当前的依赖.只要做到每次取值前先把当前Subscriber
赋值给Dep.target
,那在get
拦截函数里面就可以addSub
这个Subscriber
了.
至此,我们就最简单的完成了一个依赖收集功能.目前为止我们可以知道谁获取了谁, 那么下一步要做的就是在某个值被修改时通知依赖它的Subscriber
.
发布修改
在上具体代码之前,我们先简单分析下.
在Vue
中响应式的数据绑定一般是三种形式:
computed
watch
template
,基于模版的数据绑定.
其中computed
和watch
的创建都是在js
里完成的.computed
是定义一个方法,它的值等于这个方法返回的值,所以computed
不仅依赖着其它值同时也被其它地方所依赖着;watch
是直接监听某一个依赖的变化,从而再变化后调用定义的callback
;那模版是一串字符串或者html,它是怎么被处理的?
熟悉Vue
的朋友知道,所有的模版都会经过Vue的编译,最终每个Component
会生成一个this._render
的内部方法.同可用参数 render
([vuejs.org/v2/guide/re…]) 类似.那么在调用this._render()
时就会去获取模版所依赖的值了.而这样就可以computed
和watch
做类似的处理了.
watch
这里创建了一个叫Subscriber
的构造函数让每一个收集的依赖都是它的实例(Vue
中叫Watch
).接下来让我们来思考一下:收集的这些依赖到底都具备什么功能.
首先,需要一个update
方法用来完成值更新后的相应操作;其次我们需要保存一些当前sub
的信息,例如:值改变后的对应的回调等;最后还需要一个get
方法去获取值.所以简化版的看起来是这样:
function Subscriber(vm, exp, callback) {
// 依赖的值所在的对象
this.vm = vm
// 依赖的值对应的表达式
this.exp = exp
// 值更新后的回调
this.cb = callback
// 初始化依赖关系
this.value = this.get()
}
Subscriber.prototype = {
get () {
// 把当前Subscriber设置给Dep.target
let value
Dep.target = this
//获取值
value = this.vm[this.exp]
// 删除当前Subscriber设置给Dep.target
Dep.target = null
return value
},
update() {
this.run()
},
run() {
let oldValue,newValue
oldValue = this.value
newValue = this.get()
if(oldValue !== newValue && this.cb) {
this.cb.call(this.vm, oldValue, newValue)
}
}
}
复制代码
从上面可以看到我们在初始化每一个Subscriber
的时候去获取了一下值.这一步的作用就是更新依赖关系,因为只有在获取值时 才会调用dep.addSub
去存储相应的依赖.
与此同时,Dep
和defineReactive
也需要添加一些相应的处理代码:
// 省略号代表代码和上面相同
Dep.prototype = {
addSub(sub) { ... },
removeSub(sub) { ... },
notify() {
// 通知存的所有的Subscriber值更新了
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
function defineReactive(
obj,
key
) {
let val = obj[key]
let dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function() {...},
set: function(newVal) {
val = newVal
// 通知所有Subscriber去更新
dep.notify()
}
})
}
复制代码
至此,最最最简化版的数据绑定就实现了.现在可以这样使用:
function watch(vm, key, cb) {
return new Subscriber(vm, key, cb)
}
let data = {
message: 'Im a ruthless message'
}
defineReactive(data, 'message')
watch(data, 'message', (oVal, nVal) => {
console.log(`message already be changed, oldValue:${oVal}, newValue:${nVal}`)
})
data.message = 'Im a happy message'
// message already be changed, oldValue:Im a ruthless message, newValue:Im a happy message
复制代码
看起来是不是有一些眼熟?是的,这就是最简陋的Vue中的watch实现.接下来,让我们想想computed
是如何实现.
computed
在上代码之前,先考虑一个问题Vue
中的computed
到底是什么?
computed
在Vue
中是基于一些基础数据(data
或者其它computed
)计算后返回一个新的值,可用来避免模版过于膨胀或逻辑复用.也就是说它本身依赖一些值,返回一个可以被其它代码依赖的值.
在上computed
的代码之前,我们先修改一下数据劫持部分的代码.目前,defineReactive
函数一次只能定义data中的一个属性,像这样:
defineReactive(data, 'message')
复制代码
下面让我们包装另外一个函数observe
,使这个函数可以循环的defineReactive
某个对象中的所有属性(这里先不考虑数组和嵌套对象).
function observe(data) {
Object.keys(data).forEach(key => {
defineReactive(data, key)
})
}
复制代码
我们在这里输出一个叫compute
的函数,用来输出计算属性.
function compute(vm, name, getter) {
let dep = new Dep()
let computed = watch(vm, getter, () => {
dep.notify()
})
Object.defineProperty(vm, name, {
enumerable: true,
configurable: true,
get: function() {
if(Dep.target) {
dep.addSub(Dep.target)
}
return computed.value
}
})
return name
}
复制代码
可以看到compute
函数把computed
属性定义到vm上并收集了依赖于computed
属性自身的Subscriber
.computed
值的变化并不是因为手动修改其的值,而是它依赖的值变化导致的.所以它本身也是一个Subscriber
.这个地方和Vue
的实现有一些出入,Vue
的做法是computed依赖的值变化时去更新依赖这个computed的代码,没有监听computed
本身这一说,此处为了简化代码所以采用这种方法.
与此同时,也有些代码需要做相应的修改:
function Subscriber(vm, expOrFn, callback) {
// 依赖的值所在的对象
this.vm = vm
// 依赖的值对应的表达式
this.exp = expOrFn
// 把表达式和函数做统一处理
this.getter = typeof expOrFn === 'function' ?
exp : function(vm, exp) { return vm[exp] }
// 值更新后的回调
this.cb = callback
// 初始化依赖关系
this.value = this.get()
}
Subscriber.prototype = {
get () {
// 把当前Subscriber设置给Dep.target
let value, vm = this.vm
Dep.target = this
value = this.getter.call(vm, vm, this.exp)
// 删除当前Subscriber设置给Dep.target
Dep.target = null
return value
}
...
}
复制代码
现在就我们可以这么使用computed
的了.
compute(data, 'computedMessage', function getter() {
return this.message + ',' + this.anotherMessage
})
复制代码
当message
或者anotherMessage
变化时,compute.computedMessage
也会相应的变化了.
关于模版
之前说过,Vue
中实际上把每个component
的模版编译成了一个各自的_render
方法.这个方法每次调用会返回一个VNode
,也就是该组件的虚拟DOM.然后再根据这个虚拟DOM进行diff算法等逻辑,最终达到选择性的更新真正的DOM
.
所以,在Vue
里把包装后的vm._render
方法作为Subscriber
的getter
就可以动态的更新VNode
了.像这样:
function updateComponet() {
updateRelDOM(_someRender())
}
new Subscriber(vm, updateComponent);
复制代码
someRender
依赖了一些被defineReactive
值,这些值变化时会让这个Subscriber
去调用updateComponent
,从而达到更新组件的目的.也就是说,Vue
中的DOM更新也是一个特殊的Subscriber
.(Vue
中的虚拟DOM和diff
算法是挺大的一块儿,这篇先按下不表,之后或许会单开一篇讲讲我自己的思路)
这里还要顺便说一句:一个component
只有一个_render
方法,而_render
方法也只存在于component
中.所以当这个component
的模版依赖的值变化后,每次都会调用_render
去生成一遍整个component
模版的虚拟DOM.
有兴趣的朋友可以去看Vue
源码中的src/core/instance/lifecycle.js
文件.
双向绑定?
细心的朋友应该有注意到Vue
中的内置默认的双向绑定都离不开v-model
这个directive
.实际上呢,Vue
内部是把v-model
转化成了:value="someData"
和@change="someData = $event.target.value"
.像这样:
<div id="app">
<p>{{ message }}</p>
<input v-model="message">
</div>
// 约等于
<div id="app">
<p>{{ message }}</p>
<input :value="message" @change="message = $event.target.value">
</div>
复制代码
这样就实现了双向绑定了.
当然,根据input
的type
的不同,各自的逻辑处理也有一些区别,有兴趣的朋友可以自行查看src/platforms/web/compiler/directives/model.js
.
总结
到目前为止,我对数据绑定的相关思路就分享的差不多了.
有些朋友或许会说这里都是一块块的零碎,为什么会说已经结束了.因为我相信以大家的聪明才智,应该可以自己把这些组合起来,写出属于自己的数据绑定模块.毕竟自己实现一遍或者自己理清一下思路才能真正的掌握(绝不是因为我懒).
扩展
从本节开始,我将谈谈一些我对Vue
数据绑定这块儿中感到好奇的地方.如果对这里某小部分不感兴趣,可尽情跳过.
Vue.set/this.$set
应该来说大部分用过Vue
的朋友都接触过Vue.set
.这个方法是用来在组件初始化后,动态的往data
上添加响应式的属性.因为直接添加的话,这个添加上去的值并不是响应式的.
细心的朋友应该发现了原因: 在初始化data
的时候,我们observe
了data
中的每一个属性,但是初始化后,即observe
函数调用结束后,添加上去的属性却并没有被defineReactive过.所以导致后添加的属性不是响应式的. 那么Vue.set
代码逻辑就很清晰了,即这个方法的时候就是把这个新的值defineReactive
一下.
function set (target, key, val) {
...
// 如果属性key已经存在于target上就赋值并直接返回
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val
}
...
defineReactive(ob.value, key, val);
...
return val
}
复制代码
这段代码来自Vue
源码,中间省略了一些逻辑,包括:通知依赖于target这个对象的代码更新等.
这段代码主要逻辑就是先判断key
是否存在于target
上,如果存在就直接返回,如果不存在就defineReactive
.让这个新来的属性重新熟悉下reactive
的这条道儿上的规矩.
更新队列
从computed
那一节的例子出发,试想这么一段代码:
compute(data, 'computedMessage', function getter() {
return this.message + ',' + this.anotherMessage
})
watch(data, 'computedMessage', function(oVal, nVal) {
console.log(`oVal: ${oVal}; nVal: ${nVal}`)
})
data.message = 'Im a happy message'
data.anotherMessage = 'no, you\'re not'
复制代码
这里data.computedMessage
依赖了data.message
和data.computedMessage
.之后我们连续改变了data.message
和data.anotherMesssage
.大家应该可以想到结果,watch
中的console
会打印两遍,意味着data.computedMessage
变化了两次.
这就造成了一个问题: data.computedMessage
多了一次没有必要的update
.而这个问题衍生出去是很严重的.比如我们某个组件的模版依赖了5个值,我们在某个方法里把5个值都修改了,那么这个组件就会调用_render
五次,导致浪费很多的性能.
所以我们需要一个优化方案能让某个时间段之内需要更新的Subscriber
一起执行,并保证该时间段内的相同Subscriber
只重新get
一次值.Vue
中的做法是利用了一个更新队列.
首先,既然要保证同一个Subscriber
在某个更新流中只获取一次值.那么势必需要加个id
以作区分.
var uniqueId = 0
function Subscriber(vm, exp, callback) {
this.id = ++uniqueId
...
}
复制代码
这里保证每个Subscriber
的id随着不断的创建是不断自增的.至于这么做的原因,后文会提到.大家可以先自己思考一番.
现在每个Subscriber
有自己的id,那我们就需要在修改它更新时的代码,把它本身加入更新队列.像这样:
Subscriber.prototype = {
...
update() {
queueSub(this)
// this.run()
},
...
}
复制代码
这里可以看到我们把实例本身作为参数调用了queueSub
.而queueSub
这个方法长这样:
// 更新队列
var queue = [],
// queue中当前正在执行的sub对应的在queue中的下标
index = 0,
// 标志位,代表是否正有queue在执行.
flushing = false,
waitingForQueueExec = false,
has = {}
function queueSub(sub) {
var id = sub.id
// 队列中没有才添加
if(!has[id]) {
has[id] = true
if(!flushing) {
// 如果当前队列没在执行就直接push入队列
queue.push(sub)
} else {
var i = queue.length - 1
while(i > index && id > queue[i].id) {
i--
}
// 把sub插入到当前位置
queue.splice(i + 1, 0, sub)
}
if(!waitingForQueueExec) {
waitingForQueueExec = true
setTimeout(flushQueue, 0)
}
}
}
复制代码
这个方法主要逻辑很简单:
1.把当前sub根据创建顺序添加进入目前正在执行的队列
这一步指的是:
var i = queue.length - 1
//index表示当前队列中正在执行的Subscriber的所对应的下标
while(i > index && id > queue[i].id) {
i--
}
// 把sub插入到当前位置
queue.splice(i + 1, 0, sub)
复制代码
这段代码的意思是:把传入到queueSub
中的Subscriber
插入到队列中未执行且所有id
大于它的Subscriber
之前.
试想这么一个例子: 队列当前正在执行某个Subscriber
的回调里修改了一个值,从而导致依赖这个值的computed
的值变化.接下来这个computed
会进入更新队列.如果当前队列中有未执行的Subscriber
,比如模版,依赖这个computed
的值,我们当然是希望这些未执行的Subscriber
能基于新的computed
的值去更新.
所以这个computed
对应的Subscriber
应该先于 当前队列中未执行且有可能依赖它的Subscriber
去执行,也就是在队列中插入到这些Subscriber
的前面.
那我们怎么去确定可能的依赖关系呢?这个地方就利用到了上文提到过的每个Subscriber
实例的自增id.被依赖的值或者computed
一定会比依赖它的值先创建,所以它们的id
一定小于依赖它的Subscriber
.
2.等到当前执行栈结束后,执行当前队列
这一步指的是:
setTimeout(flushQueue, 0)
复制代码
这里用setTimeout
的原因是为了等到当前执行栈结束再去遍历更新队列.因为js
是单线程的语言,如果不这样做,在触发更新的同一时间立马就会去触发更新队列,直到更新队列清空完成又回到刚才更新值地方继续执行.这就和我们优化前并无二样了.
PS: Vue
源码中利用的是Vue.nextTick
方法,这里为了避免非相关内容就采用了setTimeout(fn,0)
(setTimeout
也是Vue.nextTick
内部实现之一).
最后贴一下flushQueue
的代码:
function flushQueue() {
flushing = true
queue.sort(function(a, b) { return a.id > b.id })
for(; index < queue.length; index++) {
var sub = queue[index]
has[sub.id] = null
sub.run()
}
resetQueue()
}
function resetQueue() {
queue = []
index = 0
flushing = waitingForQueueExec = false
has = {}
}
复制代码
写在最后
到目前为止,这篇文章就算结束了.写本文的目的主要在二:一是借鉴自费曼学习法,内化自己阅读源码形成的思路.二是做抛砖引玉,希望在惠及自己时也能稍微引起大家的思考.即使这思考是对这篇的批评,那也算是皆大欢喜,不负此工.
这是第一次正经在网络发文,所以本文中肯定有我未注意到的不严谨和错误的地方,还望海涵.如愿斧正,不胜感激.