Wie man localStorage in Vue reaktiv macht

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Reaktivität ist eines der größten Features von Vue. Sie ist auch eine der mysteriösesten, wenn man nicht weiß, was im Hintergrund passiert. Zum Beispiel, warum funktioniert sie mit Objekten und Arrays, aber nicht mit anderen Dingen wie localStorage?

Lassen Sie uns diese Frage beantworten und dabei dafür sorgen, dass Vue-Reaktivität mit localStorage funktioniert.

Wenn wir den folgenden Code ausführen würden, würden wir sehen, dass der angezeigte Zähler ein statischer Wert ist und sich nicht wie erwartet ändert, obwohl das Intervall den Wert in localStorage ändert.

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>`
});
// some-other-file.js
setInterval(() => {
  const counter = localStorage.getItem("counter");
  localStorage.setItem("counter", +counter + 1);
}, 1000);

Während die counter-Eigenschaft innerhalb der Vue-Instanz reaktiv ist, ändert sie sich nicht nur, weil wir ihren Ursprung in localStorage geändert haben. 

Dafür gibt es mehrere Lösungen, die schönste ist vielleicht die Verwendung von Vuex und die Synchronisation des Store-Werts mit localStorage. Aber was, wenn wir etwas Einfaches brauchen, wie in diesem Beispiel? Wir müssen uns ansehen, wie das Reaktivitätssystem von Vue funktioniert.

Reaktivität in Vue

Wenn Vue eine Komponenteninstanz initialisiert, beobachtet es die data-Option. Das bedeutet, es geht durch alle Eigenschaften in data und konvertiert sie mithilfe von Object.defineProperty in Getter/Setter. Indem es einen benutzerdefinierten Setter für jede Eigenschaft hat, weiß Vue, wann sich eine Eigenschaft ändert, und es kann die Abhängigen benachrichtigen, die auf die Änderung reagieren müssen. Woher weiß es, welche Abhängigen von einer Eigenschaft abhängen? Indem es in die Getter eingreift, kann es registrieren, wenn eine berechnete Eigenschaft, eine Watcher-Funktion oder eine Renderfunktion auf eine Daten-Prop zugreift.

// 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()
    }
  })
}

Warum ist localStorage also nicht reaktiv? Weil es kein Objekt mit Eigenschaften ist.

Aber warten Sie. Wir können auch keine Getter und Setter mit Arrays definieren, aber Arrays in Vue sind trotzdem reaktiv. Das liegt daran, dass Arrays ein Sonderfall in Vue sind. Um reaktive Arrays zu haben, überschreibt Vue Array-Methoden im Hintergrund und verpatcht sie mit dem Reaktivitätssystem von Vue.

Könnten wir mit localStorage etwas Ähnliches tun?

Überschreiben von localStorage-Funktionen

Als ersten Versuch können wir unser ursprüngliches Beispiel reparieren, indem wir die localStorage-Methoden überschreiben, um zu verfolgen, welche Komponenteninstanzen ein localStorage-Element angefordert haben.

// A map between localStorage item keys and a list of Vue instances that depend on it
const storeItemSubscribers = {};


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


  // Collect dependent Vue instance
  if (!storeItemSubscribers[key]) storeItemSubscribers[key] = [];
  if (target) storeItemSubscribers[key].push(target);


  // Call the original function 
  return getItem.call(localStorage, key);
};


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


  // Update the value in the dependent Vue instances
  if (storeItemSubscribers[key]) {
    storeItemSubscribers[key].forEach((dep) => {
      if (dep.hasOwnProperty(key)) dep[key] = value;
    });
  }


  // Call the original function
  setItem.call(localStorage, key, value);
};
new Vue({
  el: "#counter",
  data: function() {
    return {
      counter: localStorage.getItem("counter", this) // We need to pass 'this' for now
    }
  },
  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);

In diesem Beispiel definieren wir getItem und setItem neu, um die Komponenten zu sammeln und zu benachrichtigen, die von localStorage-Elementen abhängen. In der neuen getItem-Methode notieren wir, welche Komponente welches Element anfordert, und in setItems erreichen wir alle Komponenten, die das Element angefordert haben, und schreiben ihre Daten-Prop neu.

Um den obigen Code zum Laufen zu bringen, müssen wir eine Referenz auf die Komponenteninstanz an getItem übergeben, was die Funktionssignatur ändert. Wir können auch nicht mehr die Pfeilfunktion verwenden, da wir sonst nicht den richtigen this-Wert hätten.

Wenn wir es besser machen wollen, müssen wir tiefer graben. Wie könnten wir zum Beispiel verfolgen, wer die Abhängigen sind, ohne sie *explizit* weiterzugeben?

Wie Vue Abhängigkeiten sammelt

Zur Inspiration können wir zum Reaktivitätssystem von Vue zurückkehren. Wir haben zuvor gesehen, dass der Getter einer Daten-Eigenschaft den Aufrufer für weitere Änderungen der Eigenschaft abonnieren wird, wenn auf die Daten-Eigenschaft zugegriffen wird. Aber woher weiß es, wer den Aufruf getätigt hat? Wenn wir eine data-Prop erhalten, hat ihre Getter-Funktion keine Eingaben bezüglich des Aufrufers. Getter-Funktionen haben keine Eingaben. Woher weiß sie, wen sie als Abhängigen registrieren soll? 

Jede Daten-Eigenschaft verwaltet eine Liste ihrer Abhängigen, die in einer Dep-Klasse reagieren müssen. Wenn wir tiefer in diese Klasse eintauchen, können wir sehen, dass der Abhängige selbst in einer statischen Zielvariable definiert ist, wenn er registriert wird. Dieses Ziel wird von einer bisher mysteriösen Watcher-Klasse festgelegt. Tatsächlich werden diese Watcher, wenn sich eine Daten-Eigenschaft ändert, tatsächlich benachrichtigt und initiieren das erneute Rendern der Komponente oder die Neuberechnung einer berechneten Eigenschaft.

Aber wer sind sie schon wieder?

Wenn Vue die data-Option beobachtbar macht, erstellt es auch Watcher für jede berechnete Eigenschaftsfunktion, sowie für alle Watch-Funktionen (die nicht mit der Watcher-Klasse verwechselt werden sollten) und die Renderfunktion jeder Komponenteninstanz. Watcher sind wie Begleiter für diese Funktionen. Sie tun hauptsächlich zwei Dinge:

  1. Sie werten die Funktion aus, wenn sie erstellt werden. Dies löst die Sammlung von Abhängigkeiten aus.
  2. Sie führen ihre Funktion erneut aus, wenn sie benachrichtigt werden, dass sich ein Wert, von dem sie abhängen, geändert hat. Dies wird letztendlich eine berechnete Eigenschaft neu berechnen oder eine ganze Komponente neu rendern.

Es gibt einen wichtigen Schritt, der passiert, bevor Watcher die Funktion ausführen, für die sie verantwortlich sind: Sie setzen sich selbst als Ziel in einer statischen Variablen in der Dep-Klasse. Dies stellt sicher, dass sie als Abhängige registriert werden, wenn auf eine reaktive Daten-Eigenschaft zugegriffen wird.

Verfolgen, wer localStorage aufgerufen hat

Wir können das nicht *genau* tun, weil wir keinen Zugriff auf die inneren Mechanismen von Vue haben. Wir können jedoch die *Idee* von Vue nutzen, die es einem Watcher erlaubt, das Ziel in einer statischen Eigenschaft zu setzen, bevor er die Funktion aufruft, für die er verantwortlich ist. Könnten wir eine Referenz auf die Komponenteninstanz setzen, bevor localStorage aufgerufen wird?

Wenn wir davon ausgehen, dass localStorage beim Setzen der data-Option aufgerufen wird, können wir uns in beforeCreate und created einklinken. Diese beiden Hooks werden vor und nach der Initialisierung der data-Option ausgelöst, sodass wir eine Zielvariable mit einer Referenz auf die aktuelle Komponenteninstanz setzen und dann wieder löschen können (auf die wir in den Lifecycle-Hooks Zugriff haben). Dann können wir in unseren benutzerdefinierten Gettern dieses Ziel als Abhängigkeit registrieren.

Das Letzte, was wir tun müssen, ist, diese Lifecycle-Hooks zu einem Bestandteil all unserer Komponenten zu machen. Das können wir mit einem globalen Mixin für das gesamte Projekt tun.

// A map between localStorage item keys and a list of Vue instances that depend on it
const storeItemSubscribers = {};

// The Vue instance that is currently being initialised
let target = undefined;

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

  // Collect dependent Vue instance
  if (!storeItemSubscribers[key]) storeItemSubscribers[key] = [];
  if (target) storeItemSubscribers[key].push(target);

  // Call the original function
  return getItem.call(localStorage, key);
};

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

  // Update the value in the dependent Vue instances
  if (storeItemSubscribers[key]) {
    storeItemSubscribers[key].forEach((dep) => {
      if (dep.hasOwnProperty(key)) dep[key] = value;
    });
  }
  
  // Call the original function
  setItem.call(localStorage, key, value);
};

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

Wenn wir nun unser ursprüngliches Beispiel ausführen, erhalten wir einen Zähler, der jede Sekunde die Zahl erhöht.

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);

Das Ende unseres Gedankenexperiments

Obwohl wir unser ursprüngliches Problem gelöst haben, bedenken Sie, dass dies hauptsächlich ein Gedankenexperiment ist. Es fehlen mehrere Funktionen, wie die Behandlung von entfernten Elementen und unmounteten Komponenteninstanzen. Es hat auch Einschränkungen, wie z. B. dass der Eigenschaftsname der Komponenteninstanz denselben Namen wie das in localStorage gespeicherte Element haben muss. Dennoch ist das Hauptziel, eine bessere Vorstellung davon zu bekommen, wie Vue-Reaktivität im Hintergrund funktioniert und das Beste daraus zu machen, was ich Ihnen hoffentlich vermittelt habe.