Vue双向绑定原理:Vue内部通过 Object.defineProperty方法以属性拦截的方式,把data对象的每个数据的读写转化为getter / setter,当数据变化时通知视图更新。
一、MVVM数据双向绑定
MVVM数据双向绑定主要是指:数据变化更新视图,视图变化更新数据。
即:
- 输入框内容变化时,Data 中的数据同步变化。View 导致 Data 的变化。(通过事件监听的方式来实现)
- Data 中的数据变化时,文本节点的内容同步变化。Data 导致 View 的变化。(通过操作DOM实现)
监听器 Observer 只要是让对象变的 "可观测",即每次读写数据时,我们能感知到数据被读取了或数据被改写了。Vue2.0源码中用到Object.defineProperty()来劫持各个数据属性的setter / getter。关于Object.defineProperty 方法,在 MDN 上是这么定义的:
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。
二、Object.defineProperty() 语法
Object.defineProperty(obj, prop, descriptor)
参数:
- obj: 要在其上定义属性的对象。
- prop:要定义或修改的属性的名称。
- descriptor:将被定义或修改的属性描述符。
返回值:被传递给函数的对象。
属性描述符:
Object.defineProperty() 为对象定义属性,分为数据描述符和存取描述符,两种形式不能混用。
数据描述符和存取描述符均具有以下可选键值:
- configurable:一个总开关,一旦将它设为false,就不能删除或重新设置defineProperty监听的属性。为true时可以进行删除或重新使用defineProperty设置新值。默认为false。
- enumerable:当属性值为true时,该属性才能出现在对象枚举的属性中。默认为false。
数据描述符具有以下可选键值:
- value:该属性对应的值,可以为任意有效的 JavaScript值(数值、对象、函数等)。默认 undefined。
- writable:设置属性值是否允许被赋值运算符改变。true为允许,false为不允许被重写。默认false。
存取描述符具有以下可选键值:
- get:用于给属性提供 getter 方法,当访问该属性时,该方法会被执行,执行时不需要传入参数,但可以拿到this对象。默认为undefined。
- set:用于给属性提供 setter 方法,当属性修改时,该方法会被执行。该方法接收唯一参数,即该属性新的参数值。默认为undefined。
通过 Object.defineProperty() 实现一个简单的输入框双向绑定:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>defineProperty实现绑定</title>
<script type="text/javascript" src="./example.js"></script>
</head>
<body>
<div id="myApp">
<input type="text" id="myInput" />
<div id="myDiv"></div>
</div>
</body>
<script type="text/javascript">
var myInput = document.getElementById('myInput');
var myDiv = document.getElementById('myDiv');
let obj = {
value: '监听数据'
}
// 将初始化数据赋值给元素
myInput.value = obj.value;
myDiv.innerHTML = obj.value;
Object.defineProperty(obj, 'value', {
set(newVal) {
// 监听对象属性值改变,更新div元素innerHTML属性
myDiv.innerHTML = newVal;
}
})
myInput.oninput = function(e) {
// 更新对象值,来触发Object.defineProperty的set方法
obj.value = e.target.value;
}
</script>
</html>
要了解Vue双向绑定原理,首先要明白三个概念:
1、观察者( observer ):数据监听器,负责对数据对象的所有属性进行监听劫持,并将消息发送给订阅者进行数据更新。
2、订阅者( watcher ):负责接收数据的变化,并执行更新视图(view)。数据与订阅者是一对多的关系。
3、解析器( compile ):负责对你的每个节点元素指令进行扫描和解析,负责相关指令的数据绑定初始化及创造数据对应的订阅者(每个通过指令绑定该属性数据的元素都是一个订阅者)。
html:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>双向绑定</title>
<script type="text/javascript" src="./example.js"></script>
</head>
<body>
<div id="myApp">
<input type="button" value="加个!" z-on:click="fun1" />
<input type="button" value="加个?" @click="fun2" />
<input type="text" style="width:400px" z-model="site">
<p z-html="site"></p>
<p z-text="site"></p>
</div>
</body>
<script type="text/javascript">
var vm = new Example({
el: '#myApp',
data: {
site: 'Vue双向绑定原理',
age: 12,
sex: '男'
},
methods: {
fun1() {
this.site += '!'
},
fun2() {
this.site += '?'
}
}
})
</script>
</html>
example.js双向绑定代码:
function Example(options) { // 创建构造函数Example,并接收对象结构体options
this.$el = document.querySelector(options.el); // 获取指定挂载的元素
this.$data = options.data; // 将数据挂载到实例
this.$methods = options.methods; // 存放对象的方法
this.binding = {}; // 所有与数据相关的订阅者对象都存放于此,$data下每个数据对应一个数组,用于对应多个订阅者
this.observer(); // 调用观察者,对数据进行劫持
this.compile(this.$el); // 对元素上绑定的指令如(v-model)进行解析,并创建订阅者.(所有绑定$data下该属性的元素都将成为该属性数据的订阅者)
}
// 观察者
Example.prototype.observer = function() {
if (!this.$data || typeof this.$data !== 'object') return;
var value = ''; // 记录$data每个属性的属性值
for (var key in this.$data) { // 遍历数据对象
value = this.$data[key]; // 对象属性值
this.binding[key] = []; // 初始化数据订阅者,一对多关系,为一个数组
var binding = this.binding[key]; // 存放当前数据相关的所有订阅者
// 开始监听劫持
this.defineReactive(this.$data, key, value, binding); // 通过创建方法实现数据分离,私有化,实现闭包
}
}
Example.prototype.defineReactive = function (data, key, value, binding) {
Object.defineProperty(data, key, {
get() {
return value; // 返回当前值
},
set(newVal) { // newVal 为设置修改后的新值
if (newVal !== value) {
value = newVal; // 更新数据
// 以后该属性数据值改变后都会执行一次数据更新
binding.forEach(watcher => {
watcher.update(); // 通知与本数据相关的订阅者们(即绑定该数据的DOM元素)进行视图更新
})
}
}
})
}
// 解析器 (解析指令并创建订阅者)
Example.prototype.compile = function(el) {
var nodes = el.children; // 获取所有子节点(元素节点)
for (var i = 0; i < nodes.length; i ++) { // 遍历子节点
var node = nodes[i]; // 具体节点
if (node.children.length > 0) { // 判断是否具有子节点
this.compile(node); // 递归
}
if (node.hasAttribute("z-on:click")) { // 该节点是否拥有 z-on:click 指令
var attrVal = node.getAttribute('z-on:click'); // 获取指令对应的方法名
// 为元素绑定click事件,事件方法为$methods下的方法,并将this指向this.$data
node.addEventListener('click', this.$methods[attrVal].bind(this.$data));
}
if (node.hasAttribute("@click")) { // 该节点是否拥有@click指令
var attrVal = node.getAttribute('@click'); // 获取指令对应的方法名
// 为元素绑定click事件,事件方法为$methods下的方法,并将this指向this.$data
node.addEventListener('click', this.$methods[attrVal].bind(this.$data));
}
if (node.hasAttribute("z-model")) { // 该节点是否拥有z-model指令
var attrVal = node.getAttribute('z-model'); // 获取指令对应的数据属性
node.addEventListener("input", ((i) => { // 为指令添加input事件
this.binding[attrVal].push(new Watcher(node, "value", this, attrVal)); // 将该元素添加为当前数据的订阅者,并将数据初始值作用与绑定指令的元素上
return () => { // input事件处理函数
this.$data[attrVal] = nodes[i].value; // 更新$data的属性值,会在观察者中劫持
}
})(i));
}
if (node.hasAttribute("z-html")) { // 该节点是否拥有z-html指令
var attrVal = node.getAttribute('z-html'); // 获取指令对应的数据属性
this.binding[attrVal].push(new Watcher(node, 'innerHTML', this, attrVal));
}
if (node.hasAttribute('z-text')) { // 该节点是否用拥有z-text指令
var attrVal = node.getAttribute('z-text'); // 获取指令对应的数据属性
this.binding[attrVal].push(new Watcher(node, 'innerText', this, attrVal));
}
}
}
// 订阅者
function Watcher(el, attr, vm, val) {
this.el = el; // 指令对应的元素
this.attr = attr; // 要更改的元素属性
this.vm = vm; // 指令所在实例
this.val = val; // 指令绑定的值
this.update(); // 更新视图view
}
// 数据变化,更新视图。
Watcher.prototype.update = function() {
this.el[this.attr] = this.vm.$data[this.val];
}