vue进阶测试——数据双向绑定原理探究
本课题研究承接浏览器的渲染机制一文,写文章前感谢一下趣链大佬的面试点播,虽然不知道听来了什么,哈哈~ 依旧是放上详细的参考链接 用了vue几个月后,写前端的思维方式逐渐从操作dom的方式中解脱出来,现在思考问题的时候,几乎都是带着如何以数据驱动模板的方式思考,因此,了解一下数据是如何驱动模板是一件十分必要的事情。 关于双向绑定一词(mvvm),可以分开来解读,即:如何监...
本课题研究承接浏览器的渲染机制一文,写文章前感谢一下趣链大佬的面试点播,虽然不知道听来了什么,哈哈~
依旧是放上详细的参考链接
用了vue几个月后,写前端的思维方式逐渐从操作dom的方式中解脱出来,现在思考问题的时候,几乎都是带着如何以数据驱动模板的方式思考,因此,了解一下数据是如何驱动模板是一件十分必要的事情。
关于双向绑定一词(mvvm),可以分开来解读,即:如何监听数据改变,和如何监听dom事件。监听dom事件,如input的输入事件,你可以用原生的addEventListener,或者在dom节点上绑定oninput事件。那么如何监听数据的变化呢?如果从代码的角度出发,大概可以有这样一种猜想:
第一步:记录每一个数据的旧值
第二步:用一个定时器不断地监听这个值的新值(不管数据有没有更新),并和旧值进行比较。
第三步:当值发生改变,操作对应的dom节点。
这三步实现起来并不复杂,就是有点蠢。
扯淡到此为止,进入正题,vue中是如何实现数据双向绑定的 —— defineProperty
Object.defineProperty(obj, prop, descriptor)
obj:要在其上定义属性的对象。
prop:要定义或修改的属性的名称。
descriptor:将被定义或修改的属性描述符。
没有用过的程序猿到这里已经是一脸懵逼了,下面我举个例子分别解释下这三个参数
首先是obj,顾名思义,这个参数是个对象,可以是: 身份信息 = { 性别:'女',姓名:'晓甜甜',住址:‘XXX公寓‘ }
然后是prop,这是你要关注的属性,可以是 '性别'
最后这个descriptor比较复杂,你可以理解为,你关注这个对象的某个属性后,要对这个属性做什么事情,最常见的有,你要获取这个对象的属性的值(get),或者你想要修改这个对象的属性的值(set),来看一段代码
let person = {
'sex':'girl',
'name':'晓甜甜',
'address':'XXX公寓'
}
function observe(obj,prop,val){
Object.defineProperty(obj,prop,{
enumerable: true,
configurable: true,
get:function(){
return '此人住在'+val
},
set:function(newVal){
val = newVal
console.log('此人换新家了,现在在' + newVal)
}
})
}
observe(person,'address',person['address'])
console.log(person.address)
person.address = 'YYY小区'
//控制台打印
//此人住在XXX公寓
//此人换新家了,现在在YYY小区
结果如上面说的,当你想要监听person的address属性,并且重写了他的get和set方法后,控制台会打印和理论上不同的结果,你这种方法称为数据劫持,这是数据驱动模板的核心部分,也就是你已经可以成功监听到数据的变化了,并且在数据变化触发了set后,你可以重写里面的方法,干一些你想干的事情,比如更新dom。
注意,这里我用了observe封装了这部分代码,来看如果不用observe会发生什么事情(写代码,尽量不要偷懒,除非你很熟)
let person = {
'sex':'girl',
'name':'晓甜甜',
'address':'XXX公寓'
}
Object.defineProperty(person,'address',{
enumerable: true,
configurable: true,
get:function(){
return '此人住在'+ this.address
},
set:function(newVal){
val = newVal
console.log('此人换新家了,现在在' + newVal)
}
})
observe(person,'address',person['address'])
console.log(person.address)
person.address = 'YYY小区'
//控制台直接报错了!!!!
这里我偷了个懒,没错我一开始为了做演示就是这样写的,我利用了get回调中的this指向person,想要通过this.address去访问person的address,这里就造成了一个死循环,你在访问当前属性的时候的访问回调里又访问了当前属性,就卡死了,当然浏览器比较厉害,他会帮你发现这个错误,直接给你报错,同时这个错误也验证了,一旦你要访问该对象的某个属性,不管什么情况下他都会触发get回调,同理,当你要改变这个值的时候,他就会触发set,你可以在set回调里去改变这个属性的值,同样会发生死循环。
最后输出一下person对象,可以看到,address的get和set已经被劫持了。
数据双向绑定的数据监听已经搞定了,下面来了解一下如何实现一个简单的双向绑定。先上一张图,看一下整体设计思路。
先来解释一下上面这张图,理一下整体设计思路。
第一步:我们需要一个observer,他可以帮我们监听所有数据。我们还需要一个compile,他可以帮我们解析虚拟dom的模板,如v-on,v-if,{{}}等,你可以用正则的方式去匹配这些特殊字符。
第二步:我们已经监听到了数据,我们也有了模板解析器,这时候我们需要将数据和模板联动起来,因此我们需要一个订阅者watcher,在数据和模板初始化的他能将这两者联系起来,具体如何联系,下面会详细说明。
确立了大致的设计思路后,来一个个实现这些组件,并实现一个简易的mvvm。
1.实现observer
首先,observer的功能应该是监听所有数据,实现所借助的工具是Object.defineProperty(obj, prop, descriptor),在刚才的基础上,用递归的方法,来实现一个对象所有属性的监听。
let person = {
'sex':'girl',
'name':'晓甜甜',
'address':'XXX公寓',
'parent':{
'father':'小明',
'mother':'李红'
}
}
//监听数据对象的所有属性值
function observe(data){
if(!data||typeof(data)!=='object'){
return
}
// Object.keys(data) 和 for...in的遍历差不多,该函数返回一个对象包含的所有属性的数组
Object.keys(data).forEach((key)=>{
dataHijacking(data,key,data[key]) //用数据劫持改写get和set方法
})
}
function dataHijacking(obj,prop,val){
observe(val) //递归监听
Object.defineProperty(obj,prop,{
enumerable: true,
configurable: true,
get:function(){
console.log('get val:'+val)
return val
},
set:function(newVal){
if(val === newVal){
return
}
val = newVal
console.log('set newVal')
}
})
}
observe(person)
let receive = person.parent.father //触发两次get
person.parent.mother = '小红' //触发一次get,和一次set
2.实现dependence
在思路说明中,watcher用于连接数据更新和模板更新。而Dep(dependence的缩写)则用于watcher和数据更新的关联。为什么需要Dep这个媒介呢?先不说原理,先来看个场景说明:
observer在一家研究所工作,他负责所有研究数据的监管工作,一旦有一项研究数据发生了变更,他就需要通知所有关注这项研究的订阅者(watcher),数据发生了变更,具体怎么办你们自己看着办。那么,订阅者如何加入这项研究呢,或者说,observer观测到数据变更后应该通知哪些watcher呢?这个时候就需要一个管理员(dep),统一管理订阅者,订阅者通过加入管理员的名单,来和数据源的更新进行沟通。一旦有数据发生变更,只需要通知管理员(dep),管理员负责通知所有订阅者。
下面是dependence的实现思路
function dataHijacking(obj,prop,val){
observe(val) //递归监听
let dep = new dependence()
Object.defineProperty(obj,prop,{
enumerable: true,
configurable: true,
get:function(){
dep.addwatcher(watcher) //通过触发get回调添加订阅者watcher
return val
},
set:function(newVal){
if(val === newVal){
return
}
val = newVal
dep.notify() //数据更新时通过依赖函数通知所有订阅者
}
})
}
function dependence(){
this.watchers = [] //用一个数组用于存储watcher
}
dependence.prototype = {
addwatcher:function(watcher){
this.watchers.push(watcher)
},
notify:function(){
this.watchers.forEach((watcher)=>{
watcher.update() //通知所有订阅者触发更新函数
})
}
}
dependence.target = null //用于缓存wacher
在上面的代码中,dependence包含一个用于存储订阅者的数组,订阅者需要通过触发数据的get回调将自己添加到某个数据的管理员(dep)中去,至于如何触发,会在watcher的实现中说明,当数据更新时,会通过dep通知订阅该数据的所有wacher,需要调用watcher的更新函数。
3.实现watcher
我们知道watcher可以收到属性的变化通知并执行相应的函数,从而更新视图。在这之前,我们应当将watcher加入到对应的dep维护的数组中去,才能收到通知,在dep的实现思路中,我们是通过触发data的get回调来实现添加订阅者的操作的,来看一下具体代码和注释。
function dataHijacking(obj,prop,val){
observe(val) //递归监听
let dep = new dependence()
Object.defineProperty(obj,prop,{
enumerable: true,
configurable: true,
get:function(){
if(dependence.target){
dependence.addwatcher(dep.target) //通过触发get回调添加订阅者watcher
}
return val
},
set:function(newVal){
if(val === newVal){
return
}
val = newVal
dep.notify() //数据更新时通过依赖函数通知所有订阅者
}
})
}
function watcher(vm,key,callback){
this.callback = callback //执行回调,这里可以动态操作相关dom
this.vm = vm //这个参数一般会保留全局的vm,用于访问data
this.key = key //确定自己是哪个数据的订阅者
this.value = this.get() //通过调用这个方法将自己添加到依赖数组中去,同时缓存旧值
}
watcher.prototype = {
update:function(){
let value = this.vm.data[this.key] //一般我们都会操作实例vue上挂载的data
let oldVal = this.value //初始化时候的旧值
if(oldVal!==value){
//如果这两个值不相等,才触发回调,也就是dom操作,这有利于优化性能
this.value = value
this.callback.call(this.vm, value, oldVal)
}
},
get:function(){
dependence.target = this //将自己缓存,准备添加到依赖中去
let value = this.vm.data[this.key] //这个操作会触发observe的get
dependence.target = null // 释放自己
return value
}
}
4.实现一个数据驱动模板的单向绑定
数据双向绑定的事情到上面三步完成后已经完成一半了,也就是数据的单向绑定,来写个demo,演示一下成果。
<div id="demo"></div>
<script type="text/javascript">
let person = {
'sex':'girl',
'name':'晓甜甜',
'address':'XXX公寓',
'parent':{
'father':'小明',
'mother':'李红'
}
}
function mvvm(data,el,key){
this.data = data
observe(data)
el.innerHTML = this.data[key] //初始化模板
new watcher(this,key,function(newValue){
el.innerHTML = newValue
})
return this //返回实例,主要是为了可供全局查看当前实例
}
let el = document.getElementById('demo')
let vm = new mvvm(person,el,'name')
setTimeout(function(){
vm.data.name = '饭甜甜'
},2000)
//2s 后div的innerHTML从 晓甜甜 --> 饭甜甜
在实际使用过程中,我们希望通过访问vm.name而不是vm.data.name的方式去访问属性值,在这里可以优化一下,也就是重写mvvm()中data的set和get即可。
function mvvm(data,el,key){
let self = this
this.data = data
observe(data)
Object.keys(data).forEach(function(key) {
self.proxyKeys(key) // 绑定代理属性
})
el.innerHTML = this.data[key]
new watcher(this,key,function(newValue){
el.innerHTML = newValue
})
return this //返回实例,主要是为了可供全局查看当前实例
}
mvvm.prototype = {
proxyKeys:function(key){
let self = this
Object.defineProperty(this,key,{
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key]
},
set: function proxySetter(newVal) {
self.data[key] = newVal
}
})
}
}
let el = document.getElementById('demo')
let vm = new mvvm(person,el,'name')
setTimeout(function(){
vm.name = '饭甜甜'
},2000)
5.实现compile
complime的作用是解析虚拟dom树(当然现在还没有引入虚拟dom的概念)中的vue指令,如v-on,v-model之类的,然后将解析完成后,通过watcher监听数据初始化及其变化,最终通过回调函数更改dom数据。这样observer -- watcher -- complime就串联起来了。
function compile(el,vue){
this.vm = vue
this.compileElement(el)
}
compile.prototype = {
compileElement:function(el){
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {
var reg = /\{\{(.*)\}\}/;
var text = node.textContent;
if (self.isTextNode(node) && reg.test(text)) { // 判断是否是符合这种形式{{}}的指令
self.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
},
compileText:function(node, exp) {
var self = this;
var initText = this.vm[exp];
this.updateText(node, initText); // 将初始化的数据初始化到视图中
new watcher(this.vm, exp, function (value) { // 生成订阅器并绑定更新函数
self.updateText(node, value);
});
},
updateText:function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
isTextNode: function (node) {
return node.nodeType === 3
}
}
最终代码:
<div id="demo">
<div>{{name}}</div>
<div>{{address}}</div>
</div>
<script type="text/javascript">
let person = {
'sex':'girl',
'name':'晓甜甜',
'address':'XXX公寓',
'parent':{
'father':'小明',
'mother':'李红'
}
}
//监听数据对象的所有属性值
function observe(data){
if(!data||typeof(data)!=='object'){
return
}
// Object.keys(data) 和 for...in的遍历差不多,该函数返回一个对象包含的所有属性的数组
Object.keys(data).forEach((key)=>{
dataHijacking(data,key,data[key]) //用数据劫持改写get和set方法
})
}
function dataHijacking(obj,prop,val){
observe(val) //递归监听
let dep = new dependence()
Object.defineProperty(obj,prop,{
enumerable: true,
configurable: true,
get:function(){
if(dependence.target){
dep.addwatcher(dependence.target) //通过触发get回调添加订阅者watcher
}
return val
},
set:function(newVal){
if(val === newVal){
return
}
val = newVal
dep.notify() //数据更新时通过依赖函数通知所有订阅者
}
})
}
// 依赖,用于联系watcher和observer
function dependence(){
this.watchers = [] //用一个数组用于存储watcher
}
dependence.prototype = {
addwatcher:function(watcher){
this.watchers.push(watcher)
},
notify:function(){
this.watchers.forEach((watcher)=>{
watcher.update() //通知所有订阅者触发更新函数
})
}
}
dependence.target = null
function watcher(vm,key,callback){
this.callback = callback //执行回调,这里可以动态操作相关dom
this.vm = vm //这个参数一般会保留全局的vm,用于访问data
this.key = key //确定自己是哪个数据的订阅者
this.value = this.get() //通过调用这个方法将自己添加到依赖数组中去,同时缓存旧值
}
watcher.prototype = {
update:function(){
let value = this.vm.data[this.key] //一般我们都会操作实例vue上挂载的data
let oldVal = this.value //初始化时候的旧值
if(oldVal!==value){
//如果这两个值不相等,才触发回调,也就是dom操作,这有利于优化性能
this.value = value
this.callback.call(this.vm, value, oldVal)
}
},
get:function(){
dependence.target = this //将自己缓存,准备添加到依赖中去
let value = this.vm.data[this.key] //这个操作会触发observe的get
dependence.target = null // 释放自己
return value
}
}
function compile(el,vue){
this.vm = vue
this.compileElement(el)
}
compile.prototype = {
compileElement:function(el){
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {
var reg = /\{\{(.*)\}\}/;
var text = node.textContent;
if (self.isTextNode(node) && reg.test(text)) { // 判断是否是符合这种形式{{}}的指令
self.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
},
compileText:function(node, exp) {
var self = this;
var initText = this.vm[exp];
this.updateText(node, initText); // 将初始化的数据初始化到视图中
new watcher(this.vm, exp, function (value) { // 生成订阅器并绑定更新函数
self.updateText(node, value);
});
},
updateText:function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
isTextNode: function (node) {
return node.nodeType === 3
}
}
function mvvm(data,el){
let self = this
this.data = data
observe(data)
Object.keys(data).forEach(function(key) {
self.proxyKeys(key) // 绑定代理属性
})
new compile(el,this)
return this //返回实例,主要是为了可供全局查看当前实例
}
mvvm.prototype = {
proxyKeys:function(key){
let self = this
Object.defineProperty(this,key,{
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key]
},
set: function proxySetter(newVal) {
self.data[key] = newVal
}
})
}
}
let el = document.getElementById('demo')
let vm = new mvvm(person,el)
setTimeout(function(){
vm.name = '饭甜甜'
},2000)
</script>
FBI warning:本文中提到的双向数据绑定是有问题的,因为这里的compile维护的是真实的dom树,也就是说,如果你用for循环修改一万次vm.name,那么dom就会被更新一万次,因为他会触发dom.node.contentText change一万次。事实上,在vue中,watcher如果检测到同一个值在一个'事件周期'中被修改多次,只触发最后一次,并且先去修改虚拟dom,并一次性渲染(红色字体部分是结合官网文档的一些个人猜想,还有待探究源码证明)。不管怎么说,双向数据绑定的原理差不多就是这样,有兴趣深入的可以继续关注后续文章。
更多推荐
所有评论(0)