谈谈数据监听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

一直很懒终于总结了下做个笔记。

posted @ 2016-09-18 17:49  萧秦  阅读(6229)  评论(2编辑  收藏  举报