论如何监听一个对象某个属性的变化

3,807 阅读4分钟

往期

前言

本文分为入门和进阶两部分,建议有经验的读者直接阅读进阶部分。

本文主要参考了vue这一开源库,若读者阅读过它的源码可以直接跳过本文 :)

入门

关于访问对象的某个属性

既然是入门,还是先提一下Vue.prototype.$watch的几种用法

const vm = Vue({
  data() {
    return {
      b: true,
      o: { name: 'obj' },
      a: ['a', 'b', 'c'],
      odeep: {
        path: {
          name: 'obj deep',
          value: [],
        },
      },
    };
  },

  watch: {
    // 如果b的值改变了,打印改变前与改变后的值
    b(val, oldVal) {
      console.warn(val, oldVal);
    },
    // 如果o.name的值改变了,打印改变前与改变后的值
    'o.name': {
      handler(val, oldVal) {
        console.warn(val, oldVal);
      },
    },
  },

  created() {
    // 深度监听: 如果odeep.path.name/odeep.path.value的值改变了,打印odeep.path改变前与改变后的值
    this.$watch('odeep.path', (val, oldVal) => {
      console.warn(val, oldVal);
    }, { deep: true });
  },
});

如何去通过诸如o.name的字符串访问到vm.o.name呢? vm['o.name']当然是不行的,需要写成vm['o']['name']这样的形式。

function parsePath(path) {
  if (!/[\w.]$/.test(path)) {
    // 为什么要返回一个带参数的函数呢? 提前告诉你,是为了触发被监听对象的get方法(还记得上一篇文章的内容吗)
    return function(obj) {};
  }

  const segs = path.split('.');
  // 想知道这里为什么不用forEach吗,试试在forEach里使用return吧
  return function(obj) {
    for (let i = 0; i < segs.length; i += 1) {
      if (!obj) {
        return;
      }

      obj = obj[segs[i]];
    }

    return obj;
  };
}

const obj = {
  o: { name: 'a' },
};
console.assert(parsePath('o.name')(obj) === 'a');

关于观察者模式

先让我们看看维基百科是怎么说的:

The observer pattern is a software design pattern in which an object, called the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes, usually by calling one of their methods.

也就是说subject用来维护依赖列表, 每个依赖都是一个observer。当依赖列表中的某一项发生了变化,就自动通知subject自身状态的变更。

让我们先拷贝一下上篇文章的内容, 注意注释里的内容!

function defineReactive(obj, key, val) {
  if (isPlainObject(val)) {
    observe(val);
  } else if (Array.isArray(val)) {
    dealAugment(val, dep);
    observeArray(val);
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 将依赖加入依赖列表
      return val;
    },

    set(newVal) {
      if (val !== newVal) {
        val = newVal;
        if (isPlainObject(newVal)) {
          observe(newVal);
        } else if (Array.isArray(newVal)) {
          dealAugment(newVal, dep);
          observeArray(newVal);
        }
        // 依赖通知subject自身状态的改变,即调用callback
      }
    },
  });
}

但是callback在$watch函数中,如何传递给依赖, 并在被监听对象该属性变化时调用呢?

我们可以利用一个全局变量(在这里我们称它为DepTarget),在访问变量的时候设置为$watch函数的callback, 并将这个callback存到一个集合里,访问结束后置空。同时需要注意的是,每个$watch函数应该只对应一个观察者(依赖)

let DepTarget = null;
function $watch(obj, path, cb) {
  DepTarget = cb;
  // 访问obj,自动调用get方法实现依赖注入
  parsePath(path)(obj);
  DepTarget = null;
}

function defineReactive(obj, key, val) {
  const deps = [];
  
  if (isPlainObject(val)) {
    observe(val);
  } else if (Array.isArray(val)) {
    // 传递dep,在push等函数触发时notify, 但是我们无法访问到旧的value值
    dealAugment(val, deps, val);
    observeArray(val);
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      if (DepTarget) {
        // 将callback存入集合
        deps.push(DepTarget);
      }
      return val;
    },

    set(newVal) {
      if (val !== newVal) {
        val = newVal;
        if (isPlainObject(newVal)) {
          observe(newVal);
        } else if (Array.isArray(newVal)) {
          dealAugment(newVal, deps, val);
          observeArray(newVal);
        }
        // 依赖通知subject自身状态的改变,即调用callback
        deps.forEach((cb) => {
          if (typeof cb === 'function') {
            cb(val);
          }
        });
      }
    },
  });
}

