VUE-数据双向绑定原理及手动实现

721 阅读8分钟

概念理解

  • 什么是Vue?

官方解释,它是构建用户界面的渐进式框架

  • 框架和库的区别?

库,本身是一些函数的集合,每次调用函数,实现一个特定的功能(如:jQuery)

框架,是一个完整的解决方案,使用框架的时候,只需要按照它的规则去编写代码

个人理解,它是构建用户界面的一套完整的解决方案,有自己的一套完整流程,它的核心是只关注视图层,不需要开发人员关心数据的处理。

核心:双向绑定和diff算法。

双向绑定原理

简单的例子:

<input id="input" type="text" />
<div id="text"></div>
let input = document.getElementById("input");
let text = document.getElementById("text");
let data = { value: "" };
Object.defineProperty(data, "value", {
  set: function(val) {
    text.innerHTML = val;
    input.value = val;
  },
  get: function() {
    return input.value;
  }
});
input.onkeyup = function(e) {
  data.value = e.target.value;
};
  • 原理实现:

vue是采用数据劫持结合发布者订阅者模式,使用defineproperty劫持各个属性的setter,在数据变动的时候,发布消息给订阅者,触发相应的监听回调。

实现双向绑定,需要实现以下几点:

第一,实现一个监听器observer,observe会通过递归遍历所有数据对象,然后给数据对象的属性都加上setter,getter,如果数据有变动,就会触发setter,拿到最新的值,然后通知订阅者。

第二,实现一个指令解析器compile,compile主要做的事情是解析模板指令,将模板中的变量替换成数据,然后渲染页面视图,并对每个指令对应的节点绑定更新函数,同时,在处理指令的时候,会实例化订阅者,一旦数据变动,订阅者收到通知,更新视图。

因为遍历解析的过程中会多次操作dom节点,为提高性能和效率,会先将根节点el转换成文档碎片fragment进行操作,解析完成,再将fragment添加会原来的dom节点中。

指令的声明规定是通过特定前缀的节点属性来标记。

第三,实现一个watcher订阅者,在实现watcher之前,需要先实现一个消息订阅器dep,dep是一个数组,它的作用主要是收集订阅者,当数据变化时会触发它的notify函数,在notify中调用订阅者的update更新方法,以上是实现一个简单的订阅器。

说回来订阅者,订阅者作为observer和compile之间通信的桥梁,主要做的事情有三个,一是在自身实例化的时候,往订阅器里添加自己,第二是它自身必须有一个更新函数,第三是当数据变化,收到dep的通知时,能调用自身的更新方法,并触发compile中绑定的回调。

第四,实例化一个Vue对象,vue作为数据绑定的入口,整合了observer,compile,watcher三者,

以上就是我对vue双向绑定的理解。

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <meta name="viewport">
  <title>66666</title>
  <script src='js/jquery.min.js'></script>
</head>
<body>
<div id="app">
 <input id="input" type="text" v-model="name"/>
 <div id="viewShow">{{name}}</div>
</div>
<script type="text/javascript">


  // 实现一个观察者,递归循环将data数据劫持
  function observe(data){
  	// 判断data是否还有子属性,只支持观测对象的子属性为object
  	if(!data || typeof data !== 'object'){
  		return;
  	}
  	for(let key in data){
  		defineReactive(data,key,data[key])
  	}
  }

  // 劫持数据
  function defineReactive(data,key,val){
  	var dep = new Dep();
  	observe(val);
  	Object.defineProperty(data,key,{
  		enumberable:true,//可枚举
  		configurable:false,//不能再define
  		get:function(){
  			if(Dep.target){
  				// 判断当前是否有订阅者,若有,则加入订阅器
  				dep.addSub(Dep.target)
  			}
  			console.log("you get it")
  			return val;
  		},
  		set:function(newVal){
  			console.log("you are updating it")
  			if(newVal === val){
  				return;
  			}
  			val = newVal;
  			// 通知所有订阅者数据更新
  			dep.notify()
  		}
  	})
  }

