如何實現VM框架中的資料繫結

iKcamp發表於2019-01-16

作者:佳傑

本文原創,轉載請註明作者及出處

如何實現VM框架中的資料繫結

一:資料繫結概述

檢視(view)和資料(model)之間的繫結
複製程式碼

二:資料繫結目的

不用手動呼叫方法渲染檢視,提高開發效率;統一處理資料,便於維護
複製程式碼

三:資料繫結中的元素

檢視(view):說白了就是html中dom元素的展示
資料(model):用於儲存資料的引用型別
複製程式碼

四:資料繫結分類

view > model的資料繫結:view改變,導致model改變
model > view的資料繫結:model改變,導致view改變
複製程式碼

五:資料繫結實現方法

view > model的資料繫結實現方法
		修改dom元素(input,textarea,select)的資料,導致model產生變化,
		只要給dom元素繫結change事件,觸發事件的時候修改model即可,不細講

model > view的資料繫結實現方法
		1.釋出訂閱模式(backbone.js用到);
		2.資料劫持(vue.js用到);
		3.髒值檢查(angular.js用到);
複製程式碼

六:model > view資料繫結demo講解 (如何實現資料改變,導致UI介面重新渲染)

簡易思路 
> 1.通過defineProperty來監控model中的所有屬性(對每一個屬性都監控)
> 2.編譯template生成DOM樹,同時繫結dom節點和model(例如<div id="{{model.name}}"></div>),
	defineProperty中已經給“model.name”繫結了對應的function,
	一旦model.name改變,該funciton就操作上面這個dom節點,改變view


