Если вам нравится программирование, управляемое данными, вам нужно будет обнаруживать изменение данных. Есть несколько способов сделать это. В этой статье я расскажу о двух из них.

Первый способ, который приходит мне на ум, это Proxy. Если вы мало знаете о Proxy. Вы можете сначала прочитать это на MDN.

Использовать прокси на обычном объекте

Большая часть наших данных хранится в объекте. За простым объектом легко наблюдать. Мы предполагаем, что изменение данных заключается в установке или удалении данных. Итак, мы добавляем ловушки на set и deleteProperty.

const obj = {};
const fn = () => console.log('we capture a change');
const proxy = new Proxy(obj, {
  set (target, property, value) {
    target[property] = value;
    fn();
    return true;
  },
  deleteProperty (target, property) {
    delete target[property];
    fn();
    return true;
  }
});
proxy.a = 2; // we capture a change
console.log(obj); // {a: 2}

Теперь вы можете изменить от obj до proxy, что сделает то же самое и сообщит вам об изменении. Если вам нужно наблюдать за другими изменениями, вы можете прочитать обработчики на MDN.

Однако наши данные не всегда хранятся в простом объекте, поэтому мы должны поддерживать наблюдение за многоуровневым объектом.

Использовать прокси на многослойном объекте

Мы можем легко реализовать это с помощью рекурсии.

В приведенном выше примере мы установили ловушку на set и deleteProperty. Теперь устанавливаем еще одну ловушку на get. Когда мы обнаруживаем, что одно из наших свойств содержит объект, мы заменяем его его прокси.

Мы также должны помнить, было ли свойство проксировано. В противном случае мы можем вызвать ненужные срабатывания. Поэтому я создаю пустой объект mapStore.

  1. Если mapStore[property] === undefined, это означает, что это свойство не было проксировано.
  2. если mapStore[property] === true, это означает, что это свойство было проксировано.
  3. В остальном недвижимость не проксирована. Но у mapStore есть значение прокси.
function isObject (obj) {
  return typeof obj === 'object';
}
function deepProxy (obj, hook) {
  const mapStore = {};
  return new Proxy(obj, {
    get (target, property) {
      const value = target[property];
      // if this property has been proxied, just return
      if(mapStore[property] === true) return value;
      // if it's an non-proxied object, we return its proxy
      if(isObject(value)) {
        const proxyValue = mapStore[property] || deepProxy(value, hook);
        mapStore[property] = proxyValue;
        return proxyValue;
      }
      // else we just take a mark
      mapStore[property] = true;
      return value;
    },
    set (target, property, value) {
      const newVal = isObject(value)
        ? deepProxy(value, hook)
        : value;
      target[property] = newVal;
      mapStore[property] = true;
      hook();
      return true;
    },
    deleteProperty (target, propertty) {
      delete target[property];
      delete mapStore[property];
      hook();
      return true;
    }
  });
}
const obj = {
  foo: {
    a: 1
  }
};
const fn = () => console.log('we capture a change');
const proxy = deepProxy(obj, fn);
proxy.foo.a = 2; // we capture a change.

Отличная работа. Теперь мы можем обнаружить изменение объекта.

Итак, как насчет массива?

Использовать прокси на массиве

Кажется, это просто кусок пирога. Как насчет того, чтобы просто добавить isArray в обработчик?

function isArray (arr) {
  return Array.isArray(arr);
}
...
    get (target, property) {
      const value = target[property];
      // if this property has been proxied, just return
      if(mapStore[property] === true) return value;
      // if it's an non-proxied object, we return its proxy
      if(isObject(value) || isArray(value)) {
        const proxyValue = mapStore[property] || deepProxy(value, hook);
        mapStore[property] = proxyValue;
        return proxyValue;
      }
      // else we just take a mark
      mapStore[property] = true;
      return value;
    },
    set (target, property, value) {
      const newVal = (isObject(value) || isArray(value))
        ? deepProxy(value, hook)
        : value;
      target[property] = newVal;
      mapStore[property] = true;
      hook();
      return true;
    },
...

Давайте проведем тест.

