Quantcast
Channel: Andela
Viewing all articles
Browse latest Browse all 615

Building Your Own Version of React From Scratch (Part 1)

$
0
0

Introduction

React is a huge codebase and knowing where to start looking can be confusing. You may find files like this one – which is 3000 lines long – and you may decide to look the other way and keep building awesome UI’s with the library. This has been me for the most part up until recently when I decided that I was going to take a deep dive at looking at the codebase and other Virtual Dom implementations like PreactJS

We won’t cover all the features that React brings to the table. I still don’t understand everything about the codebase either, but we will look at some of the most important details that can help you understand React better, see what makes React 16+ faster than React 15 and as a bonus, you will learn 1 way of how NPM packages are created & bundled with Rollup.

Setup & Basic Overview

Create a folder with a name for your React implementation. If you want to publish it on NPM then make sure there’s no Js library with the name you have in mind. I’ll name mine tevreact .

Once we are done building we will be able to have a JSX powered React clone that reads from state and props as shown in the screenshot below.

A demo app that reads from local state and props.

JSX

JSX is XML like syntax that is written inside Javascript files and is transpiled to function calls which in turn is rendered to either the Dom thanks to React-Dom or React Native views

If you wrote

how you write your React code

You get

JSX transpiled to function calls.

createElement takes a node-typeprops and finally children. If either children or props are missing we get null in the fields. Lastly, createElement returns an object {type, props} which is then used to render to the dom. The above calls will produce

for React’s case, it has other properties such as key, ref, etc, we will only be interested in type and props for now.

Let’s build our own createElement function

We’ll do things a bit differently in our implementation, we want all our objects to have the same shape. React’s children can either be an array of objects or a string. For our case, we want the children to be objects. Which means the h1 children props will be

Inside the src folder create 2 files element.js && tevreact.js(what you called your React clone .js).Inside your element.js is where we will have our createElement function.

const TEXT_ELEMENT = "TEXT";

/**
 * @param {string} type - the node type
 * @param {?object} configObject - the props
 * @param  {?...any} args - the children array
 * @returns {object} - to be called by tevreact.render
 */
export function createElement(type, configObject, ...args) {
  const props = Object.assign({}, configObject);
  const hasChildren = args.length > 0;
  const nodeChildren = hasChildren ? [...args] : [];
  props.children = nodeChildren
    .filter(Boolean)
    .map(c => (c instanceof Object ? c : createTextElement(c)));

  return { type, props };
}

/**
 * @param {string} nodeValue - the text of the node
 * @returns {object} - a call to createElement
 */
function createTextElement(nodeValue) {
  return createElement(TEXT_ELEMENT, { nodeValue, children: [] });
}

Moving on to the render phase

You’ve probably seen the render function from React-Dom

const root = document.getElementById("root")
ReactDom.render(<Component />, root)

The render function is responsible for rendering to the real dom. It takes an element and a parentNode that the element will be appended to after being created. We still haven’t added support for custom JSX tags but we are getting there. Create 2 new files reconciler.js & dom-utils.js the renderwill be in the reconciler.js file. Firstly export the TEXT_ELEMENT in the element.js to be export const TEXT_ELEMENT = "TEXT";

The render method checks to see if the element is a string, if it is, a text node is created, if not an instance of the HTML element specified is created.

We will define updateDomProperties in a bit but it essentially takes the props provided and appends them to the element, the same process is repeated for the element’s children. The render function is called recursively on each child.

In dom-utils.js create the updateDomProperties function and make sure it is exported.

import { TEXT_ELEMENT } from "./element";

/**
 * @param {HTMLElement} dom - the html element where props get applied to
 * @param {object} props - consists of both attributes and event listeners.
 */
export function updateDomProperties(dom, props) {
  const isListener = name => name.startsWith("on");
  Object.keys(props)
    .filter(isListener)
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, props[name]);
    });

  const isAttribute = name => !isListener(name) && name !== "children";
  Object.keys(props)
    .filter(isAttribute)
    .forEach(name => {
      dom[name] = props[name];
    });
}

If the name starts with on like onClick, onSubmit then its an event listener and we need to add it as such via dom.addEventListener. the rest of the props just applied as attributes to the element.

Testing time

Let’s see what we have come up with up until this point.

Export functions to the tevreact.js file

import { render } from "./reconciler";
import { createElement } from "./element";

export { createElement, render };

export default {
  render,
  createElement
};
view raw