主要js模組:Observer,Compile,ViewModel

	1.Observer
		用到了釋出訂閱模式和資料監控,defineProperty用於“監控model", dom元素執行"訂閱"操作,給model中
		的屬性繫結function;model中屬性變化的時候,執行"釋出"這個操作,執行之前繫結的那個function

  	原始碼如下:
	var Observer = function(opts) {
		this.id = (opts && opts.id) ? opts.id : +new Date();
		this.opts = opts;
		this.subs = []; //觀察者陣列
		/*this.subs包含了所有觀察者,每個觀察者的結構如下:
		{
			key:"person.age.range",//這個key代表model.person.age.range這個屬性

			/*
			 和key繫結的函式陣列,每個函式操作一個dom節點,
			 一個key對應多個dom節點,所以actionList是個function陣列;
			 */
			actionList:[function(){},function(){}]
		}*/
	}
	Observer.prototype = {

		//遍歷model中所有的屬性,每個屬性用defineKey來監控所有屬性
		monit: function(data, baseUrl) {
			var me = this;
			baseUrl = baseUrl || "";
			var isTypeMatch = (data && typeof data === "object");
			if (isTypeMatch) {
				Object.keys(data).forEach(function(key) {
					var base = baseUrl ? (baseUrl + "." + key) : key;
					me.defineKey(data, key, data[key], baseUrl); //定義自己
					me.monit(data[key], base); //遞迴【定義的是下一層】
				});
			}
		},

		//用到了Object.defineProperty來定義屬性,這樣屬性改變的時候,就會自動執行裡面的set方法
		defineKey: function(data, key, val, baseUrl) {
			var me = this;
			var base = baseUrl ? (baseUrl + "." + key) : key;

			Object.defineProperty(data, key, {
				enumerable: true,
				configurable: false,
				get: function() {
					return val;
				},

				//更新並監控新的值,執行publish函式
				set: function(newVal) {
					if (newVal !== val) {
						val = newVal;

						//設定新值需要重新監控
						me.monit(newVal, base); 

						//(baseUrl+"."+key)作為觀察者模式中的監聽的那個key,也可以說是監聽的那個事件
						me.publish(base, newVal); 
					}
				}
			});
		},

		/*
		 根據key來執行繫結在這個key上的所有函式,比如說person.age.range這個key,
		 它變動的時候,publish會執行繫結在person.age.range這個key上所有的function
		 */
		publish: function(key, newVal) {
			(this.subs || []).forEach(function(sub) {
				if (sub.key == key) {
					(sub.actionList || []).forEach(function(action) {
						action(newVal);
					});
				}
			});
		},

		//給model中的某個key(例如person.age.range)新增繫結的function 
		subscribe: function(key, callback) {
			var tgIdx;
			var hasExist = this.subs.some(function(unit, idx) {
				tgIdx = (unit.key === key) ? idx : -1;
				return (unit.key === key)
			});
			if (hasExist) {
				if (Object.prototype.toString.call(this.subs[tgIdx].actionList)=="[object Array]"){
					this.subs[tgIdx].actionList.push(callback);
				} else {
					this.subs[tgIdx].actionList = [callback];
				}
			} else {
				this.subs.push({
					key: key,
					actionList: [callback]
				});
			}
		},

		//取消訂閱
		remove: function(key) {
			var removeIdx;
			this.subs.forEach(function(sub, idx) {
				removeIdx = sub.key === key ? idx : -1;
				return sub.key === key
			});
			if (removeIdx !== -1) {
				this.subs.splice(removeIdx, 1);
			}
		},

		isObject: function(data) {
			return data && typeof data === "object"
		}
	};



	2.Compile: 模板編譯器
	var Compile = function(opts) {
		this.opts = opts;
		this.data = this.opts.data;
		this.observer = this.opts.observer;
		this.regExp = /{{([sS]*)}}/;
		this.ele = document.createElement("div");
		this.ele.innerHTML = opts.template; //渲染頁面
		this.fragment = this.transToFrament(this.ele);
		this.travelAllNodes(this.fragment);
		this.ele.appendChild(this.fragment);
	};
	Compile.prototype = {

		//把頁面上的dom節點轉化成文件碎片,防止dom頻繁操作影響頁面效能
		transToFrament: function(el) {
			var fragment = document.createDocumentFragment(),
				child;
			// 將原生節點拷貝到fragment
			while (child = el.firstChild) {
				fragment.appendChild(child);
			}
			return fragment;
		},

		//遍歷文件碎片節點下所有的node節點(用到了函式遞迴呼叫),執行compileNode
		travelAllNodes: function(ele) {
			this.compileNode(ele);
			([].slice.call(ele.childNodes) || []).forEach(function(node) {
				this.compileNode(node);
				if (node.childNodes && node.childNodes.length) {
					this.travelAllNodes(node);
				}
			}.bind(this));
		},

		/*包含功能
		 1.渲染node節點
		 2.給key設定callback函式,函式內操作node節點
		 */
		compileNode: function(node) {
			if (this.isElement(node)) {
				this.compileElementNode(node);
			} else if (this.isText(node)) {
				this.compileTextNode(node);
			}
		},

		/*
		  編譯element型別的node節點,
		  需要處理屬性繫結v-bind="{{data.name}}"和
		  事件v-event="{{data.event}}"
		 */
		compileElementNode: function(node) {
			var me = this,
				nodeAttrs = node.attributes;
			[].slice.call(nodeAttrs).forEach(function(attr) {
				var attrName = attr.name;
				var attrValue = attr.value;
				var key = me.getKey(attrValue);
				me.bindKeyToNode(key, attr);
				attr.value = me.compileString(attrValue); //渲染node
			});
		},

		//編譯文字型別的node節點,裡面放了對應的"{{data.name}}"這種資料格式
		compileTextNode: function(ele) {
			var key = this.getKey(ele.textContent);
			this.bindKeyToNode(key, ele);
			ele.textContent = this.compileString(ele.textContent);
		},

		//解析“{{}}”,把它變成對應的資料值
		compileString: function(str) {
			var key = this.getKey(str);
			return str.replace(this.regExp, this.getValueByKey(key));
		},

		//繫結key和node節點,key一旦改變,就會觸發對應的函式,修改node節點
		bindKeyToNode: function(key, node) {
			if (!!key.trim()) {
				console.log(key);
				var nodeType = node.nodeType;
				var regExp = new RegExp("\{\{" + key + "\}\}");
				var originTextConetnt;
				if (nodeType === 2) {
					originTextConetnt = node.value;
				} else if (nodeType === 3) {
					originTextConetnt = node.textContent;
				}

				this.observer.subscribe(key, function(newVal) {
					var tgValue = originTextConetnt.replace(regExp, newVal);
					if (nodeType === 2) {
						node.value = tgValue;
					} else if (nodeType === 3) {
						node.textContent = tgValue;
					}
				});
			}
		},

		//從{{name.age.sex}}中獲取name.age.sex
		getKey: function(str) {
			return str.match(this.regExp) ? str.match(this.regExp)[1] : "";
		},

		//獲取key對應的value值
		getValueByKey: function(key) {
			var arr = key ? key.split(".") : [];
			var temp = this.data;
			for (var i = 0; i < arr.length; i++) {
				if (temp) {
					temp = temp[arr[i]];
				} else {
					temp = undefined;
					break
				}
			}
			return temp;
		},


		isElement: function(ele) {
			return ele.nodeType === 1 ? true : false;
		},
		isText: function(ele) {
			return ele.nodeType === 3 ? true : false;
		},
		getElement: function() {
			return this.ele;
		}
	}




	3.ViewModel:結合Observer與Compile,實現model > view的資料單向繫結
	var ViewModel = function(opts) {
		this.opts = opts;
		this.data = opts.data;
		this.wrapper = opts.wrapper;
		this.template = opts.template;
		this.Observer = (typeof Observer != undefined) ? Observer : opts.Observer;
		this.Compile = (typeof Compile != undefined) ? Compile : opts.Compile;
		this.init();
	}

	ViewModel.prototype = {
		init: function() {
			var opts = this.opts;
			this.observer = new this.Observer(opts);
			this.observer.monit(this.data); //監控資料變化,資料已經改變了
			this.compiler = new this.Compile(Object.assign(opts, {
				observer: this.observer
			})); //編譯生成節點
			if (this.wrapper) {
				this.wrapper.appendChild(this.compiler.getElement());
			}
		},
		get: function() {
			return this.compiler.getElement();
		}
	};