const obj = {
  foo: [1, 2, 3]
};
const fn = () => console.log('we caputre a change');
const proxy = deepProxy(obj, fn);
proxy.foo[1] = 4; // we capture a change
proxy.foo.unshift(2);
// we capture a change
// we capture a change
// we capture a change
// we capture a change

Что случилось?? Он фиксирует четыре изменения!!

Ничего страшного. Просто успокойся. Подумайте об этом внимательно. Что сделал unshfit?

Предположим, что исходный массив подобен таблице ниже.

0–1 , 1–4, 2–3

Когда вы выполняете unshift , он движется вот так.

  1. Изменить данные на первой позиции
  2. Переместите исходные первые данные во вторую позицию
  3. Переместите исходные вторые данные в третью позицию
  4. Переместите исходные третьи данные в новую четвертую позицию

Итак, мы фактически получаем 4 изменения! Верно.

Но в какой-то ситуации мы считаем их только одним изменением. Нам нужно внести некоторые изменения здесь.

Мы добавляем флаг arrayChanging для метода массива. Когда мы выполняем метод, мы не запускаем никаких изменений. Мы срабатываем только тогда, когда заканчиваем выполнение метода.

И, конечно же, мы переопределим метод.

const arrayChangeMethod = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse'];
function isObject (obj) {
  return typeof obj === 'object';
}
function isArray (arr) {
  return Array.isArray(arr);
}
function deepProxy (obj, hook) {
  const mapStore = {};
  let arrayChanging = false;
  return new Proxy(obj, {
    get (target, property, receiver) {
      const value = target[property];
      if(isArray(target) && arrayChangeMethod.indexOf(property) > -1) {
        // we override the array's method
        return (...args) => {
          arrayChanging = true;
          value.bind(receiver)(...args);
          arrayChanging = false;
          hook();
        };
      }
      if(mapStore[property] === true) return value;
      if(isObject(value) || isArray(value)) {
        const proxyValue = mapStore[property] || deepProxy(value, hook);
        mapStore[property] = proxyValue;
        return proxyValue;
      }
      mapStore[property] = true;
      return value;
    },
    set (target, property, value) {
      const newVal = (isObject(value) || isArray(value))
        ? deepProxy(value, hook)
        : value;
      target[property] = newVal;
      mapStore[property] = true;
      if(!arrayChanging) hook();
      return true;
    },
    deleteProperty (target, propertty) {
      delete target[property];
      delete mapStore[property];
      if(!arrayChanging) hook();
      return true;
    }
  });
}

Давайте проведем тест еще раз!

const obj = {
  foo: [1, 2, 3]
};
const fn = () => console.log('we caputre a change');
const proxy = deepProxy(obj, fn);
proxy.foo[1] = 4; // we capture a change
proxy.foo.unshift(2); // we capture a change

Это работает~

Если вы не пишете nodejs или ваш менеджер не согласен с тем, что ваша страница поддерживает только новый браузер, вы должны позаботиться о совместимости.

Давайте посмотрим на caniuse.

Что ж, вы можете обнаружить, что браузер, который вам нужно поддерживать, не имеет этой замечательной функции. Так у нас есть полипилл? К сожалению, Proxy нельзя заполнить полифиллом.

Здесь нам нужна запасная стратегия.

Возврат к Object.defineProperty

Object.defineProperty предлагает нам право добавлять геттер-сеттер к свойству. Благодаря этому мы можем наблюдать изменение данных. Если вы мало что знаете о Object.defineProperty, можете прочитать это.

Легко построить deepObserve на Object.defineProperty. Благодаря этому мы можем обнаружить любые изменения в существующем свойстве.

const {getOwnPropertyNames, getOwnPropertySymbols, defineProperty, getOwnPropertyDescriptor} = Object;
function isObject (obj) {
  return typeof obj === 'object';
}
function isArray (arr) {
  return Array.isArray(arr);
}
function isFunction (fn) {
  return typeof fn === 'function';
}
const getOwnKeys = isFunction(getOwnPropertySymbols)
  ? function (obj) {
    return getOwnPropertyNames(obj).concat(getOwnPropertySymbols(obj));
  }
  : getOwnPropertyNames;