// 指令解析
  function Compile(el,vm){
  	// 获取vm对象
  	this.$vm = vm
  	// 获取节点
  	this.$el = this.isElementNode(el) ? el : document.querySelector(el);
  	if(this.$el){
  		// 创建一个虚拟dom
  	this.$fragment = this.node2Fragment(this.$el)
  	// 初始化
  	this.init();
  	// 将碎片加入真实dom
  	this.$el.appendChild(this.$fragment)
  	}
  }

  Compile.prototype = {
  	init:function(){
  		// 解析虚拟dom
  		this.compileElement(this.$fragment)
  	},
  	node2Fragment:function(el){
  		// 创建虚拟节点对象
  		var fragment = document.createDocumentFragment()
  		var firstChild;
  		// 先将el.frstChild赋值给firstChild
  		// append方法具有可移动性,执行后,firstChild会置空
  		// 循环把真dom中的节点,一个一个放进去
  		// 直到el.firstChild = null
  		// 退出循环,返回虚拟dom
  		while(firstChild = el.firstChild){
  			fragment.appendChild(firstChild)
  		}
  		return fragment;
  	},
  	compileElement:function(el){
  		// compileElement方法将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定,
        var childNodes = el.childNodes,me = this
        //  [].slice.call 将参数变成伪数组,可使用数组的各种方法
        for(let node of [].slice.call(childNodes)){
        	var text = node.textContent;// 可获取到的内容有,html元素,空行,元素内的文本
        	 var reg = /\{\{\s*\w+\s*\}\}/
        	if(me.isElementNode(node)){
        	    // 元素编译
        		me.compile(node)
        	} else if(me.isTextNode(node) && reg.test(text)){
        		// 文本编译且符合reg
        		// text.match(reg)[0] = "{{name}}"
        		// 这里只考虑"{{name}}" 暂不考虑多种"{{name}} {{name}}"
        		me.compileText(node,text.match(reg)[0]) 
        	}
        	if(node.childNodes && node.childNodes.length){
        		// 子元素继续获取
        		me.compileElement(node)
        	}
        }
  	},
  	compile:function(node){
  		// 获取当前元素的属性,并遍历
  		var nodeAttrs = node.attributes,me = this
  		// v-text="name"
  		for(let attr of [].slice.call(nodeAttrs)){
  			var attrName = attr.name; // 如v-text
  			// 判断当前属性是否为指令
  			if(me.isDirective(attrName)){
  				// 属性的值
  				var exp = attr.value; // name
  				// 属性的后缀
  				var dir = attrName.substring(2) // text
  				if(me.isEventDirective(dir)){
  					// 是否是事件指令 v-on:click
  				} else {
  					if(compileUtil[dir]){
  						if(dir === 'model'){
  							// 为model指令
  							compileUtil[dir](node,me.$vm,exp,attr)
  						} else {
  							compileUtil[dir](node,me.$vm,exp)
  						}
  					}
  				}
  			}
  		}
  	},
  	compileText(node,matchs){
  		compileUtil.compileTextNode(node,this.$vm,matchs);
  	},
  	isElementNode(node){
  		return node.nodeType == 1;
  	},
  	isTextNode(node){
  		return node.nodeType == 3;
  	},
  	isDirective(attr){
  		return attr.indexOf('v-') == 0;
  	},
  	isEventDirective(dir){
  		return dir.indexOf('on') == 0;
  	}
  }

  // 指令处理
  var compileUtil = {
  	reg: /\{\{\s*(\w+)\s*\}\}/, // 匹配 {{ key }}中的key
  	compileTextNode:function(node,vm,matchs){
  		// 当前文本内容 "{{name}}"
  		const rawTextContent = node.textContent;
  		const key = rawTextContent.match(this.reg)[1] // {{name}} 中的 name
  		
  		// 首次更新
  		this.updateTextNode(vm, node, key,rawTextContent)
  		// 实例化订阅者
  		new Watcher(vm,key,(newVal,oldVal)=>{
  			// 回调更新文本
  			this.updateTextNode(vm, node, key, rawTextContent)
  		})
  	},
  	updateTextNode:function(vm,node,key,rawTextContent){
  	   let newTextContent = rawTextContent;
  	   // 获取 name 的值
  	   const val = this.getModelValue(vm, key);
  	   // 替换文本内容
       node.textContent = val;
  	},
  	model:function(node,vm,exp,attr){
  		// model
  		const { value: keys, name } = attr;
  		node.value = this.getModelValue(vm,keys);
  		node.removeAttribute(name)
  		// input监听
  		node.addEventListener('input', (e) => {
  			this.setModelValue(vm, keys, e.target.value);
  		});
  		// 实例化订阅者
  		new Watcher(vm, keys, (oldVal, newVal) => {
  			// 更新数据
  			node.value = newVal;
        });
  	},
  	getModelValue(vm,keys){
  		return vm[keys];
  	},
  	setModelValue(vm,keys,val){
  		vm[keys] = val
  	}
  }

  function Dep(){
  	this.subs = []
  }

  Dep.prototype = {
  	addSub:function(sub){
  		// 加入订阅器
  		this.subs.push(sub)
  	},
  	notify:function(){
  		// 通知订阅者
  		for(let sub of this.subs){
  			sub.update();
  		}
  	}
  }

  function Watcher(vm,exp,cb){
  	this.cb = cb;
  	this.vm = vm;
  	this.exp = exp;
  	// 实例化时,将自己加入dep
  	this.value = this.get();
  }

  Watcher.prototype = {
  	update:function(){
  		this.run();
  	},
  	run:function(){
  		var value = this.vm[this.exp];
  		var oldValue = this.value;
  		if(value !== oldValue){
  			this.value = value
  			// compile回调
  			this.cb(this.vm,value,oldValue)
  		}
  	},
  	get:function(){
  		// 将target指向自己
  		Dep.target = this;
  		var value = this.vm[this.exp];//触发data的getter
  		Dep.target = null;// 用完删除
  		return value;
  	}
  }

  function MVVM(options){
  	// 传入的参数
  	this.$options = options;
  	var data = this._data = this.$options.data
  	var me = this
  	for(let key of Object.keys(data)){
  		// 数据代理
  		me._proxy(key)
  	}
  	// console.log(me.name)
  	observe(data,this)
  	this.$compile = new Compile(options.el || document.body,this)
  }

