MZhou's blog

watch.js 源码解读

用麻雀虽小五脏俱全来描述Watch.js比较合适。“观察者”模式是我们在开发的时候经常需要用到的。使用Watch.js那么我们就可以实现在“每当对象属性改变的时候,执行你的函数”。虽然有很多其他的库可以实现相同的功能,但是Watch.js却可以不改变你平时书写代码的方式,并且实现属性改变的监听功能。

API

让我们来看看它的API:

//使用任意一种方式定义一个对象
var ex1 = {
    attr1: "initial value of attr1",
    attr2: "initial value of attr2"
};

//针对其中一个属性“attr1”设置“观察者”
watch(ex1, "attr1", function(){
    alert("attr1 changed!");
});

//当“attr1”修改的时候“观察者”函数会被调用
ex1.attr1 = "other value";`</pre>
[try demo](http://jsfiddle.net/NbJuh/17/)

Watch.js还提供了其他有用的api:监控多个属性、监控整个对象或移除整一个watch。你可能会想到循环调用,例如:

//使用任意一种方式定义一个对象
var ex1 = {
    attr1: "inicial value of attr1",
    attr2: "initial value of attr2"
};

//定义“attr1”属性的监听函数
watch(ex1, "attr1", function(){
    //在此域中阻止监听器的调用
    WatchJS.noMore = true;
    ex1.attr2 = ex1.attr1 + " + 1";
});

//给另一个属性定义“监听函数”
watch(ex1, "attr2", function(){
    alert("attr2 changes");
});

//attr1修改的时候,会修改attr2但不会触发attr2的“监听函数”
ex1.attr1 = "other value to 1";

WatchJS.noMore = true可以确保在此次监听函数的调用中,对属性的修改不会触发新的监听函数。接下来看看它是如何实现它们的!

Object.defineProperty

Object.defineProperty是ECMAScript 5标准提供的方法,它允许你在一个对象上定义一个新属性或是修改原有属性的描述符。这个方法接收一个属性描述符,并用它来初始化(或更新)一个属性。例如:

(function() {
    var obj = {},
        value = 1;

    Object.defineProperty( obj, "value", {
        // value: true,
        // writable: false,
        enumerable: true,
        configurable: true,
        get: function() {
            return value;
        },
        set: function(v) {
            value = v);
        }
    });
})();

defineProperty的第三个参数是描述符,value设置属性值,writable表示属性是否可写(不会报错,只是写操作不生效),enumerable表示属性是否可以通过for in迭代获取,configurable表示属性该属性的描述符无法再被定义,并且属性也无法被deletegetset分别定义属性的access方法,注意不可同时定义属性的accessor和value及writable参数。具体的方法描述参考MDN上的文档

Watch.js利用了属性的accessor方法实现了对属性变化的监听,代码如下:

defineWatcher = function (obj, prop, watcher) {
    var val = obj[prop];

    // 监听数组的操作函数,用于数组变化的监听
    watchFunctions(obj, prop);

    // 初始化watchers对象,用以存储监听者
    if (!obj.watchers) {
        defineProp(obj, "watchers", {});
    }

    // 初始化针对此属性的监听者数组
    if (!obj.watchers[prop]) {
        obj.watchers[prop] = [];
    }

    // 添加监听者
    obj.watchers[prop].push(watcher);

    var getter = function () {
        return val;
    };

    var setter = function (newval) {
        var oldval = val;
        val = newval;

        // 监听对象上所有属性
        if(obj[prop]){
            watchAll(obj[prop], watcher);
        }

        // 监听数组的操作函数
        watchFunctions(obj, prop);

        // 将对象转换成字符串之后判断是否有改变
        if (JSON.stringify(oldval) !== JSON.stringify(newval)) {
            if (!WatchJS.noMore){
                callWatchers(obj, prop, newval, oldval);
                WatchJS.noMore = false;
            }
        }
    };

    // 定义accessor
    defineGetAndSet(obj, prop, getter, setter);

};

defineWatcher函数是定义监听者的主要函数,它做了如下几件事情:

  1. 监听数组的操作函数,用于数组变化的监听
  2. 初始化watchers对象,用于存储监听函数
  3. 添加监听函数
  4. 设置accessor
    1. setter会监听属性值所有的属性变化
    2. 监听数组的操作函数
    3. 将对象转换成字符串之后判断是否有改变
    4. 如果改变则调用监听函数

降级处理

defineGetAndSet是利用了defineProperty来设置监听函数,如果不支持则会尝试如下方法:

Object.prototype.__defineGetter__.call(obj, propName, getter);
Object.prototype.__defineSetter__.call(obj, propName, setter);

有些稍微旧版本的先进浏览器可以使用这个方法来设置accessor。如果浏览器还是不支持那么会fallback到设置定时器监控。如下:

var subjects = [];

defineWatcher = function(obj, prop, watcher){
    // 存储当前状态和监听者
    subjects.push({
        obj: obj,
        prop: prop,
        serialized: JSON.stringify(obj[prop]), // 设置当前属性值的序列化值
        watcher: watcher
    });
};

unwatchOne = function (obj, prop, watcher) {
    for (var i in subjects) {
        var subj = subjects[i];
        if (subj.obj == obj &amp;&amp; subj.prop == prop &amp;&amp; subj.watcher == watcher) {
            subjects.splice(i, 1);
        }
    }
};

callWatchers = function (obj, prop) {
    for (var i in subjects) {
        var subj = subjects[i];

        if (subj.obj == obj &amp;&amp; subj.prop == prop) {
            subj.watcher.call(obj, prop);
        }
    }
};

var loop = function(){
    for(var i in subjects){
        var subj = subjects[i];
        // 新的获取序列化值
        var newSer = JSON.stringify(subj.obj[subj.prop]);
        // 与旧的序列化值判断
        if(newSer != subj.serialized){
            // 不同则调用监听函数
            subj.watcher.call(subj.obj, subj.prop, subj.obj[subj.prop], JSON.parse(subj.serialized));
            // 设置新的值
            subj.serialized = newSer;
        }
    }
};
setInterval(loop, 50);

这个降级处理是存在bug的,它无法正确处理属性设置之间的间隔。原因在于javascript的线程模型,以及setInterval不是立即能知道更新,有50ms的间隔时间。样例如下:

var obj = {
   a: 1
};

function cb() {
    console.log(this.a);
}

watch(obj, 'a', cb);
console.log('set first');
obj.a = 2;
console.log('set second');
obj.a = 3;

上面的代码在支持defineProperty的浏览器中执行结果如下:

set first
2
set second
3

在不支持的浏览器中执行结果如下:

set first
set second
3

可见setInterval的降级处理是异步的,而defineProperty是同步的。这在一些情况下会导致严重的bug,而且难以调试。所以在真正使用的环境中需要注意!

总结

抛开watch.js的bug不谈,它还是有很多可圈可点的地方。使用下来觉得watch.js的优点如下:

  1. API设计的很好,实用
  2. 不需要特殊的修改定义属性的代码
  3. 支持commonjs的模块标准
  4. 属性为object和array的时候也可以监听

缺点如下:

  1. 监听整个对象的时候,只能监听到已有属性。监听不到新属性
  2. 对于不支持Object.defineProperty的浏览器,降级方案有小bug

MZhou's blog - Taste of life.

zmmbreeze / @zhoumm