function deepObserve (obj, hook) {
  const mapStore = {};
  if(isObject(obj) || isArray(obj)) {
    getOwnKeys(obj).forEach(key => {
      let value = obj[key];
      const desc = getOwnPropertyDescriptor(obj, key);
      if(desc && desc.configurable === false) return;
      defineProperty(obj, key, {
        get () {
          if(mapStore[key]) return value;
          if(isObject(value) || isArray(value)) {
            deepObserve(value, hook);
          }
          mapStore[key] = true;
          return value;
        },
        set (val) {
          if(isObject(val) || isArray(val)) deepObserve(val, hook);
          mapStore[key] = true;
          hook();
          return val;
        },
        enumerable: desc.enumerable,
        configurable: true
      })
    });
  }
  return obj;
}
const fn = () => console.log('we capture a change');
const obj = deepObserve({a: 1, b: [1, 2, 3]}, fn);
obj.a = 2; // we capture a change
obj.b[2] = 4; // we caputure a change

Переопределить метод массива

Если вы попытаетесь использовать какой-либо метод массива, например push, pop и т. д., он может вести себя странно. Иногда он фиксирует изменения, но иногда нет. Это потому, что мы наблюдаем только часть свойства.

Поэтому нам нужно переопределить метод массива.

const arrayChangeMethod = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse'];
const {getOwnPropertyNames, getOwnPropertySymbols, defineProperty, getOwnPropertyDescriptor} = Object;
function isObject (obj) {
  return typeof obj === 'object';
}
function isArray (arr) {
  return Array.isArray(arr);
}
function isFunction (fn) {
  return typeof fn === 'function';
}
const getOwnKeys = isFunction(getOwnPropertySymbols)
  ? function (obj) {
    return getOwnPropertyNames(obj).concat(getOwnPropertySymbols(obj));
  }
  : getOwnPropertyNames;
function deepObserve (obj, hook) {
  const mapStore = {};
  let arrayChanging = false;
  function wrapProperty (key) {
    let value = obj[key];
    const desc = getOwnPropertyDescriptor(obj, key);
    if(desc && desc.configurable === false) return;
    defineProperty(obj, key, {
      get () {
        if(mapStore[key]) return value;
        if(isObject(value) || isArray(value)) {
          deepObserve(value, hook);
        }
        mapStore[key] = true;
        return value;
      },
      set (val) {
        if(isObject(val) || isArray(val)) deepObserve(val, hook);
        mapStore[key] = true;
        if(!arrayChanging) hook();
        return val;
      },
      enumerable: desc.enumerable,
      configurable: true
    });
  }
  if(isObject(obj) || isArray(obj)) {
    getOwnKeys(obj).forEach(key => wrapProperty(key));
  }
  if(isArray(obj)) {
    arrayChangeMethod.forEach(key => {
      const originFn = obj[key];
      defineProperty(obj, key, {
        value (...args) {
          const originLength = obj.length;
          arrayChanging = true;
          originFn.bind(obj)(...args);
          arrayChanging = false;
          if(obj.length > originLength) {
            const keys = new Array(obj.length - originLength)
            .fill(1)
            .map((value, index) => (index + originLength).toString());
            keys.forEach(key => wrapProperty(key));
          }
          hook();
        },
        enumerable: false,
        configurable: true,
        writable: true
      })
    })
  }
  return obj;
}
const fn = () => console.log('we capture a change');
const obj = deepObserve({a: 1, b: [1, 2, 3]}, fn);
obj.b.push(4); // we caputure a change

Теперь мы поддерживаем и метод массива. Но мы не можем обнаружить изменение, когда вы delete или добавляете новый ресурс.

Ну, это одна из причин, по которой мы не можем полифилить прокси.

Итак, если вы хотите обнаружить изменение delete или добавление свойства. Вы должны выполнить свою собственную функцию, которая также вызовет хук. Я пропускаю это здесь~

Можем ли мы использовать часы в качестве декоратора?

Ну конечно можем. Я делал это в токсик-декораторах. Вы можете попробовать.

Если вы создаете только один экземпляр, вы можете попробовать это.

import {watch} from 'toxic-decorators';
class Foo {
  @watch(() => console.log('we capture a change'))
  bar = {
    a: 1
  }
}
const foo = new Foo();
foo.bar.a = 2; // we capture a change.