简单实现vue双向数据绑定

本文详细解析了前端面试中关于双向数据绑定的实现原理,包括数据劫持、DOM监听和虚拟文档优化。通过Object.defineProperty实现get和set方法,监听数据变化并同步页面,同时借助文档碎片提升性能。
摘要由CSDN通过智能技术生成

 前端面试常见问题,双向数据绑定实现的原理。

重点1:通过Object.defineProperty 为每个数据节点设置get,set方法,实现对数据的劫持,数据内容改变必须经过set方法,因此就可以在数据改动经过set方法时,去改变页面数据显示。以此实现了数据到页面的单向绑定。

重点2:通过监听input等页面元素的changge,input等事件,node.addEventListener("input",e=>{});可以得到页面输入容的变化,将变化的内容再修改js数据对象中。以此完成页面输入值更新到内存数据对象的操作。

注:通过1,2两点已经可以完成双向数据的绑定。第3点并不是必须的,只是用于提升性能,提高实用性的,因为如果没有第3点的虚拟文档功能,每次数据变更都进行大量dom的渲染,性能消耗过大,页面将无法流程运行。

重点3:虚拟文档,或者叫文档碎片 ,document.createDocumentFragment() 可以创建一个虚拟文档对象,可以像操作页面dom元素一样操作这个虚拟文档,却别是操作页面文档会进行实时的文档渲染显示,性能消耗非常大, 而虚拟文档节点只是内存对象,并不会进入任何渲染计算,可以高效的进行对虚拟文档中的节点进行操作。当需要做的处理全部完成后,再将虚拟文档中的节点一次性加载到页面dom中,页面只需要渲染一次,就可以完成所有的更新。

下面是代码实现,此实现只提供实现逻辑,并未完善所以的情况,不可用于真实生产情况。

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>简单的数据双向绑定实现</title>
	</head>
	<body>
		<div id="app">
			<span>名称:{{name }}({{sex}})</span>
			<input type="text" v-model="name" placeholder="名称"/>
			<input type="text" v-model="sex" placeholder="性别"/>
			<h1></h1>
			<span>更多:{{ more.like }}</span>
			<input type="text" v-model="more.like" />
		</div>
		
