數據動態綁定的簡單實現——基於ES5對象的getter/setter機制

在雙向綁定的簡單實現——基於「臟檢測」中,我們使用「臟檢測」的機制,實現了一個簡單的雙向綁定計數器。儘管邏輯比較清晰簡單,性能也還可以,但每次都遍歷DOM節點,也是會有一些性能浪費的。ES5提供了Object.defineProperty與Object.defineProperties兩個API,允許我們為對象的屬性增設getter/setter函數。利用它們,我們可以很方便地監聽數據變更,並且在變更時加入自己的邏輯。

本文我將利用ES5對象的getter/setter機制,模仿Vue的原理,來實現一個簡單的數據動態綁定(暫且稱為Lue吧)。

語法設計

本次我基於Vue的三個指令:v-model、v-bind和v-click,來實現數據雙向綁定(不考慮深層次對象的數據綁定)。DOM依然沿用上篇文章中的結構:

<div id="app"> <form> <input type="text" v-model="count" /> <button type="button" v-click="increment">increment</button> </form> <p v-bind="count"></p></div>

我們希望使用類似Vue的語法創建一個Lue實例:

var app=new Lue({ el:"#app", data:{ count:0, }, methods:{ increment:function(){ this.count++; } }})

開始

開始的開始,我們需要創建一個Lue類:

function Lue(options){ this._init(options);}

其中包含一個_init初始化函數,定義如下:

Lue.prototype._init=function(options){ this.$options=options; //傳入的實例配置 this.$el=document.querySelector(options.el); //實例綁定的根節點 this.$data=options.data; //實例的數據域 this.$methods=options.methods; //實例的函數域};

綁定數據對象的改造

為了實現雙向綁定,首先我們需要使用Object.defineProperty對data中的數據對象進行改造,添加getter/setter函數,使其在賦值和取值時能夠被監聽。

/**對象屬性重定義 * @param key 數據對象名稱,本例為"count" * @param val 數據對象的值 */Lue.prototype.convert=function(key,val){ Object.defineProperty(this.$data,key,{ enumerable:true, configurable:true, get:function(){ console.log(`獲取${val}`); return val; }, set:function(newVal){ console.log(`更新${newVal}`); val=newVal; } })};

對data中的數據對象進行遍歷調用convert:

//遍曆數據域,添加getter/setterLue.prototype._parseData=function(obj){ var value; for(var key in obj){ //排除原型鏈上的屬性,僅僅遍歷對象本身擁有的屬性 if(obj.hasOwnProperty(key)){ value=obj[key]; //如果屬性值為對象,則遞歸解析。本文暫不做實現 //if(typeof value ==="object"){ //this._parseData(value); //} this.convert(key,value); } }};

在控制台做如下測試,可以看到已經成功添加了getter與setter:

綁定函數的改造

對於methods域中的函數,由於API要求我們的函數作用域與vm.$data一致,因此需要對其中的函數進行改造:

//對綁定的函數進行改造//@params {attrVal } "v-click"節點的值,如"alert("hello")"Lue.prototype._parseFunc=function(attrVal){ var args=/(.*)/.exec(attrVal); if(args) { //如果函數帶參數,將參數字元串轉換為參數數組 args=args[0]; attrVal=attrVal.replace(args,""); args=args.replace(/[()""]/g,"").split(","); } else args=[]; return this.$methods[attrVal].bind(this.$data,args);};

上述兩個改造流程必須發生在初始化階段,因此我們需要更改一下之前定義的_init函數:

Lue.prototype._init=function(options){ this.$options=options; //傳入的實例配置 this.$el=document.querySelector(options.el); //實例綁定的根節點 this.$data=options.data; //實例的數據域 this.$methods=options.methods; //實例的函數域 this._parseData(this.$data);};

至此,對於Lue實例的數據與函數的初始化就完成了。下面需要考慮的是,當數據發生變化時,如何更新DOM元素呢?

最容易想到的一個做法是遍歷所有含有v-bind指令的DOM模板,利用相應的綁定數據在內存中拼裝成一個fragment,然後再將新的fragment替換舊的DOM結構。但是這個方案存在兩個問題:

  • 修改未綁定至DOM的數據時,也會引發DOM的重新渲染。
  • 修改某個數據會導致所有DOM重新渲染,而非只更新數據變動了的相關DOM 。

為了解決這個問題,我們需要引入Directive。

Directive(指令)

Directive的作用就是建立一個DOM節點和對應數據的映射關係。它的定義和原型方法如下:

function Directive(name,el,vm,exp,attr){ this.name=name; //指令名稱,例如文本節點,該值設為"text" this.el=el; //指令對應的DOM元素 this.vm=vm; //指令所屬Lue實例 this.exp=exp; //指令對應的值,本例如"count" this.attr=attr; //綁定的屬性值,本例為"innerHTML" this.update(); //首次綁定時更新}

Directive.prototype.update=function(){ //更新DOM節點的預設屬性值 this.el[this.attr]=this.vm.$data[this.exp];};

如此便實現了更改某個數據,只觸發其對應DOM節點的更新。

下面我們需要考慮的問題是,如何讓數據對象的setter在觸發時,調用與之相關的directive?

首先我們需要在實例化時建立一個_binding對象,該對象集合了真正與DOM綁定的那些數據對象(data中聲明的對象的子集)。因此我們又一次修改_init函數:

Lue.prototype._init=function(options){ this.$options=options; //傳入的實例配置 this.$el=document.querySelector(options.el); //實例綁定的根節點 this.$data=options.data; //實例的數據域 this.$methods=options.methods; //實例的函數域 //與DOM綁定的數據對象集合 //每個成員屬性有一個名為_directives的數組,用於在數據更新時觸發更新DOM的各directive this._binding={}; this._parseData(this.$data);};

_binding對象中屬性的一個例子如下:

this._binding={ count:{ _directives:[] //該數據對象的相關指令數組 }}

然後我們改寫遍曆數據域的函數與綁定數據時的setter函數:

//遍曆數據域,添加getter/setterLue.prototype._parseData=function(obj){ var value; for(var key in obj){//排除原型鏈上的屬性,僅僅遍歷對象本身擁有的屬性 if(obj.hasOwnProperty(key)){ this._binding[key]={ //初始化與DOM綁定的數據對象 _directives:[] }; value=obj[key]; //如果屬性值為對象,則遞歸解析 if(typeof value ==="object"){ this._parseData(value); } this.convert(key,value); } }};

set:function(newVal){ console.log(`更新${newVal}`); if(val!==newVal){ val=newVal; //遍歷該數據對象的directive並依次調用update binding._directives.forEach(function(item){ item.update(); }) }}

如此,我們便能實現在數據變更後,進行精準的DOM節點更新。

編譯DOM節點

實現雙向綁定的最後一步,就是編譯帶有v-model、v-click與v-bind指令的DOM節點。我們加入一個名為_compile的原型函數:

//解析DOM的指令Lue.prototype._compile=function(root){ var _this=this; //獲取指定作用域下的所有子節點 var nodes=root.children; for(var i=0;i<nodes.length;i++){ var node=nodes[i]; //若該元素有子節點,則先遞歸編譯其子節點 if(node.children.length){ this._compile(node); } if(node.hasAttribute("v-click")) { node.onclick = (function () { var attrVal=nodes[i].getAttribute("v-click"); var args=/(.*)/.exec(attrVal); if(args) { //如果函數帶參數,將參數字元串轉換為參數數組 args=args[0]; attrVal=attrVal.replace(args,""); args=args.replace(/[(|)|"|"]/g,"").split(","); } else args=[]; return function () { _this.$methods[attrVal].apply(_this.$data,args); } })() } if(node.hasAttribute(("v-model")) && (node.tagName=="INPUT" || node.tagName=="TEXTAREA")){ //如果是input或textarea標籤 node.addEventListener("input", (function (key) { var attrVal=node.getAttribute("v-model"); //將value值的更新指令添加至_directives數組 _this._binding[attrVal]._directives.push(new Directive( "input", node, _this, attrVal, "value" )) return function () { _this.$data[attrVal] = nodes[key].value; } })(i)); } if(node.hasAttribute("v-bind")){ var attrVal=node.getAttribute("v-bind"); //將innerHTML的更新指令添加至_directives數組 _this._binding[attrVal]._directives.push(new Directive( "text", node, _this, attrVal, "innerHTML" )) } }}

改寫Lue的_init原型方法,使其在初始化時即對DOM進行編譯:

Lue.prototype._init=function(options){ this.$options=options; //傳入的實例配置 this.$el=document.querySelector(options.el); //實例綁定的根節點 this.$data=options.data; //實例的數據域 this.$methods=options.methods; //實例的函數域 //與DOM綁定的數據對象集合 //每個成員屬性有一個名為_directives的數組,用於在數據更新時觸發更新DOM的各directive this._binding={}; this._parseData(this.$data); this._compile(this.$el); //編譯DOM節點};

至此,我們便實現了一個基於getter/setter,模仿Vue的簡單的雙向綁定。整個體系搭建並不複雜,只需要注意其中三個核心的部分:getter/setter,Directive以及binding。細心的讀者不難發現,在本文的實現中,如果線程頻繁觸發數據變更,會導致DOM頻繁更新,非常影響性能。在真正的生產環境中,DOM的更新不是數據變更後立馬更新,而是被加入到批處理隊列,等待主線程運行完後再進行批處理。

整個Lue實例結構如下:

完整代碼

<div id="app"> <form> <input type="text" v-model="count" /> <button type="button" v-click="increment">increment</button> <button type="button" v-click="alert("Hello world")">alert</button> </form> <p v-bind="count"></p></div><script> function Lue(options){ this._init(options); console.log(this) } Lue.prototype._init=function(options){ this.$options=options; //傳入的實例配置 this.$el=document.querySelector(options.el); //實例綁定的根節點 this.$data=options.data; //實例的數據域 this.$methods=options.methods; //實例的函數域 //與DOM綁定的數據對象集合 //每個成員屬性有一個名為_directives的數組,用於在數據更新時觸發更新DOM的各directive this._binding={}; this._parseData(this.$data); this._compile(this.$el); //編譯DOM節點 }; //遍曆數據域,添加getter/setter Lue.prototype._parseData=function(obj){ var value; for(var key in obj){ //排除原型鏈上的屬性,僅僅遍歷對象本身擁有的屬性 if(obj.hasOwnProperty(key)){ this._binding[key]={ //初始化與DOM綁定的數據對象 _directives:[] }; value=obj[key]; this.convert(key,value); } } }; //對綁定的函數進行改造 Lue.prototype._parseFunc=function(attrVal){ var args=/(.*)/.exec(attrVal); if(args) { //如果函數帶參數,將參數字元串轉換為參數數組 args=args[0]; attrVal=attrVal.replace(args,""); args=args.replace(/[()""]/g,"").split(","); } else args=[]; return this.$methods[attrVal].bind(this.$data,args); }; /**對象屬性重定義 * @param key 數據對象名稱,本例為"count" * @param val 數據對象的值 */ Lue.prototype.convert=function(key,val){ var binding=this._binding[key]; Object.defineProperty(this.$data,key,{ enumerable:true, configurable:true, get:function(){ console.log(`獲取${val}`); return val; }, set:function(newVal){ console.log(`更新${newVal}`); if(val!=newVal){ val=newVal; binding._directives.forEach(function(item){ item.update(); }) } } }) }; function Directive(name,el,vm,exp,attr){ this.name=name; //指令名稱,例如文本節點,該值設為"text" this.el=el; //指令對應的DOM元素 this.vm=vm; //指令所屬lue實例 this.exp=exp; //指令對應的值,本例如"count" this.attr=attr; //綁定的屬性值,本例僅實驗innerHTML this.update(); //首次綁定時更新 }Directive.prototype.update=function(){ //更新DOM節點的相應屬性值 this.el[this.attr]=this.vm.$data[this.exp]; }; //解析DOM的指令 Lue.prototype._compile=function(root){ var _this=this; //獲取指定作用域下的所有子節點 var nodes=root.children; for(var i=0;i<nodes.length;i++){ var node=nodes[i]; //若該元素有子節點,則先遞歸編譯其子節點 if(node.children.length){ this._compile(node); } if(node.hasAttribute("v-click")) { node.onclick = (function () { var attrVal=nodes[i].getAttribute("v-click"); return _this._parseFunc(attrVal); })() } if(node.hasAttribute(("v-model")) && (node.tagName=="INPUT" || node.tagName=="TEXTAREA")){ //如果是input或textarea標籤 node.addEventListener("input", (function (key) { var attrVal=node.getAttribute("v-model"); //將value值的更新指令添加至_directives數組 _this._binding[attrVal]._directives.push(new Directive( "input", node, _this, attrVal, "value" )) return function () { //_this.$data[attrVal] = nodes[key].value; _this.$data.$set(attrVal,nodes[key].value); } })(i)); } if(node.hasAttribute("v-bind")){ var attrVal=node.getAttribute("v-bind"); //將innerHTML的更新指令添加至_directives數組 _this._binding[attrVal]._directives.push(new Directive( "text", node, _this, attrVal, "innerHTML" )) } } } window.onload=function(){ var app=new Lue({ el:"#app", data:{ count:0, }, methods:{ increment:function(){ this.count++; }, alert:function(msg){ alert(msg) } } }) }</script>

推薦閱讀

Vue源碼解析

ES5關於Object的新特性


推薦閱讀:

收藏指數滿格!幫你打包前端之巔一整年好文!
San - 一個傳統的MVVM組件框架
組件化必殺技:styled-components 簡明教程【附視頻下載】
有關Bootstrap你想要知道的都在這裡

TAG:前端开发 | 前端框架 | Vuejs |