Install rollup & setup npm scripts

$ yarn init -y // if you have yarn installed
$ npm init -y // if you have npm
// install rollup
$ yarn add rollup —-dev
$ npm install rollup —-save-dev

Setup the NPM scripts in package.json replace yarn with npm run if you don’t have yarn installed. Replace the tevreact.js to the file name you chose.

"scripts": {
    "build:module": "rollup src/tevreact.js -f es --exports named -n tevreact -o dist/tevreact.es.js",
    "build:main": "rollup src/tevreact.js -f umd --exports named -n tevreact -o dist/tevreact.umd.js",
    "build:all": "yarn build:module && yarn build:main",
    "prepublishOnly": "yarn build:all" // this command is run automatically before publishing to npm
  },

Example repo

Run npm run build:all

Create a folder named examples in the root of the directory and a basic index.html file.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Didact</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- we need the babel standalone transpiler here since this is just a basic html page -->
    <script src="https://unpkg.com/babel-standalone@7.0.0-beta.3/babel.min.js"></script>
    <!-- load the umd version because it sets global.tevreact -->
    <script src="../dist/tevreact.umd.js"></script>
    <script type="text/jsx">
      /** @jsx tevreact.createElement */
      /** In the comment above we are telling babel which function it should
      use the default is React.createElement and we want to use
      our own createElement function*/
      const appElement = (
          <div>
              <h1>Hello Tev, Have you watched John Wick</h1>
          </div>
      )
      tevreact.render(appElement, document.getElementById("app"));
    </script>
  </body>
</html>

replace tevreact with you went with. We’ll have a full example bundled with Webpack in the end

It works, You should be seeing something similar.

Updating the rendered JSX

If you’ve got time, try running the snippet below in a demo React app. We won’t have 2 app instances rendered on the dom but with our current implementation, our render method will render twice since it doesn’t know how to perform an update.

const rootElement = document.getElementById("root")
ReactDom.render(<Component />, rootElement) // renders for the first time
ReactDom.render(<Component />, rootElement) // does the app component render twice
We won’t have 2 app components rendered on the screen.

A duplicate call to render in your example index.html will not update the div but will append a new one.

It’s a bug that we will fix below.

In order to perform an update, we need to have a copy of the tree that has been rendered to the screen and the new tree with the updates so that we can make a comparison. We can do this by creating an object that we will call an instance.

const instance = {
  dom: HTMLElement, // the rendered dom element
  element: {type: String, props: object}, 
  childInstances: Array<instance> // array of child instances
 }

If the previous instance is null eg:(on initial render) we will create a new node

If the element.type of the previous instance is the same as the type of new instance, all we will do is just update the props of the element,

lastly, for now, If the type of the prev instance is not the same as the type of the new instance, we will replace the prev with the new instance

The above process is called reconciliation. It aims at reusing the dom nodes present as much as possible. Now that you have a grasp on the logic, keep in mind that we need to iterate the same process for the childInstances

Enough talk lets code.

We need a function inside reconciler.js that creates a new instance. It returns the instance object after an element is passed as an argument. We also need a reconcile function that will perform the reconciliation process described above. This will result in the render method offloading its functionality.

import { updateDomProperties } from "./dom-utils";
import { TEXT_ELEMENT } from "./element";

let rootInstance = null; // will keep the reference to the instance rendered on the dom

export function render(element, parentDom) {
  const prevInstance = rootInstance;
  const nextInstance = reconcile(parentDom, prevInstance, element);
  rootInstance = nextInstance;
}

function reconcile(parentDom, instance, element) {
  if (instance == null) {
    // initial render
    const newInstance = instantiate(element);
    parentDom.appendChild(newInstance.dom);
    return newInstance;
  } else if (element == null) {
    /**
     * this section gets hit when
     * a childElement was previously present
     * but in the new element is not present
     * for instance a todo item that has been deleted
     * it was present at first but is now not present
     */
    parentDom.removeChild(instance.dom);
    return null;
  } else if (instance.element.type === element.type) {
    /**
     * if the types are the same
     * eg: if prevType was "input" and current type is still "input"
     * NB:// we still havent updated
     * the props of the node rendered in the dom
     */
    instance.childInstances = reconcileChildren(instance, element);
    instance.element = element;
    return instance;
  } else {
    /**
     * if the type of the previous Instance is not the
     * same as the type of the new element
     * we replace the old with the new.
     * eg: if we had an "input" and now have "button"
     * we get rid of the input and replace it with the button
     */
    const newInstance = instantiate(element);
    parentDom.replaceChild(newInstance.dom, instance.dom);
    return newInstance;
  }
}

