DEV Community

loading...
Cover image for Build your own React.js - Part 2. React.Component

Build your own React.js - Part 2. React.Component

rinatrezyapov profile image Rinat Rezyapov Updated on ・9 min read

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;
  }
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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 declaring class App, you create a blueprint of the instance of that App class and it doesn't have this. But when you call new App() (we do it in instantiateComponent) you create an instance of the App class that has it's own this. In the Component class by saying this.props or this._currentElement we are referring to the props and _currentElement of the instance of whatever class that will extend Component 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")
  );
Enter fullscreen mode Exit fullscreen mode

From now we can call our App class not just a class but a class 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"
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Alt Text

On this scheme, App class component instance has its own props and _currentElement fields (this section) and get mountComponent and _construct methods from Component 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);
  }
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

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();
      ...
    }
  }
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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>
    }
  }

Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
Enter fullscreen mode Exit fullscreen mode

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;
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

We assume for now, that children prop of the element can only be of string 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>
Enter fullscreen mode Exit fullscreen mode

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>
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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++;
  }
Enter fullscreen mode Exit fullscreen mode

Voila! We rendered our first Class Component to the screen using our own React.js implementation.

part-2-result

Codesandbox example

part-2-flow-chart

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:

  1. Github repo with the source code from this article
  2. Codesandbox with the code from this article
  3. Building React From Scratch talk
  4. React.js docs regarding Building React From Scratch talk
  5. Introduction to the DOM
  6. The Definitive Guide to Object-Oriented JavaScript

Discussion (0)

pic
Editor guide