Build Your Own React - Part 2: Components
In this article, we pick up where we left off in part 1. Our first app isn't particularly impressive, but it works well and has some features crucial to a practical React implementation. For example, it supports event handlers and updates only the parts of the DOM that change.
Let's face it, React wouldn't be a popular framework if it weren't for its composable component system
Let's start by looking at how we use components in React. Here's a simple example. First, we create a new component by extending React.Component
class.
1 2 3 4 5 |
class MyComponent extends React.Component { render() { return <div>This is a custom comopnent.</div> } } |
Next, we call createElement()
with that class.
1 2 3 4 |
ReactDom.render( MVR.createElement(MyComponent), container ); |
The first obvious thing we have to do is create an MVR.Component
constructor()
and render()
, which is always overridden.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Component { constructor(props) { this.props = props; } render() {} } export default { createElement, Component }; |
As you can see, its constructor takes the props as an argument and assigns them to this.props
, which is why, when you override the constructor, you have to call super(props)
.
The Big Picture
The difference between components and simple virtual elements is that components don't directly represent a real DOM element. Instead, they are factories that create more simple virtual elements that will eventually be used to create DOM elements. We need a way to keep track of which component is responsible for creating which simple element.
Because our tree of virtual elements no longer matches the DOM exactly, we'll have to update the diagram from part 1.
Now each virtual element stores a reference to the component that created it. This way, as we're diffing the DOM, we can re-render components with new props to create the new virtual elements, which we'll go on and diff with the real DOM. If this sounds confusing, some code will hopefully make it easier to wrap your head around.
MVRDom.js
Let's look at MVRDom.js and Reconciler.diff()
. The new code in this function is on lines 3-7. On line 5, we test if the type of the element.type
is a function. Remember that, when rendering a component, the first argument toMVR.createElement
is the component class. The type of a class in Javascript isfunction
. Therefore, we can detect a virtual element that represents a component by checking if its type is function
. If the check returns true, we invoke a new method: Reconciler.diffComponent()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
diff: (virtualElement, container, oldDomElement, parentComponent) => { const oldVirtualElement = oldDomElement && oldDomElement._virtualElement; const oldComponent = oldVirtualElement && oldVirtualElement.component; if (typeof virtualElement.type === 'function') { Reconciler.diffComponent(virtualElement, oldComponent, container, oldDomElement, parentComponent); } else if (oldVirtualElement && oldVirtualElement.type === virtualElement.type) { if (oldVirtualElement.type === 'text') { Reconciler.updateTextNode(oldDomElement, virtualElement, oldVirtualElement); } else { Reconciler.updateDomElement(oldDomElement, virtualElement, oldVirtualElement); } // save the virtualElement on the domElement // so that we can retrieve it next time oldDomElement._virtualElement = virtualElement; virtualElement.children.forEach((childElement, i) => { Reconciler.diff(childElement, oldDomElement, oldDomElement.childNodes[i]); }); // remove extra children const oldChildren = oldDomElement.childNodes; if (oldChildren.length > virtualElement.children.length) { for (let i = oldChildren.length - 1; i >= virtualElement.children.length; i -= 1) { oldChildren[i].remove(); } } } else { Reconciler.mountElement(virtualElement, container, oldDomElement); } }, |
Next, let's inspect Reconciler.diffComponent()
. It takes the new virtual element, an optional old component, the container, and the DOM element. Now, remember that with components, the virtualElement.type
refers to the constructor of the component. To know if the new component will be of the same type as the previous one, we can compare the new constructor to the old constructor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
diffComponent: (newVirtualElement, oldComponent, container, domNode) => { if ( // are these the same constructor oldComponent && newVirtualElement.type === oldComponent.constructor ) { // update component oldComponent.updateProps(newVirtualElement.props); const nextElement = oldComponent.render(); Reconciler.diff(nextElement, container, domNode, oldComponent); } else { Reconciler.mountElement(newVirtualElement, container, domNode, parentComponent); } } |
Updating a component
If the constructors are the same, we can update the component. This involves updating the props and re-rendering the component. Let's add the updateProps()
method to the Component
superclass.
1 2 3 |
updateProps(newProps) { this.props = newProps; } |
After updating the props, we call render()
on the component, which returns a new virtual element that represents an actual DOM element (it could be another component too, but we'll handle this edge case later). Then we continue diffing by calling Reconciler.diff()
with the new virtual element.
Mounting a component
Let's first see how we mount a component into the DOM. First, we split theReconcile.mountElement()
to two functions: mountComponent()
andmountSimpleNode()
. We move the code we wrote for mountElement()
in part 1to mountSimpleNode()
and create a new method mountComponent()
.
1 2 3 4 5 6 7 |
mountElement: (element, container, oldDomNode, parentComponent) => { if (typeof element.type === 'function') { Reconciler.mountComponent(element, container, oldDomNode, parentComponent); } else { Reconciler.mountSimpleNode(element, container, oldDomNode); } } |
mountComponent()
is very straight-forward. We create a new component from the constructor, which, I hope you remember, is stored in element.type
. Then we call render()
on the component which returns a new virtual element. Before continuing with Reconciler.diff
, we store a reference to the component in the virtual element.
1 2 3 4 5 6 7 8 |
mountComponent: (virtualElement, container, oldDomElement, parentComponent) => { const component = new virtualElement.type(virtualElement.props); const nextElement = component.render(); nextElement.component = component; Reconciler.diff(nextElement, container, oldDomElement); } |
That was easy! Now we have a framework that can handle components as well as simple elements. We're done with this part, right? Well, not quite...
Nested Components
There's an annoying edge case we have to handle. Sometimes, a component doesn't return a simple element but another component. For example, we might have a MessageContainer
component that renders a Message
component. MVR must support an arbitrary degree of nesting like this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class Message extends MVR.Component { render() { return ( MVR.createElement('div', {}, [ MVR.createElement('p', {}, [ this.props.text, MVR.createElement('span', {}, [ ' World' ]) ]), MVR.createElement('button', { onClick: this.props.onButtonClick }, [ 'click me' ]) ]) ); } } class MessageContainer extends MVR.Component { render() { return <Message /> } } |
This could be solved in various ways, but in MVR the solution is to store a reference to the parent component into the first virtual element. In other words, we form a linked list of sorts, where the first item is the parent component. The diagram below will make this clearer.
Now, let's update mountComponent()
and diffComponent()
to handle nested components. But first, let's add a couple of methods to our Component
superclass, namely getChild()
and setChild()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Component { ... setChild(component) { this._child = component; } getChild() { return this._child; } ... } |
In diffComponent()
, we have to check whether the component has child components , and if it does, call diffComponent()
recursively until we get a childless component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
diffComponent: (newVirtualElement, oldComponent, container, domElement, parentComponent) => { if ( oldComponent && newVirtualElement.type === oldComponent.constructor ) { // update component oldComponent.updateProps(newVirtualElement.props); const nextElement = oldComponent.render(); nextElement.component = parentComponent || oldComponent; const childComponent = oldComponent.getChild(); if (childComponent) { Reconciler.diffComponent( nextElement, childComponent, container, domElement, oldComponent ); } else { Reconciler.diff(nextElement, container, domElement, oldComponent); } } else { Reconciler.mountElement(newVirtualElement, container, domElement, parentComponent); } } |
In mountComponent()
, we check if the virtual element returned by render()
is a component, and if it is, call mountComponent()
recursively until we get a simple element. We also have to set up the linked list I mentioned earlier using setChild()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
mountComponent: (virtualElement, container, oldDomElement, parentComponent) => { const component = new virtualElement.type(virtualElement.props); const nextElement = component.render(); if (parentComponent) { const root = parentComponent.getRoot(); nextElement.component = root; parentComponent.setChild(component); } else { nextElement.component = component; } if (typeof nextElement.type === 'function') { Reconciler.mountComponent(nextElement, container, oldDomElement, component); } else { Reconciler.diff(nextElement, container, oldDomElement); } } |
Conclusion
That's it! MVR now has support for components. We can already build pretty complicated apps with this framework, but a few critical things are missing, namely:
- Lifecycle hooks
setState()
- Better list reconciliation (using keys)
We'll tackle items 1 and 2 in the next edition of Minimum-Viable-React, and in the 4th and last part we'll add a better list reconciliation algorithm. I hope you learned something from this and remember to leave a comment if you have any feedback or questions.