function instantiate(element) {
  const { type, props } = element;

  const isTextElement = type === TEXT_ELEMENT;
  const dom = isTextElement
    ? document.createTextNode("")
    : document.createElement(type);

  updateDomProperties(dom, props);

  // Instantiate and append children
  const childElements = props.children || [];
  // we are recursively calling instanciate on each
  // child element
  const childInstances = childElements.map(instantiate);
  const childDoms = childInstances.map(childInstance => childInstance.dom);
  childDoms.forEach(childDom => dom.appendChild(childDom));

  const instance = { dom, element, childInstances };
  return instance;
}

function reconcileChildren(instance, element) {
  const dom = instance.dom;
  const childInstances = instance.childInstances;
  const nextChildElements = element.props.children || [];
  const newChildInstances = [];
  const count = Math.max(childInstances.length, nextChildElements.length); 
  
  for (let i = 0; i < count; i++) {
    const childInstance = childInstances[i];
    const childElement = nextChildElements[i];
    // the reconcile function has logic setup to handle the scenario when either 
    // the child instance or the childElement is null
    const newChildInstance = reconcile(dom, childInstance, childElement);
    newChildInstances.push(newChildInstance);
  }

  return newChildInstances.filter(instance => instance != null);
}

We also need to update the updateDomProperties function to remove the oldProps and apply the newProps

