In this series of articles, we'll build a simple React-like framework. I call it Minimum Viable React, or MVR for short, because it implements all the mechanics necessary for a React-like development experience while leaving out some of the more advanced features and optimizations. Here's a list of some of the major features we'll leave out:
- Synthetic events
React implements its own event system, which is mostly an optimization as well as a technique to add support for IE8. - Styles
You can still pass styles as a string to a component, the same way you can add style attributes to HTML elements. We won't, however, bother with parsing a style object the way React does it. - JSX
JSX would be a whole series of articles in and of itself. However, we implement the React API which means you can use MVR with JSX.
You may notice that this looks a lot like Preact. And you'd be right in thinking that. However, in MVR we ignore some edge cases and optimize the codebase for readability as opposed to minimizing compute cycles and memory usage.
First MVR App
Let's take a look at our first MVR app.
Not very exciting, I know, but we have to start somewhere.
Let's look at the code. As you can see, this looks just like React. However, in this first part, we don't have components and the whole app is composed out of simple elements. Those elements can be nested, and they can have event handlers attached to them. We'll look at how to implement those later.
|
|
In the first two lines of the app we import MVRDom.js
and MVR.js
. These
files contain the entirety of the MVR framework.
MVR.js
This is all the code in MVR.js
for now. As you can see, createElement()
just
constructs a simple object and returns it. From this point on, we'll call
objects returned by createElement()
virtual elements, as opposed to
actual DOM-elements. Virtual elements constitute the virtual DOM in MVR. They
represent the desired state of the actual DOM.
In the final version, there will be three types of virtual elements:
- strings
- simple elements
- components
In this first version we are only concerned about the first two.
|
|
High-Level View
Let's step back and talk about what we're trying to achieve here. The main idea of React is to maintain a separate representation of the DOM in memory. This is commonly called the virtual DOM (or VDOM). Then, every time we render, we render the whole app by comparing the new virtual DOM to the old one, and only touch the real DOM when we find a change.
Now, where should we store the VDOM and how do we keep track of which real DOM-element each virtual element represents? It turns out we can store a reference to the corresponding virtual element in the real DOM-element itself. Like this:
|
|
This may seem a bit hacky at first glance, but it's actually what React does. If
you don't believe me, open any React app and inspect one of the elements in the
chrome dev-console. You'll see that all the DOM-nodes have a
_reactInternalInstance
property.
So, in reality, VDOM and DOM are not two completely different data-structures but VDOM is a sort of an extension of the real DOM.
MVRDom.js
MVRDom.js
is where the main logic lives, although the external API is very
simple - it consists of a single render
method, which just delegates to an
object we call Reconciler
.
|
|
Reconciler
has the following methods:
diff()
updateTextNode()
updateDomElement()
mountElement()
We'll look at each method individually.
Reconciler.diff
It's best to start with Reconciler.diff
. It takes a virtual element, created by
React.createElement()
, a container node, and the old DOM element that we either
replace or update. Note, that the old DOM element doesn't necessarily exist. For
instance, the first time ReactDom.render
is called the DOM is empty and
naturally, there's no old DOM element to compare to.
|
|
Here, we walk through the DOM and, at each node, check if the new virtual
element has the same type as the old virtual element. If it does, we update
the corresponding DOM element using Reconciler.updateTextNode()
or
Reconciler.updateDomElement()
, depending on the type of the element. Otherwise,
we insert it into the DOM. In the case that we update an existing DOM element, we
call Reconciler.diff()
recursively with the element's children. We'll also
have to remove extra children from the DOM in case the new virtual element has fewer
children than what is currently in the DOM.
This version uses a very straightforward list reconciliation strategy where we simply compare the child element to the virtual element of the DOM element at the same index. In future posts, we'll create a better, more sophisticated algorithm using keys.
Reconciler.mountElement()
mountElement()
creates either a new text-node or a DOM element depending on
the type of the virtual element. Then it removes the old DOM element and
inserts the new DOM element into the DOM. Note that we also store the virtual
element in the new node as _virtualElement
, which allows us to easily retrieve it
on the next render and compare the props. At the end of the function, we call
mountElement()
with all the child elements.
|
|
Reconciler.updateTextNode()
|
|
updateTextNode()
is pretty self-explanatory. It replaces the text if it
has changed since the previous render.
Reconciler.updateDomElement()
updateDomElement()
has to update all event-listeners and attributes, as well as
take care of a few edge cases.
|
|
This function is a bit long so let's break it down. First, we iterate over the props of the new virtual element (line 5). Each prop is compared to the same prop of the previous virtual element. If it was changed, we update the DOM accordingly. Next, we iterate over the old props (line 28) and remove all attributes that no longer exist.
Events
To identify a prop that represents an event we look at the first two letters of the prop name (line 10). If the first two letters spell 'on', we assume that the prop is an event handler. Then we attach that handler to the DOM-node and remove the previous handler. It's that easy!
Edge Cases
The two edge case attributes we have to pay attention to are value and checked
(line 17). These attributes cannot be updated via setAttribute
but instead
are properties on the DOM-node itself that we have to reassign.
Conclusion
That's all the code it takes to implement a simple virtual DOM. Hopefully, you can see that it's not that complicated once you realize that it's possible to store the virtual elements within the DOM elements themselves, and then just traverse the DOM and check for updates. Of course, this implementation lacks a critical feature of React: components. That's the topic of part 2 of this series.