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-type, props 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
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
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.
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.
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.