談談數據監聽observable的實現


一、概述

數據監聽實現上就是當數據變化時會通知我們的監聽器去更新所有的訂閱處理,如:

var vm = new Observer({a:{b:{x:1,y:2}}});
vm.watch('a.b.x',function(newVal,oldVal){
	console.log(arguments);
});
vm.a.b.x = 11; //觸發watcher執行 輸出 11 1

數據監聽是對觀察者模式的實現,也是MVVM中的核心功能。這個功能我們在很多場景中都可以用到,可以大大的簡化我們的代碼。

二、現有MVVM框架中的Observable是怎么實現的

先看看各MVVM框架對Observable是怎么實現的,我們分析下它們的實現原理,常見的MVVM框架有以下幾種:
1、knockout,老牌的MVVM實現

<p>First name: <input data-bind="value: firstName" /></p>
<p>Last name: <input data-bind="value: lastName" /></p>
<h2>Hello, <span data-bind="text: fullName"> </span>!</h2>
var ViewModel = function(first, last) {
    this.firstName = ko.observable(first);
    this.lastName = ko.observable(last);
 
    this.fullName = ko.pureComputed(function() {
        return this.firstName() + " " + this.lastName();
    }, this);
};
 
ko.applyBindings(new ViewModel("Planet", "Earth")); 

早期微軟是把每個屬性轉換成一個observable函數,通過函數對該屬性進行取值賦值來實現的,缺點是改變了原屬性,不能夠像屬性一樣取值賦值。

2、avalon,國產框架特點是兼容IE6+

<div ms-controller="box">
    <div style=" background: #a9ea00;" ms-css-width="w" ms-css-height="h"  ms-click="click"></div>
    <p>{{ w }} x {{ h }}</p>
    <p>W: <input type="text" ms-duplex="w" data-duplex-event="change"/></p>
    <p>H: <input type="text" ms-duplex="h" /></p>
</div>
var vm = avalon.define({
 $id: "box",
  w: 100,
  h: 100,
  click: function() {
    vm.w = parseFloat(vm.w) + 10;
    vm.h = parseFloat(vm.h) + 10;
  }
});
avalon.scan()

avalon對數據監聽堪稱司徒的黑魔法,IE9+時利用ES5的defineProperty/defineProperties去實現,當IE不支持此方法時利用vbscript來實現。缺點是vbs定義后的對象不能夠動態增刪屬性。

3、angular,大而全的mvvm解決方案

<div ng-app="myApp" ng-controller="myCtrl">
名: <input type="text" ng-model="firstName"><br>
姓: <input type="text" ng-model="lastName"><br>
<br>
姓名: {{firstName + " " + lastName}}
</div>
var app = angular.module('myApp', []);
app.controller('myCtrl', function($scope) {
    $scope.firstName = "John";
    $scope.lastName = "Doe";
});

ng對數據監聽的實現,采用了AOP的編程思維,它對常用的dom事件xhr事件等進行封裝,當這些事件被觸發發,封裝的方法中有去調用ng的digest流程,在此流程去檢測數據變化並通知所有訂閱,所以我們導致使用原生的setTimeout代替$timeout后需要自已去執行執行$digest()$apply(),缺點是需要對使用到的所有外部事件進行封裝。

4、vue,現代小巧優雅(實際上是比avalon大一些)

<div id="demo">
  <p>{{message}}</p>
  <input v-model="message">
</div>
var demo = new Vue({
  el: '#demo',
  data: {
    message: 'Hello Vue.js!'
  }
})

vue對數據監聽的實現就比較單一了,因為它只支持IE9+,利用Object.defineProperty一招搞定。缺點是不兼容低版本IE。

三、Observable的實現有哪些方法及思路

