You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

180 lines
12 KiB
Plaintext

# Reconciliation, or How React Diffs
A tale of two trees. These are the two trees that people most often talk about when talking about React's "secret sauce": the virtual DOM and the browser's DOM tree. This idea is what originally set React apart. React's reconciliation is what allows you to program declaratively. Reconciliation is what makes it so we no longer have to manually update and modify the DOM whenever our own internal state changes. In a lot of ways, it is what makes React, React.
Conceptually, the way this works is that React generates a new element tree for every render and compares the newly generated tree to the tree generated on the previous render. Where it finds differences between the trees it knows to mutate the DOM state. This is the "tree diffing" algorithm.
Unfortunately, those researching tree diffing in Computer Science have not yet produced a generic algorithm with sufficient performance for use in something like React; as the current best algorithm still [runs in O(n^3^)](https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf).
Since an O(n^3^) algorithm isn't going to cut it in the real-world, the creators of React instead use a set of heuristics to determine what parts of the tree have changed. Understanding how the React tree diffing algorithm works in general and the heuristics currently in use can help immensely in detecting and fixing React performance bottlenecks. And beyond that it can help one's understanding of some of React's quirks and usage. Even though this algorithm is internal to React and can be changed anytime its details have leaked out in some ways and are overall unlikely to change in major ways without larger changes to React itself.
According to the [React documentation](https://reactjs.org/docs/reconciliation.html) their diffing algorithm is O(n) and based on two major components:
* Elements of differing types will yield different trees
* You can hint at tree changes with the `key` prop.
In this section we will focus on the first part: differing types.
4 years ago
I> In this book we won't be covering keys in depth but you will see why it's very important to follow the guidance from React's documentation that keys be: stable, predictable, and unique.
The approach we will take here is to integrate the heuristics that React uses into our `render` method. Our implementation will be very similar to how React itself does it and we will discuss React's actual implementation later when we talk about Fibers.
Before we get into the code changes that implement the heuristics it is important to remember that React *only* looks at an element's type, existence, and key. It does not do any other diffing. It does not diff props. It does not diff sub-trees of modified parents.
While keeping that in mind, here is an overview of the algorithm we will be implementing in the `render` method. `element` is the element from the current tree and `prevElement` is the corresponding element in the tree from the previous render.
{format: "javascript"}
```
if (!element && prevElement)
// delete dom element
else if (element && !prevElement)
// add new dom element, render children
else if (element.type === prevElement.type)
// update dom element, render children
else if (element.type !== prevElement.type)
// replace dom element, render children
```
Notice that in every case, except deletion, we still call `render` on the element's children. And while it's possible that the children will have their associated DOM elements reused, their `render` methods will still be invoked.
Now, to get started with our render method we must make some modifications to our previous render method. First, we need to be able to store and retrieve the previous render tree. Then we need to add code to compare parts of the tree to decide if we can re-use DOM elements from the previous render tree. And last, we need to return a tree of elements that can be used in the next render as a comparison and to reference the DOM elements that we create. These new element objects will have the same structure as our current elements but we will add two new properties: `domElement` and `parent`. `domElement` is the DOM element associated with our synthetic element and `parent` is a reference to the parent DOM element.
Here we begin by adding a global object that will store our last render tree, keyed by the `container`. `container` refers to the browser's DOM element that will be the parent for all of the React derived DOM elements. This parent DOM element can only be used to render one tree of elements at a time so it works well to use as a key for `renderTrees`.
{format: "javascript"}
```
const renderTrees = {};
function render(element, container) {
const tree =
render_internal(element, container,
renderTrees[container]);
// render complete, store the updated tree
renderTrees[container] = tree;
}
```
As you can see, the change we made is to move the core of our algorithm into a new function called `render_internal` and pass in the result of our last render to `render_internal`.
Now that we have stored our last render tree we can go ahead and update our render method with the heuristics for reusing the DOM elements. We name it `render_internal` because it is what controls the rendering but takes an additional argument now: the `prevElement`. `prevElement` is a reference to the corresponding `element` from the previous render and contains a reference to its associated DOM element and parent DOM element. If it's the first render or if we are rendering a new node or branch of the tree then `prevElement` will be `undefined`. If, however, `element` is `undefined` and `prevElement` is defined then we know we need to delete a node that previously existed.
{format: "javascript"}
```
function render_internal(element, container, prevElement) {
let domElement, children;
if (!element && prevElement) {
removeDOMElement(prevElement);
return;
} else if (element && !prevElement) {
domElement = createDOMElement(element);
} else if (element.type === prevElement.type) {
domElement = prevElement.domElement;
} else { // types don't match
removeDOMElement(prevElement);
domElement = createDOMElement(element);
}
setDOMProps(element, domElement, prevElement);
children =
renderChildren(element, domElement, prevElement);
if (!prevElement ||
domElement !== prevElement.domElement) {
container.appendChild(domElement);
}
return {
domElement: domElement,
parent: container,
type: element.type,
props: {
...element.props,
children: children
}
};
}
```
The only time we shouldn't set DOM properties on our element and render its children is when we are deleting an existing DOM element. We use this observation to group the calls for `setDOMProps` and `renderChildren`. Choosing when to append a new DOM element to the container is also part of the heuristics. If we can reuse an existing DOM element then we do, but if the element type has changed or if there was no corresponding existing DOM element then and only then do we append a new DOM element. This ensures the actual DOM tree isn't being replaced every time we render, only the elements that change are replaced.
In the real React, when a new DOM element is appended to the DOM tree, React would invoke `componentDidMount` or schedule `useEffect`.
Next up we'll go through all the auxiliary methods that complete the implementation.
Removing a DOM element is straightforward; we just `removeChild` on the parent element. Before removing the element, React would invoke `componentWillUnmount` and schedule the cleanup function for `useEffect`.
{format: "javascript"}
```
function removeDOMElement(prevElement) {
prevElement.parent.removeChild(prevElement.domElement);
}
```
In creating a new DOM element we just need to branch if we are creating a text element since the browser API differs slightly. We also populate the text element's value as the API requires the first argument to be specified even though later on when we set props we will set it again. This is where React would invoke `componentWillMount` or schedule `useEffect`.
{format: "javascript"}
```
function createDOMElement(element) {
return element.type === 'TEXT' ?
document.createTextNode(element.props.nodeValue) :
document.createElement(element.type);
}
```
To set the props on an element, we first clear all the existing props and then loop through the current props, setting them accordingly. Of course, we filter out the `children` prop since we use that elsewhere and it isn't intended to be set directly.
{format: "javascript"}
```
function setDOMProps(element, domElement, prevElement) {
if (prevElement) {
Object.keys(prevElement.props)
.filter((key) => key !== 'children')
.forEach((key) => {
domElement[key] = ''; // clear prop
});
}
Object.keys(element.props)
.filter((key) => key !== 'children')
.forEach((key) => {
domElement[key] = element.props[key];
});
}
```
I> React is more intelligent about only updating or removing props that need to be updated or removed.
W> This algorithm for setting props does not correctly handle events, which must be treated specially. For this exercise that detail is not important and we leave it out for simplicity.
For rendering children we use two loops. The first loop removes any elements that are no longer being used. This would happen when the number of children is decreased. The second loop starts at the first child and then iterates through all of the children of the parent element, calling `render_internal` on each child. When `render_internal` is called the corresponding previous element in that position is passed to `render_internal`, or `undefined` if there is no corresponding element, like when the list of children has grown.
{format: "javascript"}
```
function renderChildren(element, domElement,
prevElement = { props: { children: [] }}) {
const elementLen = element.props.children.length;
const prevElementLen = prevElement.props.children.length;
// remove now unused elements
for (let i = elementLen; i < prevElementLen - elementLen;
i++) {
removeDOMElement(element.props.children[i]);
}
// render existing and new elements
return element.props.children.map((child, i) => {
const prevChild = i < prevElementLen ?
prevElement.props.children[i] : undefined;
return render_internal(child, domElement, prevChild);
});
}
```
It's very important to understand the algorithm used here because this is essentially what happens in React when incorrect keys are used, like using a list index for a key. And this is why keys are so critical to high performance (and correct) React code. For example, in our algorithm here, if you removed an item from the front of the list you may cause every element in the list to be created anew in the DOM if the types no longer match up. In this book we won't be incorporating keys, but it's actually only a minor difference in determining which `child` gets paired with which `prevChild`. Otherwise this is effectively the same algorithm React uses when rendering lists of children.
{caption: "Example of `renderChildren` 2nd loop when the 1st element has been removed. In this case the trees for all of the children will be torn down and rebuilt."}
| i | child Type | prevChild Type |
|--- |---------- |-------------- |
| 0 | span | div |
| 1 | input | span |
| 2 | - | input |
There are a few things to note here. First, it is important to pay attention to when React will be removing a DOM element from the tree and adding a new one as this is when the related lifecycle events or hooks are invoked. And invoking those lifecycle methods or hooks, and the whole process of tearing down and building up a component is expensive. So again, if you use a bad key, like the algorithm here simulates, you'll be hitting a major performance bottleneck since React will not only be replacing DOM elements in the browser but also tearing down and rebuilding the trees of child components.