Как сделать localStorage реактивным во Vue

Spread the love

Оригинальная статья: Hunor Márton Borbély – How to Make localStorage Reactive in Vue

Реактивность – одна из главных особенностей Vue. Это также одно из самых загадочных явлений, если вы не знаете, как это работает на самом деле. Например, почему реактивность работает с объектами и массивами, но не с другими вещами, такими как localStorage?

Давайте ответим на этот вопрос, и сделаем localStorage реактивным.

Для этого создайте новый файл HTML со следующим кодом.

new Vue({
  el: "#counter",
  data: () => ({
    counter: localStorage.getItem("counter")
  }),
  computed: {
    even() {
      return this.counter % 2 == 0;
    }
  },
  template: `<div>
    <div>Counter: {{ counter }}</div>
    <div>Counter is {{ even ? 'even' : 'odd' }}</div>
  </div>`
});
setInterval(() => {
  const counter = localStorage.getItem("counter");
  localStorage.setItem("counter", +counter + 1);
}, 1000);

После запуска кода, вы увидите, что счетчик отображается как статическое значение, и не изменяется так, как мы могли бы ожидать при изменение значения в localStorage.

Хотя свойство counter внутри экземпляра Vue является реактивным, оно не изменится только потому, что мы изменили его в localStorage.

Для этого существует несколько решений, возможно, самое хорошее – использовать Vuex и синхронизировать значение в store с localStorage. Но что, если нам нужно что-то простое, подобное тому, что есть в этом примере? Для этого мы должны разобраться, как работает система реактивности во Vue.

Реактивность во Vue

Когда Vue инициализирует экземпляр компонента, он начинает наблюдать за всеми параметромы в data. Это означает, что он просматривает все свойства данных и преобразует их в методы геттеры (getters) и сеттеры (setters), используя Object.defineProperty. Имея собственный сеттер для каждого свойства, Vue знает, когда свойство изменяется, и может уведомлять зависимые объекты, которые должны реагировать на изменение. Как Vue узнает, какие объекты зависят от свойства? При подключении через геттер Vue может их регистрировать, когда вычисляемое свойство (computed), функция-наблюдатель (watcher) или функция визуализации получают доступ к свойствам в data.

// core/instance/state.js
function initData () {
  // ...
  observe(data)
}
// core/observer/index.js
export function observe (value) {
  // ...
  new Observer(value)
  // ...
}

export class Observer {
  // ...
  constructor (value) {
    // ...
    this.walk(value)
  }
  
  walk (obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
} 

export function defineReactive (obj, key, ...) {
  const dep = new Dep()
  // ...
  Object.defineProperty(obj, key, {
    // ...
    get() {
      // ...
      dep.depend()
      // ...
    },
    set(newVal) {
      // ...
      dep.notify()
    }
  })
}

Итак, почему localStorage не реагирует на изменения? Потому что это не объект со свойствами.

Но подождите. Мы также не можем определять геттеры и сеттеры с массивами, а массивы во Vue реактивны. Это потому, что массивы – это особый случай. Чтобы массивы стали реактивными, Vue переопределяет методы массива за кулисами и добавляет им реактивность.

Можем ли мы сделать что-то похожее с localStorage?

Переопределение функций localStorage

В качестве первой попытки мы можем исправить наш начальный пример, переопределив методы localStorage, чтобы отслеживать, какие экземпляры компонентов запрашивали элемент localStorage.

// Делаем соответствие между ключами элемента localStorage и
// списком экземпляров Vue, которые зависят от него
const storeItemSubscribers = {};

const getItem = window.localStorage.getItem;
localStorage.getItem = (key, target) => {
  console.info("Getting", key);

  // Создаем зависимый экземпляр Vue
  if (!storeItemSubscribers[key]) storeItemSubscribers[key] = [];
  if (target) storeItemSubscribers[key].push(target);

  // Вызываем оригинальную функцию
  return getItem.call(localStorage, key);
};

const setItem = window.localStorage.setItem;
localStorage.setItem = (key, value) => {
  console.info("Setting", key, value);

  // Обновляем значение в зависимых экземплярах Vue
  if (storeItemSubscribers[key]) {
    storeItemSubscribers[key].forEach((dep) => {
      if (dep.hasOwnProperty(key)) dep[key] = value;
    });
  }

  // Вызываем оригинальную функцию
  setItem.call(localStorage, key, value);
};
new Vue({
  el: "#counter",
  data: function() {
    return {
      counter: localStorage.getItem("counter", this) // Обратите внимание на передачу экземпляра 'this'
    }
  },
  computed: {
    even() {
      return this.counter % 2 == 0;
    }
  },
  template: `<div>
    <div>Counter: {{ counter }}</div>
    <div>Counter is {{ even ? 'even' : 'odd' }}</div>
  </div>`
});
setInterval(() => {
  const counter = localStorage.getItem("counter");
  localStorage.setItem("counter", +counter + 1);
}, 1000);

В этом примере мы переопределяем getItem и setItem, чтобы собирать и уведомлять компоненты, которые зависят от элементов localStorage. В новом getItem мы запоминаем, какой компонент запрашивает какой элемент, а в setItems мы обращаемся ко всем компонентам, которые запросили элемент, и переписываем их свойство data.

Чтобы заставить приведенный выше код работать, мы должны передать ссылку на экземпляр компонента в getItem, и это изменит его сигнатуру функции. Мы также не можем больше использовать функцию стрелки, потому что в противном случае у нас не было бы правильного значения this.

Пойдем те дальше, и улучшим наш код. Например, мы можем отслеживать зависимые элементы, не передавая их явно?

Как Vue собирает зависимости

Для вдохновения мы можем вернуться к системе реактивности Vue. Ранее мы видели, что получатель свойства data будет подписывать вызывающую сторону на дальнейшие изменения свойства при обращении к свойству data. Но как он узнал, кто сделал вызов? Когда мы получаем свойство data, его функция получения не имеет никакой информации о том, кем она была вызвана. Откуда он знает, кого регистрировать как зависимого элемента?

Каждое свойство data поддерживает список своих зависимых элементов, которые должны реагировать в классе Dep class. Если мы углубимся в этот класс, то увидим, что сам зависимый объект уже определен в статической целевой переменной всякий раз, когда он регистрируется. Эта цель установлена таинственным классом Watcher. Фактически, когда свойство данных изменяется, эти наблюдатели будут уведомлены, и они будут инициировать повторную визуализацию компонента или пересчет вычисленного свойства.

Но опять же, кто они?

Когда Vue делает параметр data наблюдаемым, он также создает наблюдателя для каждой вычисляемой функции, а также все функции наблюдения  watch (которые не следует путать с классом Watcher) и функцию рендеринга каждого экземпляра компонента. Наблюдатели, становятся компаньонами для этих функций. В основном они делают две вещи:

