Книга: Build a Frontend Web Framework (From Scratch)
Назад: 5.1.4 Result
Дальше: Summary

Element nodes are more complex than text nodes because they have attributes, styles, and event handlers and because they can have children. You’ll take care of patching the children of a node in section 8.2.6, which covers the case of fragment nodes. But you’ll focus on the attributes, styles (including CSS classes), and event handlers in this section.

The patchElement() function is in charge of extracting the attributes, CSS classes and styles, and event handlers from the old and new virtual nodes and then passing them to the appropriate functions to patch them. You’ll write functions to patch attributes, CSS classes and styles, and event handlers separately because they follow different rules:

 patchAttrs()—Patches the attributes (such as id, name, value, and so on)

 patchClasses()—Patches the CSS class names

 patchStyles()—Patches the CSS styles

 patchEvents()—Patches the event handlers and returns an object with the current event handlers

The event handlers returned by the patchEvents() function should be saved in the listeners property of the new virtual node so that you can remove them later. Recall

8.2

Patching the DOM

183

that when you implemented the mountDOM() function, you saved the event handlers in the listeners property of the virtual node.

Inside the patch-dom.js file, write the code for the patchElement() function as shown in the following listing. (You don’t need to include the // TODO comments shown in the listing; they’re simply reminders of the functions you need to write next.) Listing 8.11

Patching an element node (patch-dom.js)

function patchElement(oldVdom, newVdom) {

const el = oldVdom.el

const {

class: oldClass,

style: oldStyle,

on: oldEvents,

...oldAttrs

} = oldVdom.props

const {

class: newClass,

style: newStyle,

on: newEvents,

...newAttrs

} = newVdom.props

const { listeners: oldListeners } = oldVdom

patchAttrs(el, oldAttrs, newAttrs)

patchClasses(el, oldClass, newClass)

patchStyles(el, oldStyle, newStyle)

newVdom.listeners = patchEvents(el, oldListeners, oldEvents, newEvents)

}

// TODO: implement patchAttrs()

// TODO: implement patchClasses()

// TODO: implement patchStyles()

// TODO: implement patchEvents()

Now that we’ve broken the work of patching element nodes into smaller tasks, let’s start with the first one: patching attributes.

PATCHING ATTRIBUTES

The attributes of a virtual node are all the key-value pairs that come inside its props object—except the class, style, and on properties, which have a special meaning.

Now, given the two objects containing the attributes of the old and new virtual nodes (oldAttrs and newAttrs, respectively), you need to find out which attributes have been added, removed, or changed. In chapter 7, you wrote a function called objectsDiff() that did exactly that job.

The objectsDiff() function tells you which attributes have been added, removed, or changed. You can get rid of the attributes that have been removed by using the

184

CHAPTER 8

The reconciliation algorithm: Patching the DOM

removeAttribute() function, which you wrote in chapter 4 (inside the attributes.js file).

The attributes that have been added or changed can be set using the setAttribute() function, which you also wrote earlier.

At the bottom of the patch-dom.js file, write the code for the patchAttrs() function, as shown in the following listing. Don’t forget to include the new import statements at the top of the file.

Listing 8.12

Patching the attributes (patch-dom.js)

import {

removeAttribute,

setAttribute,

} from './attributes'

import { destroyDOM } from './destroy-dom'

import { DOM_TYPES } from './h'

import { mountDOM } from './mount-dom'

import { areNodesEqual } from './nodes-equal'

import { objectsDiff } from './utils/objects'

Finds out which

attributes have been

// --snip-- //

added, removed, or

changed

function patchAttrs(el, oldAttrs, newAttrs) {

const { added, removed, updated } = objectsDiff(oldAttrs, newAttrs) for (const attr of removed) {

Removes the attributes

removeAttribute(el, attr)

that have been removed

}

for (const attr of added.concat(updated)) {

setAttribute(el, attr, newAttrs[attr])

Sets the attributes

}

that have been added

}

or changed

PATCHING CSS CLASSES

Let’s move on to patching CSS classes. The tricky part of patching the CSS classes is that they can come as a string ('foo bar') or as an array of strings (['foo', 'bar']).

Here’s what you’ll do:

 If the CSS classes come in an array, filter the blank or empty strings out and keep them as an array.

 If the CSS classes come as a string, split it on whitespace and filter the blank or empty strings out.

This way, you work with two arrays of strings representing the CSS classes of the old and new virtual DOMs. Then you can use the arraysDiff() function that you implemented in chapter 7 to find out which CSS classes have been added and removed. The DOM element has a classList property (an instance of the DOMTokenList interface) that you can use to add CSS classes to, and remove CSS classes from, the element. Add the new classes by using the classList.add() method, and remove the old ones by

8.2

Patching the DOM

185

using the classList.remove() method. The classes that were neither added nor removed don’t need to be touched; they can stay as they are.

At the bottom of the patch-dom.js file, write the code for the patchClasses() function as shown in the following listing. Again, don’t forget to include the new import statements at the top of the file. Also, pay attention to a function that you’ll need to import: isNotBlankOrEmptyString().

Listing 8.13

Patching the CSS classes (patch-dom.js)

import {

removeAttribute,

setAttribute,

} from './attributes'

import { destroyDOM } from './destroy-dom'

import { DOM_TYPES } from './h'

import { mountDOM } from './mount-dom'

import { areNodesEqual } from './nodes-equal'

import {

arraysDiff,

} from './utils/arrays'

import { objectsDiff } from './utils/objects'

import { isNotBlankOrEmptyString } from './utils/strings'

// --snip-- //

Array of old

CSS classes

function patchClasses(el, oldClass, newClass) {

const oldClasses = toClassList(oldClass)

Array of new

const newClasses = toClassList(newClass)

CSS classes

const { added, removed } =

arraysDiff(oldClasses, newClasses)

Finds out which CSS classes

have been added and removed

if (removed.length > 0) {

el.classList.remove(...removed)

Adds the CSS classes

}

that have been added

if (added.length > 0) {

el.classList.add(...added)

Removes the CSS classes

}

that have been removed

}

function toClassList(classes = '') {

return Array.isArray(classes)

Filters blank and

empty strings

? classes.filter(isNotBlankOrEmptyString)

: classes.split(/(\s+)/)

.filter(isNotBlankOrEmptyString)

Splits the string on whitespace and

}

filters blank and empty strings

Now you need to write the isNotBlankOrEmptyString() function in the utils/strings.js file. This function takes a string as an argument; it returns true if the string is neither blank nor empty and false otherwise. Splitting that function is helpful. Use isNotEmptyString() to test whether a string is empty. isNotBlankOrEmptyString() uses

186

CHAPTER 8

The reconciliation algorithm: Patching the DOM

the former function, passing it a trimmed version of the string. Create a new file in the utils directory, name the file strings.js, and write the code for the isNotBlankOrEmptyString() function as shown in the following listing.

Listing 8.14

Filtering empty and blank strings (utils/strings.js)

export function isNotEmptyString(str) {

return str !== ''

}

export function isNotBlankOrEmptyString(str) {

return isNotEmptyString(str.trim())

}

PATCHING THE STYLE

Patching the style is similar to patching the attributes: you compare the old and new style objects (using the objectsDiff() function), and then you add the new or modified styles and remove the old ones. To set or remove styles, use the setStyle() and removeStyle() functions that you wrote in chapter 4.

Inside the patch-dom.js file, write the code for the patchStyle() function as shown in the following listing. Don’t forget to import the removeStyle() and setStyle() functions at the top of the file.

Listing 8.15

Patching the styles (patch-dom.js)

import {

removeAttribute,

setAttribute,

removeStyle,

setStyle,

} from './attributes'

import { destroyDOM } from './destroy-dom'

import { DOM_TYPES } from './h'

import { mountDOM } from './mount-dom'

import { areNodesEqual } from './nodes-equal'

import {

arraysDiff,

} from './utils/arrays'

import { objectsDiff } from './utils/objects'

import { isNotBlankOrEmptyString } from './utils/strings'

// --snip-- //

function patchStyles(el, oldStyle = {}, newStyle = {}) {

const { added, removed, updated } = objectsDiff(oldStyle, newStyle) for (const style of removed) {

removeStyle(el, style)

}

8.2

Patching the DOM

187

for (const style of added.concat(updated)) {

setStyle(el, style, newStyle[style])

}

}

PATCHING EVENT LISTENERS

Last, let’s implement the event-listeners patching. The patchEvents() function is a bit different in that it has an extra parameter, oldListeners (the second one), which is an object containing the event listeners that are currently attached to the DOM. Let’s see why having a reference to the current event listener is necessary.

If you recall, when you implemented the mountDOM() function, you wrote a function called addEventListener() to attach event listeners to the DOM. Currently, addEventListener() uses the function you pass it as the event listener, but remember that I said this situation would change. Indeed, in section 10.2, this function will wrap the event listener you pass it into a new function, and it’ll use that new function as an event listener, much like the following example:

function addEventListener(eventName, handler, el) {

// Function that wraps the original handler

function boundHandler(event) {

// -- snip -- //

handler(event)

}

el.addEventListener(eventName, boundHandler)

return boundHandler

}

You’ll understand soon why you want to do this. For now, know that the functions defined in the virtual DOM to handle events aren’t the same as the functions that are attached to the DOM. This situation is why the patchEvents() function needs the oldListeners object: the function is attached to the DOM. As you know, to remove an event listener from a DOM element, you call its removeEventListener() method, passing it the name of the event and the function that you want to remove, so you need the functions that were used to attach the event listeners to the DOM.

The second difference of the patchEvents() function compared with the previous ones is that it returns an object containing the event names and handler functions that have been added to the DOM. (The other functions didn’t return anything.) You save this object in the listeners key of the new virtual DOM node. Later, you use this listeners object to remove the event listeners from the DOM when the view is destroyed.

188

CHAPTER 8

The reconciliation algorithm: Patching the DOM

The rest of the job is a matter of using the objectsDiff() function to find out which event listeners have been added, modified, or removed and then calling the addEventListener() function and the el.removeEventListener() method as required.

In this case, though, when an event listener has been modified (that is, the event name is the same but the handler function is different), you need to remove the old event listener and then add the new one. Keep this detail in mind.

Pay attention to two important aspects of the code. First, you use the function in oldListeners[eventName] to remove the event listener, not the function in oldEvents[eventName]. I’ve already explained the reason, you must write this line of code correctly for your framework to work properly:

el.removeEventListener(eventName, oldListeners[eventName])

Second, you use your own implementation of the addEventListener() function to add the event listeners to the DOM, not the el.addEventListener() method of the DOM element. To remove the event listeners, you use the el.removeEventListener() method.

Write the code for the patchEvents() function as shown in the following listing.

Don’t forget to import the addEventListener function at the top of the file.

Listing 8.16

Patching the event listeners (patch-dom.js)

import {

removeAttribute,

setAttribute,

removeStyle,

setStyle,

} from './attributes'

import { destroyDOM } from './destroy-dom'

import { addEventListener } from './events'

import { DOM_TYPES } from './h'

import { mountDOM } from './mount-dom'

import { areNodesEqual } from './nodes-equal'

import {

arraysDiff,

} from './utils/arrays'

import { objectsDiff } from './utils/objects'

import { isNotBlankOrEmptyString } from './utils/strings'

// --snip-- //

function patchEvents(

el,

oldListeners = {},

oldEvents = {},

newEvents = {}

) {

Finds out which

event listeners

const { removed, added, updated } =

changed

objectsDiff(oldEvents, newEvents)

8.2

Patching the DOM

189

Removes removed or

modified event listeners

for (const eventName of removed.concat(updated)) {

el.removeEventListener(eventName, oldListeners[eventName])

}

const addedListeners = {}

Creates an object to store

the added event listeners

for (const eventName of added.concat(updated)) {

const listener =

addEventListener(eventName, newEvents[eventName], el)

Adds added or

addedListeners[eventName] = listener

modified event

}

listeners

return addedListeners

Returns the added

Saves the listener

}

event listeners

in the object

You’ve implemented the patchElement() function, which patched the DOM element and required four other functions to do so: patchAttrs(), patchClasses(), patchStyles(), and patchEvents(). Believe it or not, all you have to do now to have a complete implementation of the patchDOM() function is patch the children of a node.

8.2.6

Patching child nodes

Both element and fragment nodes can have children, so the patchDOM() function needs to patch the children arrays of both types of nodes. Patching the children means figuring out which children have been added, which ones have been removed, and which ones have been shuffled around. For this purpose, you implemented arraysDiffSequence() in chapter 7.

Figure 8.14 shows the patching of children in the algorithm’s flow chart. Remember that in the case of move and noop operations between children, the algorithm calls the patchDOM() function recursively, passing it the old and new children nodes. The add and remove operations terminate the algorithm.

Start by modifying the patchDOM() function to call the patchChildren() function after the switch statement. This function will execute the patchChildren() function for all types of elements except the text nodes, which return early from the patchDOM() function.

Modify the patchDOM() function by adding the code shown in bold in the following listing. This code imports arraysDiffSequence and ARRAY_DIFF_OP from the utils/arrays.js file and calls the patchChildren() function.

190

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Start

Old node

New node

No

Yes

Equal?

Text

El ment

e

Fr gment

a

node?

destroyDOM()

no e?

d

no e?

d

mountDOM()

patchText()

patchAttrs()

patchClasses()

End

End

Patching the child nodes

patchStyles()

patchEvents()

End

Has

No

Yes

children?

Add?

mountDOM()

Remove?

destroyDOM()

End

patchChildren()

For

each

Move?

Move node

Noop?

Figure 8.14

Patching the children of a node

Listing 8.17

Patching the children of a node (patch-dom.js)

import {

removeAttribute,

setAttribute,

removeStyle,

setStyle,

} from './attributes'

import { destroyDOM } from './destroy-dom'

import { addEventListener } from './events'

import { DOM_TYPES } from './h'

import { mountDOM } from './mount-dom'

import { areNodesEqual } from './nodes-equal'

import {

arraysDiff,

arraysDiffSequence,

8.2

Patching the DOM

191

ARRAY_DIFF_OP,

} from './utils/arrays'

import { objectsDiff } from './utils/objects'

import { isNotBlankOrEmptyString } from './utils/strings'

export function patchDOM(oldVdom, newVdom, parentEl) {

if (!areNodesEqual(oldVdom, newVdom)) {

const index = findIndexInParent(parentEl, oldVdom.el)

destroyDOM(oldVdom)

mountDOM(newVdom, parentEl, index)

return newVdom

}

newVdom.el = oldVdom.el

switch (newVdom.type) {

case DOM_TYPES.TEXT: {

patchText(oldVdom, newVdom)

return newVdom

}

case DOM_TYPES.ELEMENT: {

patchElement(oldVdom, newVdom)

break

}

}

patchChildren(oldVdom, newVdom)

return newVdom

}

// TODO: implement patchChildren()

The patchChildren() function extracts the children arrays from the old and new nodes (or uses an empty array in their absence) and then calls the arraysDiffSequence() function to find the operations that transform the old array into the new one. (Remember that this function requires the areNodesEqual() function to compare the nodes in the arrays.) Then, for each operation, it performs the appropriate patching.

First, you need a function—which you can call extractChildren()—that extracts the children array from a node in such a way that if it encounters a fragment node, it extracts the children of the fragment node and adds them to the array. This function needs to be recursive so that if a fragment node contains another fragment node, it also extracts the children of the inner fragment node.

Inside the h.js file, where the virtual node-creation functions are defined, write the code for the extractChildren() function as shown in the following listing.

192

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Listing 8.18

Extracting the children of a node (h.js)

export function extractChildren(vdom) {

if (vdom.children == null) {

If the node has no

return []

children, returns

}

an empty array

const children = []

for (const child of vdom.children) {

Iterates over the children

if (child.type === DOM_TYPES.FRAGMENT) {

children.push(...extractChildren(child, children))

If the child

} else {

is a fragment

children.push(child)

Otherwise, adds

node, extracts

}

the child to the

its children

}

array

recursively

return children

}

With this function ready, you can write the code for the patchChildren() function, shown in the following listing. Don’t forget to import the extractChildren() function at the top of the file.

Listing 8.19

Implementing the patchChildren() function (patch-dom.js)

import {

removeAttribute,

setAttribute,

removeStyle,

setStyle,

} from './attributes'

import { destroyDOM } from './destroy-dom'

import { addEventListener } from './events'

import { DOM_TYPES } from './h'

import { mountDOM, extractChildren } from './mount-dom'

import { areNodesEqual } from './nodes-equal'

import {

arraysDiff,

arraysDiffSequence,

ARRAY_DIFF_OP,

} from './utils/arrays'

import { objectsDiff } from './utils/objects'

import { isNotBlankOrEmptyString } from './utils/strings

// --snip-- //

Extracts the old

children array or

function patchChildren(oldVdom, newVdom) {

uses an empty array

const oldChildren = extractChildren(oldVdom)

8.2

Patching the DOM

193

const newChildren = extractChildren(newVdom)

Extracts the new children

const parentEl = oldVdom.el

array or uses an empty array

const diffSeq = arraysDiffSequence(

Finds the operations to transform

oldChildren,

the old array into the new one

newChildren,

areNodesEqual

)

Iterates over

the operations

for (const operation of diffSeq) {

const { originalIndex, index, item } = operation

switch (operation.op) {

Switches on the

case ARRAY_DIFF_OP.ADD: {

operation type

// TODO: implement

}

case ARRAY_DIFF_OP.REMOVE: {

// TODO: implement

}

case ARRAY_DIFF_OP.MOVE: {

// TODO: implement

}

case ARRAY_DIFF_OP.NOOP: {

// TODO: implement

}

}

}

}

All that’s left to do is fill in the switch statement with the code for each operation (where the // TODO comments are). This task is simpler than you think. Let’s start with the ARRAY_DIFF_OP.ADD operation.

THE ADD OPERATION

When a new node is added to the children array, it’s like mounting a subtree of the DOM at a specific place. Thus, you can simply use the mountDOM() function, passing it the index at which the new node should be inserted. Figure 8.15 shows that when a node addition is detected (an operation of type ARRAY_DIFF_OP.ADD), it’s inserted into the DOM.

Write the code shown in bold in the following listing to implement the first case of the switch statement.

194

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Old vdom

New vdom

<form>

<form>

<input>

<button>

<input>

<p>

<button>

Text

Text

Text

"..."

"..."

"..."

DOM

<form>

<input>

<p>

Add node.

<button>

</form>

Figure 8.15

Adding a node to the children array

Listing 8.20

Patching the children by adding a node (patch-dom.js)

function patchChildren(oldVdom, newVdom) {

// --snip-- //

for (const operation of diffSeq) {

const { from, index, item } = operation

switch (operation.op) {

case ARRAY_DIFF_OP.ADD: {

mountDOM(item, parentEl, index)

break

}

case ARRAY_DIFF_OP.REMOVE: {

// TODO: implement

}

case ARRAY_DIFF_OP.MOVE: {

// TODO: implement

}

case ARRAY_DIFF_OP.NOOP: {

// TODO: implement

}

}

}

}

8.2

Patching the DOM

195

THE REMOVE OPERATION

Next comes the ARRAY_DIFF_OP.REMOVE operation. When a node is removed from the children array, you want to unmount it from the DOM. Thanks to the destroyDOM() function you wrote in chapter 4, this task is easy. Figure 8.16 illustrates removing a node from the children array (an operation of type ARRAY_DIFF_OP.REMOVE) and shows how it’s removed from the DOM.

Old vdom

New vdom

<form>

<form>

<input>

<p>

<button>

<input>

<p>

<button>

Text

Text

Text

Text

"..."

"..."

"..."

"..."

DOM

<form>

<input>

Remove node.

<button>

</form>

Figure 8.16

Removing a node from the children array

Write the code shown in bold in the following listing.

Listing 8.21

Patching the children by removing a node (patch-dom.js)

function patchChildren(oldVdom, newVdom) {

// --snip-- //

for (const operation of diffSeq) {

const { from, index, item } = operation

switch (operation.op) {

case ARRAY_DIFF_OP.ADD: {

mountDOM(item, parentEl, index)

break

}

case ARRAY_DIFF_OP.REMOVE: {

destroyDOM(item)

break

}

196

CHAPTER 8

The reconciliation algorithm: Patching the DOM

case ARRAY_DIFF_OP.MOVE: {

// TODO: implement

}

case ARRAY_DIFF_OP.NOOP: {

// TODO: implement

}

}

}

}

THE MOVE OPERATION

The ARRAY_DIFF_OP.MOVE operation is a bit more nuanced. When you detect that a node has moved its position in the children array, you have to move it in the DOM as well. To do so, you need to grab the reference to the DOM node and use its insertBefore() method to move it to the new position. The insertBefore() method requires a reference to a DOM node that will be the next sibling of the node you’re moving; you need to find the node that’s currently at the desired index.

NOTE

The browser automatically removes the node from its original position when you move it. You won’t end up with the same node in two places.

After moving the node, you want to pass it to the patchDOM() function to patch it. Nodes stay in the DOM from one render to the next, so they may not only have moved around, but also changed in other ways (had a CSS class added to them, for example).

Figure 8.17 shows a node moving its position. Here, when a node moves inside its parent node’s children array (an operation of type ARRAY_DIFF_OP.MOVE), the same movement is replicated in the DOM.

Old vdom

New vdom

<form>

<form>

<input>

<p>

<button>

<p>

<input>

<button>

Text

Text

Text

Text

Move nodes.

"..."

"..."

"..."

"..."

DOM

<form>

<p>

Move nodes.

<input>

<button>

</form>

Figure 8.17

Moving a node inside the children array

8.2

Patching the DOM

197

Write the code shown in bold in the following listing.

Listing 8.22

Patching the children by moving a node (patch-dom.js)

function patchChildren(oldVdom, newVdom) {

// --snip-- //

for (const operation of diffSeq) {

const { from, index, item } = operation

switch (operation.op) {

case ARRAY_DIFF_OP.ADD: {

mountDOM(item, parentEl, index)

break

}

case ARRAY_DIFF_OP.REMOVE: {

destroyDOM(item)

Gets the old

break

Gets the new virtual

virtual node at

node at the new index

}

the original index

case ARRAY_DIFF_OP.MOVE: {

Gets the

DOM element

const oldChild = oldChildren[originalIndex]

associated with

const newChild = newChildren[index]

the moved node

const el = oldChild.el

const elAtTargetIndex = parentEl.childNodes[index]

Finds the

element at the

parentEl.insertBefore(el, elAtTargetIndex)

target index

patchDOM(oldChild, newChild, parentEl)

inside the

parent

break

element

}

Inserts the moved

case ARRAY_DIFF_OP.NOOP: {

element before the

// TODO: implement

target element

}

}

Recursively patches

}

the moved element

}

THE NOOP OPERATION

The last operation is the ARRAY_DIFF_OP.NOOP operation. If you recall, some of the child nodes may not have moved, or they may have moved because other nodes were added or removed around them (a case that I call natural movements). You don’t need to move these nodes explicitly because they fall into their new positions naturally. But you do need to patch them because they may have changed in other ways, as noted earlier. Write the code shown in bold in the following listing.

Listing 8.23

Patching the children with a noop operation (patch-dom.js)

function patchChildren(oldVdom, newVdom) {

// --snip-- //

198

CHAPTER 8

The reconciliation algorithm: Patching the DOM

for (const operation of diffSeq) {

const { from, index, item } = operation

switch (operation.op) {

case ARRAY_DIFF_OP.ADD: {

mountDOM(item, parentEl, index)

break

}

case ARRAY_DIFF_OP.REMOVE: {

destroyDOM(item)

break

}

case ARRAY_DIFF_OP.MOVE: {

const el = oldChildren[from].el

const elAtTargetIndex = parentEl.childNodes[index]

parentEl.insertBefore(el, elAtTargetIndex)

patchDOM(oldChildren[from], newChildren[index], parentEl)

break

}

case ARRAY_DIFF_OP.NOOP: {

patchDOM(oldChildren[originalIndex], newChildren[index], parentEl) break

}

}

}

}

That’s it. You’ve implemented the reconciliation algorithm. Most remarkably, you’ve done the implementation from scratch. Next, you’ll publish the new, improved version of your framework to see it in action in the TODOs application.

8.3

Publishing the framework’s new version

In chapters 6 and 7, you implemented the patchDOM() function—the reconciliation algorithm. The job took quite a lot of code, but you did it. As a result, your framework can figure out what changed between two virtual DOM trees and patch the real DOM

tree accordingly. Now is a good time to publish a new version of your framework that you can use in the TODOs application. First, bump the version of the runtime package by incrementing the version field in the package.json file to 2.0.0:

{

"version": "1.0.0",

"version": "2.0.0",

}

Then run the npm publish command to publish the new version of the package.

That’s it! Your new and improved version of the framework is available on NPM and unpkg.com.

8.4

The TODOs application

199

8.4

The TODOs application

It’s time to put your improved framework to use. The nice thing is that the public API of your framework hasn’t changed, so you can use the TODOs application code from chapter 6.

Clone the examples/ch06/todos directory containing the code of the TODOs app into examples/ch08/todos. You can do this job in your preferred way, but if you’re using a UNIX-like operating system, the simplest way is to run the following commands:

$ mkdir examples/ch08

$ cp -r examples/ch06/todos examples/ch08/todos

Now the only line you need to change is the first line in the index.js file, where you import your framework. Change it to import the new version of the framework from unpkg.com:

import { createApp, h, hFragment } from 'https://unpkg.com/<fwk-name>@1'

import { createApp, h, hFragment } from 'https://unpkg.com/ <fwk-name> @2'

With that simple change, your TODOs application is using the new version of your framework. You can serve the application by running the following command at the root of the project:

$ npm run serve:examples

Open the application in your browser at http://localhost:8080/ch08/todos/todos

.html. Everything should work exactly as it did in chapter 6, but this time, when you type in the input field, its focus is preserved, as the DOM isn’t re-created every time you type a character. How can you make sure that your algorithm isn’t doing extra work—that it’s patching only what changed?

8.4.1

Inspecting the DOM tree changes

An interesting way to check whether the reconciliation algorithm is working as expected is to open the browser’s developer tools and inspect the DOM tree. (Use the Inspector tab in Mozilla Firefox or the Elements tab in Google Chrome.) This panel in the developer tools shows the DOM tree of the page you’re currently viewing, and when a node changes, it’s highlighted with a flashing animation.

If you open the TODOs application from chapter 6, where the DOM was re-created every time, and type a character in the input field, you’ll see that the entire DOM

tree is highlighted (figure 8.18). If you followed the folder-naming conventions in appendix A, your application from chapter 6 should be running at http://localhost

:8080/examples/ch06/todos/todos.html.

If you open the TODOs application from this chapter and type a character in the input field, nothing is highlighted because the DOM tree didn’t change—only the value

200

CHAPTER 8

The reconciliation algorithm: Patching the DOM

All the elements change.

Figure 8.18

With your previous version of the framework, the entire DOM tree is highlighted when you type a character in the input field.

property of the input field. But after you type three characters, the disabled attribute is removed from the Add <button>, and you see this node flashing in the DOM tree (figure 8.19).

Only the button changes (by removing

the disabled attribute) when the text

in the input reaches a length of three

characters.

Figure 8.19

With the new version of your framework, nothing is highlighted when you type a character in the input field until you type three characters, at which point the disabled attribute is removed from the Add <button> .

8.4

The TODOs application

201

8.4.2

Using the paint-flashing tool (Chrome only)

Chrome has a neat feature that allows you to see the areas of the page that are repainted. You can find this feature on the Rendering tab of the developer tools; it’s called paint flashing (). If you select it, green rectangles highlight the parts of the page that the browser repaints. Repeating the experiment by looking at the TODOs application from chapter 6, you see that the entire page is repainted every time you type a character (figure 8.20).

Figure 8.20

With your previous version of the

framework, the entire page is repainted every

time you type a character in the input field.

But if you look at the TODOs application from this chapter, you see that only the input field’s text and the field’s label are repainted (figure 8.21).

Figure 8.21

With the new version of your

framework, only the input field’s text and

the field’s label are repainted when you

type a character in the input field.

The fact that the New TODO label is repainted is a bit surprising, but it doesn’t mean that the framework patched something it shouldn’t have. As you saw in the DOM tree, nothing flashes there as you type in the input field. These “paint flashes” are a sign that the rendering engine is doing its job, figuring out the layout of the page and repainting the pixels that might have moved or changed. You have nothing to worry about.

Experiment a bit with the developer tools to understand how your framework patches the DOM. The exercises that follow give you some idea of the experiments you can do.

202

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Exercise 8.3

Use the Elements tab in Chrome (or the Inspector tab in Firefox) to inspect the DOM

modifications (shown as flashes in the nodes) that occur when you add, modify, or mark a to-do item as completed. Compare what happens in the TODOs application from chapter 6 and the application from this chapter.

Find the solution

Exercise 8.4

Use the Sources tab in Chrome (or the Debugger tab in Firefox) to debug the patchDOM() function (you can set a breakpoint in the first line of the function) as you

 Type a character in the input field.

 Add a new to-do item.

 Mark a to-do item as completed.

 Edit the text of a to-do item.

Find the solution

Summary

 The reconciliation algorithm compares two virtual DOM trees, finds the sequence of operations that transform one into the other, and patches the real DOM by applying those operations to it. The algorithm is recursive, and it starts at the top-level nodes of both virtual DOM trees. After comparing these nodes, it moves on to their children until it reaches the leaves of the trees.

 To find out whether a DOM node can be reused, compare the corresponding virtual DOM nodes. If the virtual nodes are different types, the DOM node can’t be reused. If the virtual nodes are text or fragment nodes, they can be reused.

Element nodes can be reused if they have the same tag name.

 When the nodes being compared aren’t equal—that is, they can’t be reused—

the DOM is destroyed and re-created from scratch. This operation makes sense in most cases because if the parent node of a subtree changes, it’s likely that the children are different as well.

 You patch text nodes by setting the DOM node’s nodeValue property to the new node’s text.

 You patch element nodes by separately patching their attributes, CSS classes and styles, and event listeners. You use the objectsDiff() and arraysDiff()

Summary

203

functions to find the differences between the old and new values of these properties; then you apply the changes to the DOM.

 Both fragment and element nodes need to have their children patched. To patch the children of a node, you use the arraysDiffSequence() to find the sequence of operations that transform one array of children into the other; then you apply those operations to the DOM.

Part 3

Improving the framework

The groundwork has been laid, and your framework can successfully render a website based on a virtual DOM representation while keeping it in sync as users interact with it. But let’s be honest: having the entire application’s state confined to a single object managed by the application isn’t the most practical approach.

Wouldn’t it be more efficient if each component could take charge of its own piece of the state, focusing solely on the view it oversees?

In this final part of the book, you’ll delve into the world of stateful components—components that autonomously manage their own state and lifecycles.

They are responsible exclusively for the view they oversee and can be combined to create more intricate views. This enhancement empowers the application to bypass the reconciliation algorithm for every change, updating only the components that are affected by the modification.

You’ll explore how stateful components can incorporate other components within their view and establish communication among them. You’ll also implement a scheduler that enables the asynchronous execution of component lifecycle hooks—a potent feature that a robust framework should offer. Last but not least, you’ll master the art of performing thorough unit testing on components defined within your framework, even when they involve asynchronous behavior.

Stateful components

This chapter covers

 Understanding the anatomy of a stateful

component

 Implementing a factory function to define

components

 Implementing the first version of a component

that manages its own state

Your framework works well; it manipulates the Document Object Model (DOM) so that the developer doesn’t need to, and it patches the DOM efficiently thanks to the reconciliation algorithm. But using stateless components (pure functions) forced us to move all the state to the top of the application along with the reducers.

This approach isn’t ideal for several reasons:

 A component deep down in the hierarchy needs all the components above it to pass the state down to it, even when they don’t need it. As the number of levels increases, the number of components that need to pass the state down to their children gets unwieldy.

207

208

CHAPTER 9

Stateful components

 As the application grows, the state gets bigger, and the amount of reducers increases as well. Having all these reducers together in a single place can become a bit messy.

 When a component updates the state, all the application is re-rendered, even if the component that updated the state is deep down in the hierarchy. Your reconciliation algorithm traverses the entire application’s virtual DOM (vdom) every time the state changes.

To address these problems, you can have your components handle their own state—

that is, they can update their internal state and adjust their view accordingly when the state changes. By breaking the state into smaller parts at the component level, you avoid having a massive state object with numerous reducers at the application level.

Smaller state objects within components are more focused and simpler to understand.

Consequently, when a component’s state changes, only that specific component needs to be re-rendered, not the entire application. To create an application with your current framework, you have to pass the createApp() function a state object, the reducers object, and a root component (figure 9.1).

State

View

Reducers

reduce( , ) =

createApp({ state, view, reducers })

Figure 9.1

The current framework’s application instance requires the global state, reducers, and the root component.

The plan for the new version of the framework (version 3.0) is to have components that manage their own state and patch only the part of the DOM for which they are responsible. The application will be represented by a hierarchy of components, and the root component—that one at the top of the hierarchy—is passed to the createApp() function (figure 9.2). As you’ll see in this chapter, components won’t use the reducers system of handling the state; we’ll go for a simpler approach.

209

The root component, the one at the top of

the hierarchy, is passed to createApp().

Component

State

View

Component

Component

State

View

State

View

createApp(RootComponent)

Figure 9.2

The new version of the framework’s application instance requires only the root component.

With these changes (which will take four chapters to complete), you’re getting closer to the framework’s final architecture. You saw that architecture in chapter 1; it’s reproduced here in figure 9.3.

Before you start writing the code, let’s take a look at the anatomy of the stateful component you’ll implement—but only the parts that are relevant to this chapter.

You have many ways to implement a stateful component, but you’ll go for a simple yet powerful approach. Explaining the parts of a component will help you understand the code you’ll write.

NOTE

You can find all the listings in this chapter in the listings/ch09 directory of the book’s repository ( The code you write in this chapter is for the framework’s version v3.0, which you’ll publish in chapter 12. Therefore, the code in this chapter can be checked out from the ch12

label ): $ git switch --detach ch12.

Code catch-up

In chapter 8, you modified the mountDOM() function to insert nodes at a specific index in the child list of a parent node. You implemented a function called insert() to do the insertion. You modified the createTextNode() and createElementNode() functions to receive an index as their third parameter, enabling these functions to use the insert() function for appending nodes to the DOM. Then you implemented a function called areNodesEqual() to check whether two virtual DOM nodes are equal. This function is used by the patchDOM() function, which you implemented next.

210

CHAPTER 9

Stateful components

(continued)

The patchDOM() function—depending on the type of the virtual DOM node—called the patchText() or patchElement() functions to patch the DOM. The patchElement() function required you to break the logic into subfunctions: patchAttrs(), patchClasses(), patchStyles(), and patchEvents().

patchDOM() is recursive and calls itself to patch the children of the node it’s patching.

You implemented the work of patching the children in the patchChildren() function.

Browser

Render

Updates

cycle

Parent element

Application

Component

Private

Private

isMounted

component

vdom

parentEl

hostEl

parentComponent

riggers

Starts

Public

dispatcher

T

subscriptions

mount(parentEl)

unmount()

patch()

Public

props

state

Dispatcher

get elements

get firstElement

subscribe(name, handler)

get offset

afterEveryCommand(handler)

dispatch(name, payload)

updateProps(props)

updateState(state)

async onMounted()

async onUnmounted()

emit(eventName, payload)

render()

mount(hostEl, index)

unmount()

<<custom methods>>

Figure 9.3

The component in the framework’s architecture

9.1

Anatomy of a stateful component

211

9.1

Anatomy of a stateful component

Let’s extract the component from figure 9.3 and analyze its anatomy: the properties and methods of a component. Our discussion is based on figure 9.4. A stateful component can’t be a pure function anymore because the output of pure functions depends exclusively on their arguments; hence, they can’t maintain state. (For a discussion of how React’s stateful functional components work, see the nearby sidebar.) Anatomy of a stateful component

Whether the component

Component

is mounted

private

isMounted

The currently rendered vdom

vdom

hostEl

Host element where the

parentComponent

component is mounted

dispatcher

subscriptions

Re-renders the vdom and

patches the changes in the DOM

patch()

public

External data passed by the

props

parent component

List of DOM elements mounted

state

by the component

State of the component (data

get elements

First DOM element mounted

internal to the component)

get firstElement

by the component

get offset

Offset of the first DOM element

updateProps(props)

inside the host element

updateState(state)

Updates the state and triggers

async onMounted()

a render cycle

async onUnmounted()

emit(eventName, payload)

Renders the component’s vdom

Mounts the component into the

render()

based on the current props

DOM at the specified index

mount(hostEl, index)

and state

unmount()

Unmounts the component from

the DOM and removes all event

<<custom methods>>

handlers

Figure 9.4

The anatomy of a stateful component

DEFINITION

A stateful component maintains its own state. Stateful components can be instantiated, with each instance having a separate state and lifecycle. A stateful component updates its view when its state changes.

212

CHAPTER 9

Stateful components

React’s functional stateful components

React once used classes to define components:

import { Component } from 'react'

class Greeting extends Component {

render() {

return <h1>Hello, {this.props.name}!</h1>

}

}

Those classes can include a state property that can be updated by the setState() method. We’ll follow a similar approach. But React now encourages the use of functions (functional components) instead because they’re convenient:

function Greeting(props) {

return <h1>Hello, {props.name}!</h1>

}

As you can see, the component is the equivalent of implementing the render() method of the class. But, if to have stateful components we need to move away from pure functions, how can React make functional components stateful? The answer is that functional components in React aren’t pure functions (which is totally okay!) because to declare state, you need to use the useState() hook:

function Counter() {

const [count, setCount] = useState(0)

return (

<div>

<p>You clicked {count} times</p>

<button onClick={() => setCount(count + 1)}>Click me!</button>

</div>

)

}

The useState() hook, used to maintain state inside a functional component, has side effects. In this context, a side effect occurs when a function modifies something outside its scope. In this case, useState() is clearly storing the component’s state somewhere; thus, the component function where it’s used can’t be a pure function.

A pure function is deterministic: given the same input, it always returns the same output and doesn’t have any side effects. But you clearly don’t get the same result every time you call the component’s function because some state is maintained somewhere, and based on that state, the component returns a different virtual DOM.

When you use the useState() hook, React stores the state of that component inside an array at application level. By keeping track of the position of the state of each component, React can update the state of the component when you call the setState() method and get you the correct value when you ask for it.

9.1

Anatomy of a stateful component

213

You can find the source code for hooks at , in case you want to take a look. The ReactFiberHooks.js file inside the react-reconciler package (

is also worth looking at. The mountState<S>() function implements the functionality behind the useState() hook.

Let me finish this discussion by saying that what React has achieved with functional components and hooks is simply brilliant—the fruit of talented engineers working on designing a better way to build user interfaces. Kudos to them!

Here are the four main characteristics of the component system that you’ll implement:

 The view of a component depends on its props and state. Every time one of these changes, the view needs to be patched.

 Components can have other components as children as part of their view’s virtual DOM.

 A component can pass data to its children, and the child components receive this data as props.

 A component can communicate with its parent component by emitting events to which the parent component can listen.

We’ll focus on the first point in this chapter and leave the communication between components for chapter 10. Let’s take a look at figure 9.4 and briefly discuss the properties and methods of a stateful component that you’ll implement in this chapter.

The figure isn’t exhaustive, but it covers the most important properties and methods of a component. Some of the components are grayed out here; you’ll implement them in the following chapters.

9.1.1

The properties of a stateful component

The public properties of a stateful component (simply called component from now on) are

 props—The data that the component receives from its parent component

 state—The data that the component maintains internally

Even though these properties are public, they shouldn’t be modified directly. Why not? If they’re modified directly, the component won’t know that they have changed, so it won’t update its view. You’ll use the updateState() method to update the state of a component and updateProps() for the props; we’ll discuss these methods in section 9.1.2 and chapter 10, respectively. Apart from updating the state and props, the methods trigger the re-rendering of the component. A component also has three interesting get-only properties:

 elements—A list of the DOM elements mounted by the component. When the top node of the view of a component is a fragment, this list consists of the DOM

214

CHAPTER 9

Stateful components

elements mounted by the component’s children. If the component has a single root node, the list has a single element.

 firstElement—The first element of the elements list.

 offset—The offset of the firstElement inside the component’s host element in the DOM. You need this offset to patch the component’s DOM correctly, as you’ll see in section 9.3.3.

The component also has three private properties:

 isMounted—A Boolean that indicates whether the component is mounted

 vdom—The virtual DOM of the component that’s currently rendered in the DOM

 hostEl—The element in the DOM where the component is mounted

9.1.2

The methods of a stateful component

Let’s take a look at the methods of a component. A component has two methods that you’ll use to mount and unmount it:

 mount()—Mounts the component in the DOM

 unmount()—Unmounts the component from the DOM and unsubscribes all the subscriptions

To update the state that the component maintains internally, use updateState(), which also triggers the re-rendering of the component. This method should be called inside the component whenever the state of the component changes.

As you’ll see in this chapter and the following chapters, the state of a component can be the props of its children components; a component can pass its internal state to its children components as props. When new props are passed to a component, the component should re-render its view. To that end, the component uses two methods:

 render()—Returns the virtual DOM of the component, based on the current props and state of the component.

 patch()—Patches the DOM of the component with the new virtual DOM. This private method is called as part of updating the state or props of the component, but it isn’t meant to be called from outside the component.

The best way to understand how this component system works is to implement it, so let’s get started. We’ll start simply and add features as we go. Warm up your code editor and stretch your fingers, because you’ll write a lot of code in this chapter!

9.2

Components as classes

Start by creating a new file inside the src/ folder called component.js. We’ll write all the code in this section in this file. Now think about how you can define a component. You want to be able to instantiate components, and each component instance

9.2

Components as classes

215

should have its own state. Therefore, you can use classes to define the component like so (you don’t need to write this code yet; you’ll do that below): class Component {}

Now you can instantiate the component (new Component()), but it doesn’t do anything . . . yet. You want components to be able to render their view, and for that purpose, they need a render() method:

class Component {

render() {

// return the virtual DOM of the component

}

}

But here comes the first complication. Each component that the user defines is different; each render() method returns a different virtual DOM. In the TODOs app you wrote in chapter 8, for example, the CreateTodo() and TodoList() components return different virtual DOMs (which makes sense, right?). So how do you define the render() method of the Component class when you don’t know what the virtual DOM of the component will be beforehand? The solution is to define the defineComponent() function, which takes an object containing a render() function that creates the virtual DOM of that specific component and returns the component class:

export function defineComponent({ render }) {

class Component {

render() {

return render()

}

}

return Component

}

The defineComponent() function returns a class, so you can have as many component classes as you want simply by defining how each component renders its view: const CreateTodo = defineComponent({

render() {

// return the virtual DOM of the component

}

})

const TodoList = defineComponent({

render() {

// return the virtual DOM of the component

}

})

216

CHAPTER 9

Stateful components

In a sense, defineComponent() is a factory function that, given a render() function, creates a component class that uses that function to render its view. The returned component class can be instantiated as many times as you want, and all of its instances render the same view (figure 9.5). When we add state to the component instance, each component might render a different—although similar—view because as you know, the view of a component depends on its state.

Component

render()

render()

mount()

unmount()

defineComponent()

The function is a factory that, given a render

function, creates a component class that can

be instantiated and renders the same view.

Figure 9.5

The defineComponent() factory function creates component classes.

The component also needs mount() and unmount() methods so that their view can be added to the DOM and removed from it, so let’s add those methods to the Component class. Complete the Component class with the code in the following listing.

Listing 9.1

Basic component methods (component.js)

import { destroyDOM } from './destroy-dom'

The defineComponent()

import { mountDOM } from './mount-dom'

function takes an object

containing a render() function

export function defineComponent({ render }) {

and returns a component.

class Component {

Defines the

#vdom = null

Component as a class

#hostEl = null

The component’s render() method

render() {

returns its view as a virtual DOM.

return render()

}

Calls the render() method

and saves the result in the

mount(hostEl, index = null) {

#vdom private property

this.#vdom = this.render()

mountDOM(this.#vdom, hostEl, index)

Calls the mountDOM() function

to mount the component’s view

9.2

Components as classes

217

this.#hostEl = hostEl

}

unmount() {

destroyDOM(this.#vdom)

Calls the destroyDOM()

function to unmount the

this.#vdom = null

component’s view

this.#hostEl = null

}

}

Returns the

Component class

return Component

}

Great! But you could mount the same instance of a component multiple times, as follows const component = new MyComponent()

component.mount(document.body)

component.mount(document.body) // Same component mounted twice!

or unmount it before it is mounted:

const component = new MyComponent()

component.unmount() // Unmounting a component that's not mounted!

To prevent these situations, you need to keep track of the mounting state of the component. Add an #isMounted private property to the Component class and throw an error if you detect the aforementioned situations. Write the code shown in bold in the following listing.

Listing 9.2

Checking whether the component is already mounted (component.js)

export function defineComponent({ render }) {

class Component {

#isMounted = false

The #isMounted private

#vdom = null

property keeps track of

#hostEl = null

the component’s state.

render() {

return render()

}

A component can’t

be mounted more

mount(hostEl, index = null) {

than once.

if (this.#isMounted) {

throw new Error('Component is already mounted')

}

this.#vdom = this.render()

mountDOM(this.#vdom, hostEl, index)

When the component

is mounted, sets the

#isMounted property

this.#hostEl = hostEl

to true

this.#isMounted = true

}

218

CHAPTER 9

Stateful components

unmount() {

if (!this.#isMounted) {

A component can’t

throw new Error('Component is not mounted')

be unmounted if

}

it’s not mounted.

destroyDOM(this.#vdom)

When the component

this.#vdom = null

is unmounted, sets

the #isMounted

this.#hostEl = null

property to false

this.#isMounted = false

}

}

return Component

}

This code is a great start, but where’s the state of the component? So far, the Component class doesn’t have any state or external props, so the render() method always returns the same virtual DOM (unless it depends on global data defined outside the component, which it shouldn’t). Let’s add the state and props to the component.

Exercise 9.1

Using the defineComponent() function and the code it depends on (the mountDOM() and destroyDOM() functions), create a component that renders the following HTML:

<h1>Important news!</h1>

<p>I made myself coffee.</p>

<button>Say congrats</button>

The button should print the following message in the console when clicked: Good for you! Mount the component inside the web page of your choice (such as your local newspaper’s website). Click the button, and check whether the message is printed in the console. Last, unmount the component from the DOM, and verify that the component’s HTML is removed from the page.

Find the solution

9.3

Components with state

The components that the defineComponent() function creates are static; they always render the same virtual DOM. You haven’t implemented the state of the component yet, so the render() method inside your component can’t return different virtual DOMs. What you want to do is pass defineComponent(), a function that returns the initial state of the component (figure 9.6).

9.3

Components with state

219

Component

render()

state = state()

state()

render()

mount()

defineComponent()

unmount()

The initial state is created by calling

the passed-in state function.

Figure 9.6

The defineComponent() factory function uses the state() function to create the initial state of the component.

Inside the component’s constructor, you can call the state() function to create the initial state of the component and save it in the state property (you don’t need to write this code yet; you’ll do that below):

class Component {

constructor() {

this.state = state()

}

}

Here’s how you’d define the state() function that returns the initial state for a component that keeps track of the number of times a button is clicked: const Counter = defineComponent({

state() {

return { count: 0 }

},

render() { ... }

})

You may want to be able to set the initial state of a component’s instance when you instantiate it. Maybe you don’t want all instances of a component to have the same initial state. Consider the Counter component in the preceding example. By default, the count starts at 0, but you may want to start the count of a specific instance at 10. To sat-isfy this need, you can pass the state() function the props passed to the component’s constructor so that the initial state can be based on the props:

class Component {

constructor(props = {}) {

this.state = state(props)

}

}

220

CHAPTER 9

Stateful components

In the counter example, you could do something like this:

const Counter = defineComponent({

state(props) {

return { count: props.initialCount ?? 0 }

},

render() { ... }

})

const props = { initialCount: 10 }

const counter = new Counter(props)

Add the code shown in bold in the following listing to implement the component’s initial state and props.

Listing 9.3

Adding the state() method (component.js)

export function defineComponent({ render, state }) {

defineComponent() takes

class Component {

an object with a state()

#isMounted = false

function to create the

#vdom = null

initial state.

#hostEl = null

constructor(props = {}) {

this.props = props

The component can be

this.state = state ? state(props) : {}

instantiated with an object

}

containing the props.

render() {

return render()

The state() function

}

returns the initial state

of the component based

mount(hostEl, index = null) {

on the props.

// --snip-- //

}

unmount() {

// --snip-- //

}

}

return Component

}

Now the component has both state and props. You’ve used the props to initialize the state of the component, but now you’ll leave the props aside and focus on the state.

I’ll come back to props in chapter 10. In section 9.3.1, you’ll implement the updateState() function to update the state of the component and patch the DOM with the new view.

9.3

Components with state

221

9.3.1

Updating the state and patching the DOM

The state of the component evolves as the user interacts with it. Thus, you want to implement a function that updates the state of the component by merging the passed state with the current state. The code looks something like this:

this.state = { ...this.state, ...newState }

This way, those pieces of the state that aren’t updated are preserved.

After the state has been updated, the DOM needs to be patched with the new virtual DOM. Patching the DOM should be handled by a different method, #patch(), in the component. This process involves two steps:

1

Call the component’s render() method to get the new virtual DOM based on the new state.

2

Call the patchDOM() function to patch the DOM with the old and new virtual DOM trees. Save the result of the patchDOM() function in the #vdom property of the component.

A caveat: the component can’t be patched if it’s not mounted. Keep this fact in mind when implementing the #patch() method. Write the code shown in bold in the following listing to implement the updateState() and #patch() methods.

Listing 9.4

Update the state and the component’s DOM (component.js)

import { destroyDOM } from './destroy-dom'

import { mountDOM } from './mount-dom'

import { patchDOM } from './patch-dom'

export function defineComponent({ render, state }) {

class Component {

#isMounted = false

#vdom = null

#hostEl = null

constructor(props = {}) {

this.props = props

this.state = state ? state(props) : {}

}

updateState(state) {

Merges the new state

this.state = { ...this.state, ...state }

with the current state

this.#patch()

Calls the #patch()

}

method to reflect the

render() {

changes in the DOM

return render()

}

mount(hostEl, index = null) {

// --snip-- //

}

222

CHAPTER 9

Stateful components

unmount() {

// --snip-- //

}

If the component is not

mounted, the DOM

#patch() {

can’t be patched.

if (!this.#isMounted) {

throw new Error('Component is not mounted')

}

Calls the render() method

to get the new virtual DOM

const vdom = this.render()

this.#vdom = patchDOM(this.#vdom, vdom, this.#hostEl)

}

}

Calls the patchDOM() function to

patch the DOM and saves the result

return Component

in the #vdom property

}

Great! Now you have a component whose state can be updated, and the DOM is patched with the new virtual DOM. You can access the state property inside the render() method to render the virtual DOM based on the state of the component.

Also, the updateState() method can be called from within the component’s render() method as part of the event handlers. Let’s go back to the counter example. If next to the counter label, you have a button to increment the counter, you could do something like this:

const Counter = defineComponent({

state() {

return { count: 0 }

},

render() {

Accesses the state

return hFragment([

of the component

h('p', {}, [`Count: ${this.state.count}`]),

h(

'button',

{

Updates the state of

on: {

the component

click() {

this.updateState({ count: this.state.count + 1 })

},

},

},

['Increment'],

),

])

},

})

But if you tried this code right now, however—and I encourage you to do so—you’d get the following error:

Uncaught TypeError: Cannot read properties of undefined (reading 'count')

9.3

Components with state

223

Can you guess why? Here’s the problem: the this keyword inside the render() function that’s called inside the render() method isn’t bound to the component; rather, it’s bound to the Window object. (In strict mode, it would be undefined.) This happens because the render() function passed to the defineComponent() function isn’t a method of the class, in which case the this keyword would be bound to the component.

NOTE

You can read more about the this keyword in the MDN web docs at

. You need to understand the rules that dictate how this is bound in JavaScript so that you can follow the code in this book. I highly recommend that you also read the “This works” chapter of the Objects and Classes book from the You Don’t Know JavaScript series, by Kyle Simpson. You can read this chapter in Simpson’s GitHub account at This chapter contains everything you might need to know about the this keyword, which you’ll use in this chapter and the following chapters of the book. Trust me when I say that the chapter is worth your time.

To fix this problem, you need to use explicit binding to bind the this keyword inside the render() function to the component. The fix is simple, as shown in the following listing. Modify your code so that it matches the code in the listing.

Listing 9.5

Binding the call() function to the component (component.js) export function defineComponent({ render, state }) {

class Component {

// --snip-- //

render() {

return render()

return render.call(this)

}

// --snip-- //

}

return Component

}

9.3.2

Result

Let’s quickly review the code you’ve written so far. You’ve created a defineComponent() function that takes an object with a render() function to create a component. The render() function returns a virtual DOM based on the state of the component. The component’s state can be updated with the updateState() function, which merges the new state with the current state and patches the DOM

with the new virtual DOM. If you followed along, your component.js file should look like the following listing.

224

CHAPTER 9

Stateful components

Listing 9.6

The defineComponent() function so far (component.js)

import { destroyDOM } from './destroy-dom'

import { mountDOM } from './mount-dom'

import { patchDOM } from './patch-dom'

export function defineComponent({ render, state }) {

class Component {

#isMounted = false

#vdom = null

#hostEl = null

constructor(props = {}) {

this.props = props

this.state = state ? state(props) : {}

}

updateState(state) {

this.state = { ...this.state, ...state }

this.#patch()

}

render() {

return render.call(this)

}

mount(hostEl, index = null) {

if (this.#isMounted) {

throw new Error('Component is already mounted')

}

this.#vdom = this.render()

mountDOM(this.#vdom, hostEl, index)

this.#hostEl = hostEl

this.#isMounted = true

}

unmount() {

if (!this.#isMounted) {

throw new Error('Component is not mounted')

}

destroyDOM(this.#vdom)

this.#vdom = null

this.#hostEl = null

this.#isMounted = false

}

#patch() {

if (!this.#isMounted) {

throw new Error('Component is not mounted')

}

9.3

Components with state

225

const vdom = this.render()

this.#vdom = patchDOM(this.#vdom, vdom, this.#hostEl)

}

}

return Component

}

Now let’s discuss an interesting implication of patching the DOM owned by a component when those DOM elements are inserted into an element in which other elements are present. If you don’t take this subtle detail into account, you’ll patch the component’s DOM incorrectly. First, why don’t you try to solve the following challenge?

Exercise 9.2: Challenge

Using the defineComponent() function and the code it depends on (the mountDOM() and destroyDOM() functions), create a component called FlyingButton, which renders a <button> element absolutely positioned at a random position inside the browser’s viewport. The button’s label should be Move. When the button is clicked, the button should move to a random position inside the viewport. Bear in mind that the position of the button should be random but never be outside the viewport—at least, not entirely.

You can mount the button in the <body> element or in any other element you want (it’s absolutely positioned, so it shouldn’t affect the layout of the page) inside your favorite website.

Find the solution at .

9.3.3

The component’s offset

Suppose that you have a component whose HTML is mounted in the same <div> as a

<p> element. In this configuration, we say that the <div> is the component’s host element—the one that contains the component’s HTML. The view of this component consists of a fragment containing a <p> element and a <span> element (figure 9.7).

Here’s the thing that’s important to notice: because the component’s view is made of a fragment, all its elements are siblings of the <p> element that’s outside the component’s view. Now suppose that the component, after a change in its state, decides that it wants to swap the <p> element with the <span> element. Figure 9.8

shows the result.

After the state is changed, the updateState() method calls the #patch() method, which in turn calls the patchDOM() function, passing it its virtual DOM.

Recall that this virtual DOM is a fragment containing a <p> element and a <span> element, but not the first <p> element with the text A; that element is outside the component’s view. patchDOM() in turn asks arraysDiffSequence() to find the sequence of operations that transforms the current DOM into the new virtual DOM.

226

CHAPTER 9

Stateful components

<div>

The component’s host element

<p>

Component

<div>

<p>A</p>

<p>B</p>

<span>C</span>

Text

Fragment

</div>

"A"

The component’s HTML

<p>

<span>

Text

Text

"B"

"C"

Figure 9.7

A <div> element containing a <p> element and a component

<div>

Before

<div>

<p>A</p>

<p>

Component

<p>B</p>

<span>C</span>

</div>

Text

Fragment

"A"

After

<span>

<p>

<div>

<p>A</p>

<span>C</span>

<p>B</p>

Text

Text

</div>

"C"

"B"

Swap

Figure 9.8

Swap the <p> element with the <span> element inside the component.

Your robust and well-implemented arraysDiffSequence() function returns the following sequence of operations:

9.3

Components with state

227

1

Move the <span> element from i=1 to i=0.

2

Noop the <p> element from i=0 to i=1.

Then the function applies the sequence of operations to the DOM. Figure 9.9 shows the result of applying the first operation to the DOM. Before you look at the figure, can you guess what the result will be? Is it what you expected? Is it correct?

1. Move the <span> element to

.

i=0

The element at index 0 wasn’t

part of the component!

<div>

<div>

<p>A</p>

<span>C</span>

!

<p>B</p>

The element at index 0

<p>A</p>

<span>C</span>

<p>B</p>

</div>

The span to move

</div>

Figure 9.9

Move the <span> element from i=1 to i=0.

That’s wrong! The problem is that the arraysDiffSequence() calculates the indices of the operations relative to the lists of nodes it receives as arguments, which are the nodes inside the component’s fragment. This list of nodes doesn’t include all the elements below the parent <div> element; it’s missing the first <p> element that’s outside the component’s view. This situation arises from having components whose vdom is a fragment because its elements are inserted into a node that’s outside the component—a host element that might contain other elements external to the component. When the component’s view has a single root element, this problem doesn’t occur (why this is I’ll leave as an exercise for you; the answer is at the end of the chapter).

The indices of the move operations are relative to the fragment; they don’t account for the elements outside the component’s fragment. Something similar happens when a node is added in the component’s view if the component’s view is a fragment. The index where the node is to be added is relative to the fragment items, not accounting for the elements outside the fragment.

How do we go about fixing this problem? It’s time to introduce the concept of a component’s offset.

DEFINITION

The offset of a component is the number of elements that precede the component’s first element in the parent element when the former’s view top node is a fragment. In other words, it’s the index of the component’s first element in the parent element.

If you can compute the offset of a component, you can correct the indices of the move and add operations returned by arraysDiffSequence(). The offset for the example component is 1 because the first element of the component is the second element in the parent element (figure 9.10).

228

CHAPTER 9

Stateful components

The component’s host element

<div>

<p>A</p>

<p>B</p>

Offset = 1

<span>C</span>

</div>

Figure 9.10

The offset of the

The component’s HTML

component is 1.

If we correct the index of the operations by adding the offset, the operations will be relative to the parent element, not to the fragment. Figure 9.11 shows the correct application of the move operation.

1. Move the <span> element to i=0+offset.

1

<div>

<div>

<p>A</p>

<p>A</p>

The element at index 1

<p>B</p>

<span>C</span>

<span>C</span>

<p>B</p>

</div>

The span to move

</div>

Figure 9.11

Move the <span> element from i=2 to i=1 (corrected).

Let’s implement a getter for the component’s offset. Just below the component’s constructor, write the code shown in bold in the following listing.

Listing 9.7

Getting the component’s first element offset (component.js)

import { destroyDOM } from './destroy-dom'

import { DOM_TYPES, extractChildren } from './h'

import { mountDOM } from './mount-dom'

import { patchDOM } from './patch-dom'

export function defineComponent({ render, state }) {

class Component {

#isMounted = false

#vdom = null

#hostEl = null

constructor(props = {}) {

this.props = props

this.state = state ? state(props) : {}

}

9.3

Components with state

229

get elements() {

If the vdom is

if (this.#vdom == null) {

null, returns an

If the vdom top node

return []

empty array

is a fragment, returns

}

the elements inside

the fragment

if (this.#vdom.type === DOM_TYPES.FRAGMENT) {

return extractChildren(this.#vdom).map((child) => child.el)

}

If the vdom top

return [this.#vdom.el]

node is a single node,

}

returns its element

get firstElement() {

The component’s

return this.elements[0]

first element

The component’s

}

first element offset

inside the parent

get offset() {

element

if (this.#vdom.type === DOM_TYPES.FRAGMENT) {

return Array.from(this.#hostEl.children).indexOf(this.firstElement)

}

return 0

When the component’s view

}

isn’t a fragment, the offset is 0.

updateState(state) {

this.state = { ...this.state, ...state }

this.#patch()

}

// --snip-- //

}

return Component

}

Now that you can ask a component about its offset, you can correct the indices of the operations returned by arraysDiffSequence(). In section 9.3.4, you’ll refactor the patchDOM() function to include the offset.

9.3.4

Patching the DOM using the component’s offset

To use the component’s offset in the patchDOM() function, you need to pass the component instance to the function. The following listing shows how.

Listing 9.8

Passing the component instance to the patchDOM() function (component.js) export function defineComponent({ render, state }) {

class Component {

// --snip-- //

#patch() {

if (!this.#isMounted) {

throw new Error('Component is not mounted')

}

230

CHAPTER 9

Stateful components

const vdom = this.render()

this.#vdom = patchDOM(this.#vdom, vdom, this.#hostEl, this)

}

}

Passes the

component instance to

return Component

the patchDOM() function

}

Now you want to include the host component as an argument of the patchDOM() function and pass it to the patchChildren() function. That component will have a default value of null. In the patch-dom.js file, write the code shown in bold in the following listing.

Listing 9.9

Passing a host component to the patchDOM() function (patch-dom.js) export function patchDOM(

Passes the host

oldVdom, newVdom, parentEl, hostComponent = null

component instance to

) {

the patchDOM() function

if (!areNodesEqual(oldVdom, newVdom)) {

const index = findIndexInParent(parentEl, oldVdom.el)

destroyDOM(oldVdom)

mountDOM(newVdom, parentEl, index)

return newVdom

}

newVdom.el = oldVdom.el

switch (newVdom.type) {

case DOM_TYPES.TEXT: {

patchText(oldVdom, newVdom)

return newVdom

}

case DOM_TYPES.ELEMENT: {

patchElement(oldVdom, newVdom)

break

}

Passes the

}

component instance

to the patchChildren()

function

patchChildren(oldVdom, newVdom, hostComponent)

return newVdom

}

Now comes the important part: you need to use the component’s offset to correct the indices of the operations returned by arraysDiffSequence(), but only if a host component is passed. Otherwise, you can assume that the component’s offset is 0, which means that you’re dealing with a root component—one that doesn’t have a parent component.

Thus, the component can’t have an offset. Only the operations that reference elements in the DOM by their index need to be corrected. If you recall, only the move and add

9.3

Components with state

231

operations refer to DOM nodes by their index. Thus, neither the remove nor the noop operation needs to be corrected, but you still have to modify both operations to pass the hostComponent argument back to the patchDOM() function.

You saw a graphical example of the move operation in figure 9.8. Figure 9.12 shows a graphical example of the add operation. As you can see, the index of the add operation is relative to the fragment, not to the parent element, so you need to correct it by adding the component’s offset. Include the code shown in bold in listing 9.10.

<div>

The component’s host element is

Add inside div at index 2 + offset.

the parent of the moved children.

<p>

Component

<div>

Offset = 1

Назад: 5.1.4 Result
Дальше: Summary