export function updateDomProperties(dom, prevProps, nextProps) {
  const isEvent = name => name.startsWith("on");
  const isAttribute = name => !isEvent(name) && name != "children";

  // Remove event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });

  // Remove attributes
  Object.keys(prevProps)
    .filter(isAttribute)
    .forEach(name => {
      dom[name] = null;
    });

  // Set new attributes
  Object.keys(nextProps)
    .filter(isAttribute)
    .forEach(name => {
      dom[name] = nextProps[name];
    });
    
  // Set new eventListeners
  Object.keys(nextProps)
    .filter(isEvent)
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

Let’s update the functions that call updateDomProperties to provide the previous props and next props.

function reconcile(parentDom, instance, element) {
   /** code... */
  else if (instance.element.type === element.type) {
    // perform props update here
    updateDomProperties(instance.dom, instance.element.props, element.props);
    instance.childInstances = reconcileChildren(instance, element);
    instance.element = element;
    return instance;
  } 
     /** code... */
}

function instantiate(element) {
  const { type, props } = element;

  /** code... */
  const dom = isTextElement
    ? document.createTextNode("")
    : document.createElement(type);
   // apply new props and provide empty object as a prevObject since this is instanciation
  updateDomProperties(dom, {}, props);
   /** code... */
  
}

Build your clone again npm run build:all and reload your example app again with the 2 render calls still present.

We now have 1 app instance.

Classes and Custom JSX tags

We’ll look at class Components and custom JSX tags, we won’t be covering lifecycle methods in this tutorial.

We can do an optimization from here on out, in the previous examples, reconciliation happened for the entire virtual dom tree. With the introduction of classes, we will make reconciliation happen only for the component whose state has changed. Let’s get to it. Create a component.jsfile in src

export class Component {
  constructor(props) {
    this.props = props;
    this.state = this.state || {};
  }
  setState(partialState) {
    this.state = Object.assign({}, this.state, partialState);
  }
}

We need the component to maintain its own internal instance so that reconciliation can happen for this component alone.

// ...code

function createPublicInstance(element, internalInstance) {
  const { type, props } = element; 
  const publicInstance = new type(props); // the type is a class so we use the *new* keyword
  publicInstance.__internalInstance = internalInstance;
  return publicInstance;
}

A change needs to be made to the instantiate function, it needs to call createPublicInstance if the type is a class.

function instantiate(element) {
  const { type, props } = element;
  const isDomElement = typeof type === "string";

  if (isDomElement) {
    // Instantiate DOM element
    const isTextElement = type === TEXT_ELEMENT;
    const dom = isTextElement
      ? document.createTextNode("")
      : document.createElement(type);

    updateDomProperties(dom, [], props);

    const childElements = props.children || [];
    const childInstances = childElements.map(instantiate);
    const childDoms = childInstances.map(childInstance => childInstance.dom);
    childDoms.forEach(childDom => dom.appendChild(childDom));

    const instance = { dom, element, childInstances };
    return instance;
  } else {
    // Instantiate component element
    const instance = {};
    const publicInstance = createPublicInstance(element, instance);
    const childElement = publicInstance.render(); // each class has a render method
    // if render is called it returns the child
    const childInstance = instantiate(childElement);
    const dom = childInstance.dom;

    Object.assign(instance, { dom, element, childInstance, publicInstance });
    return instance;
  }
}

Updating a class component in our case will happen when a call to setStateis made.

import { reconcile } from "./reconciler"

export class Component {
  constructor(props) {
    this.props = props;
    this.state = this.state || {};
  }
  setState(partialState) {
    this.state = Object.assign({}, this.state, partialState);
    updateInstance(this.__internalInstance);
  }
}

function updateInstance(internalInstance) {
  const parentDom = internalInstance.dom.parentNode;
  const element = internalInstance.element;
  reconcile(parentDom, internalInstance, element);
}

We now need to make sure that our reconcile function handles reconciliation for class components.

export function reconcile(parentDom, instance, element) {
  if (instance == null) {
    // initial render
    const newInstance = instantiate(element);
    parentDom.appendChild(newInstance.dom);
    return newInstance;
  } else if (element == null) {
    /**
     * this section gets hit when
     * a childElement was previously present
     * but in the new element is not present
     * for instance a todo item that has been deleted
     * it was present at first but is now not present
     */
    parentDom.removeChild(instance.dom);
    return null;
  } else if (instance.element.type !== element.type) {
    /**
     * if the type of the previous Instance is not the
     * same as the type of the new element
     * we replace the old with the new.
     * eg: if we had an "input" and now have "button"
     * we get rid of the input and replace it with the button
     */
    const newInstance = instantiate(element);
    parentDom.replaceChild(newInstance.dom, instance.dom);
    return newInstance;
  } else if (typeof element.type === "string") {
    /**
     * if the types are the same & are HTMLElement types
     * eg: if prevType was "input" and current type is still "input"
     * NB:// we still havent updated
     * the props of the node rendered in the dom
     */
    instance.childInstances = reconcileChildren(instance, element);
    instance.element = element;
    return instance;
  } else {
    //Update instance
    instance.publicInstance.props = element.props;
    const childElement = instance.publicInstance.render();
    const oldChildInstance = instance.childInstance;
    const childInstance = reconcile(parentDom, oldChildInstance, childElement);
    instance.dom = childInstance.dom;
    instance.childInstance = childInstance;
    instance.element = element;
    return instance;
  }
}

The end result means that reconciliation on class components starts at the parentNode of the child component and not the beginning of the v-domtree.

Import the Component class in tevreact just like the rest of the functions.

import { render } from "./reconciler";
import { createElement } from "./element";
import { Component } from "./component";
export { createElement, render, Component };

export default {
  render,
  createElement,
  Component
};

Testing time

Run npm run build:all & create another HTML file in the examples directory class.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Didact</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- we need the babel standalone transpiler here since this is just a basic html page -->
    <script src="https://unpkg.com/babel-standalone@7.0.0-beta.3/babel.min.js"></script>
    <!-- load the umd version because it sets global.tevreact -->
    <script src="../dist/tevreact.umd.js"></script>
    <!-- allow the react js preset -->
    <script type="text/babel" data-presets="react">
      /** @jsx tevreact.createElement */
      /** In the comment above we are telling babel which function it should
      use the default is React.createElement and we want to use
      our own createElement function*/
      class App extends tevreact.Component {
        constructor(props) {
          super(props);
          this.state = { movieName: "John Wick" };
        }
        render() {
          const { movieName } = this.state;
          const { userName } = this.props;
          return (
            <div>
              <h1>
                Hello {userName}, Have you watched {movieName}.
              </h1>
            </div>
          );
        }
      }
      tevreact.render(<App userName={"Tev"} />, document.getElementById("app"));
    </script>
  </body>
</html>

A class component reading from props and state 🎉

If you got rid of all the comments, we have a pretty decent library size of fewer than 300 lines. Hit npm publish and you’ll have your package on the NPM registry.

Even though this works, this is how React worked prior to having Fiber. In part 2 of this tutorial, we will work on integrating the Fiber reconciler which is what React 16 > is using at the time of writing this post.

The post Building Your Own Version of React From Scratch (Part 1) appeared first on Andela.


Viewing all articles
Browse latest Browse all 615

Trending Articles