响应式是 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);
在本示例中,我们重新定义了 getItem
和 setItem
以收集并通知依赖于 localStorage
项的组件。在新 getItem
中,我们记录了哪个组件请求了哪个项目,在 setItems
中,我们联系了所有请求该项目的组件并重写了它们的数据属性。
为了使上面的代码工作,我们必须将组件实例的引用传递给 getItem
,这改变了它的函数签名。我们也不能再使用箭头函数了,因为否则我们不会有正确的 this
值。
如果我们想做得更好,我们就必须更深入地挖掘。例如,我们如何在不显式传递它们的情况下跟踪依赖项?
Vue 如何收集依赖项
为了获得灵感,我们可以回到 Vue 的响应式系统。我们之前看到,当访问数据属性时,数据属性的 getter 会 订阅调用者以获取该属性的进一步更改。但是它如何知道谁发出了调用?当我们获取 data
属性时,它的 getter 函数没有任何关于调用者是谁的输入。Getter 函数 没有输入。它如何知道将谁注册为依赖项?
每个数据属性都维护着一个需要在 Dep 类 中做出反应的依赖项列表。如果我们深入研究这个类,我们可以看到,只要依赖项被 注册,它本身就已经在 静态 target 变量中定义了。这个 target 由一个迄今为止神秘的 Watcher 类设置。事实上,当数据属性发生变化时,这些观察者将被实际 通知,并且它们将启动组件的重新渲染或计算属性的重新计算。
但是,再说一次,它们是谁?
当 Vue 使 data
选项可观察时,它还会为每个 计算属性 函数以及所有 watch 函数(不要与 Watcher 类混淆)和每个 组件实例 的渲染函数创建观察者。观察者就像这些函数的伴侣。它们主要做两件事
- 它们在创建时评估函数。这会触发依赖项的收集。
- 当它们收到通知说它们依赖的值已更改时,它们会重新运行其函数。这最终将重新计算计算属性或重新渲染整个组件。

在观察者调用它们负责的函数之前,会发生一个重要的步骤:它们将自己设置为 Dep 类中的静态属性中的目标。这确保了在访问响应式数据属性时,它们会被注册为依赖项。
跟踪谁调用了 localStorage
我们不能完全做到这一点,因为我们无法访问 Vue 的内部机制。但是,我们可以使用 Vue 中的想法,让观察者在调用它负责的函数之前,在静态属性中设置目标。我们可以在调用 localStorage
之前设置对组件实例的引用吗?
如果我们假设在设置 data 选项时会调用 localStorage
,那么我们就可以挂接到 beforeCreate
和 created
。这两个钩子在初始化 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 响应式系统在幕后如何工作并充分利用它,所以我希望您能从这一切中获得收获。
只需使用 vuex 持久插件,一行代码即可搞定
@Roberto 正是我在想的事情
非常棒的文章,谢谢!
我明白这只是一个实验,但我强烈建议不要这样做。真的没有必要使 localStorage 变成响应式的。即使你真的想这样做,你也应该使用状态管理,或者更好的是,使用简单的发布/订阅模式,在保存和检索存储值之前/之后触发事件。
此外,如果你希望持续了解 localStorage 中是否有内容发生变化,监听 storage 事件 是一个更好的主意。
很酷的东西,一个很好的例子。
哦……当 Vue3 发布后,一个月内你就会后悔说这些话了。:D
Vue3 实际上使用了这个十年(也就是代理)的技术,使响应式工作更加一致。包括能够动态添加属性(目前不支持)。而且不再有那些数组的特殊情况了。再见,IE。
请注意,Vue 对数组的特殊处理以及支持的数组方法的限制在 Vue 3 中通过使用 Proxy 支持所有对数组的访问而消失了。 https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
只需在 VueUse 中尝试
useStorage
https://vueuse.js.org/?path=/story/state–usestorage
使用服务器端变量(内存、MongoDB、Redis)并使用类似的逻辑(WebSocket、fetch+SSE)怎么样?