vue的双向绑定原理与实现
前言
vue的双向绑定,作为前端经典的面试题之一,究竟怎么回答才能更加准确详尽呢,又该如何学习它呢?那么就让我们来仔细研究一下它的来龙去脉~
1. 定义
1.1 双向绑定 & 单向绑定
vue的双向绑定,即数据与视图的响应式
设计。具体表现为:View
的改变能实时让Model
发生变化,而Model
的变化也能实时更新View
。
什么情况下用户可以更新View呢?如:填写表单
。当用户填写表单时,View的状态就被更新了,如果此时MVVM框架可以自动更新Model的状态,那就相当于把Model和View做了双向绑定。
注意区别:单向数据绑定
,所有数据只有一份,一旦数据变化,就去更新页面(只有data-->DOM,没有DOM-->data)。
- 用户在页面作出更新,需要用户手动收集(双向绑定是自动收集),在合并到原有的数据中。
Vue.js中的v-model主要用在表单的input输入框,完成视图和数据的双向绑定:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div id="app">
<input type="text" v-model="message">
<p>{{message}}</p>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
message: ''
}
});
</script>
</body>
</html>
复制代码
1.2 v-model来添加双向绑定
<input v-model="xxx">
<!-- 上面的代码等价于 -->
<input :value="xxx" @input="xxx = $event.target.value">
<!-- 双向绑定 = 单向绑定 + UI事件监听 -->
复制代码
1.3 双向绑定、单向绑定的优缺点
- 单向绑定:数据流也是单向的,对于复杂应用来说是实施统一状态管理(如redux)的前提。
- 双向绑定:在一些需要
实时
反应用户输入的场合会非常方便(如多级联动菜单)。但常认为复杂应用中这种便利比不上引入状态管理带来的优势。因为不知道状态什么时候发生改变,是谁造成的改变,数据变更也不会通知。
响应式的思路:mvvm
Model,View,View-Model
2. 原理概述
Vue 数据双向绑定原理是通过 数据劫持
+ 发布者-订阅者模式
的方式来实现的,首先是通过 ES5
提供的 Object.defineProperty()
方法来劫持(监听)各属性的 getter、setter,并在当监听的属性发生变动时通知订阅者,是否需要更新,若更新就会执行对应的更新函数。详见 vue源码。
- 常见的
基于数据劫持
的双向绑定有两种实现- 一个是目前Vue在用的
Object.defineProperty
- 一个是ES2015中新增的
Proxy
,而在Vue3.0版本后加入Proxy从而代替Object.defineProperty
- 一个是目前Vue在用的
3. 基于数据劫持实现的双向绑定
3.1 几种实现双向绑定的做法
目前几种主流的mvc(vm)框架
都实现了单向数据绑定,而双向数据绑定可以理解为是在单向绑定的基础上给可输入元素(input、textarea等)添加了change(input)事件,来动态修改model和 view。
实现数据绑定
的做法有大致如下几种:
发布者-订阅者模式(backbone.js)
脏值检查(angular.js)
数据劫持(vue.js)
发布者-订阅者模式:
一般通过sub,pub的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set('property', value)。而我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式。
脏值检查:
angular.js 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图,最简单的方式就是通过 setInterval() 定时轮询检测数据变动,当然Google 限制 angular只有在指定的事件触发时进入脏值检测,大致如下:
- DOM事件,如用户输入文本,点击按钮等。( ng-click )
- XHR响应事件 (
$http
) - 浏览器Location变更事件 (
$location
) - Timer事件(
$timeout
,$interval
) - 执行
$digest()
或$apply()
数据劫持:
vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty() 来劫持各个属性的 setter,getter,在数据变动时发布消息给订阅者,触发相应的 监听回调。
3.2 数据劫持
3.2.1 什么是数据劫持
数据劫持比较好理解,通常我们利用Object.defineProperty
劫持对象的访问器,在属性值发生变化时我们可以获取变化,从而进行进一步操作。
3.2.2 数据劫持的优势
目前业界分为两个大的流派,一个是以React为首的单向数据绑定,另一个是以Angular、Vue为主的双向数据绑定。
三大框架都是既可以双向绑定 也可以单向绑定,如React可以手动绑定onChange和value实现双向绑定,也可以调用一些双向绑定库;Vue也加入了props这种单向流的api。
对比其他双向绑定的实现方法,数据劫持的优势所在:
- 无需显示调用: 例如Vue运用数据劫持+发布订阅,直接可以通知变化并驱动视图,而如Angular的脏检测则需要显示调用markForCheck(可以用zone.js避免显示调用,不展开),react需要显示调用setState。
- 可精确得知变化数据:劫持了属性的setter,当属性值改变,可以精确获知变化的内容
newVal
,因此在这部分不需要额外的diff操作,否则只知道数据发生了变化而不知道具体哪些数据变化了,这个时候需要大量diff来找出变化值是额外性能损耗。
3.3 实现思路
基于数据劫持双向绑定的实现思路:数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现是Vue。
要实现一个完整的双向绑定需要以下几个要点:
- 利用
Proxy
或Object.defineProperty
生成的Observer
针对对象/对象的属性进行"劫持",在属性发生变化后通知订阅者 - 解析器
Compile
解析模板中的Directive
(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染 Watcher
属于Observer
和Compile
桥梁,它将接收到的Observer
产生的数据变化,并根据Compile
提供的指令进行视图渲染,使得数据变化促使视图变化
3.4 特点
基于Object.defineProperty双向绑定的特点:
对Object.defineProperty还不了解的请阅读文档
const object1 = {};
Object.defineProperty(object1, 'property1', {
value: 11,
writable: false
});
object1.property1 = 2;
*// throws an error in strict mode*
console.log(object1.property1);
*// expected output: 11*
复制代码
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
4. 深入vue的双向数据绑定原理以及核心代码模块
4.1 准备知识
1 [ ].slice.call(lis): 将伪数组转换为真数组
<body>
<ul>
<li>111</li>
<li>222</li>
<li>333</li>
</ul>
</body>
复制代码
//1. [].slice.call(lis):通过实例调用方法,将伪数组转换为真数组
//.call()让一个函数在指定对象上调用
const lis = document.getElementsByTagName('li') //lis是伪数组
console.log(lis instanceof Array, lis[1].innerHTML, lis.forEach)
//false "222" undefined
//对象属性(lis.forEach)查找的是原型链,找不到就是undefined (因为是伪数组,不具备数组的方法)
//ES6中将伪数组转换为真数组:Array.from(lis)
//ES5中Array.prototype.slice(begin,end)相当于( 浅拷贝,但是原数组不变这点类似深拷贝),若是不指定begin,end,就是全部
//slice在数组原型上,call()让一个函数在指定对象上面调用
const lis2 = Array.prototype.slice.call(lis) //推荐
//.call(lis) 让一个函数成为指定对象的方法进行调用
console.log(lis2 instanceof Array,lis2[1].innerHTML,lis2.forEach)
//true "222" ƒ forEach() { [native code] }
//这样就可以通过.forEach来遍历数组了
复制代码
2 node.nodeType: 得到节点类型
*//2. node.nodeType:得到节点类型,最大的节点document //document,element,attr,text*
const elementNode = document.getElementById('test') *//元素节点*
const attrNode = elementNode.getAttributeNode('id') *//属性节点*
const textNode = elementNode.firstChild *//文本节点*
*//不同节点的nodeType是不同的,是他们的标识。当要调用getAttr()(只有元素节点才有)时就需要知道nodeType了,nodeType=1*
console.log(elementNode.nodeType, attrNode.nodeType, textNode.nodeType) *//1 2 3*
复制代码
3 属性描述符分为:数据描述符(其他都是),访问描述符(get,set)
Object.defineProperty(obj,propName,{}): 给对象添加/修改属性(指定描述符)
//3.Object.defineProperty(obj,propertyName,{}):给对象添加属性(指定描述符)
const obj={
firstName:'A',
lastName:'B'
}
//给obj添加fullName属性,且能自动同步
/*
属性描述符:
2.访问描述符
get:回调函数,根据其他相关的属性,动态计算得到当前属性值
set:回调函数,监视当前属性值的变化,更新其他相关属性
*/
Object.defineProperty(obj,'fullName',{
get(){
return this.firstName + '-' + this.lastName
},
set(value){
//更新firstName,lastName
const names =value.split('-')
this.firstName = names[0],
this.lastName = names[1]
}
})
console.log(obj.fullName) //A-B
//修改属性
obj.firstName = 'C'
obj.lastName = 'D'
console.log(obj.fullName) //C-D
obj.fullName = 'E-F'
console.log(obj.firstName, obj.lastName) //E F
/*
属性描述符:
1.数据描述符
configurable:是否可以重新定义
enumerable:是否可以枚举
value:初始值
writable:是否可以修改属性值,默认为 false。*/
Object.defineProperty(obj,'fullName2',{
configurable:false,
enumerable:true,
value:'G-H',
writable:false
})
console.log(obj.fullName2) //G-H
obj.fullName2 = 'J-K'
console.log(obj.fullName2) //G-H 不能修改
复制代码
4 Object.keys(obj): 得到对象自身可枚举的属性名的数组
//4.Object.keys(obj):得到对象自身可枚举属性组成的数组
//枚举 for in
const names = Object.keys(obj)//返回所有对象组成的数组
console.log(names)// ["firstName","lastName","fullName2"]
复制代码
5 obj.hasOwnProperty(prop): 判断 prop 是否是 obj 自身的属性
//5.obj.hasOwnProperty(prop):判断prop是否是obj自身的属性
console.log(obj.hasOwnProperty('fullName'), obj.hasOwnProperty('toString'))
//true false
//toString也可能是原型链上的
复制代码
6 DocumentFragment: 文档碎片(高效批量更新多个节点)
<body>
<ul>
<li>111</li>
<li>222</li>
<li>333</li>
</ul>
</body>
复制代码
//6.DocumentFragment:文档碎片(高效批量更新多个节点)
//Document对应显示的页面,包含 n个element,一旦更新 Document内部的某个元素界面更新
//DocumentFragment:内存中保存 n个element的容器对象(不与界面关联),如果更新Fragment中的某个element,界面不变
//将多次更新变成了一次批量更新,减少更新的次数
/*
<ul id="fragment_test">
<li>111</li>
<li>222</li>
<li>333</li>
</ul>
*/
const ul = document.getElementById('fragment_test')
//1)创建fragment
const fragment = document.createDocumentFragment()
//2)取出ul中所有子节点,取出保存到fragment----转移
let child
while (child = ul.firstChild) { //一个节点只能有一个父亲
fragment.appendChild(child) //先将child从ul中移出,添加为fragment的子节点
}
//3)更新fragment中所有li的文本 //childNode得到所有子节点 //children得到所有子标签 //fragment.childNodes通过dom方式得到的都是伪数组
//fragment.childNodes
//Array.prototype.slice.call(fragment.childNodes) 真数组 拿到的节点有两种可能性(1.换行的文本2.标签<li>)
Array.prototype.slice.call(fragment.childNodes).forEach(node =>{
if(node.nodeType===1){ //元素节点----<li> 即判断是不是标签节点
node.textContent = 'dddddd' //此时页面不显示。不更新---与页面不相关,仅仅是内存独立的东西
}
})
//4)将fragment插入到ul
ul.appendChild(fragment) //.appendChild() 接收node类型
复制代码
4.2 数据代理
数据代理:
通过一个对象代理 对 另一个对象(在前一个对象内部)中属性的操作(读/写)- vue 数据代理: 通过 vm 对象来代理 data 对象中所有属性的操作
- 好处: 更方便的操作 data 中的数据
- 基本实现流程
- 通过 Object.defineProperty(vm, key, { }) 给vm添加与data对象的属性对应的属性
- 所有 添加的属性都包含get/set方法
- 在get/set方法中去操作data中对应的属性
<body>
<div id="test"></div>
</body>
<script type="text/javascript" src="js/vue.js"></script>
<script type="text/javascript">
const vm = new Vue({
el:'#test',
data:{
name:'feifei'
}
})
console.log(vm.name)//feifei
console.log(vm) //vm代理对data数据的“读”操作
vm.name = 'xiaoxiao' //vm代理对data数据的“写”操作
console.log(vm._data.name)//xiaoxiao 注意这里是vm._data.name
console.log(vm.name)//xiaoxiao
</script>
复制代码
案例
<body>
<div id="test"></div>
<!--实现 数据代理-->
<script type="text/javascript" src="js/mvvm/compile.js"></script> <!--编译解析模板-->
<script type="text/javascript" src="js/mvvm/mvvm.js"></script>
<script type="text/javascript" src="js/mvvm/observer.js"></script> <!--观察-->
<script type="text/javascript" src="js/mvvm/watcher.js"></script> <!--监视-->
<script type="text/javascript">
const vm = new MVVM({ //这里的MVVM只是定义的名字
el: "#test",
data: {
name: '张三'
}
})
console.log(vm.name) // 读取的是data中的name, vm代理对data的读操作
vm.name = '李四' // 数据保存到data中的name上, vm代理对data的写操作
console.log(vm.name, vm._data.name)
</script>
</body>
复制代码
4.3 MVVM.js
/*
相关于Vue的构造函数
*/
function MVVM(options) {
// 将选项对象保存到vm
this.$options = options;
// 将data对象保存到vm和data变量中
var data = this._data = this.$options.data;
//将vm保存在me变量中
var me = this;
// 遍历data中所有属性
Object.keys(data).forEach(function (key) { // key是data的某个属性名: name
// 对指定属性实现代理
me._proxy(key);
});
// 对data中所有层次的属性通过 数据劫持 实现 数据绑定。进行监视
observe(data, this);
// 创建一个编译对象,来解析模板 的compile对象
//如果左边有值传左边的,没有则传右边的
this.$compile = new Compile(options.el || document.body, this)
}
MVVM.prototype = {
$watch: function (key, cb, options) {
new Watcher(this, key, cb);
},
// 对指定属性实现代理
_proxy: function (key) {
// 保存vm
var me = this;
// 给vm添加指定属性名的属性(使用属性描述)
Object.defineProperty(me, key, {
configurable: false, // 不能再重新定义
enumerable: true, // 可以枚举
// 当通过vm.name读取属性值时自动调用,从data中获取对应的属性值返回
get: function proxyGetter() {
// 读取data中对应属性值返回(实现代理 读 操作)
return me._data[key];
},
// 当通过vm.name = 'xxx'时自动调用
set: function proxySetter(newVal) {
// 将xxx最新的值保存到data中对应的属性上(实现代理 写 操作)
me._data[key] = newVal;
}
});
}
};
复制代码
proxy:
vue 有一个特点,可以通过访问 vm 的实例属性,直接访问到 vm 初始化时 data 的属性值。这个其实是一个代理模式的实现, 对 vm 实例进行键值的代理
4.4 proxy代理
let a = {
data: {
b: 123
}
};
/** * 实现访问 a.b === 123 */
function proxy(target: Object, sourceKey: string): void {
let data = target[sourceKey];
let keys = Object.keys(data);
for (let i = 0, l = keys.length; i < l; i++) {
let key = keys[i];
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
set(val) {
this[sourceKey][key] = val;
},
get() {
return this[sourceKey][key];
}
});
}
}
proxy(a, "data");
console.log(a.b); // 123
复制代码
5. 模板解析
// 创建一个编译对象,来解析模板 的compile对象
//如果左边有值传左边的,没有则传右边的
this.$compile = new Compile(options.el || document.body, this)
复制代码
模板:
html嵌套了js代码(1.指令代码2.表达式)
5.1 模板解析的基本流程
- 将 el 的所有子节点取出, 添加到一个新建的文档 fragment 对象中
- 对 fragment 中的所有层次子节点递归进行编译解析处理
- 对大括号表达式文本节点进行解析
- 对元素节点的指令属性进行解析
- 事件指令解析
- 一般指令解析
- 将解析后的 fragment 添加到 el 中显示
5.2 模板解析
5.2.1 大括号表达式解析 {{msg}}
1.根据 正则对象 得到匹配出的 表达式字符串: 子匹配/RegExp.$1 name
var reg = /{{(.*)}}/;
me.compileText(node, RegExp.$1);
2.从 data 中取出表达式对应的属性值
// 将data对象保存到vm和data变量中
var data = this._data = this.$options.data;
3.将属性值设置为文本节点的 textContent
// 更新节点的textContent
textUpdater: function (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
},
5.2.2 编译模板
1.compile.js
//编译模板最重要的3步:
this.
$fragment
= this.node2Fragment(this.$el
);this.init();
this.
$el
.appendChild(this.$fragment
);
function Compile(el, vm) {
// 保存vm
this.$vm = vm;
// 保存el元素
//this是compile实例(编译对象),$el存的是dom元素
this.$el = this.isElementNode(el) ? el : document.querySelector(el);
// 如果el元素存在
if (this.$el) {
//模板解析最重要的3步: 2表示to,node2Fragment--->nodeToFragment将node转换为Fragment
// 1. 取出el中所有子节点, 封装在一个framgment对象中
this.$fragment = this.node2Fragment(this.$el);
// 2. 初始化显示,解析模板(在内存中进行编译),编译fragment中所有层次子节点
this.init();
// 3. 将fragment添加到页面的el元素中
this.$el.appendChild(this.$fragment); //编译完后再将其塞回页面中
}
}
复制代码
5.2.3 debug
要取对象的属性有两种方式:
1.点.出属性
2.使用[ ] ,若属性值是变量(变化的值),则必须采用[ ]的方式
由于 vue 的 parsePath 方法是用 split('.') 来做的属性分割,所以不支持abc['bbc']
5.2.4 解析 v-on:click="show"
<body>
<div id="test">
<p>{{msg}}</p>
<!--给button绑定点击事件:要指定事件名,指定回调函数-->
<button v-on:click="show">提示</button>
</div>
<script type="text/javascript">
new MVVM({
el: '#test',
methods: {
show () {
alert(this.msg)
}
}
})
</script>
</body>
复制代码
核心:
1.从指令名中取出事件名 click
var eventType = dir.split(':')[1],
2.根据指令的值(表达式)从 methods 中得到对应的事件处理函数对象 show ,exp表达式
fn = vm.
$options
.methods && vm.$options
.methods[exp];
3.给当前元素节点绑定指定事件名和回调函数的 dom 事件监听eventHandler中的
if (eventType && fn) {
node.addEventListener(eventType, fn.bind(vm), false);
}
4.指令解析完后, 移除此指令属性
node.removeAttribute(attrName);
isDirective() 判断是不是属性名,有v-的都是属性名
subString(2) 从下标为2
的地方开始截,例如 v-on:click
,截取完后为 on:click
isEventDirective() 判断 是不是事件指令
// 解析事件指令
compileUtil.eventHandler(node, me.$vm, exp, dir);
// 事件处理(器)
.eventHandler
复制代码
5.2.5 一般指令解析
<style>
.aclass {
color: red;
}
.bClass {
font-size: 30px;
}
</style>
</head>
<body>
<div id="test">
<p v-text="msg"></p>
<p v-html="msg">123</p>
<p class="bClass" v-class="myClass">xxxxxx</p>
</div>
<script type="text/javascript" src="js/mvvm/compile.js"></script>
<script type="text/javascript" src="js/mvvm/mvvm.js"></script>
<script type="text/javascript" src="js/mvvm/observer.js"></script>
<script type="text/javascript" src="js/mvvm/watcher.js"></script>
<script type="text/javascript">
new MVVM({
el: '#test',
data: {
msg: '<a href="http://www.atguigu.com">xxx</a>',
myClass: 'aclass'
},
methods: {
test () {
alert(this.msg)
}
}
})
</script>
复制代码
// 解析普通指令
compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
上面的代码:有几个指令属性,就执行几次。此处是v-text="msg",v-html="msg",v-class="myClass"有3个,就执行3次。
- 得到指令名和指令值(表达式) text/html/class msg/myClass
- 从 data 中根据表达式得到对应的值
- 根据指令名确定需要操作元素节点的什么属性
- v-text---textContent 属性
- v-html---innerHTML 属性
- v-class--className 属性
- 将得到的表达式的值设置到对应的属性上
- 移除元素的指令属性
5.3 compile.js
// 解析普通指令
compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
// 更新节点的className
classUpdater: function (node, value, oldValue) {
//静态class属性的值
var className = node.className;
className = className.replace(oldValue, '').replace(/\s$/, '');
var space = className && String(value) ? ' ' : '';
//将静态class属性的值与动态class值进行合并后设置为新的className属性值
node.className = className + space + value;
// node.className = className + (className?' ':'' )+ value; //可能是空串或者空格
},
复制代码
详情见下方6.1四个重要对象。
5.4 数据绑定
一旦更新了 data 中的某个属性数据, 所有界面上直接使用或间接使用了此属性的节点都会更新
。即 自动更新界面。
6. 数据劫持
observe(data, this)
-
数据劫持是
vue 中用来 实现数据绑定 的一种 技术 -
基本思想: 通过 defineProperty()来监视 data 中所有属性(任意层次)数据的变化, 一旦变化就去更新界面
即,给data中的属性添加set(监视变化),get方法
例如:this.xxx = 3,此时this是vm,改变vm的set的值,然后这里的set变化了,会改变data中xxx的值。(vm
——>M
)
当data中xxx的值改变时,data的set值也发生变化,就会去更新界面了。(数据绑定:M
——>V
)
即:
-
vm中的
set
是用来实现 数据代理的 -
data中的
set
是用来实现 数据绑定 的(界面会变)
6.1四个重要对象
流程
- 模板编译(Compile)
- 数据劫持(Observer)
- 发布的订阅(Dep)
- 观察者(Watcher)
MVVM模式就要将这些板块进行整合,实现模板和数据的绑定!
6.1.1 Compiler
-
用来解析模板页面的对象的构造函数(一个实例)
-
利用 compile 对象解析模板页面
-
每解析一个表达式(非事件指令)都会创建一个对应的watcher对象, 并建立watcher 与 dep 的关系
-
complie 与 watcher 关系:
一对多的关系
MVVM中调用了Compile类来编译我们的页面,开始来实现模板编译
6.1.1.1. 搭建基础的架子
class Compile {
constructor(el, vm) {
// 看看传递的元素是不是DOM,不是DOM我就来获取一下~
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
// 如果这个元素能获取到 我们才开始编译
// 1.先把这些真实的DOM移入到内存中 fragment (性能优化)
let fragment = this.node2fragment(this.el);
// 2.编译 => 提取想要的元素节点 v-model 和文本节点 {{}}
this.compile(fragment);
// 3.把编译号的fragment在塞回到页面里去
this.el.appendChild(fragment);
}
}
/* 专门写一些辅助的方法 */
isElementNode(node) {
return node.nodeType === 1;
}
/* 核心的方法 */
compileElement(node) {}
compileText(node) {}
compile(fragment) {}
node2fragment(el) {}
}
复制代码
接下来一个个的方法来搞
6.1.1.2. node2fragment
node2fragment(el) { // 需要将el中的内容全部放到内存中
// 文档碎片 内存中的dom节点
let fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
// appendChild具有移动性
}
return fragment; // 内存中的节点
}
复制代码
6.1.1.3. compile
compile(fragment) {
// 需要递归 每次拿子元素
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
// 是元素节点,还需要继续深入的检查
// 这里需要编译元素
this.compileElement(node);
this.compile(node)
} else {
// 文本节点
// 这里需要编译文本
this.compileText(node);
}
});
}
复制代码
6.1.1.4. compileElement、compileText
再弄出两个方法compileElement,compileText 来专门处理对应的逻辑
/*辅助的方法*/
// 是不是指令
isDirective(name) {
return name.includes('v-');
}
//————————————————————————————
compileElement(node) {
// 带v-model v-text
let attrs = node.attributes; // 取出当前节点的属性
Array.from(attrs).forEach(attr => {
// 判断属性名字是不是包含v-model
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 取到对应的值放到节点中
let expr = attr.value;
let [, type] = attrName.split('-'); //
// 调用对应的编译方法 编译哪个节点,用数据替换掉表达式
CompileUtil[type](node, this.vm, expr);
}
})
}
compileText(node) {
let expr = node.textContent; // 取文本中的内容
let reg = /\{\{([^}]+)\}\}/g; // {{a}} {{b}} {{c}}
if (reg.test(expr)) {
// 调用编译文本的方法 编译哪个节点,用数据替换掉表达式
CompileUtil['text'](node, this.vm, expr);
}
}
复制代码
6.1.1.5. CompileUtil
我们要实现一个专门用来配合Complie类的工具对象。
先只处理文本和输入框的情况
CompileUtil = {
text(node, vm, expr) { // 文本处理
let updateFn = this.updater['textUpdater'];
// 用处理好的节点和内容进行编译
updateFn && updateFn(node, value)
},
model(node, vm, expr) { // 输入框处理
let updateFn = this.updater['modelUpdater'];
// 用处理好的节点和内容进行编译
updateFn && updateFn(node, value);
},
updater: {
// 文本更新
textUpdater(node, value) {
node.textContent = value
},
// 输入框更新
modelUpdater(node, value) {
node.value = value;
}
}
}
复制代码
6.1.1.6. 实现text方法
text(node, vm, expr) { // 文本处理
let updateFn = this.updater['textUpdater'];
// 文本比较特殊 expr可能是'{{message.a}} {{b}}'
// 调用getTextVal方法去取到对应的结果
let value = this.getTextVal(vm, expr);
updateFn && updateFn(node, value)
},
getTextVal(vm, expr) { // 获取编译文本后的结果
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
// 依次去取数据对应的值
return this.getVal(vm, arguments[1]);
})
},
getVal(vm, expr) { // 获取实例上对应的数据
expr = expr.split('.'); // {{message.a}} [message,a] 实现依次取值
// vm.$data.message => vm.$data.message.a
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
}
复制代码
6.1.1.7. 实现Model方法
model(node, vm, expr) { // 输入框处理
let updateFn = this.updater['modelUpdater'];
// 这里应该加一个监控 数据变化了 应该调用这个watch的callback
updateFn && updateFn(node, this.getVal(vm, expr));
}
复制代码
6.1.2 Observer
(观察到了然后去“劫持”数据,定义get/set,创建dep对象)
-
用来对 data 所有属性数据进行劫持的构造函数
-
给 data 中所有属性重新定义属性描述(get/set)
-
为 data 中的每个属性创建对应的 dep 对象
可以利用
Obeject.defineProperty()
来监听属性变动 那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter
和getter
这样的话,给这个对象的某个值赋值,就会触发setter
,那么就能监听到了数据变化
接下来我们就来写下一个类Observer
// 在MVVM加上Observe的逻辑
if (this.$el) {
// 数据劫持 就是把对象的所有属性 改成get和set方法
new Observer(this.$data);
// 用数据和元素进行编译
new Compile(this.$el, this);
}
// -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
class Observer {
constructor(data) {
this.observe(data);
}
observe(data) {
// 要将这个data数据原有的属性改成set和get的形式
// defineProperty针对的是对象
if (!data || typeof data !== 'object') {
return;
}
// 要将数据 一一劫持 先获取到data的key和value
Object.keys(data).forEach(key => {
// 定义响应式变化
this.defineReactive(data, key, data[key]);
this.observe(data[key]); // 深度递归劫持
});
}
// 定义响应式
defineReactive(obj, key, value) {
// 在获取某个值的时候,想弹个框
let that = this;
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() { // 当取值时调用的方法
return value;
},
set(newValue) { // 当给data属性中设置值的适合 更改获取的属性的值
if (newValue != value) {
// 这里的this不是实例
that.observe(newValue); // 如果设置的是对象,则继续劫持
value = newValue;
}
}
});
}
}
复制代码
6.1.3 Dep(Depend)
(存放watcher的数组,即subscribes——subs)
-
data 中的每个属性(所有层次)都对应一个 dep 对象
-
创建的时机:
- 在初始化 definedata 中各个属性时创建对应的 dep 对象
- 在 data 中的某个属性值被设置为新的对象时
-
对象的结构
{
id, // 每个 dep 都有一个唯一的 id
subs //包含 n 个对应 watcher 的 数组 (subscribes 的简写)
}
- subs 属性说明
- 当 watcher 被创建时, 内部将当前 watcher对象添加到对应的 dep对象的 subs中
- 当此 data 属性的值发生改变时,subs 中所有的 watcher 都会收到更新的通知,从而最终更新对应的界面
发布订阅:Dep实现
class Dep {
constructor() {
// 订阅的数组
this.subs = []
}
addSub(watcher) {
this.subs.push(watcher);
}
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
复制代码
6.1.4 Watcher
更新显示内容的
-
模板中每个非事件指令或表达式都对应一个 watcher 对象
-
监视当前表达式数据的变化
-
创建的时机: 在初始化编译模板时
-
对象的组成
{
vm, //vm 对象
exp, //对应指令的表达式
cb, //当表达式所对应的数据发生改变的回调函数 (更新界面的回调函数)
value, //表达式当前的值
depIds //表达式中各级属性所对应的 dep 对象的集合对象 //属性名为 dep 的 > id, 属性值为 dep
}
观察者的目的就是给需要变化的那个元素增加一个观察者,用新值和老值进行比对,如果数据变化就执行对应的方法
class Watcher { // 因为要获取老值 所以需要 "数据" 和 "表达式"
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 先获取一下老的值 保留起来
this.value = this.get();
}
// 老套路获取值的方法,这里先不进行封装
getVal(vm, expr) {
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
}
get() {
let value = this.getVal(this.vm, this.expr);
return value;
}
// 对外暴露的方法,如果值改变就可以调用这个方法来更新
update() {
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if (newValue != oldValue) {
this.cb(newValue); // 对应watch的callback
}
}
}
复制代码
在哪里使用watcher?
答案肯定是:compile, 给需要重新编译的DOM增加watcher
text(node, vm, expr) { // 文本处理
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(vm, expr); +
expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
new Watcher(vm, arguments[1], (newValue) => {
// 如果数据变化了,文本节点需要重新获取依赖的属性更新文本中的内容
updateFn && updateFn(node, this.getTextVal(vm, expr));
});
})
updateFn && updateFn(node, value)
},
model(node, vm, expr) { // 输入框处理
let updateFn = this.updater['modelUpdater'];
new Watcher(vm, expr, (newValue) => {
// 当值变化后会调用cb 将新的值传递过来
updateFn && updateFn(node, newValue);
});
updateFn && updateFn(node, this.getVal(vm, expr));
}
复制代码
6.1.5 dep 与 watcher 的关系
多对多
-
a .data 中的一个属性对应一个 dep, 一个 dep 中可能包含多个 watcher(模板中有几个 表达式使用到了同一个属性)
-
b.模板中一个非事件表达式对应一个 watcher, 一个 watcher 中可能包含多个 dep(表 达式是多层:a.b)
-
c.数据绑定使用到 2 个核心技术
-
defineProperty()
-
消息订阅与发布
Dep与Watch之间的关系 :多对多
dep先创建,watcher后创建。一旦watcher创建,关系就有限制条件了
1data属性
--->1Dep
--->n个watcher(模板中有多个表达式使用了此属性)
- 例如:在模板中写了多次表达式:{{name}}/ v-text="name" ,则此时
1个name
--->1个Dep
--->2个watcher
1表达式
--->1Watcher
--->n个Dep(多层表达式)
- 例如:a.b.c 对应
1个watcher
,但对应3个dep
(a.b.c有3层)
-
同一个属性对应同一个dep
如何建立的?
-
data中属性的get()中建立
-
vm.name = 'abc' --->data中的name属性值变化 ---> name的set()调用 ---> dep --->相关的所有watcher --->cb() --->updater
什么时候建立?
-
初始化的解析模板中的表达式创建watcher对象时
-
通过get:建立dep与watcher的关系
关联dep和watcher,watcher中有个重要的逻辑就是this.get();每个watcher被实例化时都会获取数据从而会调用当前属性的get方法
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再define
get: function() {
// 建立dep与watcher的关系
if (Dep.target) {
dep.depend();
}
// 返回属性值
return val;
},
set: function(newVal) {
if (newVal === val) {
return;
}
val = newVal;
// 新的值是object的话,进行监听
childObj = observe(newVal);//observe观察data中的所有属性
// 通过dep ,通知订阅者
dep.notify();
}
});
复制代码
到此数据和视图就关联起来了!
6.1.6 发布订阅:监听输入事件
setVal(vm, expr, value) {
expr = expr.split('.');
return expr.reduce((prev, next, currentIndex) => {
if (currentIndex === expr.length - 1) {
return prev[next] = value;
}
return prev[next];
}, vm.$data);
},
model(node, vm, expr) {
let updateFn = this.updater['modelUpdater'];
new Watcher(vm, expr, (newValue) => {
// 当值变化后会调用cb 将新的值传递过来 ()
updateFn && updateFn(node, this.getVal(vm, expr));
});
node.addEventListener('input', (e) => {
let newValue = e.target.value;
// 监听输入事件将输入的内容设置到对应数据上
this.setVal(vm, expr, newValue)
});
updateFn && updateFn(node, this.getVal(vm, expr));
}
复制代码
6.1.7 发布订阅:代理数据
class MVVM {
constructor(options) {
this.$el = options.el;
this.$data = options.data;
if (this.$el) {
new Observer(this.$data);
// 将数据代理到实例上直接操作实例即可,不需要通过vm.$data来进行操作
this.proxyData(this.$data);
new Compile(this.$el, this);
}
}
proxyData(data) {
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key]
},
set(newValue) {
data[key] = newValue
}
})
})
}
}
复制代码
拓展
回调函数
此处不深入展开,可思考如下问题:
1.什么时候调,
2.做了什么事情,
3.回调函数的this是什么
7. MVVM 原理图分析
7.1原理图分析
要实现mvvm的双向绑定
,就必须要实现以下几点:
- 实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
- 实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
- 实现一个Watcher,作为连接Observer和Compile的桥梁,能够
订阅并收到
每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图 - mvvm入口函数,整合以上三者
上述流程如图所示:
7.2 实现数据初始化和数据更新
7.3 流程图
8. 双向数据绑定
双向数据绑定=单向数据绑定+input 监听
8.1 v-model
-
双向数据绑定是建立在单向数据绑定(model==>View)的基础之上的
-
双向数据绑定的实现流程:
a. 在解析 v-model 指令时, 给当前元素添加 input 监听 (从View==>model)
b. 当 input 的 value 发生改变时, 将最新的值赋值给当前表达式所对应的 data 属性
<div id="test">
<input type="text" v-model="msg">
<p>{{msg}}</p>
</div>
<script type="text/javascript">
new MVVM({
el: '#test',
data: {
msg: 'haha'
}
})
</script>
复制代码
如果需要双向数据绑定,需要事件监听。即,是如何解析v-model="msg"这一指令的
8.2 this.bind()
使用this.bind()
实现数据绑定
8.3 界面初始化显示
即将值value在input中显示出来。传过来的msg: 'haha'即value值是haha,然后将value放入到node节点的value中
同时,为exp表达式创建了以一个Watcher
8.4 bind
bind
的作用:
-
解析表达式,显示一个 value
-
同时创建一个对应的 Watcher
数据绑定:
结尾
使用 Vue 一段时间后,我们就需要开始深入 Vue 的高级用法、原理等,这里可以结合源码进行分析,更有助于整理清晰整个调用及设计过程。
❤️ 未来也会继续更新文章,欢迎围观!如有不足与错误也欢迎指正! 如果这篇文章对您有帮助麻烦 点赞、收藏 + 关注,与我一起成长!❤️