通過上面幾個框架對比我們可以看出幾種不同數據監聽的實現方法,實際上還有很多的方式可以去實現的:
1、把屬性轉換為函數(knockout
2、IE9+使用defineProperty/definePropertiesvueavalon
3、低版本IE使用VBS(avalon
4、數據檢測,對各事件進行封裝,在封裝的方法中調用digest(angular
5、利用__defineGetter__/__defineSetter__方法(avalon
6、把數據轉換成dom對象利用IE8 dom對象的defineProperty方法或onpropertychange事件
7、利用Object.observe方法
8、利用ES6的Proxy對象
9、利用setInterval進行臟檢測

那么我們就具體看下這些數據監聽實現:
1、利用函數轉換如ko.observable(),兼容所有

function observable(val){
	return function(newVal){
		if (arguments.length > 0){
			val = newVal;
			notifyChanges();
		}else{
			return val;
		}
	}
}
var data = {};
var data.a = observable(1);
var value = data.a() //取值
data.a(2); //賦值

2、利用defineProperty/defineProperties,兼容性IE9+

function defineReactive(obj, key, val){
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      return val;
    },
    set: function reactiveSetter(newVal) {
      val = newVal;
	  notifyChanges();
    }
  });
}

3、利用__defineGetter__/__defineSetter__,兼容性一些mozilla內核的瀏覽器
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineGetter

function defineReactive(obj, key, val){
  obj.__defineGetter__(key, function() {
	return val;
  });
  obj.__defineSetter__(key, function(newVal) {
	val = newVal;
	notifyChanges();
  });
}

4、利用vbs,兼容性低版本的IE瀏覽器,IE11 edge不再支持(avalon
先window.execScript得到parseVB的方法

Function parseVB(code)
	ExecuteGlobal(code)
End Function
window.execScript(parseVB_Code);

然后處理好數據屬性properties生成get/set方法放在accessors,並把notifyChanges放到get/set中,然后動態生成以下vbs代碼

Class DefinePropertyClass
	Private [__data__], [__proxy__]
	Public Default Function [__const__](d1, p1)
		Set [__data__] = d1: set [__proxy__] = p1
		Set [__const__] = Me
	End Function
	Public Property Let [bbb](val1)
		Call [__proxy__](Me,[__data__], "bbb", val1)
	End Property
	Public Property Set [bbb](val1)
		Call [__proxy__](Me,[__data__], "bbb", val1)
	End Property
	Public Property Get [bbb]
	On Error Resume Next
		Set[bbb] = [__proxy__](Me,[__data__],"bbb")
	If Err.Number <> 0 Then
		[bbb] = [__proxy__](Me,[__data__],"bbb")
	End If
	On Error Goto 0
	End Property
	Public Property Let [ccc](val1)
		Call [__proxy__](Me,[__data__], "ccc", val1)
	End Property
	Public Property Set [ccc](val1)
		Call [__proxy__](Me,[__data__], "ccc", val1)
	End Property
	Public Property Get [ccc]
	On Error Resume Next
		Set[ccc] = [__proxy__](Me,[__data__],"ccc")
	If Err.Number <> 0 Then
		[ccc] = [__proxy__](Me,[__data__],"ccc")
	End If
	On Error Goto 0
	End Property
	Public Property Let [$model](val1)
		Call [__proxy__](Me,[__data__], "$model", val1)
	End Property
	Public Property Set [$model](val1)
		Call [__proxy__](Me,[__data__], "$model", val1)
	End Property
	Public Property Get [$model]
	On Error Resume Next
		Set[$model] = [__proxy__](Me,[__data__],"$model")
	If Err.Number <> 0 Then
		[$model] = [__proxy__](Me,[__data__],"$model")
	End If
	On Error Goto 0
	End Property
	Public [$id]
	Public [$render]
	Public [$track]
	Public [$element]
	Public [$watch]
	Public [$fire]
	Public [$events]
	Public [$skipArray]
	Public [$accessors]
	Public [$hashcode]
	Public [$run]
	Public [$wait]
	Public [hasOwnProperty]
End Class

Function DefinePropertyClassFactory(a, b)
	Dim o
	Set o = (New DefinePropertyClass)(a, b)
	Set DefinePropertyClassFactory = o;
End Function

執行以上兩段vbs代碼得到observable對象

window.parseVB(DefinePropertyClass_code);
window.parseVB(DefinePropertyClassFactory_code);
var vm = window.DefinePropertyClassFactory(accessors, VBMediator);

function VBMediator(instance, accessors, name, value) {
    var accessor = accessors[name]
    if (arguments.length === 4) {
        accessor.set.call(instance, value)
    } else {
        return accessor.get.call(instance)
    }
}

5、在事件中觸發檢測digest,兼容所有(angular
以發XMLHttpRequest 為例

  var _XMLHttpRequest = window.XMLHttpRequest;
  window.XMLHttpRequest = function(flags) {
      var req;
      req = new _XMLHttpRequest(flags);
      monitorXHR(req); //處理req綁定觸發數據檢測及notifyChanges處理
      return req;
  };

6、把數據轉換成dom節點再利用defineProperty方法或onpropertychange事件,這種極端的辦法主要是用來處理IE8的,因為IE8支持defineProperty但只有DOM元素才支持

function data2dom(obj,key,val){
	if (!obj instanceof HTMLElement){
		obj = document.createElement('i');
	}
	//defineProperty or onpropertychange handle
	defineProperty(obj,key,val); //內部處理notifyChanges
	return obj;
}

這種方法的成本開銷是很大的

7、利用Object.observe,在Chrome 36 beta版本中出現,但很多瀏覽器還沒有支持已從ES7草案中移除

var data = {};
Object.observe(data, function(changes){
	changes.forEach(function(change) {
		console.log(change.type, change.name, change.oldValue);
	});
});

8、利用ES6的Proxy對象,未來的解決方案
https://developer.mozilla.org/it/docs/Web/JavaScript/Reference/Global_Objects/Proxy

//語法
var p = new Proxy(target, handler);

//示例
let setter = {
  set: function(obj, prop, value) {
    obj[prop] = value;
    notifyChanges();
  }
};

let person = new Proxy({}, setter);
person.age = 28; //觸發notifyChanges

9、利用臟檢測,兼容所有,主要用於沒有很好辦法的情況下
利用臟檢測實現Object.defineProperty方法

function PropertyChecker(obj, key, val, desc) {
   this.key = key;
   this.val = val;
   this.get = function () {
     var val = desc.get();
     if (this.val == val) {
       val = obj[key];
       if (this.val != val) {
         desc.set(val);
       }
     }
     return val;
   };
   this.set = desc.set;
}
var checkList = [];
Object.defineProperty = function (obj, key, desc) {
  var val = obj[key] = desc.value != undefined ? desc.value : desc.get();
   if (desc.get && desc.set) {
     var property = new PropertyChecker(obj, key, val, desc);
     checkList.push(property);
   }
};

function loopIE8() {
 for (var i = 0; i < checkList.length; i++) {
    var item = checkList[i];
    var val = item.get();
    if (item.val != val) {
      item.val = val;
      item.set(val);
    }
  }
}
setTimeout(function () {
  setInterval(loopIE8, 200);
}, 1000);

四、監聽數組變化

實際上以面說的這些僅僅是對數據對象進行監聽,而數據中還包括數組,如:

var data = {a:[1,2,3]};
data.a.push(4);

這種操作也會使數據產生了變化,但是僅對getter setter進行定義是捕捉不到這些變化的。所以我們要單獨針對數組做一些observable的處理。

基本思路就是重寫數組的這些方法
1、push
2、pop,
3、shift
4、 unshift
5、splice
6、sort
7、reverse

var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
var arrayKeys = Object.keys(arrayMethods);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
  var original = arrayProto[method];
  def(arrayMethods, method, function mutator() {
    var i = arguments.length;
    var args = new Array(i);
    while (i--) {
      args[i] = arguments[i];
    }
    var result = original.apply(this, args);
    var inserted;
    switch (method) {
      case 'push':
        inserted = args;
        break;
      case 'unshift':
        inserted = args;
        break;
      case 'splice':
        inserted = args.slice(2);
        break;
    }
    if (inserted) observe(inserted);
    notifyChanges(); //通知變化
    return result;
  });
});
function def(obj, key, val, enumerable) {
  obj = Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}
function protoAugment(target, src) {
  target.__proto__ = src;
}

function copyAugment(target, src, keys) {
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i];
    def(target, key, src[key]);
  }
}