function dealAugment(val, deps, val) {
  const arrayMethods = Object.create(Array.prototype);
  // 我们以push方法为例
  arrayMethods.push = function mutator(...args) {
    [].push.apply(this, args);
    // 依赖通知subject自身状态的改变,即调用callback
    deps.forEach((cb) => {
      if (typeof cb === 'function') {
        cb(val);
      }
    });
  };

  // 如果浏览器实现了__proto__, 覆盖原型对象
  if ('__proto__' in {}) {
    val.__proto__ = arrayMethods;
  } else {
    // 要是浏览器没有实现__proto__, 覆盖对象本身的该方法
    Object.defineProperty(val, 'push', {
      value: arrayMethods['push'],
      enumerable: true,
    });
  }
}

让我们试一试

const obj = {
  b: true,
  o: { name: 'obj' },
  a: ['a', 'b', 'c'],
  odeep: {
    path: {
      name: 'obj deep',
      value: [],
    },
  },
};
// observe等函数的实现请查看上一篇文章, 或是文章末尾的完整示例
observe(obj);

$watch(obj, 'b', (val, oldVal) => {
  console.warn('b watched: ', val, oldVal);
});
$watch(obj, 'a', (val, oldVal) => {
  console.warn('a watched: ',val, oldVal);
});
$watch(obj, 'odeep.path.value', (val, oldVal) => {
  console.warn('odeep.path.value watched: ',val, oldVal);
});
setTimeout(() => {
  // 当然不会有什么问题, 不过你也发现了,我们只能访问到当前的value值,所以我们需要一个对象来存储旧的value值
  obj.b = false;
  obj.a.push('d');
  obj.o.name = 'new obj';
  obj.odeep.path.value.push(1);
}, 1000);

进阶

关于Watcher和Dep

对于上述的问题,我们需要抽象出两个类: 一个用来存储value值和callback(以及传递旧的value值),我们把它称为watcher; 另一个用来添加/存储watcher,我们把它称为dep。

let DepTarget = null;
class Dep {
  constructor() {
    this.watchers = new Set();
  }
  add(watcher) {
    this.watchers.add(watcher);
  }
  notify() {
    this.watchers.forEach((watcher) => {
      // 依赖通知自身状态的改变, 即调用callback
      watcher.update();
    });
  }
}

class Watcher {
  constructor(obj, path, cb) {
    this.obj = obj;
    this.path = path;
    this.cb = cb;
    this.value = this.get();
  }
  get() {
    DepTarget = this;
    // 访问obj,自动调用get方法实现依赖注入
    const val = parsePath(this.path)(this.obj);
    DepTarget = null;
    return val;
  }
  update() {
    const val = this.get();
    // 对于当前状态改变的被监听属性才会触发callback
    if (val !== this.value) {
      const oldVal = this.value;
      this.value = val;
      // 传递val和oldVal
      this.cb(val, oldVal);
    }
  }
}

function defineReactive(obj, key, val) {
  const dep = new Dep();
  
  if (isPlainObject(val)) {
    observe(val);
  } else if (Array.isArray(val)) {
    // 传递dep,在push等函数触发时notify
    dealAugment(val, dep);
    observeArray(val);
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      if (DepTarget) {
        dep.add(DepTarget);
      }
      return val;
    },

    set(newVal) {
      if (val !== newVal) {
        val = newVal;
        if (isPlainObject(newVal)) {
          observe(newVal);
        } else if (Array.isArray(newVal)) {
          dealAugment(newVal, dep);
          observeArray(newVal);
        }
        dep.notify();
      }
    },
  });
}

function dealAugment(val, dep) {
  const arrayMethods = Object.create(Array.prototype);
  // 我们以push方法为例
  arrayMethods.push = function mutator(...args) {
    [].push.apply(this, args);
    dep.notify();
  };

  // 如果浏览器实现了__proto__, 覆盖原型对象
  if ('__proto__' in {}) {
    val.__proto__ = arrayMethods;
  } else {
    // 要是浏览器没有实现__proto__, 覆盖对象本身的该方法
    Object.defineProperty(val, 'push', {
      value: arrayMethods['push'],
      enumerable: true,
    });
  }
}

好了,这样我们就实现了监听对象某个属性的变化。

关于深度监听

对于深度监听,思路其实也是一样的: 访问obj,自动调用get方法实现依赖注入,我们只需要遍历访问对象的所有属性即可。

class Watcher {
  constructor(obj, path, cb, opts = {}) {
    this.obj = obj;
    this.path = path;
    this.cb = cb;
    this.deep = !!opts.deep;
    this.value = this.get();
  }

  get() {
    DepTarget = this;
    const val = parsePath(this.path)(this.obj);
    if (this.deep) {
      // 若当前路径属性值为对象,访问其所有属性
      traverse(val);
    }
    DepTarget = null;
    return val;
  }
  
