如何在 Vue 中使 localStorage 响应式

Avatar of Hunor Márton Borbély
Hunor Márton Borbély

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

响应式是 Vue 最强大的功能之一。如果您不知道它在幕后做了什么,它也是最神秘的功能之一。比如,为什么它适用于对象和数组,而不适用于其他东西,比如 localStorage

让我们回答这个问题,同时, Vue 的响应式系统与 localStorage 一起工作。

如果我们运行以下代码,我们会看到计数器显示为静态值,并且不会像我们预期的那样因为间隔更改 localStorage 中的值而改变。

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

虽然 Vue 实例中的 counter 属性响应式的,但仅仅因为我们在 localStorage 中更改了它的来源,它并不会改变。

有多种解决此问题的方法,也许最棒的是使用 Vuex 并使存储值与 localStorage 保持同步。但是,如果我们需要像本示例中这样简单的东西怎么办?我们必须深入了解 Vue 的响应式系统的工作原理。

Vue 中的响应式

当 Vue 初始化组件实例时,它会 观察 data 选项。这意味着它会 遍历 data 中的所有属性,并使用 Object.defineProperty 将它们转换为 getter/setter。通过为每个属性设置自定义 setter,Vue 就能知道属性何时发生更改,并且可以 通知需要对更改做出反应的依赖项。它如何知道哪些依赖项依赖于某个属性?通过利用 getter,它可以在 访问数据属性时注册计算属性、观察者函数或渲染函数。

// 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 不是响应式的呢?**因为它不是具有属性的对象。**

但是等等。我们也不能用数组定义 getter 和 setter,但 Vue 中的数组仍然是响应式的。这是因为数组是 Vue 中的一个特殊情况。为了拥有响应式数组,Vue 在幕后重写了数组方法,并 将它们与 Vue 的响应式系统结合起来

我们能对 localStorage 做类似的事情吗?

重写 localStorage 函数

作为第一次尝试,我们可以通过重写 localStorage 方法来跟踪哪些组件实例请求了 localStorage 项,从而修复我们最初的示例。

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

在本示例中,我们重新定义了 getItemsetItem 以收集并通知依赖于 localStorage 项的组件。在新 getItem 中,我们记录了哪个组件请求了哪个项目,在 setItems 中,我们联系了所有请求该项目的组件并重写了它们的数据属性。

为了使上面的代码工作,我们必须将组件实例的引用传递给 getItem,这改变了它的函数签名。我们也不能再使用箭头函数了,因为否则我们不会有正确的 this 值。

如果我们想做得更好,我们就必须更深入地挖掘。例如,我们如何在不显式传递它们的情况下跟踪依赖项?

Vue 如何收集依赖项

为了获得灵感,我们可以回到 Vue 的响应式系统。我们之前看到,当访问数据属性时,数据属性的 getter 会 订阅调用者以获取该属性的进一步更改。但是它如何知道谁发出了调用?当我们获取 data 属性时,它的 getter 函数没有任何关于调用者是谁的输入。Getter 函数 没有输入。它如何知道将谁注册为依赖项?

每个数据属性都维护着一个需要在 Dep 类 中做出反应的依赖项列表。如果我们深入研究这个类,我们可以看到,只要依赖项被 注册,它本身就已经在 静态 target 变量中定义了。这个 target 由一个迄今为止神秘的 Watcher 类设置。事实上,当数据属性发生变化时,这些观察者将被实际 通知,并且它们将启动组件的重新渲染或计算属性的重新计算。

但是,再说一次,它们是谁?

当 Vue 使 data 选项可观察时,它还会为每个 计算属性 函数以及所有 watch 函数(不要与 Watcher 类混淆)和每个 组件实例 的渲染函数创建观察者。观察者就像这些函数的伴侣。它们主要做两件事

  1. 它们在创建时评估函数。这会触发依赖项的收集。
  2. 当它们收到通知说它们依赖的值已更改时,它们会重新运行其函数。这最终将重新计算计算属性或重新渲染整个组件。

在观察者调用它们负责的函数之前,会发生一个重要的步骤:它们将自己设置为 Dep 类中的静态属性中的目标。这确保了在访问响应式数据属性时,它们会被注册为依赖项。

跟踪谁调用了 localStorage

我们不能完全做到这一点,因为我们无法访问 Vue 的内部机制。但是,我们可以使用 Vue 中的想法,让观察者在调用它负责的函数之前,在静态属性中设置目标。我们可以在调用 localStorage 之前设置对组件实例的引用吗?

如果我们假设在设置 data 选项时会调用 localStorage,那么我们就可以挂接到 beforeCreatecreated。这两个钩子在初始化 data 选项之前和之后触发,因此我们可以设置然后清除一个带有对当前组件实例(我们可以在生命周期钩子中访问)的引用的 target 变量。然后,在我们的自定义 getter 中,我们可以将此 target 注册为依赖项。

我们必须做的最后一件事是使这些生命周期钩子成为我们所有组件的一部分。我们可以通过为整个项目创建一个全局混合来做到这一点。

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

现在,当我们运行初始示例时,我们会得到一个每秒增加数字的计数器。

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 响应式系统在幕后如何工作并充分利用它,所以我希望您能从这一切中获得收获。