Responsiveness is one of the biggest features of Vue. It’s also one of the most mysterious places if you don’t know what’s going on behind the scenes. For example, why can’t it be used for objects and arrays, for other things like localStorage?
Let’s answer this question and let Vue responsiveness be used with localStorage while solving this problem.
If you run the following code, you will see that the counter is displayed as a static value and will not change as expected because setInterval changed the value in localStorage.
new Vue({
el: "#counter".data: (a)= > ({
counter: localStorage.getItem("counter")}),computed: {
even() {
return this.counter % 2= =0; }},template: `
Counter: {{ counter }}
Counter is {{ even ? 'even' : 'odd' }}
`
});
Copy the code
// some-other-file.js
setInterval((a)= > {
const counter = localStorage.getItem("counter");
localStorage.setItem("counter", +counter + 1);
}, 1000);
Copy the code
Although the Counter property in the Vue instance is responsive, it does not change because we change its source in localStorage.
There are several solutions, perhaps the best is to use Vuex and keep the stored values in sync with localStorage. But what if we need something as simple as this example? Let’s take a closer look at how Vue’s responsive system works.
Responsivity in Vue
When Vue initializes a component instance, it looks at the Data option. This means that it iterates through all the attributes in the data and converts them to getters/setters using Object.defineProperty. By setting custom setters for each property, Vue knows when the property has changed and can notify the dependencies that need to react to the change. How does it know which dependents depend on an attribute? By accessing Getters, it can register when a calculated property, observer function, or render function accesses a data property.
// core/instance/state.js
function initData () {
// ...
observe(data)
}
Copy the code
// 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()
}
})
}
Copy the code
So, why isn’t localStorage responding? Because it’s not an object with attributes.
But wait, we can’t define getters and setters with arrays either, but arrays in Vue are still reactive. This is because arrays are a special case in Vue. In order to have reactive arrays, Vue overwrites array methods behind the scenes and tinkers with Vue’s reactive system.
Can we do something similar with localStorage?
Override the localStorage function
First try to fix the original example by overwriting the localStorage method to track which component instances requested the localStorage project.
// Mapping between the LocalStorage project key and the 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 instances
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 values in related 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);
};
Copy the code
new Vue({
el: "#counter".data: function() {
return {
counter: localStorage.getItem("counter".this) // We now need to pass "this"}},computed: {
even() {
return this.counter % 2= =0; }},template: `
Counter: {{ counter }}
Counter is {{ even ? 'even' : 'odd' }}
`
});
Copy the code
setInterval((a)= > {
const counter = localStorage.getItem("counter");
localStorage.setItem("counter", +counter + 1);
}, 1000);
Copy the code
In this example, we redefined getItem and setItem to collect and notify components that depend on the localStorage project. In the new getItem, we notice which component requested which item, and in setItems, we contact all components that requested the item and rewrite their data properties.
For the above code to work, we have to pass a reference to the component instance to getItem, which changes its function signature. We can’t use arrow functions anymore either, because otherwise we won’t have the correct this value.
If we want to do better, we have to dig deeper. For example, how do we keep track of dependents without explicitly passing them?
How does Vue collect dependencies
For inspiration, we can go back to Vue’s responsive system. As we saw earlier, when accessing a data property, the getter for the data property causes the caller to subscribe to further changes to the property. But how does it know who made the call? When we get a data property, its getter has no input about who is calling it. The Getter function has no input, so how does it know who to register as a dependent?
Each data attribute maintains a list of dependencies that need to be responded to in the Dep class. If we dig deeper in this class, we can see that dependencies are already defined in static target variables whenever they are registered. This goal is determined by a very mysterious Watche class. In fact, these observers are actually notified when the data properties change, and they initiate either a re-rendering of the component or a recalculation of the calculated properties.
But who are they?
When Vue makes the Data option observable, it also creates a Watcher for each calculated property function, as well as all watch functions (not to be confused with the Watcher class) and the Render function for each component instance. The observer is like the companion of these functions. They do two main things:
- When they are created, they evaluate the function. This triggers a collection of dependencies.
- When they are notified that a value they depend on has changed, they re-run their function. This will eventually recalculate a calculated property or re-render the entire component.
Before the observer calls its responsible function, one important step occurs: they set themselves as the target of static variables in the Dep class. This ensures that reactive data properties are registered as dependent when they are accessed.
Trace who called localStorage
We couldn’t do this completely because we couldn’t use Vue’s internal mechanics. However, we can use the idea of Vue where the observer can set the target to a static property before calling the function it is responsible for. Can we set a reference to a component instance before calling localStorage?
If we assume that localStorage was called when setting the data option, we can insert it into beforeCreate and Created. Both hooks are fired before and after initializing the Data option, so we can set a target variable, then clear that variable and reference the current component instance (which we can access in the lifecycle hook). Then, in our custom getter, we can register the target as a dependency.
The last thing we need to do is make these lifecycle hooks part of all of our components, and we can do this through a global mix of the entire project.
// Mapping between the LocalStorage project key and the list of Vue instances that depend on it
const storeItemSubscribers = {};
// The Vue instance currently being initialized
let target = undefined;
const getItem = window.localStorage.getItem;
localStorage.getItem = (key) = > {
console.info("Getting", key);
// Collect dependent Vue instances
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 values in related 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; }});Copy the code
Now, when we run the first example, we get a counter that increments by one digit per second.
new Vue({
el: "#counter".data: (a)= > ({
counter: localStorage.getItem("counter")}),computed: {
even() {
return this.counter % 2= =0; }},template: `
Counter: {{ counter }}
Counter is {{ even ? 'even' : 'odd' }}
`
});
Copy the code
setInterval((a)= > {
const counter = localStorage.getItem("counter");
localStorage.setItem("counter", +counter + 1);
}, 1000);
Copy the code
Our thought experiment is over
When we solve the initial problem, keep in mind that this is mainly a thought experiment. It lacks some features, such as handling deleted items and uninstalled component instances. It also has some limitations, such as the need for component instance property names to be the same names as the items stored in localStorage. That said, the main goal is to get a better understanding of how Vue responsiveness works behind the scenes and take advantage of that, so I hope you’ll benefit from all of these things.
Source: CSS-Tricks.com, by Roberto Roberto, Translated by The public account Front-end Full Stack Developer