Build your own React.js - Part 2. React.Component
Rinat Rezyapov
Posted on January 23, 2021
Introduction
First of all, thank you for all your likes in the previous article, it encouraged me to finish the second part as soon as possible. If some parts of these articles seem confusing to you, please, leave a comment. It will be super helpful.
This is a second part of Build your own React.js series. Click here if you didn't read the first part.
Table Of Content
Implementing Component
In the previous article, we stopped at creating our first App
class and passing it to render
(ReactDOM.render
in React.js) function. We figured out that in order to continue we need to implement Component
class (React.Component
in React.js) and extend App
class from it.
From instantiateComponent
function, we know that when we create an instance of the App
class we pass element.props
to its constructor and then call _construct
method.
// Component.js
function instantiateComponent(element) {
const wrapperInstance = new element.type(element.props);
wrapperInstance._construct(element);
return wrapperInstance;
}
This means that Component
class should be able to handle element.props
in its constructor and should have the _construct
method so that App
class could inherit them.
Turns out this part is pretty simple. We get element.props
in the constructor and assign them to this.props
so that our instance of the App
class could have access to its props.
// Component.js
class Component {
constructor(props) {
this.props = props;
this._currentElement = null;
}
_construct(element) {
this._currentElement = element;
}
}
In the _construct
method, we assign { type: App }
element, to the _currentElement
. We will discuss why we do it a little bit later.
Remember that only class instances can have
this
. It's an oversimplification but when you create, let's say an App class by declaringclass App
, you create a blueprint of the instance of that App class and it doesn't havethis
. But when you callnew App()
(we do it ininstantiateComponent
) you create an instance of the App class that has it's ownthis
. In theComponent
class by sayingthis.props
orthis._currentElement
we are referring to theprops
and_currentElement
of the instance of whatever class that will extendComponent
class. Again, it's a very simplified explanation but hopefully, it will help you understand the logic behind this.
For now, let's return to the place where we created the App
class and passed it to render
. Since now we have Component
class let's extend App
class from it.
// index.js
class App extends Component {}
ReactDOM.render(
{ type: App, props: { title: "React.js" } },
document.getElementById("root")
);
From now we can call our
App
class not just a class but aclass component
.
As you can see I also added props
field to the element
object to check if Component
class constructor works. To see results, we need to go back to the mount
function and console.log
the result of the instantiateComponent
function call.
// react-dom.js
function mount(element, node) {
node.dataset[ROOT_KEY] = rootID;
const component = instantiateComponent(element);
console.log(component);
}
App: {
props: {
title: "React.js"
},
_currentElement: {
type: App,
props: {
title: "React.js"
}
}
}
On this scheme, App class component instance has its own
props
and_currentElement
fields (this
section) and getmountComponent
and_construct
methods fromComponent
class using prototype chain (__proto__
section). If you want to know more about prototype chain, I would recommend to look at this video: The Definitive Guide to Object-Oriented JavaScript.
Nice! We've got an instance of our App
class component with the fields that we expected.
Try to look at the dev console yourself using this Codesandbox example (marked as App instance
in dev console).
Now let's continue implementing the mount
function.
// react-dom.js
let instancesByRootID = {};
let rootID = 1;
function mount(element, node) {
node.dataset[ROOT_KEY] = rootID;
const component = instantiateComponent(element);
instancesByRootID[rootID] = component;
const renderedNode = Reconciler.mountComponent(component, node);
}
We add the newly created instance of the App
class component to the instancesByRootID
object by rootID
. We will need instancesByRootID
object later when we will perform the update
and unmount
.
Next, we call mountComponent
of the Reconciler
. This is where the fun begins.
// Reconciler.js
function mountComponent(component) {
return component.mountComponent();
}
As you can see, mountComponent
just calls mountComponent
of the instance of the App
class component itself. Since App
class component extends Component
class, we need to add mountComponent
method to Component
class.
// Component.js
class Component {
...
mountComponent() {
const renderedElement = this.render();
...
}
}
In the mountComponent
we start with calling this.render
function. You may think that it's the function that we've implemented at the beginning, i.e. ReactDOM.render
but it's not. Remember how in the real React.js library we usually create a class component with render
method and return jsx
from it? That's the method that we call here. Except that we will use objects instead of jsx.
Notice how we declared render
in App
class component unlike previously we declared, for example, mountComponent
in Component
class. That's because mountComponent
is an internal mechanism which is controlled by React.js library itself. render
method in the App
class component, on the other side, is controlled by developers, by you. You may say "How about props
? Props are controlled by developers, but the assignment of props happens in the Component
class". That's true, but we actually just say to React.js library "Hey! I need to pass these props to this class component" and React.js creates an instance of this class component with the props that you passed. In the real React.js application we never assign props inside of the constructor of the class component, right?
Do you remember what we usually assign in the constructor when we create a class component?
That's right! We assign state
. So React.js kind of says to developer "Put the data that periodically changes in the state
and put some jsx into render
when you create a class component. And I will do the rest". That's really important to understand to go further.
Now we need to go to the App
class component and create render
method that returns div
element with the text We are building ${this.props.title}
as a child.
// index.js
class App extends Component {
render() {
return {
type: "div",
props: { children: `We are building ${this.props.title}` }
};
}
}
// is the same as
class App extends Component {
render() {
return <div>{`We are building ${this.props.title}`}</div>
}
}
Let's look at the results of calling this.render
in the Component
class implementation.
// Component.js
mountComponent() {
const renderedElement = this.render();
console.log(renderedElement);
// { type: "div", props: { children: `We are building ${this.props.title}` } }
const renderedComponent = instantiateComponent(renderedElement);
}
We've got what we declared in the render
method of the App class component. Nice! Then, we call instantiateComponent
with this result.
Implementing DOMComponentWrapper
The current implementation of instantiateComponent
expects element.type
to be a class component. So we need to add support for DOM elements in the element.type
, i.e. div
, a
. Pay attention that we use string ("div") to describe a DOM element and not actual HTML tag (div).
// Component.js
// before
function instantiateComponent(element) {
const wrapperInstance = new element.type(element.props);
wrapperInstance._construct(element);
return wrapperInstance;
}
// after
function instantiateComponent(element) {
let wrapperInstance;
if (typeof element.type === 'string') {
wrapperInstance = HostComponent.construct(element);
} else {
wrapperInstance = new element.type(element.props);
wrapperInstance._construct(element);
}
return wrapperInstance;
}
We added the condition that checks if the type of element.type
is a string
(e.g. "div") and if it's true, we call HostComponent.construct
which is very simple. I think the real React.js does some more work here and it was left in such a way just to preserve the structure.
// HostComponent.js
function construct(element) {
return new DOMComponentWrapper(element);
}
DOMComponentWrapper
, as you can see from the name, is a class wrapper around DOM elements (such as "div"). This wrapper is necessary for storing the state (don't confuse with the class component state) of the element. Also, it creates homogeneity between handling class components and DOM elements because it's quite similar to Component
implementation.
// DOMComponentWrapper.js
class DOMComponentWrapper {
constructor(element) {
//element == {type: "div", props: {children: We are building ${this.props.title}}}
this._currentElement = element;
}
}
For now, we just get an element in the constructor and assign it to the _currentElement
.
Now we need to return to the mountComponent
of the the Component
class. We get DOMComponentWrapper
from instantiateComponent
and pass it to Reconciler.mountComponent
. Remember we used it in mount
function in the beginning? The difference is that we used it to mount App
class component and now we use it to mount the content of render
method of an App
class instance.
// Component.js
class Component {
constructor(props) {
this.props = props;
this._currentElement = null;
this._renderedComponent = null;
}
...
mountComponent() {
const renderedElement = this.render();
const renderedComponent = instantiateComponent(renderedElement);
console.log(renderedComponent) // DOMComponentWrapper
this._renderedComponent = renderedComponent; // needed for update
return Reconciler.mountComponent(renderedComponent);
}
}
Don't feel frustrated if you don't understand some parts - it will make sense after several passes as it was in my case. Also, there will be a flowchart at the end of the article that will, hopefully, help you to build a mental model of the process.
In Reconciler
we call mountComponent of the DOMComponentWrapper
.
// Reconciler.js
function mountComponent(component) { // DOMComponentWrapper
return component.mountComponent();
}
Let's implement it.
// DOMComponentWrapper.js
class DOMComponentWrapper {
constructor(element) {
this._currentElement = element;
this._domNode = null;
}
mountComponent() {
let el = document.createElement(this._currentElement.type);
this._domNode = el;
this._createInitialDOMChildren(this._currentElement.props);
return el;
}
}
We take element from _currentElement
that we assigned when we created DOMComponentWrapper
and use it to create a div
DOM element by calling document.createElement('div')
. Exciting!
_domNode
will hold the newly created DOM element.
Now time to create children of this div
element. For it, we need to implement _createInitialDOMChildren
.
// DOMComponentWrapper.js
class DOMComponentWrapper {
constructor(element) {
this._currentElement = element;
this._domNode = null; // <div></div>
}
...
_createInitialDOMChildren(props) {
// element === { children: `We are building ${props.title}` }
if (typeof props.children === "string") {
this._domNode.textContent = props.children;
}
}
}
We assume for now, that
children
prop of the element can only be ofstring
type.
In _createInitialDOMChildren
we get DOM node from _domNode
and assign children
prop, which is string, to its textContent
attribute. Now we have
<div>We are building React.js</div>
DOM element.
We are very close to rendering our App
class component to the screen.
Let's return to the mount
function and add final steps.
// react-dom.js
function mount(element, node) {
node.dataset[ROOT_KEY] = rootID;
const component = instantiateComponent(element);
instancesByRootID[rootID] = component;
const renderedNode = Reconciler.mountComponent(component, node);
console.log(renderedNode) // <div>We are building React.js</div>
}
DOM
We know that Reconciler.mountComponent(component, node)
returns a DOM element. We need to append it to the root node <div id="root"></div>
in our HTML file so we could see it in the browser. For that let's create DOM tools. They are pretty easy. [].slice.call(node.childNodes)
is just a way to create an array from node.childNodes
because originally node.childNodes
is not an array.
// DOM.js
function empty(node) {
[].slice.call(node.childNodes).forEach(node.removeChild, node);
}
function appendChild(node, child) {
node.appendChild(child);
}
If you are not sure what the DOM is, you may read this article.
Now let's empty our root node in case if something was appended to it before and then append <div>We are building React.js</div>
to it using DOM.appendChild(node, renderedNode)
. Then we increment rootID
(we will discuss later why we do it).
// react-dom.js
function mount(element, node) {
node.dataset[ROOT_KEY] = rootID;
const component = instantiateComponent(element);
instancesByRootID[rootID] = component;
const renderedNode = Reconciler.mountComponent(component, node);
DOM.empty(node);
DOM.appendChild(node, renderedNode);
rootID++;
}
Voila! We rendered our first Class Component to the screen using our own React.js implementation.
Feel free to open it in the second tab/monitor and go through this article again.
In this article, we were able to render only one child { children: We are building ${this.props.title} }
but in the real React.js application we usually have multiple children. In the next episode of the Build your own React.js series we will implement MultiChild class that will help us with that.
Links:
Posted on January 23, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 3, 2024
August 26, 2024