<script >
	class Vue{
		constructor(obj_instance) {
		    this.$data = obj_instance.data;//将传入的初始化数据对象保存到内部对象中
			this.$el = document.querySelector(obj_instance.el);//获取 主节点的 node对象
			this.$watchers = new WatchersManager();//观察者,简单来说就是页面中动态绑定数据的node 对应的 数据key 列表,如果数据更新了,就在这个列表中把页面动态数据的地方也同步修改,
			this.Observer(this.$data);//遍历data中的所有属性,并通过 Object.defineProperty 重写对象的 get 和set 方法,从而监听 数据的变化,在更新到页面上去
			this.Compile();//初始化编译页面,用data中的数据去填充页面元素的占位符{{}} 或者 v-model的值,并建立input监听 以及  数据观察机制。
		}
		/**
		 * 监听数据变化
		 * 重点1:这里是双向绑定最重要的点,利用 Object.defineProperty 重写对象属性的功能,对数据对象进行劫持,也就是重写数据的get set方法。
		 * 1.通过 Object.defineProperty 重写 get set方法,在数据读取和写入的时候,均会触发重写的 get  set 函数执行。就可以知道数据什么时候发生变化了
		 * 2.set 修改属性值被调用后, 去 watchers 通过属性名称,可以找到所有使用此属性的node节点,对节点内的数据进行更新即可。
		 * 注意:Observer是函数内部存在递归调用,因为data对象可能存在多级结构,比如 data.user , user.name, data.user.wallet.money 存在很多级的数据结构。
		 * 所以每一级别对象调用Observer方法后,将递归查询所有子对象 再次执行Observer方法。达到绑定所有对象的目的。
		 **/
		Observer(data_instance,partenDirectory){//所有父级调用层级
			if(!data_instance || typeof data_instance !== 'object'){//如果传入的参数是对象,则通过Object.defineProperty 重写get set方法,否则是单个值了的话就不用重写了。
				return ;
			}
			const $this = this;
			Object.keys(data_instance).forEach(key => {//遍历对象中所有的子对象
				let thisDirec = partenDirectory ? partenDirectory+"."+key : key ;//记录一个当前值的访问路径。 如 user  ,user.name  等
				console.log("为指定数据绑定get,set方法=》"+thisDirec);
				let value = data_instance[key];//将原值获取并保存到零时变量  value中。
				this.Observer(value,thisDirec);//如果将当前对象 低估嗲用绑定操作,直到所有的子对象对绑定get set方法后 继续向下执行
				Object.defineProperty(data_instance,key,{ //为对象重新定义属性,包括get set方法
					enumerble:true,//表示能否通过for in循环访问属性,默认值为true
					configurable:true,//表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性,默认值为true。
					get(){//获取值,通过任意方式获取属性值时触发此方法,并返回值
						console.log("触发"+key+" get方法 =>",value)
						return value;//返回属性值
					},
					set(newVal){//set方法,修改属性值时会触发此方法,并在方法中修改属性的值
						$this.Observer(newVal,thisDirec);//注意,这里是防止 修改一整个对象,比如 user.name  和 user.age  都是user对象中的子对象,如果直接 user = {} 一个新对象,那 user中的name 等子对象就绑定的get set 监听会丢失。 所以需在这里为子对象重新绑定。
						console.log("触发"+thisDirec+" set方法 =>" +newVal)
						value = newVal;//更新 新传入的值 
						$this.$watchers.notify(thisDirec);//通过观察者管理类,传入修改的 元素对象的值,触发使用此数据的 node节点进行数据更新
					}
				})
			})
		}
		
		/**
		 * 页面初始化方法,将 data中的数据 展示到 {{xxx}} 占位符或者 v-model绑定值的input中。
		 * 重点2:文档碎片 DocumentFragment, 通过  document.createDocumentFragment() 可以创建一个虚拟文档。 
		 * 		 操作这个虚拟文档中的内容与 操作页面中显示的文档元素是一样的,但是最大的区别就是虚拟文档只在内存中构建节点对象,并不进行页面渲染,所以修改虚拟文档要快很多。如果直接去修改页面文档,网页响应速度会非常缓慢。
		 * 		 所以vue等均采用虚拟文档建立好元素结构之后,再 append 到页面中。
		 * 1.获取页面中所有的元素节点node。将节点添加到虚拟文档中。
		*/
		Compile(){//页面初始化编译方法
			const fragment = document.createDocumentFragment();//创建一个文档碎片 或者说是 虚拟文档对象
			let child;
			while(child = this.$el.firstChild){//遍历 this.$el 也就是  div#app 这个dom元素下的所有节点,并将节点赋值给 child, 注意:firstChild 就是获取当前节点下的第一个子节点元素
				fragment.append(child);// 将获取的到得一个子节点元素 添加到 虚拟文档的最后面。   特别注意:child只想的是页面中的元素,如果将child append到虚拟文档中,页面中的节点将被删除。
				//因为append 后,原页面节点被删除了,所以 后续while循环获取第一个节点的时候,才会不断的获取到新的节点。
			}
			//所有原页面中的所有节点就被移动到了 虚拟文档fragment中了。次是如果打断点的话,会看到页面中无任何内容显示了
			console.log(fragment)
			this.fragment_compile(fragment);//处理虚拟文档,替换节点 中的 {{xxx}}占位符  和 v-moled,替换值
			
			this.$el.append(fragment)//将虚拟文档重新添加会页面显示。
		}
		
		/**
		 * 1.遍历所有的node节点,查看text节点中是否有 {{xxx}} 占位符,如果有就用data中的值去替换占位符。
		 * 2.变量所有的node节点,查看是否有 属性 v-model 对应的input节点,如果有就将data中的值赋值给input。
		 * 3.上面 2.3两点中的text 节点和 input节点,都生成对应的 watcher观察者对象,其实就是将node节点和 属性名称封装到 watcher对象中,
		 * 		并添加到 当前class 的 $watchers观察者管理器中,用于后面数据变化后,直接通过属性名称就找到观察者并通知修改属性
		 */
		fragment_compile(node){
			console.log("all node=>",node)
			if(node.nodeType === 3){//如果是text 节点
				let oldNodeValue = node.nodeValue;//获取原始的text节点中的字符串,因为这里面包含 {{xxx}} 占位符,必须保留旧字符串,否则一旦 占位符替换后,就无法进行二次解析了。
				this.changePlachHolderToVal(node,oldNodeValue,this,true);//替换 node 文本中的 占位符为真实 数据值, 因为一个节点可能用到多个值,所以用单独的方法去处理
				return ;
			}
			if(node.nodeType === 1 && node.nodeName==='INPUT'){//如果是input节点,
				if(node.hasAttribute("v-model")){//判断input 节点上是否绑定有 v-model 属性
					let valKey = node.getAttribute("v-model");//获取 v-model 绑定的 data中的属性的值
					//为input 绑定input 事件,触发了input 事件时,会调用set方法去修改 data中数据的值,从而通知观察者进行数据更新
					node.addEventListener("input",e=>{//绑定input事件
						let nameSplit = valKey.split(".");//data中对象的 属性访问名称数组
						nameSplit.reduce((upVal,currentKey,currentIndex)=>{//通过reduce归纳函数的层级执行特性,逐级获取到data中对应属性的值
							if(currentIndex==nameSplit.length-1){//最后一层时,不用在返回对象,而是直接进行赋值
								upVal[currentKey] = node.value;
							}
							return upVal[currentKey];//非最后一级的情况,返回当前获取到的属性对象,给与下一级继续执行
						},this.$data);
					})
					node.value = valKey.split(".").reduce((upVal,currentKey)=>{ return upVal[currentKey]; },this.$data);//reduce 层级获取data中的数据,赋值给node节点的value属性
					const $this = this;
					$this.$watchers.add(valKey,new Watcher(this,valKey,function(){//以属性访问节点为索引 user.name 等,为节点建立观察者对象并加入观察者管理器,只要data中属性发生变化,将通知观察者执行回调函数进行更新
						node.value = valKey.split(".").reduce((upVal,currentKey)=>{ return upVal[currentKey]; },$this.$data);//观察者对象回调函数,从data中获取新的值赋值到node节点上,完成更新
					}));
				}
				return ;
			}
			node.childNodes.forEach(child=>this.fragment_compile(child,this));//通过递归,查找节点下是否有子节点,如果有,递归解析内容,直到所有的虚拟节点均处理完成
			
			//注意:真实的vue可能会涉及到其它情况,需要进行值解析,这里不再全部实现了。比如 或存在select 回选,checkbox等,有必要自行改造吧
		}
		//占位符 转换成 data中的真实值
		/**
		 * @param {Object} node 虚拟文档中的元素节点
		 * @param {Object} oldNodeValue  节点中的原生字符串,未解析过{{xxx}}占位符的时候
		 * @param {Object} vm 当前vue实例对象本身
		 * @param {Object} iscompile 是否首次编译,因为这个解析值方法是公用的,首次编译也用,后续data中数据变化也是调用此方法更新数据,所以要区分是否是首次编译
		 */
		changePlachHolderToVal(node,oldNodeValue,vm , iscompile){
			let reg = /\{\{\s*(\S+)\s*\}\}/;//正则表达式 匹配字符串中的 {{xxx.xxx}} 包括一些书写变种 {{ xxx.xxx }} {{xxx.xxx }}
 			let result_regex ;//定义一个零时变量存储 正则表达式的匹配结果
			const $this = this;
			let tempOldNodeValue = oldNodeValue;//建立一个零时参数存储原始的 元素节点字符串,后续都要用到,所以不可直接修改原始字符串中的占位符
			while(result_regex= reg.exec(tempOldNodeValue)){//正则表达式while循环匹配所有的 {{xxx.xxx}} 占位符,如果有,就替换
				console.log("原始占位odeValue=>",tempOldNodeValue)
				console.log("匹配是否有动态绑定数据=>",result_regex)
				let props = result_regex[1].split(".");//result_regex[1] 是正则表达式匹配的结果,获得的内容是 {{xxx.xxx}} 中的 xxx.xxx这个部分,split 以后,就可以获得属性访问的属性名称数组,如user.name 得到的就是 ['user','name]
				let lastval = props.reduce(function(qv,key){
					return qv[key];
				},vm.$data);//通过reduce 层级执行 获得 data中 user.name 属性的值 存到 lastval中
				console.log("动态绑定数据为=>",lastval)
				tempOldNodeValue = tempOldNodeValue.replace(result_regex[0],lastval);//替换零时字符串 tempOldNodeValue 中的占位符 为data中的真实数据。
				if(iscompile){//如果是首次编译处理数据显示,就需要建立观察者对象,当 xxx.xxx属性发生变化时,通知观察者进行数据更新
					$this.$watchers.add(result_regex[1],new Watcher(vm,result_regex[1],function(){ //建立观察者对象,并将对象放到观察者管理器中,如果data中的 xxx.xxx数据发生变化,就通知此观察者更新数据
						$this.changePlachHolderToVal(node,oldNodeValue,vm,false);//调用 changePlachHolderToVal 函数 重新更新绑定的数据
					}));
				}
				reg.lastIndex = 0;//此处是一个特殊点,正则表达式匹配 exec 执行后,如果找到了匹配值,匹配器中将记录 当前索引到的下标,存放到 lastIndex中,下次在执行exce匹配是,只会从上次查到数据的位置开始匹配,会出现bug,所以这里将lastIndex=0,确保后续匹配从0开始,不会漏掉
			}
			node.nodeValue = tempOldNodeValue;//字符串中所有占位符绑定的数据都解析好了,在更新到节点对象中,因为节点已经append到了网页中进行显示,所以这里只要复制完成,页面会直接更新,也就是局部更新了。
		}
	}

	/**
	 * 观察者管理类,管理观察者列表  
	 * add 添加 指定属性的观察者 
	 * notify 触发指定属性 的观察  进行数据更新
	 */
	class WatchersManager{
		constructor() {
			this.$watchers = [];
		}
		
		changeDataKeyFramte(dataKey){//将 user.name 这种链式访问的属性名转换为 key,watchkey_user_point_name
			return "watchkey_"+dataKey.split(".").join("_point_");
		}
		
		add(dataKey,watcher){//添加观察者到管理列表,通过将属性访问名称  转换为key,同一个属性名的对象装进同一个列表,之后触发更新只要获取相应列表进行更新即可
			let watchers = this.$watchers[this.changeDataKeyFramte(dataKey)];
			if(!watchers){
				watchers = [];
			}
			watchers.push(watcher);
			this.$watchers[this.changeDataKeyFramte(dataKey)] = watchers;
		}
		
		notify(dataKey){//通过属性名 获得 此属性的观察者列表,forEach遍历观察者列表,并调用观察者的更新操作
			let watchers = this.$watchers[this.changeDataKeyFramte(dataKey)];
			if(!watchers){
				return ;
			}
			watchers.forEach(watcher => watcher.update() )
		}
	}
	
	/**
	 * 观察者 对象
	 * 主要就用于包装回调函数
	 */
	class Watcher{
		constructor(vm, key, callback) {
		    this.vm = vm;
		    this.key = key;
		    this.callback = callback;
		}
		update(){
			this.callback();
		}
	}
</script>
<script>
	const vm = new Vue(
		{
			el:'#app',
			data:{
				name:"小明",
				sex:"男",
				more:{
					like:"大家好,这是测试数据",
					abc:"1234"
				}
			}
		}
	);
</script>
	</body>
</html>

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值