vue双向数据绑定原理
1、什么是数据双向绑定?
数据的双向绑定就是:当数据发生变化时,view同时发生变化;在单页spa中或表单操作中比较适用,常用的mvvm框架中,都存在数据的双向绑定,只是实现的原理不一样。
2、mvvm的数据双向绑定方案对比
Angular:事件触发
原理:其是通过’验脏’实现双向绑定,这种‘验脏’是什么时候触发呢?如DOM事件,XHR事件[ajax异步]时,便会执行‘验脏’。还有一种非常简单的方法实现,那就是使用setTimeout()实现,进行定时检查。当然这样效率、性能都比较差。
Vue:数据劫持
原理:vue的数据双向绑定是通过依赖跟踪与属性劫持实现的。当相应的数据发生变更的时候,对应的视图也随之发生变更,从而实现最小的变化。其技术核心为ES5当中提出的,Object.defineProperty(),通过对数据属性的setter/getter进行劫持,从而实现数据变更时,视图变更。
3、Vue双向数据绑定的实现
下面的代码,与Vue源码比较更为简单,但原理一致。省去了属性深度监控,mutation observer过程等,着重分析数据双向绑定的过程。
3.1、角色
-
观察者
这个角色很重要,它是用来观察数据对应的视图,每个数据对应的视图都有一个相应的观察者,当该数据发生变化时,对相应视图进行更新。这样也就实现了依赖跟踪。 -
订阅器
对每个数据实现一个订阅器,其维护一个观察着队列,当数据发生更新时,相应的订阅器接受到通知,使得相应的观察者进行视图更新。 -
角色与数据之间的关系如下:
图1 角色关系图
如图,以单个属性为中心,其与订阅器为一对一的关系;与观察者为一对多的关系。
- 下面看看两个角色的具体实现
- 订阅器
class Dep {
constructor() {
this.subList = []
}
// 增加订阅者
addDep(watcher) {
if (!watcher) {
return
}
this.subList.push(watcher)
}
// 订阅通知
notify() {
this.subList.forEach((watcher) => {
watcher.update()
})
}
}
其中的数组用来保存对应的观察者watcher;addDep方法,用来增加订阅者,每个订阅者为一个watcher实例。notify方法,用来在数据发生变更时,接收通知,并触发对应的watcher更新视图。
* 观察者
class Watcher {
constructor({name, node, nodeType, vm}) {
Object.assign(this, {
name,
node,
nodeType,
vm
})
}
update() {
if (this.nodeType == 'text') {
this.node.nodeValue = this.vm.data[this.name]
}
if (this.nodeType == 'input') {
this.node.value = this.vm.data[this.name]
}
}
}
在其构造函数中,包含了数据属性名(name),DOM节点(node),节点类型(nodeType),对应的vm实例(vm);从而将数据对应属性与DOM节点建立依赖,从而实现依赖可追踪。另update方法,可实现对应节点的更新。
3.2、劫持
上面有说到劫持,到底是如何实现数据属性的劫持呢?
-
劫持过程如下图所示:
图2 数据劫持过程 -
具体实现如下
_defineReactive(obj, key, val) {
depBuff[key] = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return val
},
set(newValue) {
if (newValue != val) {
val = newValue
depBuff[key].notify()
}
}
})
}
如代码所示,通过使用defineProperty对数据的getter/setter进行劫持,实现变更的监测。当数据被赋值是,set方法被调用,当数据发生更新时,通知对应的订阅器,进行视图更新。
3.3、依赖追踪
在模版编译过程中,实现依赖追踪:
// 使用fragment进行模版编译
_compile(pNode, vm) {
let fragment = document.createDocumentFragment()
let child = null
while(child = pNode.firstChild) {
if (child.children) {
child.appendChild(this._compile(child, vm))
}
this._compileOneNode(child, fragment, vm)
fragment.appendChild(child)
}
return fragment
}
// 遇到特殊命令,或者文本节点类型,则生成观察者,并将其放入订阅器中[可根据特殊命令的格式进行订制]
_compileOneNode(child, fragment, vm) {
// 为tag节点时,查找其属性节点,看看是否有v-model指令
if (child.nodeType == 1) {
let attrs = child.attributes
for (let i = 0; i < attrs.length; i++) {
let item = attrs[i]
if (item.nodeName == 'v-model' && child.nodeName == 'INPUT') {
// 当input输入时,更新vm数据
child.addEventListener('input', function (e) {
vm.data[item.nodeValue] = e.target.value
})
// 生成对应模版的观察者
let watcher = new Watcher({
name: item.nodeValue,
node: child,
nodeType: 'input',
vm: vm
})
// 初始数据编译
watcher.update()
// 将观察者放入订阅器
depBuff[item.nodeValue].addDep(watcher)
// 移除命令属性
child.removeAttribute(item.nodeName)
}
}
}
// 为文本节点时,判断是否有相应的特殊字符匹配;有则标记为文本节点
if (child.nodeType == 3) {
let reg = /\{\{(.*)\}\}/
if (reg.test(child.nodeValue)) {
let name = RegExp.$1
name = name.trim()
let watcher = new Watcher({
name,
node: child,
nodeType: 'text',
vm: vm
})
watcher.update()
depBuff[name].addDep(watcher)
}
}
}
3.4、流程
数据的遍历[劫持] -> 模版编译[依赖追踪]
流程如下图所示:
图3 流程图
代码如下:
class Vue {
constructor({data, $el}) {
Object.assign(this, {
data,
$el
})
// 遍历数据,进行属性劫持
Object.keys(data).forEach((key) => {
this._defineReactive(data, key, data[key])
})
// 进行模板编译,生成追踪依赖
let $elElem = document.querySelector($el)
$elElem.appendChild(this._compile($elElem, this))
}
}
如代码所示,在vue的构造函数中,实现初始化流程:
step1:vue实例化赋值;
step2:遍历data,实现属性值的劫持设置;
step3:模版编译,实现数据的依赖追踪。
在step2中,借助ES5的Object.defineProperty()实现。
在step3中,模版编译过程借助了documentFragment进行DOM碎片的管理;其编译的特殊命令(模式)匹配可根据具体业务进行订制。
3.5、完整代码
html代码
<div id="demo">
<div style="margin-bottom: 40px">
<p>姓名:<input id="name" type="text" v-model="name" /></p>
<p>学号:<input id="number" type="text" v-model="number" /></p>
<p>勋章:<input id="metal" type="text" v-model="metal" /></p>
<p>等级:<input id="level" type="text" v-model="level" /></p>
</div>
<div>
<p>姓名:<span>{{name}}</span></p>
<p>学号:<span>{{number}}</span></p>
<p>勋章:<span>{{metal}}</span></p>
<p>等级:<span>{{level}}</span></p>
<p>签名:<span>{{name}}</span></p>
</div>
<button>点我提交</button>
</div>
js代码
let depBuff = {}
class Vue {
constructor({data, $el}) {
Object.assign(this, {
data,
$el
})
// 生成每个数据的订阅器,并在数据被set的时候,如有数据更新,则发布通知
Object.keys(data).forEach((key) => {
this._defineReactive(data, key, data[key])
})
// 进行模板编译,并对模版中的数据,增加观察着,并将观察者加入响应的订阅器当中
let $elElem = document.querySelector($el)
$elElem.appendChild(this._compile($elElem, this))
}
// 使用fragment进行模版编译
_compile(pNode, vm) {
let fragment = document.createDocumentFragment()
let child = null
while(child = pNode.firstChild) {
if (child.children) {
child.appendChild(this._compile(child, vm))
}
this._compileOneNode(child, fragment, vm)
fragment.appendChild(child)
}
return fragment
}
// 遇到特殊命令,或者文本节点类型,则增加观察者,放入订阅器
_compileOneNode(child, fragment, vm) {
// 为tag节点时,查找其属性节点,看看是否有v-model指令
if (child.nodeType == 1) {
let attrs = child.attributes
for (let i = 0; i < attrs.length; i++) {
let item = attrs[i]
if (item.nodeName == 'v-model' && child.nodeName == 'INPUT') {
// 当input输入时,更新vm数据
child.addEventListener('input', function (e) {
vm.data[item.nodeValue] = e.target.value
})
// 生成对应模版的观察者
let watcher = new Watcher({
name: item.nodeValue,
node: child,
nodeType: 'input',
vm: vm
})
watcher.update()
// 将观察者放入订阅器
depBuff[item.nodeValue].addDep(watcher)
child.removeAttribute(item.nodeName)
}
}
}
if (child.nodeType == 3) {
let reg = /\{\{(.*)\}\}/
if (reg.test(child.nodeValue)) {
let name = RegExp.$1
name = name.trim()
let watcher = new Watcher({
name,
node: child,
nodeType: 'text',
vm: vm
})
watcher.update()
depBuff[name].addDep(watcher)
}
}
}
// 对每个数据生成对应的订阅器,并进行修改劫持
_defineReactive(obj, key, val) {
depBuff[key] = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return val
},
set(newValue) {
if (newValue != val) {
val = newValue
depBuff[key].notify()
}
}
})
}
}
// 观察者:观察模版及数据的一一对应,一个数据存在多个观察者
class Watcher {
constructor({name, node, nodeType, vm}) {
Object.assign(this, {
name,
node,
nodeType,
vm
})
}
update() {
if (this.nodeType == 'text') {
this.node.nodeValue = this.vm.data[this.name]
}
if (this.nodeType == 'input') {
this.node.value = this.vm.data[this.name]
}
}
}
// 发布/订阅 每个订阅者对应一个数据,一个数据存在多个观察者,所以一个订阅器,存在多个观察者,此处维护一个观察者列表
class Dep {
constructor() {
this.subList = []
}
addDep(watcher) {
if (!watcher) {
return
}
this.subList.push(watcher)
}
notify() {
this.subList.forEach((watcher) => {
watcher.update()
})
}
}
let vm = new Vue({
data: {
name: '嘻哈',
number: '1234',
metal: '金辉',
level: 'level1',
},
$el: '#demo'
})
- tips:在实际的Vue的视图更新中,会用到HTML5的Mutation Observer,其作用是可以通过监测DOM tree的变化,当该循环中,DOM tree的变更结束之后,才触发一次事件执行回调,从而减少浏览器的等待时间[异步]。例如我有100个段落要插入DOM当中,只有所有DOM插入结束之后,才会触发事件并执行相应回调。原本的mutation Events是同步执行的,也就是插入一个DOM,触发一次事件,执行回调,处理完成再处理下一个,会拖慢浏览器。详情链接:http://www.jianshu.com/p/b5c9e4c7b1e1