  1. После создания функции они запускают сбор зависимостей.
  2. Они повторно запускают свою функцию, когда получают уведомление об изменении значения, на которое они полагаются. Это в конечном итоге пересчитает вычисленное свойство или повторно отображает весь компонент.

Есть важный шаг, который происходит перед тем, как наблюдатели вызывают функцию, за которую они отвечают: они устанавливают себя в качестве цели в статической переменной в классе Dep. Это гарантирует, что они зарегистрированы как зависимые при обращении к свойству реактивных данных.

Отслеживание того, кто вызвал localStorage

Мы не можем точно воспроизвести поведение localStorage как у array, потому что у нас нет доступа к внутренней механике Vue. Однако мы можем использовать идею из Vue, которая позволяет наблюдателю установить target в статическом свойстве, прежде чем он вызовет функцию, за которую он отвечает. Можем ли мы установить ссылку на экземпляр компонента до вызова localStorage?

Если мы предполагаем, что localStorage вызывается при настройке параметра data, тогда мы можем подключиться к beforeCreate и created. Эти два хука запускаются до и после инициализации параметра данных, поэтому мы можем установить, а затем очистить целевую переменную со ссылкой на текущий экземпляр компонента (к которому мы имеем доступ в хуках жизненного цикла). Затем в наших пользовательских методах получения мы можем зарегистрировать этот target как зависимый.

Последнее, что нам нужно сделать, – это сделать хуки жизненного цикла частью всех наших компонентов. Мы можем сделать это с помощью глобального миксина для всего проекта.

// Соответствия между ключами элемента localStorage и 
// списком экземпляров Vue, которые зависят от него
const storeItemSubscribers = {};

// Экземпляр Vue, который в данный момент инициализируется
let target = undefined;

const getItem = window.localStorage.getItem;
localStorage.getItem = (key) => {
  console.info("Getting", key);

  // Собраем зависимый экземпляр Vue
  if (!storeItemSubscribers[key]) storeItemSubscribers[key] = [];
  if (target) storeItemSubscribers[key].push(target);

  // Вызоваем оригинальную функцию
  return getItem.call(localStorage, key);
};

const setItem = window.localStorage.setItem;
localStorage.setItem = (key, value) => {
  console.info("Setting", key, value);

  // Обновляем значение в зависимых экземплярах Vue
  if (storeItemSubscribers[key]) {
    storeItemSubscribers[key].forEach((dep) => {
      if (dep.hasOwnProperty(key)) dep[key] = value;
    });
  }
  
  // Вызоваем оригинальную функцию
  setItem.call(localStorage, key, value);
};

Vue.mixin({
  beforeCreate() {
    console.log("beforeCreate", this._uid);
    target = this;
  },
  created() {
    console.log("created", this._uid);
    target = undefined;
  }
});

Теперь, когда мы запустим наш первоначальный пример, мы получим счетчик, который увеличивает число каждую секунду.

new Vue({
  el: "#counter",
  data: () => ({
    counter: localStorage.getItem("counter")
  }),
  computed: {
    even() {
      return this.counter % 2 == 0;
    }
  },
  template: `<div class="component">
    <div>Counter: {{ counter }}</div>
    <div>Counter is {{ even ? 'even' : 'odd' }}</div>
  </div>`
});
setInterval(() => {
  const counter = localStorage.getItem("counter");
  localStorage.setItem("counter", +counter + 1);
}, 1000);

Завершение

Хотя мы решили нашу первоначальную задачу, имейте в виду, что это в основном мысленный эксперимент. В нем отсутствуют некоторые функции, такие как обработка удаленных элементов и несмонтированных экземпляров компонентов. Наша реализация также имеет ограничения, так как имя свойства экземпляра компонента требует того же имени, что и элемент, хранящийся в localStorage. Тем не менее, основная цель состоит в том, чтобы получить лучшее представление о том, как работает реактивность Vue за кулисами, и получить из этого максимум пользы, и я надеюсь, что статья для вас была полезна.

Была ли вам полезна эта статья?
[16 / 4.3]

Spread the love
Подписаться
Уведомление о
guest
0 Комментарий
Inline Feedbacks
View all comments