論如何監聽物件某個屬性的變化

OrekiSH發表於2019-05-13

往期

前言

本文介紹了兩種監聽物件某個屬性的變化的思路, 分別是利用私有屬性以及利用觀察者模式,建議有經驗的讀者直接閱讀最後的實現部分 :)

前景回顧

上一篇文章我們談到了如何監聽物件的變化。

下面我們來探究如何用$watch方法中的callback來替換console.warn(newVal, oldVal), 以及如何只監聽物件的某個屬性的變化。

另外本文只討論Vue.prototype.$watchkeypath的寫法,即this.$watch('a.b.c', () => {});

function proxy(obj) {
  const handler = {
    get(target, prop) {
      try {
        return new Proxy(target[prop], handler);
      } catch (error) {
        return target[prop];
      }
    },
    set(target, prop, newVal) {
      const oldVal = target[prop];
      if (oldVal !== newVal) {
        // 如何替換這個強耦合的函式
        console.warn(newVal, oldVal);
      }
      target[prop] = newVal;
      return true;
    },
  };

  return new Proxy(obj, handler);
}

const obj = proxy({
  a: 'a',
  b: 'b',
  c: 'c',
});

// 以及如何做到當obj.a改變時只觸發第一個callback
$watch(obj, 'a', (val, oldVal) => {
  console.warn('watch obj.a: ', val, oldVal);
});
$watch(obj, 'b', (val, oldVal) => {
  console.warn('watch obj.b: ', val, oldVal);
});
複製程式碼

思路

關於私有屬性

有個十分簡單的思路: 把callback和要監聽的屬性值, 作為被監聽物件某一層級的私有屬性注入

// 監聽obj.a和obj.b
const obj = {
  a: 'a',
  b: 'b',
  c: 'c',
  // 因為我們需要監聽兩個屬性,所以需要使用集合
  __waters__: [{
    key: 'a',
    cb: () => {},
  }, {
    key: 'b',
    cb: () => {},
  }],
};

// 對於多層級的被監聽物件, __watchers__掛載在不同的層級下
const obj = {
  o: {
    name: 'obj',
    __watchers__: [{
      key: 'name',
      cb: () => {},
    }],
  },
  odeep: {
    path: {
      name: 'obj deep',
      __watchers__: [{
        key: 'name',
        cb: () => {},
      }],
    },
  },
};
複製程式碼

關於觀察者模式

先讓我們看看維基百科是怎麼說的:

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 proxy(obj) {
  const handler = {
    get(target, prop) {
      // 設定observer(依賴)
      return target[prop]; // 不遞迴監聽
    },

    set(target, prop, newVal) {
      const val = target[prop];
      if (newVal !== val) {
        target[prop] = newVal;
        // observer通知自身狀態的改變, 即呼叫callback
      }
      return true;
    },
  };

  return new Proxy(obj, handler);
}
複製程式碼

但是callback在$watch函式中,如何傳遞給observer, 並在被監聽物件變化時呼叫呢?

我們可以利用一個全域性變數,在訪問變數的時候設定為$watch函式的callback, 訪問結束後置空。

let DepTarget = null;
function $watch(obj, path, cb) {
  DepTarget = cb;
  // 訪問obj,自動呼叫get方法實現依賴注入
  DepTarget = null;
}

class Dep {
  constructor() {
    this.subs = new Set();
  }
  add(sub) {
    this.subs.add(sub);
  }
  notify() {
    this.subs.forEach((sub) => {
      // sub需要儲存oldVal和newVal, 當且僅當oldVal不等於newVal時呼叫callback
      sub.update();
    });
  }
}
複製程式碼

實現

關於私有屬性

對於這個思路而言,我們只需要找出需要監聽的屬性的上一層級, 不難抽象出下面的函式:

function parseParentPath(path, obj) {
  if (/[^\w.$]/.test(path)) {
    return {};
  }

  let segs = path.split('.');
  // 監聽屬性的上一層,所以是length - 1
  segs = segs.slice(0, segs.length - 1);
  for (let i = 0; i < segs.length; i += 1) {
    if (!obj) {
      return {};
    }
    obj = obj[segs[i]];
  }

  return obj;
}
複製程式碼

那麼$watch也不難寫出來了

function $watch(obj, path, cb) {
  const parent = parseParentPath(path, obj);
  // 限於篇幅,邊界判斷還請自行腦補 :)
  const segs = path.split('.');
  const key = segs[segs.length - 1];

  if (!parent.__watchers__) {
    Object.defineProperty(parent, '__watchers__', {
      value: [],
      configurable: true,
    });
  }
  parent.__watchers__.push({ key, cb });
}

const handler = {
  get(target, prop) {
    try {
      return new Proxy(target[prop], handler);
    } catch (error) {
      return target[prop];
    }
  },

  set(target, prop, newVal) {
    const oldVal = target[prop]; 
    const { __watchers__ } = target;
    
    if (__watchers__) {
      const current = __watchers__.find(e => e.key === prop);
      if (oldVal !== newVal && current && typeof current.cb === 'function') {
        current.cb(newVal, oldVal);
      }
    }
    
    target[prop] = newVal;
    return true;
  },
};

