- How to Build a Reactive Engine in JavaScript. Part 2: Computed Properties and Dependency Tracking
- Author: This article has been authorized by the original author Damian Dulisz
- The Nuggets translation Project
- Translator: IridescentMia
- Proofread by: Malcolm U, AceLeeWinnie
Hey! If you’ve used Vue. Js, Ember, or MobX, I’m sure you’ve been stumped by calculated attributes. Computed attributes allow you to create functions that are used as normal values, but once computed, they are cached until one of its dependencies changes. In general, this concept is very similar to getters, which will be used in the following implementation. They’re just doing it a little smarter. 😉
This is the second in a series of articles on how to build a responsive engine using JavaScript. I strongly recommend reading Part 1: Observable objects before diving in, because the next implementation builds on the code of the previous article.
Calculate attribute
Suppose you have a calculated attribute called fullName that is a combination of firstName and lastName with a space between them.
In vue.js such computed values can be created as follows:
data: {
firstName: 'Cloud',
lastName: 'Strife'
},
computed: {
fullName () {
return this.firstName + ' ' + this.lastName // 'Cloud Strife'}}Copy the code
Now if we use fullName in the template, we want it to be updated with changes to firstName or lastName. If you have an AngularJS background, you probably remember using expressions inside templates or function calls. Of course, using render functions (with or without JSX) is the same as here; It doesn’t really matter.
Consider the following example:
<! -- expression --> <h1>{{firstName +' '+ lastName }}</h1> <! Function call --> <h2>{{getFullName()}}</h2> <! <h3>{{fullName}}</h3>Copy the code
The result of executing the above code is almost the same. Each time firstName or lastName changes, the view will update those
and display the full names.
However, what if expressions, function calls, and attributes are evaluated multiple times? Expressions and function calls are evaluated each time, and evaluated properties are cached after the first evaluation until their dependencies change. It also lasts through the re-rendering cycle! This is indeed an optimization if you consider that in modern user interfaces based on the event model, it is difficult to predict what the user will do first.
Base computed properties
In the previous article, we learned how to track and respond to changes within an observable’s properties by using event emitters. We know that when we change firstName, all handlers subscribed to the ‘firstName’ event will be called. So it’s fairly easy to build calculated properties by manually subscribing to its dependencies. This is also how Ember implements counting properties:
fullName: Ember.computed('firstName'.'lastName'.function() {
return this.get('firstName') + ' ' + this.get('lastName')})Copy the code
The downside of this is that you have to declare the dependency yourself. You know this is a problem when your calculated property is the result of a string of expensive, complex functions. Such as:
selectedTransformedList: Ember.computed('story'.'listA'.'listB'.'listC'.function() {
switch (this.story) {
case 'A':
return expensiveTransformation(this.listA)
case 'B':
return expensiveTransformation(this.listB)
default:
return expensiveTransformation(this.listC)
}
})
Copy the code
In the above case, even though this.story is always equal to ‘A’, if lists change, the calculated properties will have to be computed repeatedly each time.
Depend on the track
Vue.js and MobX take a different approach to this problem. The difference is that you don’t have to declare dependencies at all, because they are automatically detected during computation. Given this.story = ‘A’, the detected dependencies would be:
this.story
this.listA
When this.story becomes’ B ‘, it will collect a new set of dependencies and remove those that were previously used but are no longer used (this.listA). This will not trigger recalculation of the selectedTransformedList even if other lists change. Brilliant!
Now it’s time to go back and look at the code from the previous article – JSFiddle – from which the next changes will be based.
The code in this article is written as simply as possible, ignoring many integrity checks and optimizations. By no means ready for use in a production environment, only for educational purposes.
Let’s create a new data model:
Const App = Seer({data: {goodCharacter:'Cloud Strife',
evilCharacter: 'Sephiroth',
placeholder: 'Choose your side! ', side: null, // calculate attributesselectedCharacter () {
switch (this.side) {
case 'Good':
return `Your character is ${this.goodCharacter}! `case 'Evil':
return `Your character is ${this.evilCharacter}! ` default:returnThis.placeholder}}, // calculation attributes that depend on other calculation attributesselectedCharacterSentenceLength () {
return this.selectedCharacter.length
}
}
})
Copy the code
Detection of depend on
In order to find the dependencies of the current evaluated evaluated property, you need a way to collect the dependencies. As you know, each observable property is in the form of a getter and setter that has been converted. When evaluating evaluated properties (functions), other properties need to be used, namely the getters that trigger them.
For example, this function:
{
fullName () {
return this.firstName + ' ' + this.lastName
}
}
Copy the code
Getters firstName and lastName will be called.
Let’s take advantage of that!
When evaluating a calculated property, we need to gather information about getters being called. To do this, you first need space to store the computed properties currently evaluated. We can use a simple object like this:
letDep = {// The name of the current evaluated attribute target: null}Copy the code
We used the makeReactive function to convert raw properties into observable properties. Now let’s create a transformation function for the evaluated property and name it makeComputed.
function makeComputed (obj, key, computeFunc) {
Object.defineProperty(obj, key, {
get() {// If there is no target setif(! Dep.target) {// Set target to the currently evaluated attribute dep.target = key} const value = computeFunc.call(obj) // Clear the target context dep.target = nullreturn value
},
set() { // Do nothing! }})} // This will be used to call makeComputed(data,'fullName', data['fullName'])
Copy the code
Okay! Now that the context is available, modify the makeReactive function created in the previous article to use the obtained context.
The new makeReactive function looks like this:
function makeReactive (obj, key) {
letVal = obj[key] // Create an empty array to store dependencieslet deps = []
Object.defineProperty(obj, key, {
get() {// only executed when called in the context of evaluated propertiesif(dep.target) {// If it has not been added, it is added as a calculated property that depends on this valueif(! deps.includes(Dep.target)) { deps.push(Dep.target) } }return val
},
set(newVal) {val = newVal // If there are computed attributes that depend on this valueif(demos.length) {// Notify the observer of each calculated property deps.foreach (notify)} notify(key)}})}Copy the code
The last thing we need to do is to modify the observeData function slightly so that it runs makeComputed instead of makeReactive for functional attributes.
function observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'function') {
makeComputed(obj, key, obj[key])
} else {
makeReactive(obj, key)
}
}
}
parseDOM(document.body, obj)
}
Copy the code
That’s basically it! We just created our own implementation of computed properties by relying on tracing.
Unfortunately – the above implementation is very basic and still lacks important features found in vue.js and MobX. I guess the most important thing is caching and removing deprecated dependencies. So let’s add them.
The cache
First, we need space to store the cache. We add a cache manager to the makeComputed function.
function makeComputed (obj, key, computeFunc) {
letObserve (key, () => {// Clear cache = null}) object.defineProperty (obj, key, {get () {
if(! Dep.target) {dep.target = key} // When there is no cacheif(! Cache) {// Calculate the new value and store it in the cache cache = computefunc.call (obj)} dep.target = nullreturn cache
},
set() { // Do nothing! }})}Copy the code
That’s it! Now after initializing the calculation, it returns the cached value each time the calculation property is read until it has to be recalculated. Pretty simple, isn’t it?
Thanks to the Observe function, we use it internally in makeComputed during data conversion to ensure that the cache is cleared before other signal handlers execute. This means that a dependency on the calculated property changes and the cache is cleared just before the interface is updated.
Remove unnecessary dependencies
Now all that remains is to clean up the invalid dependencies. This is usually a case where computed properties depend on different values. We want to achieve the effect that the evaluated property depends only on the last dependency used. The above implementation is flawed in this respect, as once the computed property registers a dependency on it, it is always there.
There might be a better way to handle this situation, but because we want to keep it simple, let’s create a second dependency list to store the dependencies that calculate the properties. To sum up, our dependency list:
- The list of computed attribute names that depend on this value (observable or otherwise computed) is stored locally. Think of it this way: These are dependent on my values.
- The second dependency list is used to remove obsolete dependencies and store the most recent dependencies for calculated properties. Think of it this way: These are values that I depend on.
With these two lists, we can run a filter function to remove invalid dependencies. Let’s start by creating an object that stores the second list of dependencies and some useful functions.
letDep = {target: null, // Store the calculated attribute's dependency subs: {}, // Create a bidirectional dependency between the calculated property and other calculated or observable values depend (deps, dep) {// If not already added, add the current context (dep.target) to the local DEPS as dependent on the current propertyif(! Deps.push (this.target)} // If not already added, add the current attribute as a dependency of the calculated valueif(! Dep.subs[this.target].includes(dep)) { Dep.subs[this.target].push(dep) } }, getValidDeps (deps, {// Only valid dependencies are filtered out by removing discarded dependencies that were not used in the previous calculationreturnDeps. Filter (dep => this.subs[dep].includes(key))}, notifyDeps (deps) {// Notify all existing deps deps.foreach (notify)}}Copy the code
The dep. depend function isn’t useful yet, but we’ll use it later. Its usefulness here will be clearer then.
First, tune the makeReactive conversion function.
function makeReactive (obj, key, computeFunc) {
let deps = []
let val = obj[key]
Object.defineProperty(obj, key, {
get() {// is executed only if it is in the context of the computed valueif(dep.target) {// Add dep.target as a dependency on this value, which will change the deps array because we passed it a reference to dep.Depend (deps, key)}return val
},
set(newVal) {val = newVal // Clears obsolete dependencies deps = dep.getValidDeps (deps, key) // and notifies valid deps dep.notifydeps (deps, key) notify(key) } }) }Copy the code
Similar changes need to be made inside the makeComputed transformation function. The difference is that instead of using setters, a signal callback to the observe function is used to call the handler. Why is that? Because this callback is called whenever the evaluated value is updated, that is, the dependency changes.
function makeComputed (obj, key, computeFunc) {
letCache = null // Create a local DEPS list similar to makeReactive's DEPSletDeps = [] observe(key, () => {cache = null // Empty and notify valid DEps deps = dep.getValidDeps (dePS, key) dep.notifyDeps (DEps, key) }) Object.defineProperty(obj, key, {get() {// If if is computed while other computed attributes are being computedif(dep.target) {// Create a dependency relationship between the two calculated attributes dep.depend (deps, key)} // Standardize dep.target as it should be, which makes it possible to build a dependency tree, Instead of a flat structure, dep.target = keyif(! Subs [key] = [] cache = computefunc.call (obj)} // Emp.target = nullreturn cache
},
set() { // Do nothing! }})}Copy the code
Done! You may have noticed that it allows computed properties to depend on other computed properties without knowing the observable behind them. Pretty good, isn’t it?
Asynchronous trap
Now that you know how dependency tracing works, it’s obvious why you can’t track asynchronous data for calculated attribute classes in MobX and vue.js. This is broken because even setTimeout(callback, 0) will be called outside the current context, where dep.target no longer exists. This means that no matter what happens in the callback function, it is not traced.
Bonus: Watchers
However, the above problem can be partially solved by Watchers. You’ve probably already seen them in vue.js. It was really easy to build Watchers on what we already had. After all, Watcher is a signal handler that is called when a given value changes.
We just had to add a Watchers registration method and trigger it within the Seer function.
function subscribeWatchers(watchers, context) {
for (let key in watchers) {
if(watchers. HasOwnProperty (key)) {// use function.prototype.bind to bind data model, Bind (context)}}} subscribeWatchers(config.watch, config.data)Copy the code
That’s all. You can use it like this:
const App = Seer({
data: {
goodCharacter: 'Cloud Strife'}, // We can ignore watchers watch: {//'goodCharacter'Watch when it changesgoodCharacter() {// Output console.log(this.goodcharacter)}}}Copy the code
The full code is available at: github.com/shentao/see…
You can try it online (Opera/Chrome only) at jsfiddle.net/oyw72Lyy/
conclusion
I hope you enjoyed this tutorial and that I did a good job of illustrating the inner workings of Vue or MobX when it comes to calculating properties. Keep in mind that the implementation provided in this article is fairly basic and not at the same level as the implementation in the libraries mentioned. It is not directly usable in a production environment by any means.
What’s next?
Part 3 covers support for nested properties and observable arrays, and I might add a way to unsubscribe from events at the end as well! 😀 As for the fourth part, maybe data flow? Are you interested?
Feel free to give your feedback in the comments section!
Thanks for reading!
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. Android, iOS, React, front end, back end, product, design, etc. Keep an eye on the Nuggets Translation project for more quality translations.