var _augmentArr = ('__proto__' in {})? protoAugment : copyAugment;
function augmentArr(arr){
  _augmentArr(arr, arrayMethods, arrayKeys);
};

使用時只需要調用augmentArr(arr)即可實現

五、數據監聽存在哪些問題

目前主流的數據監聽方案還是defineProperty + augmentArr的方式,已有不少的mvvm框架及一些observable類庫,但是還存在一些問題:
1、所有的屬性必須預先定義好

var data = new Observer({a:{b:1}});//這里沒有定義a.c
data.$watch('a.c',function(newVal,oldVal){
	console.log(arguments);
});
data.a.c = 1; //此時,監聽a.c的watcher是不生效的,因為沒有提前定義c屬性

2、屬性被覆蓋后監聽失效

var data = new Observer({a:{b:1}});
data.$watch('a.b',function(newVal,oldVal){
	console.log(arguments);
});
data.a.b = 2; //生效
data.a = {b:3}; //此時b屬性的原結構遭破壞,對b的監聽失效

3、對數組元素的賦值是不會觸發監聽器更新的

var data = new Observer({a{c:[1,2,3]}});
data.$watch('a.c',function(newVal,oldVal){
	console.log(arguments);
});
data.a.c[1] = 22; //不會觸發a.c的watcher

