本文來自我的github
0.前言
使用者最滿意的,無非就是介面的操作能實事反應到資料。而實現這種的可以有雙向資料繫結、單向資料流的形式。雙向資料繫結是,ui行為改變model層的資料,model層的資料變了也能反映到ui上面。比如點選按鈕,數字data+1,如果我們自己在控制檯再給data+1,那麼v層也能馬上看見這個變化。而單向資料流就不同了,我們只有ui行為改變,data就改變並馬上反饋到v層,而我們自己在控制檯改變data這個值,v層居然不變(model是已經變了並沒有反應),只能等到下一次ui行為改變,帶上這個data結果一起處理。僅僅在V層的單向資料,真的能滿足使用者需求?資料很龐大的時候,雙綁效能如何?其實,每一種都有每一種的適用場景,還是那句話,脫離實際場景談效能,就是扯淡
1.單向資料(代表:react)
一般的過程:ui行為→觸發action→改變資料state→mumtation再次渲染ui介面,通常就是基於view層,一個很簡單的例子: html部分:
<input id="ipt" type="text" name="">
<p id="a"></p>
複製程式碼
js部分:
var str = ''
a.innerHTML = str//初始化
ipt.oninput = function(){//點選觸發action
str = ipt.value//改變state狀態值
a.innerHTML = str//重新渲染
}
複製程式碼
但是如果在控制檯獲取input這個dom,在設定value,不會馬上反映,只能等下一次帶著這個結果一起作用。這僅僅是V->M的過程
我們再做一個超級簡單的雙綁: html部分:
<input id="ipt" type="text" name="">
<p id="a"></p>
複製程式碼
js部分:
var $scope = {
data:''
}
a.innerHTML = ''
setInterval(function(){
a.innerHTML = $scope.data
},60)
ipt.oninput = function(){
$scope.data = ipt.value
}
複製程式碼
這裡除了單向資料繫結,當你改變$scope.data,p標籤的內容也是會馬上改變。因為用了定時器,他會非同步地將資料反映上去。
2.觀察者模式
首先,我們先訂閱事件,比如事件‘a’,回撥函式是function (){console.log(1)},訂閱後,如果事件‘a’被觸發了,就呼叫回撥函式。
function Event(){
this.list=[],
this.on=function(key,cb){//訂閱事件
if(!this.list[key]){
this.list[key] = []
}
this.list[key].push(cb)
},
this.emit = function(){//觸發事件
var key = Array.prototype.shift.call(arguments)
var e = this.list[key]
if(!e){
return
}
var args = Array.prototype.slice.call(arguments)
for(var i = 0;i<e.length;i++){
e[i].apply(null,args)
}
}
}
複製程式碼
嘗試一下:
var a = new Event()
a.on('a',function(x){console.log(x)})
a.emit('a',1)//1
複製程式碼
這樣子,在1中單向資料的小例子,首先我們on裡面加入事件a,回撥是a.innerHTML = str,然後我們可以在改變model層的時候,順便觸發一下(emit(‘a’)),不就可以做到M->V的反映了嗎?
對的,是行得通,可是這都是死的,也不能自動讓他雙向資料繫結,所以我們借用js底層的Object.defineproperty。
3.雙綁的中間樞紐——Object.defineproperty(代表:vue)
在第二篇文章已經講過,這裡再重複一次:
var obj = {name:'pp'}
console.log(obj.name)//pp
Object.defineProperty(obj,'name',{
get:function(){
return 1
},
set:function(newVal){
console.log(newVal)
}
})
console.log(obj.name)//1
obj.name = 2;//2
console.log(obj.name)//1
複製程式碼
這是vue雙綁的核心思想,v層能讓m層變了,m層也能讓v層變了,只是不能互相關聯起來,不能做到改變一個層另一個層也能改變。但是,現在就可以了。 html部分:
<input id="ipt" type="text" name="">
<p id="a"></p>
複製程式碼
//js:
var data = {
str:''
}
a.innerHTML = data.str//初始化
function E (){
this.list=[],
this.on=function(key,cb){//訂閱事件
if(!this.list[key]){
this.list[key] = []
}
this.list[key].push(cb)
},
this.emit = function(){//觸發事件
var key = Array.prototype.shift.call(arguments)
var e = this.list[key]
if(!e){
return
}
var args = Array.prototype.slice.call(arguments)
for(var i = 0;i<e.length;i++){
e[i].apply(null,args)
}
}
}
var e = new E()//例項化
e.on('change',function(x){//訂閱change這個事件
a.innerHTML = x
})
Object.defineProperty(data,'str',{
set:function(newval){//當data.str被設定的時候,觸發事件change
e.emit('change',newval)
return newval
}
})
ipt.oninput = function(){
data.str = ipt.value//使用者的action
}
複製程式碼
這下,不僅僅是有改變input的內容的單向的資料繫結,而且你還可以去控制檯改變data.str=1,p標籤的內容馬上變成1,實現了雙向資料繫結。 我們的例子其實不用觀察者模式都可以實現雙綁,但是在實際應用中肯定也不可以不用觀察者模式,為了程式碼可讀性和可維護性以及擴充性。具體的v-model實現在前面文章已經講過
到這裡,你大概比較深入理解雙向資料繫結是什麼了。網上有很多人有vue雙綁demo,但是他們有一部分是僅僅單向繫結的,不妨手動去控制檯改一下那個核心繫結的資料,V層的顯示內容能馬上變化的就是雙綁、不能馬上有變化的只是單向資料
4. 髒值檢測(代表:angular1)
前面說的定時器雙綁是扯淡
前面特地埋了個坑,關於Angular髒檢查,並不是一些人想象的那樣子用定時器週期性進行髒檢測(我前面寫的那個超級簡單的雙綁就是人們傳聞的angular)
只有當UI事件,ajax請求或者 timeout 等非同步事件,才會觸發髒檢查。而我們前面的vue,當我們在控制檯改了資料,就可以馬上反映到v層。angular並沒有這個操作,也沒有意義。因為雙綁的M->V一般就是基於ui行為、定時器、ajax這些非同步動作,所以這就知道為什麼ng-model只能對錶單有效了。想做到像vue那樣的極致雙綁,能夠在控制檯改個資料就改變檢視的,大概就只有defineproperty(聽說新版vue現在用ES6的proxy了)和定時器輪詢了吧。
在angular1中,私有變數以$$開頭,$$watch是一個存放很多個繫結的物件的陣列,用$watch
方法來新增的,每一個被繫結的物件屬性是:變數名、變數舊值、一個函式(用來返回變數新值)、檢測變化的回撥函式。
對於為什麼使用一個函式來記錄新值(類似vue的computed)?這樣子可以每次呼叫都得到資料上最新的值,如果把這個值寫死,不就是不會變化了嗎?這是監控函式的一般形式:從作用域獲取值再返回。
接著我們對$scope
的非函式資料進行繫結,再到 核心的$digest
迴圈,對於每一個$$watch
裡面的每一個watch,我們使用 getNewValue()
並且把scope例項 傳遞進去,得到資料最新值。然後和上一次值進行比較,如果不同,那就呼叫 getListener,同時把新值和舊值一併傳遞進去。 最終,我們把last屬性設定為新返回的值,也就是最新值。$digest
裡會呼叫每個getNewValue(),因此,最好關注監聽器的數量,還有每個獨立的監控函式或者表示式的效能。
在作用域上新增資料本身不會有效能問題。如果沒有監聽器在監控某個屬性,它在不在作用域上都無所謂。$digest
並不會遍歷作用域的屬性,它遍歷的是監聽器。一旦將資料繫結到UI上,就會新增一個監聽器。
最後,我們需要將新的變數值更新到DOM上,只要加上ng的指令,並解釋,觸發$digest
迴圈即可
html:
<input type="text" ng-bind="s" />
<div ng-bind="s"></div>
複製程式碼
js:
function Scope(){
this.$$watchers=[]; //監聽器
}
Scope.prototype.$watch=function(name,exp,listener){
this.$$watchers.push({
name:name, //資料變數名
last:'', //資料變數舊值
newVal:exp, //返回資料變數新值的函式
listener:listener || function(){} //監聽回撥函式,變數“髒”時觸發
})
}
Scope.prototype.$digest=function(){
var bindList = document.querySelectorAll("[ng-bind]"); //獲取所有含ng-bind的DOM節點
var dirty=true;
while(dirty){
dirty=false;
for(var i=0;i<this.$$watchers.length;i++){
var newVal=this.$$watchers[i].newVal();
var oldVal=this.$$watchers[i].last;
if(newVal!==oldVal && !isNaN(newVal) && !isNaN(oldVal)){
dirty=true;
this.$$watchers[i].listener(oldVal,newVal);
this.$$watchers[i].last=newVal;
for (var j = 0; j < bindList.length; j++) {
//獲取DOM上的資料變數的名稱
var modelName=bindList[j].getAttribute("ng-bind");
//資料變數名相同的DOM才更新
if(modelName==this.$$watchers[i].name) {
if (bindList[j].tagName == "INPUT") {
//更新input的輸入值
bindList[j].value = this[modelName];
}
else {
//更新非input的值
bindList[j].innerHTML = this[modelName];
}
}
}
}
}
}
};
var $scope=new Scope();
$scope.count=0;
var inputList=document.querySelectorAll("input[ng-bind]");
for(var i=0;i<inputList.length;i++){
inputList[i].addEventListener("input",(function(index){
return function(){
$scope[inputList[index].getAttribute("ng-bind")]=inputList[index].value;
$scope.$digest(); //呼叫函式時觸發$digest
}
})(i));
}
//繫結非函式資料
for(var key in $scope){
if(key!="$$watchers" && typeof $scope[key]!="function") {
$scope.$watch(key, (function (index) {
return function(){
return $scope[index];
}
})(key))
}
}
$scope.$digest();//第一次digest
複製程式碼
當然,還會有一個問題,當有兩個$watch
迴圈監聽(watch1
監聽watch2
,watch2
監聽watch1
),一個$digest
迴圈執行很多次,而且是多餘操作(並且可能把瀏覽器炸了)。
var scope = new $scope();
scope.a = 5;
scope.b = 1;
scope.$watch('a', function(scope) {
return scope[this.name]
},
function(newValue, oldValue) {
scope.b ++;
})
scope.$watch('b', function(scope) {
return scope[this.name]
},
function(newValue, oldValue) {
scope.a ++;
})
複製程式碼
angular有一個概念叫迭代的最大值:TTL(short for Time To Live)。這個值預設是10。因為digest經常被執行,而且每個digest執行了所有的$watch,再加上使用者一般不會建立10個以上鍊狀的監聽器。 angular的處理辦法是
$scope.prototype.$digest = function() {
var dirty = true;
var checkTimes = 0;
while(dirty) {
dirty = this.$$digestOnce();
checkTimes++;
if(checkTimes>10 &&dirty){
throw new Error();
}
};
};
複製程式碼
對於雙綁,如果是大迴圈,迴圈改變一個值,vue的setter這種即時性的雙綁就會在每一次迴圈都跑一次,而angular1的髒檢測這種慢性雙綁你可以控制在迴圈後才一次跑一次,效能取捨就看實際場景吧。
單向資料流和單向資料繫結是什麼區別呢?
單向資料流,你得按照他的順序辦事。比如我們假設有一個這樣的生命週期:1.從data裡面讀取資料2.ui行為(如果沒有ui行為就停在這裡等他有了為止)3.觸發data更新4.再回到步驟1
改了一個數,v層不能反回頭來找他來更新v層檢視(從步驟2跳回去1),你得等下一個迴圈(轉了一圈)的步驟1才能更新檢視。react都是這樣子,你得setState觸發更新,如果你this.state = {...},是沒用的,他一直不變。
單向資料繫結,就是繫結事件,比如繫結oninput、onchange、storage這些事件,只要觸發事件,立刻執行對應的函式。所以,不要再說一個input綁一個oninput,然後回撥改變一個檢視層資料就叫他雙向資料繫結了。