複製程式碼

總結

簡單地呼叫new ViewModel({data:data,template:template}),完成了model和view的繫結,
ViewModel內部大致執行順序是:

1. 建立資料監控物件this.observer,該物件監控data(監控以後,data的屬性改變,
   就會執行defineProperty中的set函式,set函式裡面新增了publish釋出函式)

2. 建立模板編譯器物件this.compiler,該物件編譯template,生成最終的dom樹,
   並且給每個需要繫結資料的dom節點新增了subscribe訂閱函式

3. 最後,改變data裡面的屬性,會自動觸發defineProperty中的set函式,set函式呼叫publish函式,
   publish會根據key的名稱,找到對應的需要執行的函式列表,依次執行所有函式
複製程式碼

Git地址

https://github.com/devil1989/databind/
複製程式碼

demo

	<!DOCTYPE html>
	<html lang="en">
	<head>
		<meta charset="UTF-8">
		<title>Document</title>
		<link rel="stylesheet" type="text/css" href="demo.css">
		<script type="text/javascript" src="./observe.js"></script>
	</head>
	<body>
		<template id="inner" type="text/template">
			
			<div title="{{des}}">
				<div>
					<ul id="list">
						<li >
							<span >age:</span>
							<input  type="text" name="" value="{{age}}" >
							<span id="age" style="float: left;">+</span>
						</li>
						<li>
							<span>name:</span>
							<input id="firstName" type="text" name="" value="{{name}}">
						</li>
						<li><span>{{name}}</span></li>
					</ul>
				</div>
				
			</div>
		</template>
		<script type="text/javascript">
			(function(){
				window.data={name:"jeffrey",age:28,des:"測試"};
				var vm=new VM({
					data:data,
					template:document.getElementById("inner").innerHTML
					/* wrapper:document.body//可以指定對應容器,也可以不指定容器,
					直接獲取元素,再手動插入對應dom元素*/
				});
				document.body.appendChild(vm.get());

				document.getElementById("age").addEventListener("click",function(){
					data.age++;//只需要修改屬性,html就會重新渲染
				});

				document.getElementById("firstName").addEventListener("keyup",function(e){
					data.name=this.value;//只需要修改屬性,html就會重新渲染
				});
			})();
		</script>
	</body>
	</html>
複製程式碼

使用場景說明:

當我們想要修改頁面某個元素的資訊,但又不想費勁地查詢dom元素再去修改元素的值,
這種情況下,可以用demo中的資料繫結,只需修改資料的值,就實現了頁面元素重新渲染
請看下面的gif動畫中展示的,只要修改data.age和data.name,頁面元素就自動重新渲染了
複製程式碼
avatar

結束語

本demo只是簡單實現資料繫結,很多功能並未實現,只是提供一種思路,拋磚引玉;
如果對上述程式碼中的Observer類的程式碼不是很理解,可以先了解下觀察者模式以及實現原理;
最後,感謝大家的閱讀!!

如何實現VM框架中的資料繫結
如何實現VM框架中的資料繫結

推薦: 翻譯專案Master的自述:

1. 乾貨|人人都是翻譯專案的Master

2. iKcamp出品微信小程式教學共5章16小節彙總(含視訊)

3. 開始免費連載啦~每週2更共11堂iKcamp課|基於Koa2搭建Node.js實戰專案教學(含視訊)| 課程大綱介紹


如何實現VM框架中的資料繫結

2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章