Skip to content

Instantly share code, notes, and snippets.

@ycmjason

ycmjason/blog.md Secret

Created February 26, 2020 16:26
Show Gist options
  • Save ycmjason/d2d76e8c02c1948ce585562ab8d78573 to your computer and use it in GitHub Desktop.
Save ycmjason/d2d76e8c02c1948ce585562ab8d78573 to your computer and use it in GitHub Desktop.

{% youtube O0b6A6Wi87Q %}

This article is roughly based off the talk I gave on 20th November 2019 at Vue.js London #13 meetup. You can find the video of the talk here and the repo here.

Typescript will be used in this article so we can look at the problem in a slightly different perspective. If you hate typescript, you can watch my talk instead which was in Javascript.

Introduction to Vue 3 Reactivity API

You can read about the Official Vue 3 Reactivity API. But here is a brief introduction with examples.

There are 4 functions in the reactivity API:

  1. reactive()
  2. ref()
  3. computed()
  4. watch()

Consider example 1:

https://gist.github.com/bf1c13ee60912e2d387b00b1e7ad7dba

{% replit @ycmjason/recreating-vue-3-reactivity-api-1 %}

This code uses reactive() and watch() from the reactivity API. reactive() create an reactive object, i.e. the retrieval and setting of any properties will be tracked. watch() takes in a callback that will be executed immediately; whenever the callback's dependencies are changed, the callback will be evaluated again.

So in this example, car.position is updated every 1000ms. And we will see the car moving from the right to the left.

car moving to the left

Consider example 2

https://gist.github.com/4a97281e1c3dc818af591953a5210aa1

{% replit @ycmjason/recreating-vue-3-reactivity-api-2 %}

This code uses ref(), computed() and watch(). ref() and computed() both returns a Ref. A Ref is simply defined as:

https://gist.github.com/1d441b4f225f4a2a14e35e337254065c

From the example, ref(0) returns { value: 0 } where the value will be reactive. computed() takes in a function returns a Ref whose value is whatever the function returns.

Hopefully this quick introduction by examples makes sense. If you are in doubt, make sure you read the official description of the Vue 3 Reactivity API before reading the rest of the article.

Quick introduction to ES6 Proxy

Proxy is an ES6 feature; it is the real magic behind Vue 3's reactivity. You can see the full documentation here.

In this introduction, I am just going to include the parts we need from proxy to create reactivity.

Proxy is an object which allow us to programmatically control how it behaves on native operations.

Consider example 3

https://gist.github.com/41bec02b8f0cb8b0a4c920d4f815879f

{% replit @ycmjason/recreating-vue-3-reactivity-api-3 %}

Here is the output:

https://gist.github.com/d3c73a220c572ef150c8e40a4c25dcec

Please note that the reason for key: string | number is because Typescript currently cannot handle symbols as keys in objects. This is so stupid and there is a 5-year-old issue created regarding this. key will be typed as string | number | symbol otherwise.

As you can see in the example, we have set up the set and get trap for the proxy p. Whenever p's property is set or retrieved, our traps will be called and we can change how it behaves.

In this example, we always return 'nope' in the get function. This is why we see 'nope' for both p.x and p.y.

If you are still unsure about how Proxy works, make sure you read more into it in the mdn documentation.

Let's recreate Vue 3's reactivity API

You should be familiar with Vue 3's reactivity API and Proxy by now. Let's now try to recreate Vue 3's reactivity API.

reactive() and watch()

Let's recall example 1:

https://gist.github.com/a228ea13119fa1544f7f4b2a8c887c14

Our aim in this section is to make example 1 work with our customreactive() and watch().

Brute-force "reactivity"

We can quickly make example 1 work as expected by simply calling the watchers (watch() callbacks) whenever a reactive property is set. Let's implement this first and see where we can depart from there.

First, let's keep track of the watchers in watch().

https://gist.github.com/90694145fbc9f876c12e1b2e8dd4ff7f

Pretty straightforward. Now we have a list of watchers. Next we have to trigger them whenever a reactive property is changed.

We can achieve this by having reactive() to return a proxy whose set trap will trigger all watchers.

https://gist.github.com/b8826b7fb07b8f312b827e8052b9a4c7

Two things to note about the types:

  1. Please note that the reason for key: keyof T is because Typescript would require key to be a key of T before being able to do target[key] = value. Without : keyof T, key will be typed as stirng | number | symbol which introduces another problem with the 5-year-old issue mentioned earlier.
  2. Previously string | number was sufficient because the target was a Record<any, any>, so typescript knows that the target can be extended.

An example to illustrate how the type works.

https://gist.github.com/9c134a8a7232ec500d6ce2b0c26b5fcd

Exporting our watch() and reactive(), we can combine them with example 1:

Example 4:

{% replit @ycmjason/recreating-vue-3-reactivity-api-4 %}

And the car is moving! ✅

car moving to the left