obj = new Proxy(obj, handler);
複製程式碼

好了,讓我們來試試吧!

let obj = {
  b: true,
  o: { name: 'obj', age: 18 },
  a: ['a', 'b', 'c'],
  odeep: {
    path: {
      name: 'obj deep',
      value: [],
    },
  },
};

$watch(obj, 'b', (newVal, oldVal) => {
  console.error('watch b: ', newVal, oldVal);
});
$watch(obj, 'o.name', (newVal, oldVal) => {
  console.error('watch o.name: ', newVal, oldVal);
});
$watch(obj, 'odeep.path.name', (newVal, oldVal) => {
  console.error('watch odeep.path.name: ', newVal, oldVal);
});

setTimeout(() => {
  // 當然不會有什麼問題
  obj.o.name = 'new obj';
  obj.b = false;
  obj.odeep.path.name = 'new obj deep';
}, 1000);
複製程式碼

但是這樣的寫法存在一些侷限性

$watch(obj, 'odeep', (newVal, oldVal) => {
  console.error('watch odeep: ', newVal, oldVal);
}, { deep: true });

setTimeout(() => {
  // 對於{ deep: true }, 需要在物件的每個層級新增__watchers__屬性,顯然不太合適
  obj.odeep.path.name = 'new obj deep';
}, 1000);
複製程式碼

關於觀察者模式

我們利用sub來儲存oldVal和newVal, 並將$watch的邏輯寫入sub的get方法中

class Sub {
  constructor(obj, path, cb) {
    this.obj = obj;
    this.path = path;
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    // 因為Dep的add方法傳參為sub, 因此全域性變數設定為當前sub
    DepTarget = this;
    // 訪問obj
    const value = parsePath(this.path)(this.obj);
    DepTarget = null;
    
    return value;
  }

  update() {
    const value = this.get();

    if (this.value !== value) {
      const oldVal = this.value;
      this.value = value;
      this.cb.call(this.obj, value, oldVal);
    }
  }
}
複製程式碼

下面是完整的例子:

let DepTarget = null;

class Sub {
  constructor(obj, path, cb) {
    this.obj = obj;
    this.path = path;
    this.cb = cb;
    this.value = this.get();
  }

  get() {
    DepTarget = this;
    // 訪問obj
    const value = parsePath(this.path)(this.obj);
    DepTarget = null;
    return value;
  }

  update() {
    const value = this.get();
    if (this.value !== value) {
      const oldVal = this.value;
      this.value = value;
      this.cb.call(this.obj, value, oldVal);
    }
  }
}

class Dep {
  constructor() {
    this.subs = new Set();
  }
  add(sub) {
    this.subs.add(sub);
  }
  notify() {
    this.subs.forEach((sub) => {
      // sub需要儲存oldVal和newVal, 當且僅當oldVal不等於newVal時呼叫callback
      sub.update();
    });
  }
}

function proxy(obj) {
  const dep = new Dep();
  
  const handler = {
    get(target, prop) {
      if (DepTarget) {
        dep.add(DepTarget);
      }
      // 不遞迴監聽
      return target[prop];
    },
    set(target, prop, newVal) {
      const val = target[prop];
      if (newVal !== val) {
        target[prop] = newVal;
        dep.notify();
      }
      return true;
    },
  };

  return new Proxy(obj, handler);
}

function parsePath(path) {
  if (/[^\w.$]/.test(path)) {
    return;
  }
  var segs = path.split('.');
  return function(obj) {
    for (let i = 0; i < segs.length; i += 1) {
      if (!obj) {
        return;
      }
      obj = obj[segs[i]];
    }
    return obj;
  };
}

const obj = proxy({
  a: 'a',
  b: 'b',
  o: { name: 'a', age: 18 },
  arr: [1, 2],
});

function $watch(obj, path, cb) {
  return new Sub(obj, path, cb);
}

$watch(obj, 'a', (val, newVal) => {
  console.warn('watch a: ', val, newVal);
});
$watch(obj, 'b', (val, newVal) => {
  console.warn('watch b: ', val, newVal);
});
$watch(obj, 'o.age', (val, newVal) => {
  console.warn('watch o.age: ', val, newVal);
});
$watch(obj, 'arr', (val, newVal) => {
  console.warn('watch arr: ', val, newVal);
});

setTimeout(() => {
  obj.b = 'new b';
  obj.o.age -= 1;
  // vue會列印相同的值, 你會發現我們的實現不會列印
  obj.arr.push(3);
  obj.arr = [3];
}, 1000);
複製程式碼

細心的你應該發現了,我們沒有實現Vue.prototype.$watch常用的{ deep: true }引數, 限於篇幅, 筆者決定還是放在下一篇文章介紹 :)

相關文章