論如何監聽一個物件的變化

OrekiSH發表於2019-04-29

前言

本文分為入門和進階兩部分,建議有經驗的讀者直接閱讀進階部分。

本文主要參考了vue和on-change兩個開源庫,若讀者閱讀過它們的原始碼可以直接跳過本文 :)

入門

關於Object.defineProperty

首先我們需要知道如何通過Object.defineProperty這個API來監聽一個物件的變化, 注意註釋裡的內容!

const obj = {};

let val = obj.name;
Object.defineProperty(obj, 'name', {
  set(newVal) {
    console.warn(newVal);
    // 想知道為什麼不直接寫成obj.name = newVal嗎, 自己試試吧 :)
    val = newVal;
  },
});

setTimeout(() => {
  // 一秒鐘後我們將obj這個物件的name屬性賦值為字串a, 看看會發生什麼
  obj.name = 'a';
}, 1000);
複製程式碼

好了,現在你知道如何通過Object.defineProperty這個API來監聽一個物件的變化了吧,不過你還要注意一些細節

const obj = {};

let val = obj.name;
Object.defineProperty(obj, 'name', {
  set(newVal) {
    console.warn(newVal);
    val = newVal;
  },
});

setTimeout(() => {
  obj.name = 'a';
  // 由於我們沒有設定enumerable描述符,所以它是預設值false, 也就是說obj的name屬性是無法被列舉的
  console.warn(obj);
  // 這個很好理解,因為我們沒有設定get方法
  console.warn(obj.name);
}, 1000);
複製程式碼

也就是說我們需要加上這些

Object.defineProperty(obj, 'name', {
  enumerable: true,
  // 想知道為什麼要加上configurable描述符嗎,試試delete obj.name吧
  configurable: true,
  get() {
    return val;
  },
  set(newVal) {
    console.warn(newVal);
    val = newVal;
  },
});
複製程式碼

另外,陣列物件是個特例,mutable的原型方法我們無法通過Object.defineProperty來監聽到

const obj = {
  val: [],
};

let val = obj.val;
Object.defineProperty(obj, 'val', {
  get() {
    return val;
  },
  set(newVal) {
    console.warn(newVal);
    val = newVal;
  },
});

setTimeout(() => {
  // 沒有任何反應
  obj.val.push('b');
}, 1000);
複製程式碼

因此我們還需要去劫持陣列物件mutable的原型方法, 包括push, pop, shift, unshift, splice, sort, reverse, 我們以push為例:

const obj = {
  val: [],
};

const arrayMethods = Object.create(Array.prototype);
arrayMethods.push = function mutator(...args) {
  console.warn(args);
  [].push.apply(this, args);
};

// 如果瀏覽器實現了__proto__, 覆蓋原型物件
if ('__proto__' in {}) {
  obj.val.__proto__ = arrayMethods;
} else {
  // 要是瀏覽器沒有實現__proto__, 覆蓋物件本身的該方法
  Object.defineProperty(obj.val, 'push', {
    value: arrayMethods['push'],
    enumerable: true,
  });
}

setTimeout(() => {
  obj.val.push('b');
}, 1000);
複製程式碼

好了,以上就是關於如何通過Object.defineProperty這個API來監聽一個物件的變化的全部。

關於Proxy

通過Proxy來監聽物件變化要比Object.defineProperty容易的多

let obj = {};

obj = new Proxy(obj, {
  set(target, prop, newVal) {
    console.warn(newVal);
    // 你也可以使用Reflect.set()
    target[prop] = newVal;
    return true;
  },
});

setTimeout(() => {
  // 一秒鐘後我們將obj這個物件的name屬性賦值為字串a
  obj.name = 'a';
  // 顯然我們不需要更多的設定
  console.warn(obj);
  console.warn(obj.name);
}, 1000);
複製程式碼

同樣的對於陣列物件的監聽也沒有那麼多hacky的味道

const obj = {
  val: [],
};

obj.val = new Proxy(obj.val, {
  set(target, prop, newVal) {
    const oldVal = target[prop];
    if (oldVal !== newVal) {
      console.warn(oldVal, newVal);
    }

    target[prop] = newVal;
    
    return true;
  },
});

setTimeout(() => {
  obj.val.push('a');
}, 1000);
複製程式碼

好了,以上就是關於如何通過Proxy來監聽一個物件的變化的全部。

進階

關於分類和遞迴

假如我們現在有這樣一個物件obj, 如何監聽它的所有屬性呢

let obj = {
  b: true,
  o: { name: 'obj' },
  a: ['a', 'b', 'c'],
  odeep: {
    path: {
      name: 'obj deep',
      value: [],
    },
  },
};
複製程式碼

我們可以分類討論,先考慮基本型別的變數以及Object型別的變數

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

// 首先先定義一個劫持物件屬性的通用函式
function defineReactive(obj, key, val) {
  if (isPlainObject(val)) {
    observe(val);
  }

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

    set(newVal) {
      console.warn(newVal);
      
      val = newVal;
      // 賦的新值不為基本型別, 也同樣需要劫持
      if (isPlainObject(newVal)) {
        observe(newVal);
      }
    },
  });
}

// 遍歷所有屬性並劫持
function observe(obj) {
  Object.keys(obj).forEach((key) => {
    defineReactive(obj, key, obj[key]);
  });
}

observe(obj);
setTimeout(() => {
  // 顯然不會有什麼問題
  obj.b = false;
  obj.o.name = 'newObj';
  obj.odeep.path.name = 'newObj deep';

  obj.b = { name: 'obj created' };
  obj.b.name = 'newObj created';
}, 1000);
複製程式碼

我們再來考慮Array型別的變數

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

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

    set(newVal) {
      console.warn(newVal);
      
      val = newVal;
      if (isPlainObject(newVal)) {
        observe(newVal);
      } else if (Array.isArray(newVal)) {
        dealAugment(newVal);
        observeArray(newVal);
      }
    },
  });
}

function dealAugment(val) {
  const arrayMethods = Object.create(Array.prototype);
  // 我們以push方法為例
  arrayMethods.push = function mutator(...args) {
    console.warn(args);
    [].push.apply(this, args);
  };

  // 如果瀏覽器實現了__proto__, 覆蓋原型物件
  if ('__proto__' in {}) {
    obj.val.__proto__ = arrayMethods;
  } else {
    // 要是瀏覽器沒有實現__proto__, 覆蓋物件本身的該方法
    Object.defineProperty(obj.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);
    }
  });
}

observe(obj);
setTimeout(() => {
  // 顯然不會有什麼問題
  obj.a.push('d');
  obj.odeep.path.value.push(1);

  obj.b = ['a'];
  obj.b.push('b');
}, 1000);
複製程式碼

顯然,Object.defineProperty的版本有些冗長,那麼Proxy的版本如何呢?

const handler = {
  get(target, prop) {
    try {
      // 還有比這更簡潔的遞迴嗎
      return new Proxy(target[prop], handler);
    } catch (error) {
      return target[prop]; // 或者是Reflect.get
    }
  },

  set(target, prop, newVal) {
    const oldVal = target[prop];
    if (oldVal !== newVal) {
      console.warn(oldVal, newVal);
    }

    target[prop] = newVal;
    
    return true;
  },
};

obj = new Proxy(obj, handler);

setTimeout(() => {
  // 試試吧,太不可思議了!
  obj.b = false;
  obj.o.name = 'newObj';
  obj.odeep.path.name = 'newObj deep';

  obj.b = { name: 'obj created' };
  obj.b.name = 'newObj created';

  obj.a.push('d');
  obj.odeep.path.value.push(1);

  obj.b = ['a'];
  obj.b.push('b');
  obj.b[0] = 'new a';
}, 1000);
複製程式碼

以上就是監聽一個物件變化的所有內容了。不過細心的你應該發現了,我們使用了console.warn(newVal)這樣強耦合的寫法, 下篇文章將會介紹如何使用觀察者模式實現類似Vue.prototype.$watch和計算屬性的功能。

相關文章