There are couple of problems with this approach:

  1. Watchers will be called N times if we trigger mutate reactive object N times

    Watchers should only be fired once after a series of consecutive mutation. Currently each mutation will trigger the watchers immediately.

  2. Watchers will be called even when it doesn't need to

    Watchers should only be reevaluated whenever their dependencies changes. We currently do not care and call the watchers whenever somethings is mutated.

Brute-force reactivity (fixing problem 1)

We aim to solve the first problem in the last section.

To illustrate the problem, I have modified the code to add one more car which will trigger another mutation in the interval. You can see the code in example 5.

https://gist.github.com/158ab0ea105852a4934c6967aec424f1

counter incrementing by 2

You can see how the callCount increments by 2. This is because there are two mutations happening every 1000ms so the watcher was called twice every 1000ms.

Our aim is to have the watchers only called once after a series of consecutive mutations.

How do we achieve this? "Firing something only once after a series of invocation"? Does this sound familiar? We actually have probably encountered this already in many places. For example, showing search suggestions only after user has stopped typing for a while; firing scroll listener once only after the user has stopped scrolling for a while?

Debounce! Yes, we can just debounce the watchers. This will allow a series of mutation finish before triggering the watcher. And it will only do it once! Perfect for this use case!

I will just use lodash's debounce here so we won't need to implement it.

See example 6:

https://gist.github.com/6891cc66e8e97caf36e5a27373662e63

{% replit @ycmjason/recreating-vue-3-reactivity-api-6 %}

You can see how the callCount only increment by 1 every 1000ms.

incrementing 1 at a time

Dependency tracking

The second problem: "watchers will be called even when it doesn’t need to", can be solved with dependency tracking. We need to know what a watcher depend on and only invoke the watcher when those dependencies are mutated.

In order to illustrate the problem, I have modified the index.ts.

https://gist.github.com/d9ba2c1448e02569231244855d27346c

{% replit @ycmjason/recreating-vue-3-reactivity-api-7 %}

With this example, we can see the problem clearly. We expect r1.x to be logged every second and r2.x every 5 seconds. But both values are logged every second because all watchers are called.

Here are the steps we can implement dependencies tracking:

  1. We can keep track of the dependencies of a watcher in a Set, which helps avoid duplications. A dependency is a property in a reactive. We can represent each property in a reactive with a unique identifier. It could be anything unique but I'll use a Symbol() here.
  2. Clear the dependencies set before calling the watcher.
  3. When a reactive property is retrieved, add the symbol representing that property to the dependencies set.
  4. After the watcher callback finishes, dependencies will be populated with symbols that it depends on. Since each watcher now relates to a set of dependencies, we will keep { callback, dependencies} in the watchers list.
  5. Instead of triggering all watchers as a property is being set, we could trigger only the watchers that depend on that property.

https://gist.github.com/ebc2faeb500186cc4440a890396be81b

{% replit @ycmjason/recreating-vue-3-reactivity-api-8 %}

With this we can see the result matches our expectation and this means dependency tracking is working!!!

Update dependencies on the fly

A watcher may change its dependencies. Consider the following code:

https://gist.github.com/fdbf7bf559c1c76a30afcb65937fccb8

In this example, we expect the log to happen after 1 second and every 500ms afterwards.

However our previous implementation only logs once:

{% replit @ycmjason/recreating-vue-3-reactivity-api-9 %}

This is because our watcher only access r1.x at its first call. So our dependency tracking only keeps track of r1.x.

To fix this, we can update the dependencies set every time the watcher is called.

https://gist.github.com/f80e3a7960115f1ca7c0f8bca9f020b8

This wraps the dependency tracking into the watcher to ensure the dependencies is always up to date.

With this change, it is now fully working! 🎉

{% replit @ycmjason/recreating-vue-3-reactivity-api-10 %}

ref(), computed()

We can build ref() and computed() pretty easily by composing reactive() and watch().

We can introduce the type Ref as defined above:

https://gist.github.com/9ab88958f479cf34f0b15b68bcf68f1d

Then ref() simply returns a reactive with just .value.

https://gist.github.com/e9fdc5397894607b7114927debd4e4bd

And a computed() just return a ref which includes a watcher that updates the value of the ref.

https://gist.github.com/1f91cb51db6c214f70c0052f1c430458

See the following example:

{% replit @ycmjason/recreating-vue-3-reactivity-api-11 %}

Conclusion

Thanks for reading this tedious article and hope you have gain some insights about how the magic behind Vue's reactivity works. This article has been worked on across months because I travelled to Japan in the middle of writing this. So please let me know if you spot any mistakes/inconsistency which can improve this article.

The reactivity we have built is just a really rough naive implementation and there are so many more considerations put into the actual Vue 3 reactivity. For example, handling Array, Set, Map; handling immutability etc. So please do not use these code in production.

Lastly, hopefully we will see Vue 3 soon and we can make use of this amazing api to build awesome things! Happy coding!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment