Made and hosted in the EU.
file sharing application
Fully compliant with GDPR. Made and hosted in the EU.
Exploring Frontend Frameworks’ Internals – Part 1: The basic structure of Frontend frameworks + Vue 3’s reactivity
Author: Tạ Quốc Huy, Javascript Developer, Linagora Vietnam
This is a series about exploring Frontend frameworks’ internals. Its purpose is to shed some light on the way these frameworks operate under the hood. It may not be suitable for beginners or those who don’t often need to know what’s happening underneath their code.
Motivation
Depending on which land you come from:
- Vue 3: Have you ever wondered why
watchEffect
can magically re-run every time the reactive states inside it change without explicitly being told? In React, you have to explicitly telluseEffect
of its dependencies. - React: Have you ever wondered why stale states/closures happen when using hooks? Why does the order of hooks matter and you are forced to always use Hooks at the top level of your React function?
- Solid: Why does this React-like component function only run once and still manage to have the same (or even better) functionalities? Why is the signal getter a function? And why the hell is it so fast? etc.
If you have asked yourself such questions and want to dig deeper to find the answers, you will find this series interesting.
This series will tap into the internals of these libraries/frameworks, part by part, until we can understand and build a simplified version of each library/framework by ourselves. We might not be able to reach that end goal, but we’ll surely learn a lot in the process.
The basic structure of Frontend frameworks
The currently popular Frontend frameworks can be divided into two categories: those with Virtual DOM and those without.
First, we’ll go with the Frontend libraries/frameworks that use Virtual DOM (e.g. Vue, React). The basic structure of these frameworks can be depicted as such:
At a high level, the roles of each unit in the structure is as follows:
- Compiler: Templates or JSX are fed into the compiler, which then outputs render function code.
- Virtual DOM Renderer/Reconciler: This unit is in charge of invoking the render functions and managing the virtual DOM tree (e.g. “reacting” to state changes and updating the virtual DOM accordingly). React calls this component the reconciler (the source code is here), while Vue calls it the runtime renderer. It is called the reconciler because one of its main job is to perform Virtual DOM “diffing”, or “reconciliation”.
- Reactivity (State): This unit handle reactivity and states in each component and throughout the whole application. It is often decoupled from every other unit in this structure, which is why you can create reusable “hooks” without the presence of any render functions.
- Native Renderer: This unit “takes render instructions” from the virtual DOM renderer/reconciler and then decides how to actually render according to its targeted environment. For example, if its target is the Web, it will render and update the actual DOM. When targeting the Web, Vue uses @vue/runtime-dom which is “abstracted away” most of the time, and React uses react-dom which is not abstracted away and requires you to explicitly import and use it. Both allow creating a custom renderer when targeting non-DOM environments:
- Vue’s Custom Renderer API
- React Reconciler’s examples of building a custom renderer (React Native and React Three Fiber are two really great custom renderer examples).
What about the frameworks that don’t use Virtual DOM? Well, the virtual DOM renderer/reconciler and the virtual DOM tree simply no longer exist:
There’s a few points worth noting here:
- Don’t let the directions of the arrows fool you. Looking at the diagram above, you might think that the reactivity unit needs to directly call the renderer, and the renderer needs to know about the methods of the reactivity system. But that’s not how reactivity works. We’ll continue to explore and understand this later in the series.
In fact, you can completely replace the reactivity system with another without changing the renderer and vice versa. For example:
- mobx-jsx is using MobX as the reactivity system together with Solid’s DOM renderer.
- vuerx-jsx is using Vue’s reactivity system (@vue/reactivity) with Solid’s DOM renderer. Both offer blazingly fast performance, (much) faster than their original usage.
- The template/JSX unit, the compiler, and the native renderer are the ones that will most definitely be affected when the targeted environments change. For example, you can’t write
<div>Hello World</div>
in the template/JSX when your targeted environment is Canvas or native mobile apps. - This is still a simplified structure. For instance, we haven’t taken into account the scheduler, which is a very important part of these frontend frameworks. Later in the series, when we’ve discussed the motivation behind a scheduler, we’ll have an updated diagram which includes the scheduler.
Now that we’ve explored the basic structure of frontend frameworks, we’ll proceed to explore each of the units inside the structure for each library/framework. My plan is to explore those aforementioned units in Vue 3 as it is currently the framework I’m using extensively for my projects (though I have also used React, Solid, and Svelte to a large degree and I do plan to include them in this series).
And in this first part of the series, let’s look into how Vue 3’s reactivity works.
Vue 3’s reactivity
Within the scope of this article, we’ll only explore ref
and watchEffect
for now. The other reactivity functions will be explored in the next part of the series.
We’re talking about reactivity a lot here, so what is it really? It’s actually not a new paradigm. The typical example is an Excel spreadsheet:
In the example above, cell B1 is defined as = A1 + A2
. When you update A1 or A2, B1 will also be reactively updated.
However, in JavaScript, variables don’t work that way:
let A1 = 1
let A2 = 4
let B1 = A1 + A2
console.log(B1) // 5
A1 = 3
console.log(B1) // still 5
If our purpose is only to log the sum of A1 and A2 when either A1 or A2 changes, we can write something like this in Vue 3:
const A1 = ref(1)
const A2 = ref(4)
watchEffect(() => {
console.log('B1 =', A1.value + A2.value)
})
But how does it work? How does watchEffect
magically know when A1 or A2 changes? To answer this question, let’s build ref
from scratch. It begins with an object with only 1 property – value
– with its getter and setter:
export function ref(value) {
return new RefImpl(value)
}
class RefImpl {
private _value
constructor(value) {
this._value = value
}
get value() {
return this._value
}
set value(newVal) {
this._value = newVal
}
}
Nothing happens here yet. The magic trick we’re going to use here is when the value
property of the ref
object is read (the getter method is called), we will automatically add the caller as the subscriber of the ref
object. We also call this subscriber an effect (short for side effect). The ref
object now becomes a dependency of the effect.
export function ref(value) {
return new RefImpl(value)
}
class RefImpl {
private _value
public dep = undefined // Ironically, in the Vue's codebase, the subscribers/effects of a ref object seem to also be called its dependencies.
constructor(value) {
this._value = value
}
get value() {
trackRefValue(this)
return this._value
}
set value(newVal) {
this._value = newVal;
}
}
export function trackRefValue(ref) {
if (!activeEffect) return;
if (!ref.dep) ref.dep = new Set() // We must use Set here to avoid duplication
ref.dep.add(activeEffect) // Add the active (currently running) effect as one of the ref object's subscribers
}
Now there’s 2 problems left:
- Where does the value of
activeEffect
come from? In other words, how do we know which effect is currently running? - We need to trigger/inform all of a
ref
object’s subscribers/dependencies when itsvalue
property changes.
Let’s deal with the second problem first because it’s quite straightforward:
export function ref(value) {
return new RefImpl(value)
}
class RefImpl {
private _value
public dep = undefined // Ironically, in the Vue's codebase, the subscribers/effects of a ref object seem to also be called its dependencies.
constructor(value) {
this._value = value
}
get value() {
trackRefValue(this)
return this._value
}
set value(newVal) {
this._value = newVal
triggerRefValue(this)
}
}
export function trackRefValue(ref) {
if (!activeEffect) return;
if (!ref.dep) ref.dep = new Set() // We must use Set here to avoid duplication
ref.dep.add(activeEffect) // Add the active (currently running) effect as one of the ref object's subscribers
}
export function triggerRefValue(ref) {
if (!ref.dep) return;
for (const effect of ref.dep) {
effect() // run the effect
}
}
Now the most important question left is: Where does the value of activeEffect
come from? Let’s take a look at the rawest implementation of watchEffect
:
export let activeEffect = undefined
export function watchEffect(effectHandler) {
const effect = () => {
activeEffect = effect
effectHandler()
// The above function call will read the value property of any ref object inside it
// and trigger the getter method of the value property,
// which in turn adds this effect as one of the subscribers of that ref object.
activeEffect = undefined
}
effect() // watchEffect triggers the effect (handler) immediately
}
Because watchEffect
always runs immediately, the first time it runs will always trigger the getter methods of the value
property of ref
objects inside it, and registers the effect as a subscriber of these ref
objects. This is why you don’t need to explicitly specify the dependencies for watchEffect
.
Here’s a working code example of ref
and watchEffect
:
For now, we have a roughly working version of ref
and watchEffect
. It is nowhere near usable because we’ve left out too many cases where it might fail, plus there’s no batching, and the flow of control is a little bit messed up here. But it serves its purpose as an oversimplified example of what’s happening behind the scenes, hopefully.
Conclusion
In this part of the series, we’ve explored the basic structure of Frontend frameworks and tapped into the gist of Vue 3’s ref
and watchEffect
.
In the next part, we’ll continue to improve the oversimplified version of ref and watchEffect to match the real implementaion more closely, and we’ll also explore reactive
, computed
, and watch
.
I hope that you find this useful somehow. If you have any suggestion or advice, please don’t hesitate to reach out to me in the comment section.