  update() {
    const val = this.get(); 
    if (val !== this.value || isObject(val)) {
      const oldVal = this.value;
      this.value = val;
      this.cb(val, oldVal);
    }
  }
}

function traverse(val) {
  let i = 0;
  if (Array.isArray(val)) {
    i = val.length;
    while (i--) { traverse(val[i]); }
  } else if (isPlainObject(val)) {
    const keys = Object.keys(val);
    i = keys.length;
    while (i--) { traverse(val[keys[i]]); }
  }
}

完整示例

let DepTarget = null;
class Dep {
  constructor() {
    this.watchers = new Set();
  }

  add(watcher) {
    this.watchers.add(watcher);
  }

  notify() {
    this.watchers.forEach((watcher) => {
      watcher.update();
    });
  }
}

class Watcher {
  constructor(obj, path, cb, opts = {}) {
    this.obj = obj;
    this.path = path;
    this.cb = cb;
    this.deep = !!opts.deep;
    this.value = this.get();
  }

  get() {
    DepTarget = this;
    const val = parsePath(this.path)(this.obj);
    if (this.deep) {
      traverse(val);
    }
    DepTarget = null;
    return val;
  }
  
  update() {
    const val = this.get(); 
    if (val !== this.value || isObject(val)) {
      const oldVal = this.value;
      this.value = val;
      this.cb(val, oldVal);
    }
  }
}

function parsePath(path) {
  if (!/[\w.]$/.test(path)) {
    return function(obj) {};
  }

  const segs = path.split('.');
  return function(obj) {
    for (let i = 0; i < segs.length; i += 1) {
      if (!obj) {
        return;
      }

      obj = obj[segs[i]];
    }

    return obj;
  };
}

function traverse(val) {
  let i = 0;
  if (Array.isArray(val)) {
    i = val.length;
    while (i--) { traverse(val[i]); }
  } else if (isPlainObject(val)) {
    const keys = Object.keys(val);
    i = keys.length;
    while (i--) { traverse(val[keys[i]]); }
  }
}

function isObject(val) {
  const type = typeof val;
  return val != null && (type === 'object' || type === 'function');
}

function isPlainObject(obj) {
  return ({}).toString.call(obj) === '[object Object]';
}

function defineReactive(obj, key, val) {
  const dep = new Dep();

  if (isPlainObject(val)) {
    observe(val);
  } else if (Array.isArray(val)) {
    dealAugment(val, dep);
    observeArray(val);
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {      
      if (DepTarget) {
        dep.add(DepTarget);
      }
      return val;
    },

    set(newVal) {
      if (val !== newVal) {
        val = newVal;
        if (isPlainObject(newVal)) {
          observe(newVal);
        } else if (Array.isArray(newVal)) {
          dealAugment(newVal, dep);
          observeArray(newVal);
        }
        dep.notify();
      }
    },
  });
}

function dealAugment(val, dep) {
  const arrayMethods = Object.create(Array.prototype);
  // 我们以push方法为例
  arrayMethods.push = function mutator(...args) {
    [].push.apply(this, args);
    dep.notify();
  };

  // 如果浏览器实现了__proto__, 覆盖原型对象
  if ('__proto__' in {}) {
    val.__proto__ = arrayMethods;
  } else {
    // 要是浏览器没有实现__proto__, 覆盖对象本身的该方法
    Object.defineProperty(val, 'push', {
      value: arrayMethods['push'],
      enumerable: true,
    });
  }
}

function observeArray(obj) {
  obj.forEach((el) => {
    if (isPlainObject(el)) {
      observe(el);
    } else if (Array.isArray(el)) {
      observeArray(el);
    }
  });
}

function observe(obj) {
  Object.keys(obj).forEach((key) => {
    defineReactive(obj, key, obj[key]);
  });
}

const obj = {
  b: true,
  o: { name: 'obj' },
  a: ['a', 'b', 'c'],
  odeep: {
    path: {
      name: 'obj deep',
      value: [],
    },
  },
};
observe(obj);

new Watcher(obj, 'b', (val, oldVal) => {
  console.warn('b watched: ', val, oldVal);
});
new Watcher(obj, 'a', (val, oldVal) => {
  console.warn('a watched: ',val, oldVal);
});
new Watcher(obj, 'odeep.path.value', (val, oldVal) => {
  console.warn('odeep.path.value watched: ',val, oldVal);
});
new Watcher(obj, 'odeep', (val, oldVal) => {
  console.warn('odeep watched: ',val, oldVal);
}, { deep: true });

setTimeout(() => {
  obj.b = false;
  obj.a.push('d');
  obj.o.name = 'b';
  obj.odeep.path.value.push(1);
  obj.odeep.path.name = 'new obj deep';
}, 1000);

好了,以上就是关于如何监听一个对象某个属性的全部内容。