相信只要去面试 Vue
,都会被用到 vue
的双向数据绑定,你若是只说个 mvvm
就是视图模型模型视图,只要数据改变视图也会同步更新,那可能达不到面试官想要的那个层次。甚至能够说这一点就让面试官以为你知识了解的还不够,只是粗略地明白双向绑定这个概念。javascript
本博客旨在经过一个简化版的代码来对 mvvm
理解更加深入,如若存在问题,欢迎评论提出,谢谢您!html
最后,但愿你给一个点赞或 star
:star:,谢谢您的支持!前端
实现源码传送门vue
同时,也会收录在小狮子前端笔记仓库里 ✿✿ヽ(°▽°)ノ✿java
小狮子前端の学习整理笔记 Front-end-learning-to-organize-notesnode
实现效果: git
目前几种主流的 mvc(vm)
框架都实现了单向数据绑定,即用数据操做视图,数据更新,视图同步更新。而双向数据绑定无非就是在单向绑定的基础上给可输入元素(如 input
、textarea
等)添加了 change(input)
事件,来动态修改 model
和 view
,这样就能用视图来操做数据了,即视图更新,数据同步更新。github
实现数据绑定的作法大体有以下几种:面试
发布者-订阅者模式(backbone.js) 脏值检查(angular.js)将旧值和新值进行比对,若是有变化的话,就会更新视图,最简单的方式就是经过
setInterval()
定时轮询检测数据变更。 数据劫持(vue.js)c#
发布者-订阅者模式:通常经过 sub
,pub
的方式实现数据和视图的绑定监听,更新数据方式一般作法是 vm.set('property', value)
但上述方式对比如今来讲知足不了咱们须要了,咱们更但愿经过 vm.property = value
这种方式更新数据,同时自动更新视图,因而有了下面两种方式:
脏值检测: angular.js
是经过脏值检测的方式比对数据是否变动,来决定是否更新视图,最简单的方式就是经过 setInterval()
定时轮询检测数据变更。固然,它只在指定的事件触发时才进入脏值检测,大体以下:
DOM
事件,譬如用户输入文本,点击按钮等。(ng-click
)XHR
响应事件($http
)Location
变动事件($location
)Timer
事件($timeout
, $interval
)数据劫持: vue.js
则是采用数据劫持结合发布者-订阅者模式的方式,经过 object.defineProperty()
来劫持各个属性的 setter
、getter
,在数据变更时发布消息给订阅者,触发相应的监听回调。
mvvm
的双向绑定要实现 mvvm
的双向绑定,就必需要实现如下几点:
Compile
,对每一个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数Observer
,可以对数据对象的全部属性进行监听,若有变更可拿到最新值并通知订阅者Watcher
,做为链接Observer
和Compile
的桥梁,可以订阅并收到每一个属性变更的通知,执行指令绑定的相应回调函数,从而更新视图mvvm
入口函数,整合以上三者整合流程图以下图所示:
compile
主要作的事情是解析模板指令,将模板中的变量替换成数据,而后初始化渲染页面视图,并将每一个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变更,收到通知,更新视图,以下图所示:
由于遍历解析的过程有屡次操做 dom
节点,为提升性能和效率,会先将 vue
实例根节点的 el
转换成文档碎片fragment
进行解析编译操做,解析完成,再将fragment
添加回原来的真实dom
节点中。
html
页面引入咱们从新写的 myVue.js
<script src="./myVue.js"></script>
复制代码
myVue
类建立一个 myVue
类,构造函数以下所示,将页面的挂载 el
、数据 data
、操做集 options
进行保存。
class myVue{
constructor(options){
this.$el = options.el
this.$data = options.data
this.$options = options
if(this.$el){
// 1.实现数据观察者(省略...)
// 2.实现指令解析器
new Compile(this.$el,this)
}
// console.log(this)
}
}
复制代码
Compile
类具体实现步骤:
query
dom
节点,目的是减小页面的回流和重绘class Compile{
constructor(el,vm){
// 判断是否为元素节点,若是不是就query
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 一、获取文档碎片对象,放入内存中,会减小页面的回流和重绘
const fragment = this.node2Fragment(this.el)
// 二、编译模板
this.compile(fragment)
// 三、追加子元素到根元素
this.el.appendChild(fragment)
}
复制代码
判断是否为元素节点,直接判断nodeType是否为1便可
isElementNode(node){
return node.nodeType === 1
}
复制代码
经过 document.createDocumentFragment()
建立文档碎片对象,经过 el.firstChild
是否还存在来判断,而后将 dom
节点添加到文档碎片对象中,最后 return
node2Fragment(el){
// 建立文档碎片对象
const fragment = document.createDocumentFragment()
let firstChild
while(firstChild = el.firstChild){
fragment.appendChild(firstChild)
}
return fragment
}
复制代码
解析模板时,会获取获得全部的子节点,此时分两种状况,即元素
节点和文本
节点。若是当前节点还存在子节点,则须要经过递归操做来遍历其子节点。
compile(fragment){
// 一、获取全部子节点
const childNodes = fragment.childNodes;
[...childNodes].forEach(child=>{
// console.log(child)
// 若是是元素节点,则编译元素节点
if(this.isElementNode(child)){
// console.log('元素节点',child)
this.compileElement(child)
}else{
// 其它为文本节点,编译文本节点
// console.log('文本节点',child)
this.compileText(child)
}
if(child.childNodes && child.childNodes.length){
this.compile(child)
}
})
}
复制代码
节点 node
上有一个 attributes
属性,来获取当前节点的全部属性,经过是否以 v-
开头来判断当前属性名称是否为一个指令。若是是一个指令的话,还需进行分类编译,用数据来驱动视图。更新数据完毕后,再经过 removeAttribute
事件来删除指令上标签的属性。
若是是非指令的话,例如事件 @click="sayHi"
,仅需经过指令 v-on
来实现便可。
对于不一样的指令,咱们最好进行一下封装,这里就巧妙运用了 策略模式 。
compileElement(node){
const attributes = node.attributes;
[...attributes].forEach(attr=>{
// console.log(attr)
const {name,value} = attr;
// console.log(name,value)
// 判断当前name值是否为一个指令,经过是否以 'v-' 开头来判断
if(this.isDirective(name)){
// console.log(name.split('-'))
const [,directive] = name.split('-') // text html model on:click
// console.log(directive)
const [dirName,eventName] = directive.split(':') // text html model on
// 更新数据 数据驱动视图
complieUtil[dirName](node,value,this.vm,eventName)
// 删除指令上标签上的属性
node.removeAttribute('v-' + directive)
}else if(this.isEventName(name)){ // @click="sayHi"
let [,eventName] = name.split('@')
complieUtil['on'](node,value,this.vm,eventName)
}
})
}
复制代码
判断当前 attrName
是否为一个指令,仅需判断是否以 v-
开头
isDirective(attrName){
return attrName.startsWith('v-')
}
复制代码
判断当前 attrName
是否为一个事件,就看是否以'@'
开头的事件绑定
isEventName(attrName){
return attrName.startsWith('@')
}
复制代码
指令处理集合
const complieUtil = {
getVal(expr,vm){
return expr.split('.').reduce((data,currentVal)=>{
// console.log(currentVal)
return data[currentVal]
},vm.$data)
},
text(node,expr,vm){
let value;
// 元素节点
if(expr.indexOf('{{') !== -1){
value = expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
return this.getVal(args[1],vm);
})
}else{ // 文本节点
value = this.getVal(expr,vm)
}
this.updater.textUpdater(node,value)
},
html(node,expr,vm){
const value = this.getVal(expr,vm)
this.updater.htmlUpdater(node,value)
},
model(node,expr,vm){
const value = this.getVal(expr,vm)
this.updater.modelUpdater(node,value)
},
on(node,expr,vm,eventName){
let fn = vm.$options.methods && vm.$options.methods[expr]
// 一、让fn经过bind函数指向原来的vm 二、默认冒泡
node.addEventListener(eventName,fn.bind(vm),false)
},
bind(node,expr,vm,attrName){
},
// 更新的函数
updater:{
textUpdater(node,value){
node.textContent = value
},
htmlUpdater(node,value){
node.innerHTML = value
},
modelUpdater(node,value){
node.value = value
}
}
}
复制代码
利用 Obeject.defineProperty()
来监听属性变更,那么将须要 observe
的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter
和 getter
。这样的话,给这个对象的某个值赋值,就会触发 setter
,那么就能监听到了数据变化。具体代码以下:
class Observer{
constructor(data){
this.observe(data)
}
observe(data){
if(data && typeof data === 'object'){
// console.log(Object.keys(data))
// 进行数据劫持
Object.keys(data).forEach(key=>{
this.defineReactive(data,key,data[key])
})
}
}
defineReactive(obj,key,value){
// 递归遍历
this.observe(value)
Object.defineProperty(obj,key,{
enumerable: true,
configurable: false,
get(){
// 订阅数据变化时,往Dep中添加观察者,进行依赖收集
return value
},
// 经过箭头函数改变this指向到class Observer
set:(newVal)=>{
this.observe(newVal)
if(newVal !== value){
value = newVal
}
}
})
}
}
复制代码
data
示例以下:
data: {
person:{
name: 'Chocolate',
age: 20,
hobby: '写代码'
},
msg: '超逸の技术博客',
htmlStr: '<h3>欢迎一块儿学习~</h3>'
},
复制代码
Watcher
订阅者做为
Observer
和
Compile
之间通讯的桥梁,主要作的事情是:
dep
)里面添加本身update()
方法dep.notify()
通知时,能调用自身的 update()
方法,并触发 Compile
中绑定的回调。Watcher
订阅者实例化 Watcher
的时候,调用 getOldVal()
方法,来获取旧值。经过 Dep.target = watcherInstance(this)
标记订阅者是当前 watcher
实例(即指向本身)。
class Watcher{
constructor(vm,expr,cb){
this.vm = vm
this.expr = expr
this.cb = cb
// 先将旧值进行保存
this.oldVal = this.getOldVal()
}
getOldVal(){
// 将当前订阅者指向本身
Dep.target = this
// 获取旧值
const oldVal = complieUtil.getVal(this.expr,this.vm)
// 添加完毕,重置
Dep.target = null
return oldVal
}
// 比较新值与旧值,若是有变化就更新视图
update(){
const newVal = complieUtil.getVal(this.expr,this.vm)
// 若是新旧值不相等,则将新值callback
if(newVal !== this.oldVal){
this.cb(newVal)
}
}
}
复制代码
强行触发属性定义的 get
方法,get
方法执行的时候,就会在属性的订阅器 dep
添加当前watcher
实例,从而在属性值有变化的时候,watcherInstance(this)
就能收到更新通知。
// 上文省略...
defineReactive(obj,key,value){
// 递归遍历
this.observe(value)
const dep = new Dep()
Object.defineProperty(obj,key,{
enumerable: true,
configurable: false,
get(){
// 订阅数据属性时,往Dep中添加观察者,进行依赖收集
Dep.target && dep.addSub(Dep.target)
return value
},
// 经过箭头函数改变this指向到class Observer
set:(newVal)=>{
this.observe(newVal)
if(newVal !== value){
value = newVal
// 若是新旧值不一样,则告诉Dep通知变化
dep.notify()
}
}
})
}
复制代码
dep
主要作两件事情:
class Dep{
constructor(){
this.subs = []
}
// 收集观察者
addSub(watcher){
this.subs.push(watcher)
}
// 通知观察者去更新
notify(){
console.log('观察者',this.subs);
this.subs.forEach(watcher => watcher.update())
}
}
复制代码
Compile.js
文件作完上述事情后,此时,当咱们修改某个数据时,数据已经发生了变化,可是视图没有更新。那咱们在何时来添加绑定 watcher
呢?请继续看下图
也就是说,当咱们订阅数据变化时,来绑定更新函数,从而让 watcher
去更新视图。此时咱们修改咱们本来的 Compile.js
文件以下:
// 指令处理集合
const complieUtil = {
getVal(expr,vm){
return expr.split('.').reduce((data,currentVal)=>{
// console.log(currentVal)
return data[currentVal]
},vm.$data)
},
// 获取新值 对{{a}}--{{b}} 这种格式进行处理
getContentVal(expr,vm){
return expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
// console.log(args[1]);
return this.getVal(args[1],vm);
})
},
text(node,expr,vm){
let value;
if(expr.indexOf('{{') !== -1){
value = expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
// 绑定watcher从而更新视图
new Watcher(vm,args[1],()=>{
this.updater.textUpdater(node,this.getContentVal(expr,vm))
// console.log(expr);
})
return this.getVal(args[1],vm);
})
}else{ // 也多是v-text='obj.name' v-text='msg'
value = this.getVal(expr,vm)
}
this.updater.textUpdater(node,value)
},
html(node,expr,vm){
const value = this.getVal(expr,vm)
new Watcher(vm,expr,(newVal)=>{
this.updater.htmlUpdater(node,newVal)
})
this.updater.htmlUpdater(node,value)
},
model(node,expr,vm){
const value = this.getVal(expr,vm)
// 订阅数据变化时 绑定更新函数 更新视图的变化
// 数据==>视图
new Watcher(vm,expr,(newVal)=>{
this.updater.modelUpdater(node,newVal)
})
this.updater.modelUpdater(node,value)
},
on(node,expr,vm,eventName){
let fn = vm.$options.methods && vm.$options.methods[expr]
// 一、让fn经过bind函数指向原来的vm 二、默认冒泡
node.addEventListener(eventName,fn.bind(vm),false)
},
bind(node,expr,vm,attrName){
let attrVal = this.getVal(expr,vm)
this.updater.attrUpdater(node,attrName,attrVal)
},
// 更新的函数
updater:{
textUpdater(node,value){
node.textContent = value
},
htmlUpdater(node,value){
node.innerHTML = value
},
modelUpdater(node,value){
node.value = value
},
attrUpdater(node, attrName, attrVal){
node.setAttribute(attrName,attrVal)
}
}
}
class Compile{
constructor(el,vm){
// 判断是否为元素节点,若是不是就query
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 一、获取文档碎片对象,放入内存中,会减小页面的回流和重绘
const fragment = this.node2Fragment(this.el)
// 二、编译模板
this.compile(fragment)
// 三、追加子元素到根元素
this.el.appendChild(fragment)
}
// 判断是否为元素节点,直接判断nodeType是否为1便可
isElementNode(node){
return node.nodeType === 1
}
node2Fragment(el){
// 建立文档碎片对象
const fragment = document.createDocumentFragment()
let firstChild
while(firstChild = el.firstChild){
fragment.appendChild(firstChild)
}
return fragment
}
compile(fragment){
// 一、获取全部子节点
const childNodes = fragment.childNodes;
[...childNodes].forEach(child=>{
// console.log(child)
// 若是是元素节点,则编译元素节点
if(this.isElementNode(child)){
// console.log('元素节点',child)
this.compileElement(child)
}else{
// 其它为文本节点,编译文本节点
// console.log('文本节点',child)
this.compileText(child)
}
if(child.childNodes && child.childNodes.length){
this.compile(child)
}
})
}
// 编译元素节点
compileElement(node){
const attributes = node.attributes;
[...attributes].forEach(attr=>{
// console.log(attr)
const {name,value} = attr;
// console.log(name,value)
// 判断当前name值是否为一个指令,经过是否以 'v-' 开头来判断
if(this.isDirective(name)){
// console.log(name.split('-'))
const [,directive] = name.split('-') // text html model on:click
// console.log(directive)
const [dirName,eventName] = directive.split(':') // text html model on
// 更新数据 数据驱动视图
complieUtil[dirName](node,value,this.vm,eventName)
// 删除指令上标签上的属性
node.removeAttribute('v-' + directive)
}else if(this.isEventName(name)){ // @click="sayHi"
let [,eventName] = name.split('@')
complieUtil['on'](node,value,this.vm,eventName)
}
})
}
// 编译文本节点
compileText(node){
// {{}} v-text
// console.log(node.textContent)
const content = node.textContent
if(/\{\{(.+?)\}\}/.test(content)){
// console.log(content)
complieUtil['text'](node,content,this.vm)
}
}
isDirective(attrName){
return attrName.startsWith('v-')
}
// 判断当前attrName是否为一个事件,以'@'开头的事件绑定
isEventName(attrName){
return attrName.startsWith('@')
}
}
复制代码
此时,咱们就能经过数据变化来驱动视图了,例如更改咱们的年龄 age
从原来的 20
设置为 22
,以下图所示,发现数据更改, watcher
去更新了视图。
有了以前的代码与流程图结合,我想对于Vue
源码分析应该更加了解了,那么咱们再次来梳理一下咱们学习的知识点。依旧是结合下面流程图:
Compile
来
解析指令,找到
{{xxx}}
、指令、事件、绑定等等,而后再
初始化视图。但此时还有一件事情没作,就是当数据发生变化的时候,在更新数据以前,咱们还要
订阅数据变化,绑定更新函数,此时就须要加入订阅者
Watcher
了。当订阅者观察到数据变化时,就会触发
Updater
来更新视图。
固然,建立 Watcher
的前提时要进行数据劫持来监听全部属性,因此建立了 Observer.js
文件。在 get
方法中,须要给 Dep
通知变化,此时就须要将 Dep
的依赖收集关联起来,而且添加订阅者 Watcher
(这个 Watcher
在 Complie
订阅数据变化,绑定更新函数时就已经建立了的)。此时 Dep
订阅器里就有不少个 Watcher
了,有多少个属性就对应有多少个 Watcher
。
那么,咱们举一个简单例子来走一下上述流程图:
假设本来 data
数据中有一个 a:1
,此时咱们进行更新为 a:10
,因为早已经对咱们的数据进行了数据劫持而且监听了全部属性,此时就会触发 set
方法,在 set
方法里就会通知 Dep
订阅器发生了变化,而后就会通知相关 Watcher
触发 update
函数来更新视图。而这些订阅者 Watcher
在 Complie
订阅数据变化,绑定更新函数时就已经建立了。
上述,咱们基本完成了数据驱动视图,如今咱们来完成一下经过视图的变化来更新数据,真正实现双向数据绑定的效果。
在咱们 complieUtil
指令处理集合中的 model
模块,给咱们当前节点绑定一个 input
事件便可。咱们能够经过 e.target.value
来获取当前 input
输入框的值。而后比对一下旧值和新值是否相同,若是不一样的话,就得须要更新,调用 setVal
方法(具体见下文代码)。
model(node,expr,vm){
let value = this.getVal(expr,vm)
// 订阅数据变化时 绑定更新函数 更新视图的变化
// 数据==>视图
new Watcher(vm,expr,(newVal)=>{
this.updater.modelUpdater(node,newVal)
})
// 视图==》数据
node.addEventListener('input',(e)=>{
var newValue = e.target.value
if(value == newValue) return
// 设置值
this.setVal(expr,vm,newValue)
value = newValue
})
this.updater.modelUpdater(node,value)
},
复制代码
setVal
和 getVal
二者没有多大区别,只是 set
时多了一个 inputVal
。它们都是找到最底层 key
值,而后更新 value
值。
getVal(expr,vm){
return expr.split('.').reduce((data,currentVal)=>{
// console.log(currentVal)
return data[currentVal]
},vm.$data)
},
setVal(expr,vm,inputVal){
return expr.split('.').reduce((data,currentVal)=>{
data[currentVal] = inputVal
},vm.$data)
},
复制代码
更新 bug
:在上文,对于 v-text
指令处,咱们遗漏了绑定 Watcher
步骤,如今进行补充。
text(node,expr,vm){
let value;
if(expr.indexOf('{{') !== -1){
value = expr.replace(/\{\{(.+?)\}\}/g, (...args)=>{
// 绑定watcher从而更新视图
new Watcher(vm,args[1],()=>{
this.updater.textUpdater(node,this.getContentVal(expr,vm))
// console.log(expr);
})
return this.getVal(args[1],vm);
})
}else{ // 也多是v-text='obj.name' v-text='msg'
value = this.getVal(expr,vm)
// 绑定watcher从而更新视图
new Watcher(vm,expr,(newVal)=>{
this.updater.textUpdater(node,newVal)
// console.log(expr);
})
}
this.updater.textUpdater(node,value)
},
复制代码
最终,当咱们更改 input
输入框中的值时,发现其余节点也跟着修改,这表明咱们的数据进行了修改,相关订阅者触发了 update
方法,双向绑定功能实现!
咱们在使用 vue
的时候,一般能够直接 vm.msg
来获取数据,这是由于 vue
源码内部作了一层代理.也就是说把数据获取操做 vm
上的取值操做 都代理到 vm.$data
上。
class myVue{
constructor(options){
this.$el = options.el
this.$data = options.data
this.$options = options
if(this.$el){
// 1.实现数据观察者
new Observer(this.$data)
// 2.实现指令解析器
new Compile(this.$el,this)
// 3.实现proxy代理
this.proxyData(this.$data)
}
// console.log(this)
}
proxyData(data){
for(const key in data){
Object.defineProperty(this,key,{
get(){
return data[key]
},
set(newVal){
data[key] = newVal
}
})
}
}
}
复制代码
咱们简单测试一下,例如咱们给 button
绑定一个 sayHi()
事件,经过设置 proxy
作了一层代理后,咱们不须要像后面那样经过 this.$data.person.name
来更改咱们的数据,而直接能够经过 this.person.name
来获取咱们的数据。
methods: {
sayHi() {
this.person.name = '超逸'
//this.$data.person.name = 'Chaoyi'
console.log(this)
}
}
复制代码
请阐述一下你对 MVVM
响应式的理解
vue.js
则是采用数据劫持结合发布者-订阅者模式的方式,经过 Object.defineProperty()
来劫持各个属性的getter
,setter
,在数据变更时发布消息给订阅者,触发相应的监听回调。
MVVM
做为数据绑定的入口,整合Observer
、Compile
和 Watcher
三者,经过Observer
来监听本身的model
数据变化,经过Compile
来解析编译模板指令,最终利用Watcher
搭起Observer
和Compile
之间的通讯桥梁,达到数据变化 -> 视图更新;视图交互变化(input
) -> 数据model
变动的双向绑定效果。
最开始,咱们实现了 Compile
来解析指令,找到 {{xxx}}
、指令、事件、绑定等等,而后再初始化视图。但此时还有一件事情没作,就是当数据发生变化的时候,在更新数据以前,咱们还要订阅数据变化,绑定更新函数,此时就须要加入订阅者Watcher
了。当订阅者观察到数据变化时,就会触发Updater
来更新视图。
固然,建立 Watcher
的前提时要进行数据劫持来监听全部属性,因此建立了 Observer.js
文件。在 get
方法中,须要给 Dep
通知变化,此时就须要将 Dep
的依赖收集关联起来,而且添加订阅者 Watcher
(这个 Watcher
在 Complie
订阅数据变化,绑定更新函数时就已经建立了的)。此时 Dep
订阅器里就有不少个 Watcher
了,有多少个属性就对应有多少个 Watcher
。
那么,咱们举一个简单例子来走一下上述流程图:
假设本来 data
数据中有一个 a:1
,此时咱们进行更新为 a:10
,因为早已经对咱们的数据进行了数据劫持而且监听了全部属性,此时就会触发 set
方法,在 set
方法里就会通知 Dep
订阅器发生了变化,而后就会通知相关 Watcher
触发 update
函数来更新视图。而这些订阅者 Watcher
在 Complie
订阅数据变化,绑定更新函数时就已经建立了。
总算是把这篇长文写完了,字数也是达到将近 1w8。经过学习 Vue MVVM源码
,对于 Vue
双向数据绑定这一块理解也更加深入了。固然,本文书写的代码还算是比较简单,也参考了大佬的博客与代码,同时,也存在不足而且小部分功能没有实现,相较于源码来讲仍是有不少可优化和可重构的地方,那么也欢迎小伙伴们来 PR
。一块儿来动手实现 mvvm
。
本篇博客参考文献 笑马哥:Vue的MVVM实现原理 github:mvvm 视频学习:Vue源码解析