//从代码中可看出监听的数据对象是options.data,每次需要更新视图,则必须通过var vm = new MVVM({data:{name: 'kindeng'}}); vm._data.name = 'dmq'; 这样的方式来改变数据。
  MVVM.prototype = {
  	_proxy:function(key){
  		var me = this;
  		Object.defineProperty(me,key,{
  			configurable:false,
  			enumberable:true,
  			get:function proxyGetter(){
  				return me._data[key];
  			},
  			set:function proxySetter(newVal){
  				me._data[key] = newVal
  			}
  		})
  	}
  }

  new MVVM({
  	el:'#app',
  	data:{
  		name:'yang'
  	}
  })
</script>
</body>
</html>

自己画的简略图:

引申提问

  • 为什么要进行依赖收集?

如果模板中没有使用到数据,但该数据在data中定义了,当该数据改变的时候,会触发setter事件,重新渲染页面,这样显然会影响性能,所以呢,需要进行依赖收集,数据在模板中用到就收集,没有用到,就不收集了。

  • 依赖是怎么收集的?

在解析模板指令的时候,会实例化订阅者watcher,watcher实例化的时候会触发defineprotype的getter方法,同时会标记一个target,然后在defineprotype的getter方法中,会去做一个判断,如果target存在,就会把当前target添加到订阅器中。

  • 基于vue数据绑定,如果data中的数据进行了一秒1000次的改变,每次改变会全部显示在页面中吗?

不会。因为dom异步更新。

  • 在new vue()中,data可以直接是一个对象,为什么在vue组件中,data必须是一个函数呢?

因为,每一个vue组件通过new Vue()实例化,引用的是同一个对象,如果组件中的data直接是一个对象的话,那么一旦修改组件的data,其他组件相同的数据就会被改变,而如果data是一个函数,那么组件的data就会有一个自己的作用域,做到互不干扰。

v-model实现原理

v-model是vue的一个指令,主要是在input,textarea,select,radio,checkbox,以及子组件上实现数据双向绑定。

v-model在元素中实现:以input元素为列子,

1.指令解析:模板在编译的时候,会将元素中的指令进行解析。

2.v-model会被拆分成两段代码,第一个是,将元素的value值绑定到data数据比如message中,同时给元素添加input事件,触发input事件,动态的把当前input的value赋值给message。

  • text 和 textarea 元素使用 value 属性和 input 事件;

  • checkbox 和 radio 使用 checked 属性和 change 事件;

  • select 字段将 value 作为 prop 并将 change 作为事件。

<input
  v-bind:value="message"
  v-on:input="message=$event.target.value">

v-model在组件中实现:它会默认传入一个prop,并且prop的名称为value,再定义一个触发事件。

v-model修饰符

.lazy

在触发 change 事件的时候进行数据同步

.number

将输入的值自动转成数字类型

使用 parseFloat() 函数对输入的值进行处理,如果输入的值是 parseFloat() 函数不能解析的,如以非数字开头的字符串,就会返回原始值。

.trim

去掉收尾空白字符。

v-bind分析

v-bind可绑定的类型:

  • v-bind:key

  • v-bind:title

  • v-bind:class

  • v-bind:style

vue-cli

首先我们要知道,vue-cli生成的项目,帮我们配置好了哪些功能?

  • ES6代码转换成ES5代码
  • scss/sass/less/stylus转css
  • .vue文件转换成js文件
  • 使用 jpg、png,font等资源文件
  • 自动添加css各浏览器产商的前缀
  • 代码热更新
  • 资源预加载
  • 每次构建代码清除之前生成的代码
  • 定义环境变量
  • 区分开发环境打包跟生产环境打包