這個問題,不少框架中是提供了一個$set方法來賦值,這是個解決問題的辦法,但是原生代碼賦值仍是不生效的。

def(arrayProto, '$set', function $set(index, val) {
  if (index >= this.length) {
    this.length = Number(index) + 1;
  }
  return this.splice(index, 1, val)[0];
});

4、刪除對象的屬性也不會觸發監聽器更新

var data = new Observer({a:{b:1},c:'xyz'});
data.$watch('a',function(newVal,oldVal){
	console.log(arguments);
});
delete data.a; //不會觸發a的watcher

同數組也可以父節點中定義一個$remove來實現

六、這些問題的解決方案

上述問題中:
1、第1、2其實是屬於同一類的問題,就是因為這些notifyChanges直接在defineProperty時定義在屬性中,當這個屬性未定義或遭破壞時,那么對該屬性的監聽肯定是要失效的。對於這個問題的解決,我的思路是這樣的

function Observer(data){
	this.data = data;
	var watches=[];
	//監聽時,先把監聽數據保存在該observer實例的watches中
	this.watch=function(path,subscriber,options){
		watches.push(new Watcher(path,subscriber,options));
	};
	//當publish時把watcher轉換為subscriber綁定到對應的屬性上
	this.publish = function(watch){
		var target = queryProperty(watch.path);
		var subscriber = new Subscriber(watch,target);
		target.ob.subscribes.add(subscriber );
	}	
}

每當重新賦新值時,會從根節點拉取watches重新publish,這樣的話保證了賦新值時原來的監聽數據不會被覆蓋。

var ob = new Observer(data);
ob.watch('a.b',function(){
	console.log(arguments);
});

此watcher信息是保存在根節點的ob對象中,每一個object類型的屬性都會對應一個ob對象,這樣即使data.a = {b:123}重新賦值導致data.a.b的定義被覆蓋,但是根節點並沒有被覆蓋,在它被得新賦值時我們可以重新調用父節點ob中的publish方法把watcher重新生效,這樣的話這個問題就可以解決了。

2、第3個問題,其實很容易解決,比如vue中只需要修改一句代碼就可以解決,也許是出於性能還其它的考慮它沒有這么去做。即把數組的每個元素當做屬性來定義

function observeArr(arr){
  for (var i = 0, l = arr.length; i < l; i++) {
    observeProperty(arr, i, arr[i]);
  }
}

3、第4個問題除了父節點中增加$remove方法我目前也沒有想到什么好的辦法,如果大家有什么好的想法可以跟我交流下。

七、我對數據監聽的實現

既然研究了下這個領域的東西,也就順便造了個輪子實現了一個數據observable的功能,用法大概如下:

var data = {a:{b:{x:1,y:2}},c:[1,2,3]};
var ob = new Observer(data);
data.$watch('a.b',function(){
	console.log(arguments);
},{deep:true})
data.a.b.x = 11;

主要是利用了es5的Object.defineProperty + augmentArr來實現的,代碼400行左右。
https://github.com/liuhuisheng/actionjs/blob/master/src/observer.js

然后想支持下IE8寫了個polifill,用臟檢查實現了下
https://github.com/liuhuisheng/actionjs/blob/master/src/polifill.js

一直很懶終於總結了下做個筆記。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM