Книга: Build a Frontend Web Framework (From Scratch)
Назад: 3 Rendering and the virtual DOM
Дальше: 8.2.6 Patching child nodes

}

}

}

5.1

The state manager

99

Dispatcher

1. Register a function that runs after

Command handler registry

every command is handled.

'abc'

f(payload)

afterEveryCommand(handlerFn)

'def'

f(payload)

Command

'ghi'

f(payload)

dispatch('ghi', { ... })

After every event

2. Dispatch a command with

After event registry

a payload associated.

f()

f()

3. The dispatcher looks up the command

4. The after-command handlers

name and executes the associated

run after all consumers of the

consumers, passing them the payload.

command run.

Figure 5.9

The dispatcher’s afterEveryCommand() method registers handlers to run after every command is handled.

This method is similar to the subscribe() method except that it doesn’t take a command name as a parameter: these handlers are called for all dispatched commands.

This time, we’re not checking for duplicates; we’re allowing the same handler to be registered multiple times. After-command handlers don’t modify the state; they’re a notification mechanism, so delivering notifications of the same event multiple times might be a valid use case. The last part that’s missing is the dispatch() method, which dispatches a command and calls all the registered handlers.

DISPATCHING COMMANDS

A dispatcher wouldn’t be much of a dispatcher if it didn’t have a dispatch() method, would it? This method takes two parameters: the name of the command to dispatch and its payload. It looks up the handlers registered for the given command name and calls them one by one, in order, passing them the command’s payload as a parameter.

Last, it runs the after-command handlers, as in the following listing.

Listing 5.3

Dispatching a command given its name and payload (dispatcher.js)

export class Dispatcher {

// --snip-- //

Checks whether

handlers are registered

dispatch(commandName, payload) {

and calls them

if (this.#subs.has(commandName)) {

this.#subs.get(commandName).forEach((handler) => handler(payload))

} else {

console.warn(`No handlers for command: ${commandName}`)

}

100

CHAPTER 5

State management and the application’s lifecycle

this.#afterHandlers.forEach((handler) => handler())

Runs the

}

after-command

}

handlers

Note that if a command with no handlers associated is dispatched, we warn the developer about it in the console. This approach will be handy when you write your example applications. It’s easy to misspell a command name without noticing and then bang your head against the wall because you don’t understand why your code isn’t working.

That’s it! Figure 5.10 shows the framework’s first-version architecture. You’ve implemented the dispatcher: the state manager. Now it’s time to integrate the dispatcher with the renderer to create a working framework.

First version of the framework =

State manager

state manager + renderer

Command handler registry

'abc'

f(payload)

'def'

f(payload)

Read/write

Command

'ghi'

f(payload)

After every event

After event registry

f()

f()

JS event

Notify renderer

State

Renderer

Render

destroyDOM( )

vdom

Read

vdom = View(state)

mountDOM( , )

vdom parentEl

Figure 5.10

The framework’s first-version architecture

Before integrating the dispatcher with the rendering system, make sure that the code you wrote matches the one in listing 5.4 in section 5.1.4. If you need to copy and paste to compare, you can find the complete listing in the book’s GitHub repository; see appendix A for more information.

5.1

The state manager

101

5.1.4

Result

For your reference, the following listing shows the complete Dispatcher class implementation.

Listing 5.4

Complete Dispatcher implementation (dispatcher.js)

export class Dispatcher {

#subs = new Map()

#afterHandlers = []

subscribe(commandName, handler) {

if (!this.#subs.has(commandName)) {

this.#subs.set(commandName, [])

}

const handlers = this.#subs.get(commandName)

if (handlers.includes(handler)) {

return () => {}

}

handlers.push(handler)

return () => {

const idx = handlers.indexOf(handler)

handlers.splice(idx, 1)

}

}

afterEveryCommand(handler) {

this.#afterHandlers.push(handler)

return () => {

const idx = this.#afterHandlers.indexOf(handler)

this.#afterHandlers.splice(idx, 1)

}

}

dispatch(commandName, payload) {

if (this.#subs.has(commandName)) {

this.#subs.get(commandName).forEach((handler) => handler(payload))

} else {

console.warn(`No handlers for command: ${commandName}`)

}

this.#afterHandlers.forEach((handler) => handler())

}

}

Make sure that you wrote the code correctly and that your implementation matches the one in listing 5.4. If so, let’s move on to the next section.

102

CHAPTER 5

State management and the application’s lifecycle

Exercise 5.3

To put your newly implemented Dispatcher class to use, paste the code in listing 5.4 into the browser’s console. (Remember to leave out the export statement.) Then create a new instance of the Dispatcher class and register a handler for a command called 'greet'. This command’s payload should be a string with the name of the person to greet. When the command is dispatched, the handler should log a greeting to the console: 'Hello, < name>!' (where < name> is the name of the person in the payload). You also want to have an after-command handler that logs a message to the console: Done greeting!

When the command 'greet' is dispatched with the payload 'John', for example, dispatcher.dispatch('greet', 'John')

the console should log the following:

Hello, John!

Done greeting!

Find the solution

5.2

Assembling the state manager into the framework

Merriam-Webster defines assemble as bringing together (as in a particular place or for a particular purpose). To assemble the state manager and renderer, you need a particular place to bring them together. This place is an object that contains and connects them so that they can communicate. If you think about it, this object represents the running application that uses your framework, so we can refer to it as the application instance. Let’s think about how you want developers to create an application instance in your framework.

5.2.1

The application instance

The application instance is the object that manages the lifecycle of the application. It manages the state, renders the views, and updates the state in response to user input.

Developers need to pass three things to pass to the application instance:

 The initial state of the application

 The reducers that update the state in response to commands

 The top-level component of the application

Your framework can take care of the rest: instantiating a renderer and a state manager and wiring them together. (Remember that your initial version of the framework won’t be much more than these two things glued together.) The application instance can expose a mount() method that takes a DOM element as a parameter, mounts the application in it, and kicks off the application’s lifecycle. From this point on, the

5.2

Assembling the state manager into the framework

103

application instance is in charge of keeping the state in sync with the views, like the waiters in the restaurant at the beginning of this chapter.

This process may sound a bit abstract at the moment, but it’ll become clear when you rewrite the TODO application by using your framework. For now, bear with me.

Let’s move on to implementing the application instance, starting with the renderer.

5.2.2

The application instance’s renderer

First, implement a function called createApp() that returns an object with a single method, mount(), which takes a DOM element as a parameter and mounts the application in it. This object is the application instance inside which you implement the renderer and the state manager.

The createApp() function takes an object with two properties: state and view.

The state property is the initial state of the application, and the view property is the top-level component of the application. You’ll add the reducers later.

You need two variables in the closure of the createApp() function: parentEl and vdom. These variables keep track of the DOM element where the application is mounted and the virtual DOM tree of the previous view, respectively. Both should be initialized to null because the application hasn’t been mounted yet.

Then comes the renderer, which is implemented as a function: renderApp(). This function, as previously discussed, renders the view by destroying the current DOM

tree (if one exists) and then mounting the new one. At this point, this function is called only once: when the application is mounted by the mount() method, the only method exposed to the developer. It takes a DOM element as a parameter and mounts the application in it. Note that you save the DOM element in the parentEl variable so that you can use it later to unmount the application.

Create a new file inside the src/ directory called app.js, and write the code in the following listing. The listing shows the implementation of the createApp() function that returns the application instance.

Listing 5.5

The application instance with its renderer (app.js)

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

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

The function

that creates the

application object

export function createApp({ state, view }) {

let parentEl = null

let vdom = null

function renderApp() {

if (vdom) {

If a previous view

destroyDOM(vdom)

exists, unmounts it

}

vdom = view(state)

Mounts the

mountDOM(vdom, parentEl)

new view

}

104

CHAPTER 5

State management and the application’s lifecycle

return {

mount(_parentEl) {

Method to mount

parentEl = _parentEl

the application in

renderApp()

the DOM

},

}

}

Okay, you’ve got the renderer; you could already render an application in the browser, but it wouldn’t respond to user input. For that purpose, you need the state manager, which tells the renderer to re-render the application when the state changes.

5.2.3

The application instance’s state manager

The state manager is a bit more complex than a function. The Dispatcher class that you implemented in section 5.2.2 is the central piece of the state manager, but you have to hook some things up. Most notably, you need to wrap the state reducers—

given by the developer—in a consumer function that the dispatcher will call every time a command is dispatched. Let’s see how this is done.

Write the code in bold in the following listing. Note that part of the code you wrote earlier is omitted for clarity.

Listing 5.6

Adding the state manager to the application instance (app.js)

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

import { Dispatcher } from './dispatcher'

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

export function createApp({ state, view, reducers = {} }) {

let parentEl = null

Re-renders the

let vdom = null

application after

every command

const dispatcher = new Dispatcher()

const subscriptions = [dispatcher.afterEveryCommand(renderApp)]

for (const actionName in reducers) {

const reducer = reducers[actionName]

const subs = dispatcher.subscribe(actionName, (payload) => {

state = reducer(state, payload)

})

Updates the

subscriptions.push(subs)

state calling the

Adds each command

}

reducer function

subscription to the

subscriptions array

// --snip-- //

}

Let’s unpack what you’ve done here. First, you added a reducers property to the createApp() function parameter. This property is an object that maps command names to reducer functions—functions that take the current state and the command’s payload and return a new state.

5.2

Assembling the state manager into the framework

105

Next, you created an instance of the Dispatcher class and saved it in the dispatcher variable. The next line is crucial: you subscribed the renderApp() function to be an after-command handler so that the application is re-rendered after every command is handled. Not every command necessarily changes the state, but you don’t know in advance, so you have to re-render the application after every command.

NOTE

To avoid re-rendering the application when the state didn’t change, you could compare the state before and after the command was handled.

This comparison can become expensive if the state is a heavy and deeply nested object and the commands are frequent. In chapters 7 and 8, you’ll improve the performance of the renderer by patching the DOM only where necessary, so re-rendering the application will be a reasonably fast operation.

Not checking whether the state changed is a tradeoff we’re making to keep the code simple.

Can you see now why after-command handlers are a good idea? The afterEveryCommand() function returns a function that unsubscribes the handler, so you saved it in the subscriptions array—an array that you initialized to have this function as its first element.

Next, you iterated the reducers object, wrapped each of the reducers inside a handler function that calls the reducer and updates the state, and subscribed that handler to the dispatcher. You were careful to save the subscription functions in the subscriptions array so that you can unsubscribe them when the application is unmounted.

Great—you’ve got the state manager hooked up to the renderer. But we haven’t talked about one thing yet: how the components dispatch commands.

5.2.4

Components dispatching commands

If you recall from chapter 3, your virtual DOM implementation allows you to attach event listeners to DOM elements:

h(

'button',

{ on: { click: () => { ... } } },

['Click me']

)

If you want to dispatch commands from within those event listeners, you need to pass the dispatcher to the components. In a way, you can imagine the dispatcher as being a remote control; each button dispatches a command whose handler function can modify the state of the application (figure 5.11). By passing the dispatcher to the components, you give it the capability to dispatch commands in response to user input.

The dispatcher in the application instance has the command handlers that the developer has provided. The component can dispatch those commands using the dispatch()

106

CHAPTER 5

State management and the application’s lifecycle

Dispatcher

dispatch('abc', )

dispatch('ghi', )

Payload

Payload

dispatch('def', )

dispatch('jkl', )

Payload

Payload

Figure 5.11

The dispatcher is like a remote control: each button dispatches a command whose handler function can modify the state of the application.

method of the dispatcher. To remove a to-do item from the list, for example, the component can dispatch a remove-todo command this way:

h(

'button',

{

on: {

click: () => dispatcher.dispatch('remove-todo', todoIdx)

}

},

['Done']

)

To allow the components to dispatch commands, change your code in app.js, adding the code shown in bold in the following listing.

Listing 5.7

Allowing components to dispatch commands (app.js)

export function createApp({ state, view, reducers = {} }) {

let parentEl = null

let vdom = null

const dispatcher = new Dispatcher()

const subscriptions = [dispatcher.afterEveryCommand(renderApp)]

function emit(eventName, payload) {

dispatcher.dispatch(eventName, payload)

}

// --snip-- //

5.2

Assembling the state manager into the framework

107

function renderApp() {

if (vdom) {

destroyDOM(vdom)

}

vdom = view(state, emit)

mountDOM(vdom, parentEl)

}

// --snip-- //

}

To allow components to dispatch commands more conveniently, you implemented an emit() function. So instead of writing dispatcher.dispatch(), you can write emit() inside the components, which is a bit more concise. Then you passed the emit() function to the components as a second argument.

Bear in mind that from now on, components will receive two arguments: the state and the emit() function. If a component doesn’t need to dispatch commands, it can ignore the second argument.

You’re almost done! There’s one thing left to do: unmount the application.

5.2.5

Unmounting the application

When an application instance is created, the state reducers are subscribed to the dispatcher, and the renderApp() function is subscribed to the dispatcher as an after-command handler. When the application is unmounted, apart from destroying the view, you need to unsubscribe the reducers and the renderApp() function from the dispatcher.

To clean up the subscriptions and destroy the view, you need to add an unmount() method to the application instance, as shown in bold in the following listing.

Listing 5.8

Unmounting the application (app.js)

export function createApp({ state, view, reducers = {} }) {

let parentEl = null

let vdom = null

// --snip-- //

return {

mount(_parentEl) {

parentEl = _parentEl

renderApp()

},

unmount() {

destroyDOM(vdom)

vdom = null

subscriptions.forEach((unsubscribe) => unsubscribe())

},

}

}

108

CHAPTER 5

State management and the application’s lifecycle

The unmount() method uses the destroyDOM() function from chapter 4 to destroy the view, sets the vdom property to null, and unsubscribes the reducers and the renderApp() function from the dispatcher.

That’s it! Now is a good time to review the code you wrote in app.js to make sure that you got it right.

5.2.6

Result

Your app.js should look like the code in the following listing.

Listing 5.9

The application instance (app.js)

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

import { Dispatcher } from './dispatcher'

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

export function createApp({ state, view, reducers = {} }) {

let parentEl = null

let vdom = null

const dispatcher = new Dispatcher()

const subscriptions = [dispatcher.afterEveryCommand(renderApp)]

function emit(eventName, payload) {

dispatcher.dispatch(eventName, payload)

}

for (const actionName in reducers) {

const reducer = reducers[actionName]

const subs = dispatcher.subscribe(actionName, (payload) => {

state = reducer(state, payload)

})

subscriptions.push(subs)

}

function renderApp() {

if (vdom) {

destroyDOM(vdom)

}

vdom = view(state, emit)

mountDOM(vdom, parentEl)

}

return {

mount(_parentEl) {

parentEl = _parentEl

renderApp()

},

unmount() {

destroyDOM(vdom)

vdom = null

Summary

109

subscriptions.forEach((unsubscribe) => unsubscribe())

},

}

}

That’s the first version of your framework, put together in fewer than 50 lines of code.

The framework is fairly simple, but it’s good enough to build simple applications. In chapter 6, you build and publish your framework to NPM and refactor the TODOs application to use it.

Summary

 Your framework’s first version is made of a renderer and a state manager wired together.

 The renderer first destroys the DOM (if it exists) and then creates it from scratch. This process isn’t very efficient and creates problems with the focus of input fields, among other things.

 The state manager is in charge of keeping the state and view of the application in sync.

 The developer of an application maps the user interactions to commands, framed in the business language, that are dispatched to the state manager.

 The commands are processed by the state manager, updating the state and notifying the renderer that the DOM needs to be updated.

 The state manager uses a reducer function to derive the new state from the old state and the command’s payload.

Publishing

and using your

framework’s first version

This chapter covers

 Publishing the first version of your framework

to NPM

 Refactoring the TODOs application to use your

framework

In chapter 5, you implemented a state manager and assembled it, together with a renderer, to build the first version of your frontend framework. In this chapter, you’ll publish the first version of your framework and then refactor the TODOs application you built using vanilla JavaScript.

NOTE

You can find all the listings in this chapter in the listings/ch06

directory of the book’s repository (The code in this chapter can be checked out from the ch6 label (

$ git switch --detach ch6.

110

6.1

Building and publishing the framework

111

Code catch-up

In chapter 5, you implemented the Dispatcher class, used to dispatch commands from the application and subscribe handler functions to those commands. You also implemented the createApp() function, which takes in the state of the application, reducer functions to update the state, and a view function to render the application and then wires everything together.

6.1

Building and publishing the framework

The first thing you want to do to build your framework is export the functions that you want to expose to the developer using it from the src/index.js barrel file. Whatever you export from this file makes the public API of the framework. The src/index.js file is the entry point of the build process, so everything that’s exported from this file will be made available in the final bundle.

You want developers to use the h(), hString(), and hFragment() functions to create virtual Document Object Model (DOM) nodes and the createApp() function to create an application. You don’t need to export the mountDOM() and destroyDOM() functions because they’re used internally by the framework. Open the src/index.js file, delete whatever is in there, and add the following lines:

export { createApp } from './app'

export { h, hFragment, hString } from './h'

Now run the build script inside the runtime workspace to build the framework: $ npm run build

As you see in appendix A, this script bundles the JavaScript code into a single ESM

file: dist/< fwk-name>.js. (< fwk-name> is the name of your framework, as named in the package.json file’s name property.)

NOTE

Before you proceed, make sure that you follow the instructions in appendix A and set up your NPM account to publish your package.

You can import this file directly from packages/runtime/dist/< fwk-name>.js in your examples to use the framework. But you can also publish it to NPM so that you and other developers can install it in their projects using NPM like so: $ npm install <fwk-name>

You want the version field in your package.json file to be 1.0.0, which is the first version of your framework, but you’ll be publishing more versions later:

"version": "1.0.0",

112

CHAPTER 6

Publishing and using your framework’s first version

To publish the package to, make sure that your terminal’s working directory is the runtime workspace and then run the publish NPM script as follows: $ npm publish

That’s it. You’ve published the first version of your frontend framework to NPM. You can find it at followed by the name of your framework (the name field in your package.json file), and you can install it in your projects through NPM by running the command npm install < fwk-name>. You can also find it at followed by the name of your package, where you can import it directly into your HTML files. We’ll use the latter method in most of the examples in the book.

6.2

A short example

You’re probably excited to start using your framework and seeing how it works. You’ll rewrite your TODOs app to use the framework in the next section, but first, I want to help you make sense of everything you’ve done so far. To that end, you might appreciate a short example that shows how the framework works.

You don’t need to write the code in this section (unless you want to try it); just read it to see how the framework works. You’ll get your hands dirty in section 6.3.

I can’t think of anything simpler than a button that counts how many times you click it. This application’s view is a button, but it’s interactive; it renders something different based on the state, which is a number representing a count. You can implement this simple application with the following few lines of code:

createApp({

state: 0,

reducers: {

add: (state, amount) => state + amount,

},

view: (state, emit) =>

h(

'button',

{ on: { click: () => emit('add', 1) } },

[hString(state)]

),

}).mount(document.body)

This code is all you need to make a simple application with the framework. No DOM

manipulation drama is involved; the framework handles that work now. You can focus on the important stuff: the application’s logic.

This application renders as a single button with the number 0 on it (you haven’t clicked it yet):

<body>

<button>0</button>

</body>

6.3

Refactoring the TODOs app

113

When you click the button, the number it displays increments by 1 and the button renders the new number:

<body>

<button>1</button>

</body>

The framework removes the <button> element from the DOM and creates a new one every time the state changes—that is, when you click the button. This change happens in a fraction of a millisecond, so you won’t even notice it. To you, the button appears to be updating its number, but in reality, you never click the same button twice. (How philosophical is that?)

Exercise 6.1

Using your framework, implement a counter application that allows the user to increment and decrement a counter. The counter should start at 0, and the user should be able to increment it by clicking a button with a + (plus sign) label. The user should also be able to decrement it by clicking another button, this one with the – (minus sign) label. The counter should be displayed between the two buttons. You can create the application inside the examples/ directory.

Find the solution at .

6.3

Refactoring the TODOs app

Now let’s take the TODOs application you built without a framework and refactor it to use the framework you’ve built. You’ll see how much simpler the code becomes (except for the nuances of writing virtual DOMs instead of HTML), and you’ll be in a good position to assess the benefits of using a frontend framework versus writing your own DOM-manipulation code.

The first step in refactoring the TODOs application is cleaning up the <body> tag in the todos.html file. Your framework creates all the HTML markup this time, so the

<body> tag should be empty. Your todos.html file should look like the following listing.

Listing 6.1

Removing all the markup from the HTML file (todos.html)

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="UTF-8" />

<script type="module" src="todos.js"></script>

<title>My TODOs</title>

</head>

Removes everything

<body></body>

inside the <body> tag

</html>

114

CHAPTER 6

Publishing and using your framework’s first version

Do the same thing with the todos.js file; you’ll be writing all the code from scratch.

Then import the functions exported by the framework from the dist/ directory or (preferably) unpkg.com. (I’ll be using unpkg.com in this book, but you can use whichever method you prefer.) Here’s what your todos.js file should look like: import { createApp, h, hFragment } from 'https://unpkg.com/ <fwk-name> @1'

NOTE

Please recall (and this is the last time I’ll mention it to avoid sounding repetitive) that <fwk-name> is the name of your framework, as named in the package.json file’s name property.

Next, you want to define the application’s state. This time, the state will be a bit more nuanced than a simple array of to-do items.

6.3.1

Defining the state

The state of the TODOs application, when you wrote it using vanilla JavaScript, was simply an array of strings, with each string being a to-do item. You didn’t need to keep other pieces of information as part of the state, such as the text of the new to-do item that the user was typing in the input field, because you could grab it from the DOM, like so:

const addTodoInput = document.getElementById('todo-input')

// The text of the new to-do item

addTodoInput.value

The point of using a framework is to abstract away the manipulation of the DOM, so we want to avoid accessing the DOM. Any piece of information that’s relevant to the application should be part of the state. The value of the input field in which the user writes the new to-do item’s text has to be part of the state, and it must be up to date with what the user is typing. You’ll need to keep three pieces of information in the state this time:

 todos—The array of to-do items (same as before)

 currentTodo—The text of the new to-do item that the user is typing in the input field

 edit—An object containing information about the to-do item being edited by the user:

– idx—The index of the to-do item in the todos array that’s being edited

– original—The original text of the to-do item before the user started editing it (in case the edition is canceled and you need to bring back the original value)

– edited—The text of the to-do item as the user is editing it

With these requirements in mind, use the code in the following listing to define the new state.

6.3

Refactoring the TODOs app

115

Listing 6.2

The state for the TODOs application (todos.js)

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

const state = {

currentTodo: '',

edit: {

idx: null,

original: null,

edited: null,

},

todos: ['Walk the dog', 'Water the plants'],

}

Next, let’s think about the actions that the user can perform on the application and how they affect the state.

6.3.2

Defining the reducers

This seemingly simple application has a few actions that the user can perform on it.

We need to write a reducer function to update the state for each of these actions. If you think about the different ways in which a user can interact with the application, you’ll come up with a list similar to the following:

Update the current to-do. The user types a new character in the input field, so the current to-do needs to be updated.

Add a new to-do. The user clicks the Add button to add a new to-do to the list.

Start editing a to-do. The user double-clicks a to-do item to start editing it.

Edit a to-do. The user types a new character in the input field while editing a to-do item.

Save an edited to-do. The user finishes editing a to-do and saves the changes.

Cancel editing a to-do. The user cancels editing a to-do and discards the changes.

Remove a to-do. The user marks a to-do as completed so that it can be removed from the list.

Write a reducer function for each of these actions below the state definition, as shown in the following listing. Recall from section 5.1.2 that a reducer must be a pure function, so it can’t have side effects or mutate its arguments; it has to return a new state object, not a modified version of the current state.

Listing 6.3

The reducer functions of the state (todos.js)

const reducers = {

The reducer receives the

'update-current-todo': (state, currentTodo) => ({

current TODO as payload.

...state,

currentTodo,

Updates the current TODO in the state

}),

'add-todo': (state) => ({

...state,

Sets an empty string as the

current TODO (to clean the field)

currentTodo: '',

116

CHAPTER 6

Publishing and using your framework’s first version

Saves the original TODO in

case the edition is canceled

Adds the new

TODO to the list

todos: [...state.todos, state.currentTodo],

}),

'start-editing-todo': (state, idx) => ({

The reducer receives the index

...state,

of the TODO to edit as payload.

edit: {

idx,

original: state.todos[idx],

The edited version of the

edited: state.todos[idx],

TODO is the current TODO.

},

}),

The reducer receives the

edited TODO as payload.

'edit-todo': (state, edited) => ({

...state,

edit: { ...state.edit, edited },

Updates the edited TODO

}),

in the state

'save-edited-todo': (state) => {

Copies the TODOs array

const todos = [...state.todos]

todos[state.edit.idx] = state.edit.edited

Replaces the edited

TODO in the TODOs array

return {

...state,

edit: { idx: null, original: null, edited: null },

Resets the edit

todos,

TODO part of

}

the state

},

'cancel-editing-todo': (state) => ({

Resets the edit TODO

...state,

part of the state

edit: { idx: null, original: null, edited: null },

}),

The reducer receives the index of

the TODO to remove as payload.

'remove-todo': (state, idx) => ({

...state,

todos: state.todos.filter((_, i) => i !== idx),

Filters out the TODO

}),

with the given index

}

One interesting thing to note is that some events, such as 'add-todo', don’t have a payload associated with them. A payload isn’t necessary because now the new to-do description is part of the state, so the reducer can access it directly:

'add-todo': (state) => ({

...state,

currentTodo: '',

todos: [...state.todos, state.currentTodo],

})

Now that you have the state and the reducers, you can define the view.

6.3

Refactoring the TODOs app

117

6.3.3

Defining the view

Let’s break the application into small components, starting with the top-level component, which we’ll call App(). This component consists of a fragment containing the title (an <h1> element), a CreateTodo() component, and a TodoList() component.

Write the code in the following listing.

Listing 6.4

The App() component, top-level view (todos.js)

function App(state, emit) {

return hFragment([

h('h1', {}, ['My TODOs']),

CreateTodo(state, emit),

TodoList(state, emit),

])

}

As you recall from section 5.2.4, the components are now functions that take in not only the state, but also the emit() function to dispatch events to the application’s dispatcher. Implement the CreateTodo() component next, as shown in the following listing. This component is equivalent to the static HTML markup you had in the todos.html file: the <label>, <input>, and <button> elements.

Listing 6.5

The CreateTodo() component (todos.js)

Destructures the currentTodo

function CreateTodo({ currentTodo }, emit) {

from the state object

return h('div', {}, [

h('label', { for: 'todo-input' }, ['New TODO']),

The input’s label

h('input', {

type: 'text',

The input field’s value is the

id: 'todo-input',

Updates the

currentTodo in the state.

value: currentTodo,

field’s

on

value

: {

when the user input: ({ target }) =>

types in it

Checks whether

emit('update-current-todo', target.value),

the user pressed

keydown: ({ key }) => {

the Enter key and

if (key === 'Enter' && currentTodo.length >= 3) {

the input field

emit('add-todo')

Dispatches

has at least three

}

the 'add-todo'

characters

},

command },

}),

h(

'button',

Disables the button if

the input field has fewer

{

than three characters

disabled: currentTodo.length < 3,

on: { click: () => emit('add-todo') },

Dispatches the 'add-todo'

},

command when the user

['Add']

clicks the button

),

])

}

118

CHAPTER 6

Publishing and using your framework’s first version

This code is the first time you see the emit() function being used inside a component, so a few words of explanation are in order. The case of the <input> is particularly interesting because you’ve set up a two-way binding between the input field and the state. A two-way binding reflects the changes in the state in the input field, and anything typed in the input field is set in the state. Whatever side changes (the state or the DOM), the other is updated. You’ve accomplished this two-way binding by using the value attribute of the <input> element and by setting an event listener on the input event: h('input', {

type: 'text',

value: state.currentTodo,

on: {

input: ({ target }) => emit('update-current-todo', target.value)

}

})

This way, when the currentTodo in the state changes its value, this change is reflected in the input field. When the user types a new character in the input field, the input event is triggered and the update-current-todo event is dispatched, so the reducer updates the state. This flow is a beautiful use of your renderer and state manager working together, as you can see in figure 6.1.

State manager

3. Update state.

2. Dispatch command.

Payload

4. Notify.

Renderer

State

a

6. Re-render.

5. Read new state.

1. User types something.

Figure 6.1

A two-way binding between the state and the DOM

Next, we’ll implement the TodoList() component, which is the list of to-do items.

Write the following code below the CreateTodo() component.

Listing 6.6

The TodoList() component (todos.js)

function TodoList({ todos, edit }, emit) {

return h(

'ul',

{},

todos.map((todo, i) => TodoItem({ todo, i, edit }, emit))

)

}

6.3

Refactoring the TODOs app

119

The TodoList() component is a simple one: a <ul> element with a list of TodoItem() components. Implement the missing TodoItem() component to finish the application, as shown in the following listing.

Listing 6.7

The TodoItem() component (todos.js)

function TodoItem({ todo, i, edit }, emit) {

const isEditing = edit.idx === i

The item in edit mode

return isEditing

? h('li', {}, [

The input field’s value is the

h('input', {

edited TODO’s description.

value: edit.edited,

on: {

input: ({ target }) => emit('edit-todo', target.value)

},

}),

Updates the field’s value

h(

when the user types in it

'button',

{

on: {

click: () => emit('save-edited-todo')

Saves the edited TODO

}

by dispatching the

},

'save-edited-todo'

['Save']

command

),

h(

'button',

{

Cancels the

on: {

edit mode by

click: () => emit('cancel-editing-todo')

dispatching the

}

'cancel-editing-

},

todo' command

['Cancel']

),

])

: h('li', {}, [

The item in read mode

Starts editing the

h(

TODO by dispatching

'span',

the 'start-editing-

{

todo' command

on: {

dblclick: () => emit('start-editing-todo', i)

}

},

The TODO’s description

[todo] coming from the state

),

h(

'button',

{

Removes the

on: {

TODO by

click: () => emit('remove-todo', i)

dispatching the

}

'remove-todo'

},

command

['Done']

120

CHAPTER 6

Publishing and using your framework’s first version

),

])

}

You’re passing the to-do item’s index because some of the dispatched events need to know it, such as when you want to edit or remove a to-do. That index is known by the parent component, TodoList(), so it needs to pass it to the child component as a prop. This index isn’t part of the application’s state—what we typically pass as the first argument to the component—but it’s relevant to the component.

The last task is putting everything together. Write the last line of the todos.js file as follows:

createApp({ state, reducers, view: App }).mount(document.body)

Now if you run the serve:examples script, you’ll see the application running in your browser:

$ npm run serve:examples

At this point, you can add new to-do items, edit them, and remove them. Everything works the same way as before, but this time, you wrote no DOM manipulation code. You should be proud of yourself for having built your first frontend framework. Congratulations!

You probably noticed one thing when you attempted to add a new to-do item: every keystroke you type in the input field removes the focus from the input field, so you have to click it again to type the next character. Can you guess why? Every time the state changes, your framework is destroying the DOM and re-creating it from scratch. As a result, the field where you wrote the last character is no longer in the DOM; you’re writing in a new input field. (You never write two characters in the same input field. Oh, philoso-phy!) This situation is far from ideal, but worry not: you’ll fix the problem in chapter 7.

Exercise 6.2: Challenge

Write the tic-tac-toe game using your framework. If you need a refresher on how the game works, you can find the rules at . The game should have the following features:

 The game should start with an empty board. (You can use a <table> element to represent the board.)

 The first player is always the X player. A title should say whose turn it is: “It’s X’s turn” or “It’s O’s turn.”

 When a player clicks a cell, the cell should be filled with the player’s symbol (X or O), and the turn should be passed to the other player.

 When a player wins the game, a message should say who won the game:

“Player X wins!” or “Player O wins!” The remaining cells should be disabled.

 When all the cells are filled and neither player has won, a message should say that the game ended in a draw: “It’s a draw!”.

Summary

121

You can use the same state and reducer you used in exercise 5.2, but this time, you’ll need to write the logic to determine whether a player has won the game (the checkWinner() function).

Find the solution at .

The next two chapters are among the most challenging in the book. You’ll write the reconciliation algorithm, thanks to which your framework can update the DOM when the state changes, without needing to destroy and re-create it. The reconciliation algorithm is complex, but it’s also lots of fun to write, so I hope you’ll enjoy it.

Be sure to grab a cup of coffee and maybe go out for a relaxing walk before you move on to chapter 7. If you’re into yoga, you might want to do a couple of Sun Salu-tations. Whatever you do, come back refreshed and ready to write some code!

Summary

 To publish your framework on NPM, first bundle it by using npm run build; then publish it by using the npm publish command.

 Whatever you export from the src/index.js file is what’s going to be available to the users of your framework. (You configure it this way in appendix A.)

 In a two-way binding, the state and the DOM are synchronized. Whatever changes on one side is reflected on the other.

 Using the first version of your framework, you’ve rewritten the to-do application you wrote in chapter 2. This time, you didn’t have to worry about DOM manipulation—only about the application’s logic.

The reconciliation

algorithm: Diffing

virtual trees

This chapter covers

 Comparing two virtual DOM trees

 Finding the differences between two objects

 Finding the differences between two arrays

 Finding a sequence of operations that transforms

one array into another

Picture this: you’re in the supermarket, using the shopping list your partner gave you. You walk around the aisles, picking up the items one by one and putting them in the cart. When you’re done, you head to the checkout counter, but at that very moment your phone vibrates in your pocket; it’s a message from your partner. She realized that there are already a dozen eggs in the fridge; what’s missing is a bottle of wine for that fancy dinner you’re going to have tonight with some friends. She’s texted you the updated shopping list. Now you have two lists: the original one (whose items are already in your cart) and the updated one, as you can see in figure 7.1.

122

123

Original shopping list

New shopping list

- Rice bag

- Rice bag

- Parmesan wedge

- Parmesan wedge

- Egg carton

- Greek yogurt

- Greek yogurt

- Tomato jar

- Tomato jar

- Lettuce

- Lettuce

- Wine bottle

Figure 7.1

The two shopping lists: the original one and the updated one

You have two options:

 You can start over, emptying the cart by putting back the items on the shelves and then picking up the items in the updated list. This way, you’ll be sure that you have the right items—the ones on the updated list—in your cart.

 You can pause for a minute to compare the two lists and figure out which items were removed and which items were added. Then you can get rid of the items that were removed from the original list and pick up the items that were added to the updated list.

You’re a smart person (after all, you’re reading this book), so you choose the second option, which requires some extra brainpower but is less labor-intensive. It takes you only a few seconds to realize that if you take the original list, strike out the egg carton, and add the bottle of wine, you’ll have the updated list (figure 7.2). You figured out the minimum number of operations you need to put the items on the new list in your cart.

Updated shopping list

- Rice bag

- Parmesan wedge

Remove from

- Egg carton

the list.

- Greek yogurt

- Tomato jar

- Lettuce

Add to the list.

Figure 7.2

The updated shopping list: the

- Wine bottle

original list with the egg carton struck out

and the bottle of wine added

124

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

This analogy perfectly illustrates what the reconciliation algorithm does: compares two virtual Document Object Model (DOM) trees, figures out the changes, and applies those changes to the real DOM. Destroying the entire DOM and re-creating it from scratch every time the state changes would be like emptying the cart and starting from scratch every time you get a new shopping list: you don’t need to think, but the process is laborious. You want your framework to be smart enough to figure out the minimum number (or at least a reasonably small number) of operations to apply to the real DOM to make it reflect the new state.

Figure 7.3 shows how your framework currently renders the view when the state changes. Destroying and re-creating the DOM every time the state changes is an expensive operation. In a frontend application, the state can change a couple of times every second. Suppose that your shopping list changed that often and that you had to empty your cart and start over every time your partner sent you an updated list.

Old vdom

mountDOM()

Expensive!

The state

changes.

Destroying and mounting

the DOM every time the

state changes is expensive.

New vdom

destroyDOM()

mountDOM()

Figure 7.3

The current framework destroys and re-creates the DOM every time the state changes, which is an expensive operation.

125

The objective of this chapter is to implement the first half of the reconciliation algorithm; you’ll implement the rest in chapter 8. This algorithm does two things:

 Figures out the differences between two virtual DOM trees (this chapter)

 Applies those differences to the real DOM so that the framework can update it more efficiently (chapter 8)

The process is the same as getting a new shopping list, comparing it with the original one, finding the differences, and then changing the items in your cart. You have to compare the old virtual DOM with the new virtual DOM, find the differences, and then apply those differences to the real DOM. You’ll focus on the first part in this chapter. In chapter 8, you’ll write a patchDOM() function to implement the algorithm, putting all the pieces together. Then your framework will update the view much more efficiently, as you can see in figure 7.4.

Old vdom

mountDOM()

Efficient!

The state

changes.

Figuring out what changed

and applying only those

changes is efficient.

New vdom

patchDOM()

These two nodes were added.

Figure 7.4

The framework will update the DOM more efficiently by using the reconciliation algorithm written in the patchDOM() function.

126

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

NOTE

You can find all the listings in this chapter in the listings/ch07 directory of the book’s repository (). The code you’ll write in this chapter is for the framework’s second version, which you’ll publish in chapter 8. Therefore, the code in this chapter can be checked out from the ch8

label ( $ git switch --detach ch8.

Code catch-up

In chapter 6, you rewrote the TODOs application using the framework you built in the previous chapters.

7.1

The three key functions of the reconciliation algorithm

Believe it or not, finding the differences between two virtual DOMs, which is the most complex part of the reconciliation algorithm, can be solved with three functions:

 A function to find the differences between two objects, returning the keys that were added, the keys that were removed, and the keys whose associated values changed (used to compare the attributes and CSS style objects)

 A function to find the differences between two arrays, returning the items that were added and the items that were removed (used to compare two CSS classes’ arrays)

 A function that, given two arrays, figures out a sequence of operations to apply to the first array and transform it into the second array (used to turn the array of a node’s children into its new shape)

The two first functions are somehow straightforward, but the third is a bit more complex; I’d say it’s where 90 percent of the complexity of the reconciliation algorithm lies.

In this chapter, you’ll write these functions, which you’ll use in chapter 8 to implement the patchDOM() function. Let’s start by exploring how to figure out what changed from one virtual DOM tree to another and how to apply those changes to the real DOM.

7.2

Comparing two virtual DOM trees

As we’ve seen, the view is a function of the state: when the state changes, the virtual DOM

representing the view also changes. The reconciliation algorithm compares the old virtual DOM—the one used to render the current view—with the new virtual DOM after the state changes. Its job is to reconcile the two virtual DOM trees—that is, figure out what changed and apply those changes to the real DOM so that the view reflects the new state.

To compare two virtual DOM trees, start comparing the two root nodes, checking whether they’re equal (we’ll see what it takes for two nodes to be equal) and whether their attributes or event listeners have changed. If you find that the node isn’t the same, you look no further. First, you destroy the subtree rooted at the old node and replace it with the new node and everything under it. (We’ll explore this topic in detail in chapter 8.) Then you compare the children of the two nodes, traversing the trees recursively in a depth-first manner. When you compare two arrays of child nodes, you need to figure

7.2

Comparing two virtual DOM trees

127

out whether a node was added, removed, or moved to a different position. In a sense, depth-first traversal is the natural order in which the DOM is modified.

NOTE

By traversing the tree in a depth-first manner, you ensure that the changes are applied to a complete branch of the tree before moving to the next branch. Traversing the tree this way is important in the case of fragments because the children of a fragment are added to the fragment’s parent, and if the number of children changes, that change could potentially alter the indices where the siblings of the fragment’s parent are inserted.

7.2.1

Finding the differences

Let’s look at an example. Suppose that you’re comparing the two virtual DOM trees in figure 7.5: the old virtual DOM tree at top and the new virtual DOM tree at the bottom.

Old vdom

<div>

id: "abc"

style: {

color: "blue"

}

<p>

<p>

class: "foo"

class: ["foo", "bar"]

Text

Text

"Hello"

"World"

New vdom

<div>

1. Attribute modified

id: "def"

2. Style modified

style: {

color: "red"

}

6. Class added and

class removed

3. Class modified

<p>

<span>

<p>

class: "fox"

class: "foo"

class: [ , "bar"]

"baz"

Text

Text

Text

"Hi"

"there"

"World!"

4. Text changed

5. Node added

7. Text changed

Figure 7.5

Comparing two virtual DOM trees to find out what changed

128

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

In this figure, the differences are already highlighted, but we’ll explore how to find them step by step.

Exercise 7.1

Being able to translate from a virtual DOM diagram to HTML (and vice versa) is useful.

Can you write the HTML that both the old and new virtual trees in figure 7.5 would produce?

Find the solution

STEP 1

You compare the two root <div> nodes (figure 7.6) and find that the id attribute changed from "abc" to "def" and the style attribute changed from "color: blue" to

"color: red". These changes, labeled “1. Attribute modified” and “2. Style modified”

in figure 7.5, are the first changes you need to apply to the real DOM.

Old vdom

New vdom

<div>

<div>

id: "abc"

id: "def"

style: {

style: {

color: "blue"

color: "red"

}

}

<p>

<p>

<p>

<span>

<p>

class: "foo"

class: ["foo", "bar"]

class: "fox"

class: "foo"

class: [ "baz" , "bar"]

Text

Text

Text

Text

Text

"Hello"

"World"

"Hi"

"there"

"World!"

Figure 7.6

Comparing the root <div> nodes

STEP 2

You compare the two children of the <div> node one by one. The first child is a <p> element (figure 7.7), and it seems to be in the same position in both trees; it didn’t move. Its class attribute, however, has changed from "foo" to "fox". This change is labeled “3. Class modified” in figure 7.5, and it’s the next change to apply to the real DOM.

7.2

Comparing two virtual DOM trees

129

Old vdom

New vdom

<div>

<div>

id: "abc"

id: "def"

style: {

style: {

color: "blue"

color: "red"

}

}

<p>

<p>

<p>

<span>

<p>

class: "foo"

class: ["foo", "bar"]

class: "fox"

class: "foo"

class: [ "baz" , "bar"]

Text

Text

Text

Text

Text

"Hello"

"World"

"Hi"

"there"

"World!"

Figure 7.7

Comparing the first child of the <div> nodes: A <p> element STEP 3

Remember that you want to iterate the trees in a depth-first manner, so now you compare the children of the <p> element: their text content. The text content has changed from "Hello" to "Hi" (figure 7.8). This change, labeled “4. Text changed” in figure 7.5, is applied to the real DOM next.

Old vdom

New vdom

<div>

<div>

id: "abc"

id: "def"

style: {

style: {

color: "blue"

color: "red"

}

}

<p>

<p>

<p>

<span>

<p>

class: "foo"

class: ["foo", "bar"]

class: "fox"

class: "foo"

class: [ "baz" , "bar"]

Text

Text

Text

Text

Text

"Hello"

"World"

"Hi"

"there"

"World!"

Figure 7.8

Comparing the text nodes of the <p> element

STEP 4

You find that the second child is a <p> in the old tree, but it’s a <span> in the new tree (figure 7.9). By looking at the children of the new <div> node, you quickly realize that the <p> that used to be the second child has moved one position to the right. You conclude that the <span> was added, and it naturally moved the <p> one position to the right. Adding the new <span> node, including its text node, is the next change to apply to the DOM. This change is labeled “5. Node added” in figure 7.5.

130

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

Old vdom

New vdom

<div>

<div>

Not the same node

id: "abc"

id: "def"

style: {

style: {

color: "blue"

color: "red"

}

}

<p>

<p>

<p>

<span>

<p>

class: "foo"

class: ["foo", "bar"]

class: "fox"

class: "foo"

class: [ "baz" , "bar"]

Text

Text

Text

Text

Text

"Hello"

"World"

"Hi"

"there"

"World!"

Figure 7.9

Comparing the second child of the <div> nodes: A <p> element in the old tree and a <span> element in the new tree

STEP 5

You look at the <p> node that you know moved one position (figure 7.10), and you see that its class attribute changed from ["foo", "bar"] to ["baz", "bar"], which means that the class "foo" was removed and the class "baz" was added. This change is labeled “6. Class added and class removed” in figure 7.5.

Old vdom

New vdom

<div>

<div>

Moved node

id: "abc"

id: "def"

style: {

style: {

color: "blue"

color: "red"

}

}

<p>

<p>

<p>

<span>

<p>

class: "foo"

class: ["foo", "bar"]

class: "fox"

class: "foo"

class: [ "baz" , "bar"]

Text

Text

Text

Text

Text

"Hello"

"World"

"Hi"

"there"

"World!"

Figure 7.10

Comparing the <p> element that moved one position to the right STEP 6

Last, you check the children of the <p> element (figure 7.11), and you find that the text content changed from "World" to "World!". This change is labeled “7. Text changed” in figure 7.5.

After comparing the two trees, you found a total of seven changes that need to be applied to the real DOM. The next step is to apply them. Let’s see how you do that.

7.2

Comparing two virtual DOM trees

131

Old vdom

New vdom

<div>

<div>

id: "abc"

id: "def"

style: {

style: {

color: "blue"

color: "red"

}

}

<p>

<p>

<p>

<span>

<p>

class: "foo"

class: ["foo", "bar"]

class: "fox"

class: "foo"

class: [ "baz" , "bar"]

Text

Text

Text

Text

Text

"Hello"

"World"

"Hi"

"there"

"World!"

Figure 7.11

Comparing the text nodes of the <p> element that moved one position to the right NOTE

Remember that you’ll modify the DOM in chapter 8. But I want you to see how it works here so that you’ll have a good high-level understanding of the complete reconciliation process.

7.2.2

Applying the changes

Let’s see how you’d apply these changes in order. Figure 7.12 shows the HTML

markup of the old virtual DOM tree.

<div id="abc" style="color: blue;">

<p class="foo">

Hello

</p>

<p class="foo bar">

World

</p>

Figure 7.12

The HTML markup

</div>

of the old virtual DOM tree

You’ll apply the changes you identified by comparing the two trees in the preceding section. Applying a change means modifying the real DOM to match the new virtual DOM tree. Every change you make will be shown in figures so you can see how the DOM changes with each operation.

MODIFYING THE ID AND STYLE ATTRIBUTES OF THE PARENT <DIV> NODE

The first two changes were made to the root <div> node’s attributes, as shown in figure 7.13. To apply these changes, you need to update the id and style attributes of the <div> node:

div.id = 'def'

div.style.color = 'red'

132

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

1. Change ID value.

2. Change color value.

<div id="def" style="color: red;">

<p class="foo">

Hello

</p>

<p class="foo bar">

World

</p>

Figure 7.13

Applying the attribute and

</div>

style changes to the DOM’s <div> node

MODIFYING THE CLASS ATTRIBUTE AND TEXT CONTENT OF THE FIRST <P> NODE

Next are the changes to the first <p> node, as shown in figure 7.14. In this case, you’d need to update the class attribute and the text content of the <p> node: p1.className = 'fox'

p1Text.nodeValue = 'Hi'

<div id="def" style="color: red;">

<p class="fox">

Hi

3. Change class value.

</p>

4. Change text content.

<p class="foo bar">

World

</p>

Figure 7.14

Applying the

</div>

changes to the first <p> node

ADDING THE NEW <SPAN> NODE

The next change adds the <span> node, as shown in figure 7.15. To add a node (and its children) to the DOM, you can pass the virtual node to the mountDOM() function you wrote earlier, passing the <div> node as the parent node: mountDOM(spanVdom, div)

Note that, as it is, the mountDOM() function would append the <span> node to the children array of the <div> node. But you want to specify that the <span> node should be inserted at the second position (index 1) of the children array. You need to modify the mountDOM() function to accept a third argument representing the index where the new node should be inserted:

mountDOM(spanVdom, div, 1)

You’ll modify the mountDOM() function in chapter 8. For now, assume that this function takes an index as the third argument and inserts the new node at that position.

7.2

Comparing two virtual DOM trees

133

<div id="def" style="color: red;">

<p class="fox">

Hi

</p>

<span class="foo">

there

5. Add element node.

</span>

<p class="foo bar">

World

</p>

Figure 7.15

Adding the new

</div>

<span> node and its text node

MODIFYING THE CLASS ATTRIBUTE AND TEXT CONTENT OF THE SECOND <P> NODE

Last, you reach the second <p> node. You don’t need to move it because when the

<span> node was added, it naturally moved the second <p> node one position to the right. When I say that it was naturally moved, I mean that the movement happened as the result of another operation; you don’t need to write an explicit move operation.

(Keep this example in mind; it’ll be important later.) The class attribute changed from ["foo", "bar"] to ["baz", "bar"], so you need to change the classList property of the <p> node:

p2.classList.remove('foo')

p2.classList.add('baz')

The text content changed from "World" to "World!", so you need to update the text node:

p2Text.nodeValue = 'World!'

You can see these changes in figure 7.16.

<div id="def" style="color: red;">

<p class="fox">

Hi

</p>

<span class="foo">

there

</span>

<p class="baz bar">

World!

6. Change class value.

</p>

Figure 7.16

Applying the changes

7. Change text content.

</div>

to the second <p> node

In a nutshell, this discussion shows what the patchDOM() function does. To gain better high-level understanding of how the rendering works when the patchDOM() function

134

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

enters the scene, let’s start by modifying the renderApp() function to call the patchDOM() function instead of destroying and re-creating the DOM every time.

7.3

Changes in the rendering

To understand the role of the patchDOM() function—the implementation of the reconciliation algorithm—it’s helpful to see the big picture of how rendering works with it. Figure 7.17 shows the changes in the rendering mechanism, using the patchDOM() function. As you can see, the renderer is split into three sections: mounting, patching, and unmounting. Whereas in chapter 6, the same renderApp() function was responsible for mounting and updating the DOM, now you’ll split it into the two following functions:

 mount()—Internally uses mountDOM()

 renderApp()—Internally uses patchDOM()

State manager

Command handler registry

'abc'

f(payload)

'def'

f(payload)

Read/write

Command

'ghi'

f(payload)

After every event

After event registry

f()

f()

JS event

Notify renderer

State

Renderer

Re der

n

Mounting (mount())

Pa ch

t

vdom = view(state, emit)

Read

mountDOM( , )

vdom parentEl

Patching (renderApp())

The application is mounted only once.

newVdom = view(state, emit)

vdom vdom newVdom parentEl

= patchDOM( , , )

Every time the state changes,

Unmounting (unmount())

the view is patched.

destroyDOM( )

oldVdom

Figure 7.17

Changes in the rendering mechanism, using the patchDOM() function You need to modify the code so that the renderApp() function doesn’t destroy and re-create the DOM anymore, and to do so, you use the patchDOM() function. patchDOM() takes the last saved virtual DOM (stored in the vdom variable inside the application’s

7.3

Changes in the rendering

135

instance), the new virtual DOM resulting from calling the view() function, and the parent element (stored in the parentEl instance) of the DOM, where the view was mounted, and figures out the changes that need to be applied to the DOM.

The application’s instance mount() method doesn’t need to use the renderApp() function anymore; renderApp() is called only when the state changes. mount() calls the view() function to get the virtual DOM and then calls the mountDOM() function to mount the DOM. The user is supposed to call the mount() method only once. If they call it more than once, the same application will be mounted multiple times, which is not what you want. You can add a check to prevent this situation by throwing an error if the vdom variable isn’t null (which you’ll do in exercise 7.2).

Let’s reflect this important change in the code. Open the app.js file, and make the changes shown in the following listing.

Listing 7.1

Changes in the rendering

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

import { Dispatcher } from './dispatcher'

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

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

export function createApp({ state, view, reducers = {} }) {

let parentEl = null

let vdom = null

// --snip-- //

function renderApp() {

if (vdom) {

destroyDOM(vdom)

Computes the

}

new virtual DOM

const newVdom = view(state, emit)

mountDOM(vdom, parentEl)

vdom = patchDOM(vdom, newVdom, parentEl)

Patches the DOM

}

every time the

state changes

return {

mount(_parentEl) {

parentEl = _parentEl

renderApp()

vdom = view(state, emit)

mountDOM(vdom, parentEl)

Mounts the

},

DOM only once

// --snip-- //

}

}

WARNING

You’re importing the patchDOM() from the patch-dom.js file, which

you’ll write in chapter 8. (Your IDE might complain that the file doesn’t exist, but that’s okay for now.)

136

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

Now that you’ve changed the rendering flow, let’s start thinking about the three functions that you’ll use to compare two virtual DOM trees: objectsDiff(), arraysDiff(), and arraysDiffSequence().

Exercise 7.2

Implement a check that doesn’t allow the user to call the mount() method more than once to prevent the same application from being mounted multiple times. If you detect that the application was already mounted, throw an error.

Find the solution .

7.4

Diffing objects

When comparing two virtual nodes, you need to find the differences between the attributes of the two nodes (the props object) to patch the DOM accordingly. You want to know what attributes were added, what attributes were removed, and what attributes changed. We call this process diffing.

To illustrate, figure 7.18 shows the attributes of an <input> element. In the figure, you see an old object (the one at the top) and a new object (the one at the bottom).

By observing the two objects, you see that the min key was added in the new object, the max key was removed, and the disabled key changed from false to true.

Old object

{

type: 'number',

disabled: false,

max: 999,

Added

}

min

Removed

max

New object

{

Modified

type: 'number',

disabled

disabled: true,

min: 0,

Figure 7.18

Finding the difference

}

between two objects. Which keys

were added, removed, or changed?

This exercise, which you’ve done by mere observation, is what the objectsDiff() function does. The code you write works similarly to your observation, following these steps: 1

Take a key in the old object. If you don’t see it in the new object, you know that the key was removed. Repeat with all keys.

7.4

Diffing objects

137

2

Take a key in the new object. If you don’t see it in the old object, you know that the key was added. Repeat with all keys.

3

Take a key in the new object. If you see it in the old object and the value associated with the key is different, you know that the value associated with the key changed.

Create a new file inside the utils folder called objects.js, and write the objectsDiff() function shown in the following listing.

Listing 7.2

Finding the difference between two objects (utils/objects.js)

export function objectsDiff(oldObj, newObj) {

const oldKeys = Object.keys(oldObj)

Keys in the new

const newKeys = Object.keys(newObj)

object that are not

in the old object

return {

were added.

added: newKeys.filter((key) => !(key in oldObj)),

removed: oldKeys.filter((key) => !(key in newObj)),

Keys in the old

updated: newKeys.filter(

object that are

(key) => key in oldObj && oldObj[key] !== newObj[key]

not in the new

),

object were

}

Keys in both objects that have

removed.

}

different values were changed.

NOTE

In the code for the objectsDiff() function, I’m iterating the newKeys set twice. I could have avoided that part by performing a single iteration in a for loop where I saved the added and updated keys. This approach would have been more efficient, but I chose to use two separate iterations—one for the added keys and another for the updated keys—to make the code more readable. As I mention in appendix A, I’m favoring readability and code simplicity over performance. The goal of the code is to show you, the reader, how to build a frontend framework; it isn’t being super-performant. But you should feel free to optimize your code if you want.

Fantastic—you’ve implemented the objectsDiff() function. Can you think of a set of unit tests that would help you verify that the function works as expected?

Exercise 7.3

Implement a set of test cases to cover all possible scenarios for the objectsDiff() function. If you’re not familiar with unit testing, try to think of the different cases your function should handle; then execute the function in the browser’s console with different inputs to see whether it works as expected.

Find the solution at

138

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

7.5

Diffing arrays

Let’s look at the second function that you’ll implement, which does a similar job for arrays. Remember that the classes in an element virtual node can be given in the form of an array:

h('p', { class: ['foo', 'bar'] }, ['Hello world'])

When you compare two virtual nodes, you need to find the differences between the classes of the two nodes (the class array) to patch the DOM accordingly (as you did in the example of figure 7.5, in step 5). In this case, it doesn’t matter whether the items in the array are in a different order; you care about only the items that were added or removed. For this purpose, you’ll implement the arraysDiff() function next. First, though, consider the example in figure 7.19. The figure shows an old array (the one at the top) and a new array (the one at the bottom). When you compare the two arrays, you see the following:

 A appears in both arrays.

 B and C were removed (appear only in the old array).

 D and E were added (appear only in the new array).

Old array

[A, B, C]

Added

D, E

New array

Removed

B, C

Figure 7.19

Finding the difference

[A, D, E]

between two arrays. Which items

were added or removed?

Note that in this comparison, items are added or removed but never changed. If an item is changed, you detect it as a removal of the old item and an addition of the new item.

With this example in mind, open the utils/arrays.js file, where you previously implemented the withoutNulls() function, and write the arraysDiff() function as shown in the following listing.

Listing 7.3

Finding the differences between two arrays (utils/arrays.js)

export function arraysDiff(oldArray, newArray) {

Items in the new array

return {

that are not in the old

added: newArray.filter(

array were added.

(newItem) => !oldArray.includes(newItem)

),

Items in the old array

that are not in the new

removed: oldArray.filter(

array were removed.

(oldItem) => !newArray.includes(oldItem)

7.6

Diffing arrays as a sequence of operations

139

),

}

}

WARNING

In the arraysDiff() function, you’re not specifying the index at

which an item was added or removed because to modify the classList of an element, you simply add or remove the classes. This approach might be problematic because, as you know, the order of classes in the classList matters. A class that comes later in the list can override the styles of a class that comes earlier in the list. You’re making this tradeoff to keep the code simple, but bear in mind that a more robust solution is to maintain the order of classes in the classList.

With these two items out of the way, you’re ready to tackle the beast: the arrayDiffSequence() function. You might sweat this function a bit, but after you’ve written it, the rest of the book will feel like a breeze (or something like that). Take a deep breath, and let’s move on.

7.6

Diffing arrays as a sequence of operations

A virtual node has children, and those children can move around from a render to the next one. Child nodes are added and removed all the time as well. When you compare two virtual nodes, you need to find the differences between the children of the two nodes (the children array) to patch the DOM accordingly. You want to find a sequence of operations that, when executed on the DOM, transforms the old children array—rendered as HTML elements—into the new children array.

If you have two arrays and I ask you to come up with a list of add, remove, and move operations that transform the first array into the second one, how would you go about it? You can see an example in figure 7.20. In this figure, the old array is [A, B, C], and the new array is [C, B, D]. I was able to find a sequence of three operations that, if applied in order, will transform the old array into the new array.

Old array

[A, B, C]

Operations

1. Remove A from i=0.

(result: )

[B, C]

New array

2. Move C (originally at 2) from i=1 to i=0.

(result: )

[C, B]

3. Add D at i=2.

(result: )

[C, B, D]

[C, B, D]

Figure 7.20

Finding a sequence of operations that transforms the original array into the new one NOTE

Notice that the move operation’s from index is different from the original index that C had in the old array. C appeared at i=2, but after A was removed, it moved to i=1. The indices in the operations are always relative to

140

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

how they find things in the array at the moment of the operation. But you still want to keep track of the original index of the item, and I’ll explain why in section 7.6.2.

Many other sequences of operations—an infinite number, in fact—would yield the same result. In other words, the sequence of operations that transforms the old array into the new array isn’t unique.

Exercise 7.4

Can you find a sequence of similar operations that would also transform the old array into the new array?

Find the solution .

One constraint that you can impose on the sequence is that you want to minimize the number of operations. You could come up with a sequence that starts by moving an item from its position to another one, moves it back to its original position, and repeats that operation a lot of times, but that’s not what you want to do—DOM modifications are relatively expensive, so the fewer operations you perform, the better.

7.6.1

Defining the operations you can use

Let’s start by defining the operations you’ll use to transform the old array into the new array. The preceding example used three operations: add, remove, and move. Now you need to add a fourth operation: noop (no operation).

You’ll use a noop operation when you find an item that’s in both arrays, which requires no action to stay where it ends up. Notice that I said “ends up,” not “starts.”

This distinction is important. Some items move naturally to their final position because items are added, removed, or moved around them. If you want to go from [A, B] to

[B], for example, you simply need to remove A. B falls into place naturally: without any explicit move operation, it goes from i=1 to i=0.

When an item moves naturally, you need to include a noop operation in the sequence. This operation helps you keep track of the items that moved naturally from where they started to where they ended up. You need this operation because patching the DOM is a recursive process: each item in the children array that moved around also needs to be compared for differences. For this operation to be possible, you need to know where each item started and where it ended up—in other words, its initial and final indexes in the array. This topic will become much clearer in chapter 8. The following list shows the operations and the data they need:

 add—{ op: 'add', item: 'D', index: 2 }

 remove—{ op: 'remove', item: 'B', index: 0 }

 move—{ op: 'move', item: 'C', originalIndex: 2, from: 2, index: 0 }

 noop—{ op: 'noop', item: 'A', originalIndex: 0, index: 1 }

7.6

Diffing arrays as a sequence of operations

141

As you can see, in all cases the object that describes the operation has an op property that indicates the type of operation. The item property is the item that’s being added, removed, or moved (naturally or forcefully). The index property is the index where the item ends up, except in the case of a remove operation, where it’s the index from which the item was removed. In the move and noop operations, the originalIndex property indicates the index where the item started. Last, in the move operation, we also keep track of the from property, which is the index from which the item was moved. Note that in the case of a move operation, the from and originalIndices need not be the same. Following the example in figure 7.20, the sequence of operations that transforms the old array into the new array is

[

{op: 'remove', index: 0, item: 'A'}

{op: 'move', originalIndex: 2, from: 1, index: 0, item: 'C'}

{op: 'noop', index: 1, originalIndex: 1, item: 'B'}

{op: 'add', index: 2, item: 'D'}

]

Exercise 7.5

Apply the operations in the preceding sequence to the old array ([A, B, C]), and check whether you end up with the new array ([C, B, D]). You’ll likely perform this task manually a couple of times in this chapter to debug the code you’ll write.

Find the solution at

7.6.2

Finding the sequence of operations: The algorithm

Let’s talk about how the algorithm that finds the sequence of operations works. The idea is to iterate over the indices of the new array and, for each step, find a way of transforming the old array so that the items in both arrays are the same at the current index. You focus on one item at a time so that after each step, that item and all the preceding ones are in their final position. The algorithm modifies the old array (a copy of it, to avoid mutating the original one) as it goes, and it keeps track of the operations that transform the old array into the new one.

Going one item at a time and modifying a copy of the old array ensures that every operation is performed over the updated indices of the old array. When the new array is completely iterated over, any excess items in the old array are removed; the new array doesn’t have them, so they’re not needed. The algorithm is as follows: 1

Iterate over the indices of the new array:

– Let i be the index (0 ≤ i < newArray.length).

– Let newItem be the item at i in the new array.

– Let oldItem be the item at i in the old array (provided that there is one).

2

If oldItem doesn’t appear in the new array:

– Add a remove operation to the sequence.

142

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

– Remove the oldItem from the array.

– Start again from step 1 without incrementing i (stay at the same index).

3

If newItem == oldItem:

– Add a noop operation to the sequence, using the oldItem original index (its index at the beginning of the process).

– Start again from step 1, incrementing i.

4

If newItem != oldItem and newItem can’t be found in the old array starting at i:

– Add an add operation to the sequence.

– Add the newItem to the old array at i.

– Start again from step 1, incrementing i.

5

If newItem != oldItem and newItem can be found in the old array starting at i:

– Add a move operation to the sequence, using the oldItem current index and the original index.

– Move the oldItem to i.

– Start again from step 1, incrementing i.

6

If i is greater than the length of newArray:

– Add a remove operation for each remaining item in oldArray.

– Remove all remaining items in oldArray.

– Stop the algorithm.

This algorithm isn’t straightforward, but it’s simpler than it appears at first glance. Figure 7.21 shows a flow chart of the algorithm with the same steps as the preceding list.

oldItem

Start

oldArray =

i=0

newArray =

i

newItem

Old

i <

No

Yes

it m

e

in

New size

new array?

New

No

Yes

removeItemsAfter()

=

Ol ?

d

New

Yes

No

it m

e

in

removeItem()

End

old array?

noopItem()

No

Yes

i++

addItem()

moveItem()

i++

i++

Figure 7.21

The algorithm’s flow chart

7.6

Diffing arrays as a sequence of operations

143

I’ll use it in section 7.6.4 when you have to implement the algorithm in code to show where you are in the process. The algorithm is complex, and I want to make sure that you don’t get lost.

7.6.3

An example by hand

Let’s work through an example to see how the algorithm works. Suppose that you have the following arrays:

 oldArray = [X, A, A, B, C]

 newArray = [C, K, A, B]

Let’s apply the algorithm step by step.

STEP 1 (I=0)

The X in the old array doesn’t appear in the new array, so you want to remove it (figure 7.22).

i=0

old = [ X , A , A , B , C ]

X doesn’t appear

in the new array.

new = [ C , K , A , B ]

OPERATION: Remove X at i=0.

old = [ X , A , A , B , C ]

new = [ C , K , A , B ]

Figure 7.22

At i=0 in the old array is X, which doesn’t appear in the new array; it’s a remove operation.

After removing the item and adding the remove operation to the list of operations, you keep the index at i=0 because you haven’t fulfilled the condition of having the same item in both arrays at that index.

STEP 2 (I=0)

You find a C in the new array, but there’s an A in the old array at that same index. Let’s look for the C in the old array, starting at i=0. Oh! There’s one at i=3 (figure 7.23).

Note that if there were more than one C in the old array, you’d choose the first one you find.

You add the move operation to the sequence of operations and move the C from i=3 to i=0 in the old array.

144

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

i=0

old = [ A , A , B , C ]

C appears in the

new = [ C , K , A , B ]

old array at i = 3.

OPERATION: Move C (originally at 4) from i=3 to i=0.

old = [ C , A , A , B ]

The items at i = 0 aligned

new = [ C , K , A , B ]

after the move operation.

Figure 7.23

At i=0, there’s a C in the new array. We can move the C in the old array from i=4.

STEP 3 (I=1)

There’s a K in the new array but an A in the old array. You look for a K in the old array, starting at i=1. In this case there isn’t one, so you need to add it (figure 7.24).

i=1

old = [ C , A , A , B ]

K doesn’t appear

new = [ C , K , A , B ]

in the old array.

OPERATION: Add K at i=1.

old = [ C , K , A , A , B ]

The items at i = 1 aligned

new = [ C , K , A , B ]

after the add operation.

Figure 7.24

At i=0 is a K in the new array, but there’s no K in the old array. We need an add operation.

You add the K to the old array at i=1 and append the operation to the sequence of operations.

STEP 4 (I=2)

At i=2, both arrays have an A (figure 7.25). Hooray! You add a noop operation in these cases. For that purpose, you need the original index of A in the old array. But that A moved naturally due to the preceding operations:

 It moved one position to the left when the X was removed in step 1.

7.6

Diffing arrays as a sequence of operations

145

 It moved one position to the right when the C was moved in step 2, which canceled the previous move.

 It moved one position to the right when the K was added in step 3.

In total, the A moved one position to the right, so you need to subtract 1 from the current index of the A in the old array, making the A’s original index i=1.

i=2

old = [ C , K , A , A , B ]

A already appears in the correct

new = [ C , K , A , B ]

position of the old array.

OPERATION: Noop A (originally at 1) to i=2.

A was originally at i = 1, but it moved

due to the previous operations.

old = [ C , K , A , A , B ]

The items at i = 2 were already

new = [ C , K , A , B ]

aligned.

Figure 7.25

At i=2, both arrays have an A. We add a noop operation, using the original index of the A in the old array.

You want to keep track of the old array items’ original indexes so that you’ll have them available when you add noop and move operations. As you can imagine, calculating the original index of an item based on the preceding operations isn’t complicated, but it’s much better to save it at the beginning of the process.

STEP 5 (I=3)

At i=3, the new array has a B, but the old array has an A. Look for a B in the old array, starting at i=3. There’s one at i=4, so you can move it (figure 7.26).

i=3

old = [ C , K , A , A , B ]

new = [ C , K , A , B ]

B appears in the old array at i=4.

OPERATION: Move B (originally at 3) from i=4 to i=3.

B was originally at i = 3, but it moved

due to the previous operations.

old = [ C , K , A , B , A ]

The items at i = 3 aligned after the

new = [ C , K , A , B ]

move operation.

Figure 7.26

At i=3, there’s a B in the new array. You can move the B in the old array from i=4.

146

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

You append the move operation to the sequence of operations and move the B to i=3

in the old array. At this point, you’re done iterating the elements in the new array, and all items from i=0 to i=3 in the old array are aligned with the new array. All you have to do now is remove what’s left in the old array because the excess items don’t appear in the new array.

STEP 6

At i=4 in the old array is an extra A that you want to remove (figure 7.27).

i=?

There’s an extra A at i=4.

old = [ C , K , A , B , A ]

All items in the new array are

already aligned in the old array.

new = [ C , K , A , B ]

OPERATION: Remove A at i=4.

old = [ C , K , A , B , A ]

new = [ C , K , A , B ]

Figure 7.27

At i=4 in the old array is an extra A that we need to remove.

If there were more items in the old array, you’d want to remove them as well. All those remove operations would be at index i=4 because as an item is removed, the next one occupies its place at that index.

I hope that the algorithm is clear to you now that you’ve worked out an example by hand. It’s time to implement it in code.

7.6.4

Implementing the algorithm

Let’s start by defining a constant for the operation names. Inside the utils/arrays.js file, add the following constant:

export const ARRAY_DIFF_OP = {

ADD: 'add',

REMOVE: 'remove',

MOVE: 'move',

NOOP: 'noop',

}

You want a way to keep track of the old array’s original indices so that when you modify a copy of the old array and you apply each operation, you can keep the original indices. A good solution is to create a class—let’s call it ArrayWithOriginalIndices—

that wraps a copy of the old array and keeps track of the original indices. For whatever item moves inside that array wrapper, the original index also moves.

7.6

Diffing arrays as a sequence of operations

147

This wrapper class will search for a given item in the wrapped array (as in step 2 of the preceding example, where you looked for a C somewhere in the array) for which you want a custom comparison function. The items inside the array will be virtual nodes, and you want a function that tells you whether two virtual nodes are equal.

(You’ll implement this function in chapter 8.)

WARNING

Recall that in JavaScript, two objects are equal if they’re the same reference—that is, the same object in memory. Because our virtual nodes are objects, you need to define a custom function to compare them. You can’t rely on the default === comparison because if a virtual node is the same as another one but the object is different, the comparison will return false, which is not what you want. You can test that premise as follows:

const a = { foo: 'bar' }

const b = { foo: 'bar' }

console.log(a === b) // false

In the utils/arrays.js file, add the class in the following listing. You’ll add functionality to this class soon.

Listing 7.4

Keeping track of the original indices (utils/arrays.js)

class ArrayWithOriginalIndices {

Defines the class

#array = []

#originalIndices = []

#equalsFn

The internal array is a

copy of the original.

constructor(array, equalsFn) {

this.#array = [...array]

Keeps track of the

this.#originalIndices = array.map((_, i) => i)

original indices

this.#equalsFn = equalsFn

Saves the function

}

used to compare

items in the array

get length() {

The current

return this.#array.length

length of the

}

array

}

Let’s define the arraysDiffSequence() function with some TODO comments where each case of the algorithm is implemented. Inside the utils/arrays.js file, add the function shown in the following listing.

Listing 7.5

The arraysDiffSequence() function (utils/arrays.js)

export function arraysDiffSequence(

oldArray,

The equalsFn is

used to compare

newArray,

items in the array.

equalsFn = (a, b) => a === b

) {

const sequence = []

148

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

const array = new ArrayWithOriginalIndices(oldArray, equalsFn)

for (let index = 0; index < newArray.length; index++) {

// TODO: removal case

Iterates the indices

// TODO: noop case

of the new array

// TODO: addition case

Wraps the old array in an

ArrayWithOriginalIndices

// TODO: move case

instance

}

// TODO: remove extra items

Returns the sequence

return sequence

of operations

}

Giving the equalsFn parameter a default value using the default equality operator (===) will be handy, as you’ll test this function (mostly) by passing it arrays of strings in this chapter. Let’s start with the remove case.

THE REMOVE CASE

To find out whether an item was removed, you check whether the item in the old array at the current index doesn’t exist in the new array. Figure 7.28 shows this branch of the flow chart.

oldItem

Start

oldArray =

i=0

newArray =

i

newItem

Old

i <

No

Yes

it m

e

in

New size

new array?

No

Yes

New

removeItemsAfter()

=

Ol ?

d

New

Yes

No

it m

e

in

removeItem()

old array?

End

noopItem()

No

Yes

i++

addItem()

moveItem()

i++

i++

Figure 7.28

The remove case in the flow chart

7.6

Diffing arrays as a sequence of operations

149

Implement this logic inside a method called isRemoval() in the ArrayWithOriginalIndices class, as shown in bold in the following listing.

Listing 7.6

Detecting whether an operation is a removal (utils/arrays.js)

class ArrayWithOriginalIndices {

#array = []

#originalIndices = []

#equalsFn

constructor(array, equalsFn) {

this.#array = [...array]

this.#originalIndices = array.map((_, i) => i)

this.#equalsFn = equalsFn

}

get length() {

return this.#array.length

}

If the index is out

of bounds, there’s

isRemoval(index, newArray) {

nothing to remove.

if (index >= this.length) {

return false

}

Gets the item in

the old array at

the given index

const item = this.#array[index]

const indexInNewArray = newArray.findIndex((newItem) =>

Tries to find the

this.#equalsFn(item, newItem)

same item in

)

Uses the #equalsFn

the new array,

to compare the items

returning its

return indexInNewArray === -1

index

}

If the index is -1, the

}

item was removed.

Now let’s implement a method to handle the removal of an item and return the operation. You want to reflect the removal operation in both the #array and #originalIndices properties of the ArrayWithOriginalIndices instance. Add the removeItem() method to the ArrayWithOriginalIndices class, as shown in the following listing.

Listing 7.7

Removing an item and returning the operation (utils/arrays.js)

class ArrayWithOriginalIndices {

// --snip-- //

isRemoval(index, newArray) {

// --snip-- //

}

Creates the

operation for

removeItem(index) {

the removal

const operation = {

op: ARRAY_DIFF_OP.REMOVE,

150

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

index,

item: this.#array[index],

The current index of

}

the item in the old array

(not the original index)

this.#array.splice(index, 1)

this.#originalIndices.splice(index, 1)

Removes the item

from the array

return operation

Returns the

}

Removes the original

operation

}

index of the item

With these two methods, you can implement the remove case in the arraysDiffSequence() function. Add the code in bold in the following listing to the arraysDiffSequence() function.

Listing 7.8

Implementing the removal case (utils/arrays.js)

export function arraysDiffSequence(

oldArray,

newArray,

equalsFn = (a, b) => a === b

) {

const sequence = []

const array = new ArrayWithOriginalIndices(oldArray, equalsFn)

for (let index = 0; index < newArray.length; index++) {

if (array.isRemoval(index, newArray)) {

Checks whether the

sequence.push(array.removeItem(index))

item in the old array

index--

at the current index

continue

was removed

}

Removes the item and

// TODO: noop ecase

pushes the operation

to the sequence

// TODO: addition case

Decrements the index to

stay at the same index in

// TODO: move case

the next iteration

}

Continues with the loop

// TODO: remove extra items

return sequence

}

Great! Let’s take care of the noop case next.

THE NOOP CASE

The noop case happens when, at the current index, both the old and new arrays have the same item. Thus, detecting this case is straightforward. Figure 7.29 highlights the noop case in the flow chart.

7.6

Diffing arrays as a sequence of operations

151

oldItem

Start

oldArray =

i=0

newArray =

i

newItem

Old

i <

No

Yes

it m

e

in

New size

new array?

New

No

Yes

removeItemsAfter()

=

Ol ?

d

New

Yes

No

it m

e

in

removeItem()

End

old array?

noopItem()

No

Yes

i++

addItem()

moveItem()

i++

i++

Figure 7.29

The noop case in the flow chart

Implement a method called isNoop() in the ArrayWithOriginalIndices class, as shown in bold in the following listing.

Listing 7.9

Detecting whether an operation is a noop (utils/arrays.js)

class ArrayWithOriginalIndices {

// --snip-- //

If the index is out

of bounds, there

isNoop(index, newArray) {

can’t be a noop.

if (index >= this.length) {

return false

}

The item in

the old array

const item = this.#array[index]

const newItem = newArray[index]

The item in the new array

return this.#equalsFn(item, newItem)

Checks whether the

}

items are equal

}

When you detect a noop at the current index, you need to return the noop operation.

Here’s where the original indices come in handy.

Implement a method called noopItem() in the ArrayWithOriginalIndices class, as shown in bold in the following listing. Note that you also implement an originalIndexAt() method to get the original index of an item in the old array.

152

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

Listing 7.10

Returning a noop operation (utils/arrays.js)

class ArrayWithOriginalIndices {

// --snip-- //

isNoop(index, newArray) {

// --snip-- //

}

Returns the original

index of the item in

originalIndexAt(index) {

the old array

return this.#originalIndices[index]

}

noopItem(index) {

Creates the

noop operation

return {

op: ARRAY_DIFF_OP.NOOP,

Adds the original index

originalIndex: this.originalIndexAt(index),

to the operation

index,

item: this.#array[index],

}

Includes the item

in the operation

}

}

As you can see, you don’t need to do anything to the old array to reflect the noop operation. Things stay the same. With these methods in place, you can implement the noop case in the arraysDiffSequence() function. Write the code shown in bold in the following listing inside the arraysDiffSequence() function.

Listing 7.11

Implementing the noop case (utils/arrays.js)

export function arraysDiffSequence(

oldArray,

newArray,

equalsFn = (a, b) => a === b

) {

const sequence = []

const array = new ArrayWithOriginalIndices(oldArray, equalsFn)

for (let index = 0; index < newArray.length; index++) {

if (array.isRemoval(index, newArray)) {

sequence.push(array.removeItem(index))

index--

continue

}

Checks whether the

operation is a noop

if (array.isNoop(index, newArray)) {

Pushes the noop operation

sequence.push(array.noopItem(index))

to the sequence

continue

}

Continues with the loop

// TODO: addition case

// TODO: move case

}

7.6

Diffing arrays as a sequence of operations

153

// TODO: remove extra items

return sequence

}

THE ADDITION CASE

To check whether an item was added in the new array, you need to check whether that item doesn’t exist in the old array, starting from the current index. Figure 7.30 shows the addition case in the flow chart.

oldItem

Start

oldArray =

i=0

newArray =

i

newItem

Old

i <

No

Yes

it m

e

in

New size

new array?

New

No

Yes

removeItemsAfter()

=

Ol ?

d

New

Yes

No

it m

e

in

removeItem()

old array?

End

noopItem()

No

Yes

i++

addItem()

moveItem()

i++

i++

Figure 7.30

The addition case in the flow chart

To find the item in the old array starting from a given index, you implement a method called findIndexFrom(). When you use that method, the isAddition() method is straightforward to implement. Add the code shown in bold font in the following listing to the ArrayWithOriginalIndices class.

Listing 7.12

Detecting whether an operation is addition (utils/arrays.js)

class ArrayWithOriginalIndices {

// --snip-- //

Checks whether the

item exists starting

isAddition(item, fromIdx) {

at the given index

return this.findIndexFrom(item, fromIdx) === -1

}

154

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

findIndexFrom(item, fromIndex) {

Starts looking from

for (let i = fromIndex; i < this.length; i++) {

the given index

if (this.#equalsFn(item, this.#array[i])) {

return i

If the item at the index

}

i equals the given one,

}

returns i

return -1

Returns -1 if the

}

item wasn’t found

}

Dealing with an addition is as simple as adding the item to the old array and returning the add operation. You have to add an entry to the #originalIndices property, but the added item wasn’t present in the original old array, so you can use -1 in this case.

Add the addItem() method, shown in bold in the following listing, to the ArrayWithOriginalIndices class.

Listing 7.13

Adding an item and returning an add operation (utils/arrays.js) class ArrayWithOriginalIndices {

// --snip-- //

isAddition(item, fromIdx) {

return this.findIndexFrom(item, fromIdx) === -1

}

findIndexFrom(item, fromIndex) {

for (let i = fromIndex; i < this.length; i++) {

if (this.#equalsFn(item, this.#array[i])) {

return i

}

}

return -1

}

addItem(item, index) {

Creates the add

const operation = {

operation

op: ARRAY_DIFF_OP.ADD,

index,

item,

}

Adds the new item to

the old array at the

given index

this.#array.splice(index, 0, item)

this.#originalIndices.splice(index, 0, -1)

Adds a -1 index to the

#originalIndices array

return operation

Returns the add

at the given index

}

operation

}

These two methods make it easy to implement the addition case in the arraysDiffSequence() function. Add the code shown in bold in the following listing to the arraysDiffSequence() function.

7.6

Diffing arrays as a sequence of operations

155

Listing 7.14

Implementing the addition case (utils/arrays.js)

export function arraysDiffSequence(

oldArray,

newArray,

equalsFn = (a, b) => a === b

) {

const sequence = []

const array = new ArrayWithOriginalIndices(oldArray, equalsFn)

for (let index = 0; index < newArray.length; index++) {

if (array.isRemoval(index, newArray)) {

sequence.push(array.removeItem(index))

index--

continue

}

if (array.isNoop(index, newArray)) {

sequence.push(array.noopItem(index))

continue

}

Gets the item in

the new array at

the current index

const item = newArray[index]

if (array.isAddition(item, index)) {

Checks whether

sequence.push(array.addItem(item, index))

the case is an

continue

addition

}

Continues

with the loop

Appends the add

// TODO: move case

operation to the

}

sequence

// TODO: remove extra items

return sequence

}

Let’s move to the move case. This one is interesting.

THE MOVE CASE

The neat thing about the move case is that you don’t need to test for it explicitly; if the operation isn’t a remove, an add, or a noop, it must be a move. Figure 7.31 highlights this branch of the flow chart.

To move an item, you want to extract it from the array (you can use the splice() method for that purpose) and insert it into the new position (you can use the splice() method again). Keep two things in mind: you also need to move the original index to its new position, and you have to include the original index in the move operation. The importance of saving the original index will become clear in chapter 8.

Inside the ArrayWithOriginalIndices class, add the code shown in bold in the following listing to implement the moveItem() method.

156

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

oldItem

Start

oldArray =

i=0

newArray =

i

newItem

Old

i <

No

Yes

it m

e

in

New size

new array?

New

No

Yes

removeItemsAfter()

=

Ol ?

d

New

Yes

No

it m

e

in

removeItem()

End

old array?

noopItem()

No

Yes

i++

addItem()

moveItem()

i++

i++

Figure 7.31

The move case in the flow chart

Listing 7.15

Detecting whether an operation is addition (utils/arrays.js)

class ArrayWithOriginalIndices {

// --snip-- //

Looks for the item

in the old array,

moveItem(item, toIndex) {

starting from the

target index

const fromIndex = this.findIndexFrom(item, toIndex)

Creates the move

const operation = {

operation

op: ARRAY_DIFF_OP.MOVE,

originalIndex: this.originalIndexAt(fromIndex),

Includes the

from: fromIndex,

original index in

index: toIndex,

the operation

item: this.#array[fromIndex],

}

Extracts the item

from the old array

const [_item] = this.#array.splice(fromIndex, 1)

this.#array.splice(toIndex, 0, _item)

Inserts the item into

the new position

const [originalIndex] =

this.#originalIndices.splice(fromIndex, 1)

Extracts the

this.#originalIndices.splice(toIndex, 0, originalIndex)

original index

from the

return operation

Inserts the

#originalIndices

Returns

}

original index into

array

the move

}

the new position

operation

7.6

Diffing arrays as a sequence of operations

157

Adding the move case to the arraysDiffSequence() function is straightforward. Add the line shown in bold in the following listing to the arraysDiffSequence() function.

Listing 7.16

Adding an item to the old array (utils/arrays.js)

export function arraysDiffSequence(

oldArray,

newArray,

equalsFn = (a, b) => a === b

) {

const sequence = []

const array = new ArrayWithOriginalIndices(oldArray, equalsFn)

for (let index = 0; index < newArray.length; index++) {

if (array.isRemoval(index, newArray)) {

sequence.push(array.removeItem(index))

index--

continue

}

if (array.isNoop(index, newArray)) {

sequence.push(array.noopItem(index))

continue

}

const item = newArray[index]

if (array.isAddition(item, index)) {

sequence.push(array.addItem(item, index))

continue

}

sequence.push(array.moveItem(item, index))

}

// TODO: remove extra items

return sequence

}

You’re almost there! You need only remove the outstanding items from the old array.

REMOVING THE OUTSTANDING ITEMS

What happens when you reach the end of the new array and the old array still contains items? You remove them all, of course. Figure 7.32 shows the removal case in the flow chart.

To remove the outstanding items, you want to remove all items past the current index (which is the length of the new array) from the old array. You can use a while loop that keeps removing items while the old array is longer than the index. Write the code for the removeItemsAfter() method, shown in bold in the following listing.

158

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

oldItem

Start

oldArray =

i=0

newArray =

i

newItem

Old

i <

No

Yes

it m

e

in

New size

new array?

New

No

Yes

removeItemsAfter()

=

Ol ?

d

New

Yes

No

it m

e

in

removeItem()

old array?

End

noopItem()

No

Yes

i++

addItem()

moveItem()

i++

i++

Figure 7.32

The removal case in the flow chart

Listing 7.17

Detecting whether an operation is addition (utils/arrays.js)

class ArrayWithOriginalIndices {

// --snip-- //

removeItemsAfter(index) {

const operations = []

Keeps removing items

while the old array is

longer than the index

while (this.length > index) {

operations.push(this.removeItem(index))

Adds the removal

}

operation to the array

return operations

Returns the removal

}

operations

}

Finally, add the code shown in bold in the following listing to the arraysDiffSequence() function to remove the outstanding items. Note that removeItemsFrom() returns not a single operation, but an array of operations.

Listing 7.18

Removing outstanding items (utils/arrays.js)

export function arraysDiffSequence(

oldArray,

newArray,

equalsFn = (a, b) => a === b

) {

7.6

Diffing arrays as a sequence of operations

159

const sequence = []

const array = new ArrayWithOriginalIndices(oldArray, equalsFn)

for (let index = 0; index < newArray.length; index++) {

if (array.isRemoval(index, newArray)) {

sequence.push(array.removeItem(index))

index--

continue

}

if (array.isNoop(index, newArray)) {

sequence.push(array.noopItem(index))

continue

}

const item = newArray[index]

if (array.isAddition(item, index)) {

sequence.push(array.addItem(item, index))

continue

}

sequence.push(array.moveItem(item, index))

}

sequence.push(...array.removeItemsAfter(newArray.length))

return sequence

}

Just like that, you have a working implementation of the arraysDiffSequence() function! Believe it or not, this algorithm is the hardest one to implement in the whole book. If you’re still with me, the rest of the book will be easy to follow.

Exercise 7.6

Write a function called applyArraysDiffSequence() that, given an array and a sequence of operations, applies the operations to the array and returns the resulting array. To test it, pass the following arrays to the arraysDiffSequence() function:

Old array—['A', 'A', 'B', 'C']

New array— ['C', 'K', 'A', 'B']

Save the resulting operations in a variable called sequence. Then pass the old array and the sequence to the applyArraysDiffSequence() function, and check whether the resulting array is the same as the new array.

Find the solution at

160

CHAPTER 7

The reconciliation algorithm: Diffing virtual trees

Summary

 The reconciliation algorithm has two main steps: diffing (finding the differences between two virtual trees) and patching (applying the differences to the real DOM).

 Diffing two virtual trees to find their differences boils down to solving three problems: finding the differences between two objects, finding the differences between two arrays, and finding a sequence of operations that can be applied to an array to transform it into another array.

The reconciliation

algorithm: Patching

the DOM

This chapter covers

 Implementing the patchDOM() function

 Using the objectsDiff() function to find the

differences in attributes and styles

 Using the arraysDiff() function to find the

differences between CSS classes

 Using the arraysDiffSequence() function to find

the differences between virtual DOM children

 Using the Document API to patch DOM changes

In chapter 7, you saw how the reconciliation algorithm works in two phases: finding the differences between two virtual Document Object Model (DOM) trees and patching those differences in the real DOM. You implemented the three key functions to find differences between two objects or two arrays: objectsDiff(), arraysDiff(), and arraysDiffSequence(). In this chapter, you’ll use these functions to implement the reconciliation algorithm inside the patchDOM() function. patchDOM() finds the differences between two virtual DOM trees (the one that’s currently rendered and the one after the state has changed) and patches the real DOM accordingly.

161

162

CHAPTER 8

The reconciliation algorithm: Patching the DOM

With the patchDOM() function, your framework will be able to update the DOM by using a small set of operations instead of replacing the whole DOM tree every time the state changes. Thanks to this function, the new version of your framework—which you’ll publish at the end of this chapter—will be much more efficient than the previous version. The best part is that the TODOs application rewrite you did in chapter 6

will still work with the new version of your framework, as its public API doesn’t change. You’ll need only to update the import statement to import the new version of the framework instead of the old one. This time, though, you won’t lose the focus from the <input> fields every time you type a character because the fields will be updated without being replaced.

We’ll end the chapter by looking at a few ways you can use browser developer tools to inspect the DOM and observe how the reconciliation algorithm patches small portions of the DOM.

NOTE

You can find all the listings in this chapter in the listings/ch08 directory of the book’s repository (The code in this chapter can be checked out from the ch8 label $ git switch

--detach ch8.

Code catch-up

In chapter 7, you modified the createApp() so that the returned application’s object renderApp() method doesn’t destroy and re-create the whole DOM; rather, it calls a patchDOM() function to patch the changes in the DOM. You’ll implement the patchDOM() function in this chapter.

Then you wrote the objectsDiff() and arraysDiff() functions to find the differences between two objects or two arrays, respectively. Last, you implemented the arraysDiffSequence() function to find the sequence of operations that transform one array into another. To help this function keep track of the indices of the elements in the original array and the way they change, you wrote the ArrayWithOriginalIndices class.

8.1

Mounting the DOM at an index

To patch the DOM, you compare the two virtual DOM trees by traversing them individually in a depth-first manner and apply the differences you find along the way to the real DOM (as you saw in the opening example of chapter 7). Very often, a new node is added somewhere in the middle of another’s node children array. Let’s look at an example.

Think of an error message that appears between an <input> field and a <button> element when the user enters an invalid value in the <input> field. Before the error appears, the DOM looks like this:

<form>

<input type="text"/>

8.1

Mounting the DOM at an index

163

<button>Validate</button>

</form>

Then the user writes some invalid text in the <input> field (whatever invalid means here) and clicks the validate <button>. The error message appears:

<form>

<input type="text"/>

<p class="error">Invalid text! Try something else</p>

<button>Validate</button>

</form>

As you can see, the error message is inserted between the <input> field and the <button> element (at position i=1) inside the <form> element’s children, as illustrated in figure 8.1. Your current implementation of the mountDOM() function doesn’t allow you to insert a node at a given index inside a parent node.

<form>

<input>

<p>

<button>

type: "text"

class: "error"

Text

Text

"Invalid..."

"Validate"

Node inserted at i=1.

Node moved to i=2.

Figure 8.1

Inserting the node at index i=1

To insert new nodes at arbitrary positions, you need to modify the mountDOM() function so that it accepts an index in which to put a node instead of appending it at the end (as the function does at the moment).

8.1.1

The insert() function

Let’s write an insert() function inside the mount-dom.js file to insert a node at a given index inside a parent node. Then you can use this function inside the mountDOM() function. You also want the function to append nodes at the end without having to figure out what the index would be because that use case is still a common one.

164

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Here’s what you can do: if the passed-in index is null or undefined, append the node to the parent node. Then, if the index is defined (neither null nor undefined), you need to account for the following cases:

 If the index is negative, throw an error because negative indices don’t make sense.

 If the index is greater or equal to the number of children, append the node to the parent node.

 Otherwise, you know that the index lies somewhere in the children array, so you insert the node at the given index.

To insert a node at a given index, you can use the insertBefore() method

of the Node interface. This method requires two arguments: the new element to insert and the reference element, which is the element before which the new element will be inserted. Figure 8.2 illustrates how the insertBefore() method would work for the example in figure 8.1.

<form>

<form>

<input>

<input>

Button at i=1

<button>

<p>

</form>

<button>

</form>

insertBefore( <p> , <button> )

Inserts the paragraph at the

The button moves to the

current button position

next position (

).

i=2

Figure 8.2

Using the insertBefore() method

Exercise 8.1

In a blank HTML file, create a <div> element with two <p> children, as follows:

<div>

<p>One</p>

<p>Three</p>

</div>

Then use the insertBefore() method to insert a new <p> element between the two existing ones. The new <p> element should contain the text Two.

Find the solution

Open the mount-dom.js file. Write the insert() function inside it, below the mountDOM() function, as shown in the following listing.

8.1

Mounting the DOM at an index

165

Listing 8.1

The insert() function (mount-dom.js)

function insert(el, parentEl, index) {

// If index is null or undefined, simply append.

// Note the usage of == instead of ===.

if (index == null) {

parentEl.append(el)

When no index is

return

provided, appends

}

the node

if (index < 0) {

throw new Error(

Negative indices

Ìndex must be a positive integer, got ${index}`)

are considered

}

to be an error.

const children = parentEl.childNodes

If the index is beyond

the last child, the

if (index >= children.length) {

node is appended.

parentEl.append(el)

} else {

parentEl.insertBefore(el, children[index])

Otherwise, the

}

node is inserted at

}

the given index.

NOTE

Recall that depending on how you configured your linter, you might get a complaint about using == instead of ===, which comes from the eqeqeq rule (). You can safely ignore this warning. The == operator is fine here because you want to check for both null and undefined values.

With the insert() function in place, you can modify the mountDOM() function to accept an index as a third parameter (as shown in the following listing) so that you can insert the node at that index. The index should be passed down to the text and element node-creation functions, which will use the insert() function to insert the node at the given index.

Listing 8.2

Adding the index as a parameter to the functions (mount-dom.js) export function mountDOM(vdom, parentEl, index) {

switch (vdom.type) {

case DOM_TYPES.TEXT: {

createTextNode(vdom, parentEl, index)

break

}

case DOM_TYPES.ELEMENT: {

createElementNode(vdom, parentEl, index)

break

}

166

CHAPTER 8

The reconciliation algorithm: Patching the DOM

case DOM_TYPES.FRAGMENT: {

createFragmentNodes(vdom, parentEl, index)

break

}

default: {

throw new Error(`Can't mount DOM of type: ${vdom.type}`)

}

}

}

8.1.2

Text nodes

Now let’s modify the createTextNode(), createElementNode(), and createFragmentNodes() functions to use the insert() function to insert the node at the given index.

To insert a text node at a given index, you modify the createTextNode() function to use the insert() function, as shown in the following listing.

Listing 8.3

Using insert() inside createTextNode() (mount-dom.js) function createTextNode(vdom, parentEl, index) {

const { value } = vdom

const textNode = document.createTextNode(value)

vdom.el = textNode

parentEl.append(textNode)

insert(textNode, parentEl, index)

}

8.1.3

Element nodes

Let’s look at element nodes. In the case of element nodes, the change is exactly the same as for text nodes, as you can see in the following listing.

Listing 8.4

Using insert() inside createElementNode() (mount-dom.js) function createElementNode(vdom, parentEl, index) {

const { tag, props, children } = vdom

const element = document.createElement(tag)

addProps(element, props, vdom)

vdom.el = element

children.forEach((child) => mountDOM(child, element))

parentEl.append(element)

insert(element, parentEl, index)

}

8.2

Patching the DOM

167

8.1.4

Fragment nodes

The case of fragment nodes is slightly different because you need to insert all the children of the fragment starting at the given index. Each child is inserted at an index that’s computed by adding the given index and its own index inside the fragment: children.forEach((child, i) => {

mountDOM(child, parentEl, index + i)

})

Don’t forget to account for the case when the passed index is null, in which case you want to pass null to the insert() function so that the children are appended at the end of the parent node. Modify the createFragmentNodes() function to use the insert() function, as shown in the following listing.

Listing 8.5

Using insert() inside createElementNode() (mount-dom.js) function createFragmentNodes(vdom, parentEl, index) {

const { children } = vdom

vdom.el = parentEl

children.forEach((child) => mountDOM(child, parentEl))

children.forEach((child, i) =>

mountDOM(child, parentEl, index ? index + i : null)

)

}

With these changes in place, let’s turn our attention to the patchDOM() function.

8.2

Patching the DOM

In chapter 7, we saw an example of how two virtual DOM trees are compared and the changes are applied to the real DOM. Figure 8.3 (reproduced from chapter 7) shows the two virtual DOM trees in the example and the changes that need to be applied to the real DOM.

We compared the two virtual DOM trees manually, starting with the top-level <div> element and then moving to its children in a depth-first manner. Let’s turn these steps into an algorithm that you can implement.

168

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Old vdom

<div>

id: "abc"

style: {

color: "blue"

}

<p>

<p>

class: "foo"

class: ["foo", "bar"]

Text

Text

"Hello"

"World"

New vdom

<div>

1. Attribute modified

id: "def"

2. Style modified

style: {

color: "red"

}

6. Class added and

class removed

3. Class modified

<p>

<span>

<p>

class: "fox"

class: "foo"

class: [ , "bar"]

"baz"

Text

Text

Text

"Hi"

"there"

"World!"

4. Text changed

5. Node added

7. Text changed

Figure 8.3

Comparing two virtual DOM trees to find out what changed

8.2.1

The reconciliation algorithm

First, I’ll define the algorithm in plain English; then I’ll translate it into code. I’ve already touched on the nature of this algorithm, but it’s time for a more formal definition.

DEFINITION

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

8.2

Patching the DOM

169

Thanks to the exercise you worked out by hand in chapter 7, you have a fair understanding of what the algorithm does. Now try to put that knowledge into words. Here’s the algorithm, step by step:

1

Start at the top-level nodes of both virtual DOM trees.

2

If the nodes are different, destroy the DOM node (and everything that’s below it), and replace it with the new node and its subtree.

3

If the nodes are equal:

a

Text nodes—Compare and patch their nodeValue (the property containing the text).

b

Element nodes—Compare and patch their props (attributes, CSS classes and styles, and event listeners).

4

Find the sequence of operations that transforms the first node’s children array into the second node’s.

5

For each operation, patch the DOM accordingly:

a

Adding a node. Mount the new node (and its subtree) at the given index.

b

Removing a node. Destroy the node (and its subtree) at the given index.

c

Moving a node. Move the node to the new index. Start from step 1, using the moved nodes as the new top-level nodes.

d

Doing no operation. Start from step 1, using the current nodes as the new top-level nodes.

NOTE

The idea of destroying a DOM node and its subtree when it’s found to have changed comes from React. Soon, I’ll explain why this idea is a good one in practice.

The algorithm doesn’t look complicated, but the implementation has a lot of details.

The good news is that you implemented the most complex part—the arraysDiffSequence() function—in chapter 7.

Figure 8.4 shows the algorithm as a flow chart. Keep this figure handy as you read the next section; it’ll help you during implementation.

The first thing you should notice is that the algorithm is recursive. When you find two nodes in the children arrays that are the same, you start the algorithm again, this time using those nodes as the top-level nodes. This situation happens in steps 5c and 5d, the cases in which nodes moved—either forcefully or naturally—so you also want to inspect what happened to their subtrees. In figure 8.4, the move and noop branches after the patchChildren() call go back to the top of the algorithm.

The cases in which a child node was added or removed are simple enough: you simply call the mountDOM() or destroyDOM() function, respectively. If you recall, these functions take care of mounting or destroying the whole subtree, which is why the algorithm isn’t recursive in these cases. As you can see in figure 8.4, the add and remove branches after the patchChildren() call don’t go back to the top of the algorithm; they end the algorithm.

170

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Start

Old node

New node

No

Yes

Equal?

Text

Element

Fragment

node?

destroyDOM()

node?

node?

mountDOM()

patchText()

patchAttrs()

patchClasses()

End

End

patchStyles()

patchEvents()

End

Has

No

Yes

children?

Add?

mountDOM()

Remove?

destroyDOM()

End

patchChildren()

For

each

Move?

Move node

Noop?

Figure 8.4

The reconciliation algorithm flow chart

When you find a fragment node in the children array, instead of keeping it, you want to extract its children. In figure 8.5, the fragment nodes at every level are replaced by their children.

You want to extract the children of a fragment node so that the trees with which the reconciliation algorithm works are as similar as possible to the real DOM. If you recall, fragments aren’t part of the DOM; they’re simply a way to group nodes. In figure 8.5, the DOM would look like this:

<p>

<span>Once</span>

<span>upon</span>

<span>a</span>

<span>time</span>

</p>

8.2

Patching the DOM

171

<p>

Substitute the children

for the fragments.

<span>

Fragment

Text

<span>

<span>

Fragment

"Once"

Text

Text

<span>

"upon"

"a"

Text

"time"

Fragments disappear from

the virtual DOM tree.

<p>

<span>

<span>

<span>

<span>

Text

Text

Text

Text

"Once"

"upon"

"a"

"time"

Figure 8.5

The child nodes of a fragment are extracted and added to the parent’s children array.

As you can see, there’s no trace of the fragments in the real DOM. Fragments never make it to the reconciliation algorithm because there are no fragments in the real DOM; fragments are a useful construct to group nodes in the virtual DOM.

The last thing I want you to notice is step 2, where if you find two top-level nodes that are different, you destroy the whole subtree to mount the new one. Two things require some explanation here: what do we mean when we say that two virtual nodes

172

CHAPTER 8

The reconciliation algorithm: Patching the DOM

are different, and why do we have to destroy the whole subtree? Doesn’t that sound a bit wasteful? I’ll discuss node equality first.

8.2.2

Virtual node equality

You want to know when two virtual nodes are equal so that you can reuse the existing DOM node (as saved in the virtual node’s el property); if not, that node needs to be destroyed and replaced by a new one. Reusing an existing DOM node and patching its properties is much more efficient than destroying it and mounting a new one, so you want to reuse nodes as much as possible. For two virtual nodes to be equal, first they need to be of the same type. A text node and an element node, for example, can never be equal because you wouldn’t be able to reuse the existing DOM node. When you know that the two nodes you’re comparing are of the same type, the rules are as follows:

Text nodes—Two text nodes are always equal (even if their nodeValue is different).

Fragment nodes—Two fragment nodes are always equal (even if they contain different children).

Element nodes—Two element nodes are equal if their tagName properties are equal.

These rules are illustrated in figure 8.6:

 Two text nodes are always equal because their text is something that can be patched, so there’s no need to destroy it and mount a new node with a different text.

 Fragment nodes are always equal because they’re simply containers for other nodes and have no properties of their own.

 The element node is the most interesting case: for two element nodes to be equal, their tag must be the same. You can’t change the tag of a <div> to a

<span> programmatically and expect the browser to render it correctly because a <div> and a <span> are two different objects (HTMLDivElement and HTMLSpanElement, to be precise) that have different properties.

NOTE

You could change the tag of a <div> to a <span> in the HTML source by using the browser’s developer tools, and that change would work perfectly well. But under the hood, the browser is destroying the old HTMLDivElement and creating a new HTMLSpanElement. That’s exactly what you need to do in your code using the Document API if you compare two element nodes and find the tag changed: destroy the node with the old tag and mount a new one with the new tag.

8.2

Patching the DOM

173

Text nodes are always equal.

Text

Text

"What is love?"

"Baby don't hurt me"

=

Fragment nodes are always equal.

Fragment

Fragment

=

Element nodes are equal if they have the same tag.

<input>

<input>

type: "text"

type: "number"

=

class: "error"

id: "age"

<input>

<button>

type: "text"

type: "button"

=

class: "error"

class: "error"

Nodes of different types are never equal.

Text

Fragment

=

"What is love?"

<input>

Text

type: "text"

"Baby don't hurt me"

=

class: "error"

Figure 8.6

The rules for virtual node equality

Let’s implement a function called nodesEqual() to check whether two nodes are equal. If you look at the algorithm’s flow chart, the equality check happens at the top, when the old and new nodes enter the algorithm (highlighted in figure 8.7).

To create a function to check whether two nodes are equal, create a new file called nodes-equal.js and write the code in the following listing. The areNodesEqual() function applies the aforementioned rules to check whether two nodes are equal.

174

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Start

Checking whether the old and

Old node

New node

new virtual nodes are equal

No

Yes

Equal?

Te t

x

El ment

e

Fr gment

a

no e?

d

destroyDOM()

no e?

d

no e?

d

mountDOM()

patchText()

patchAttrs()

patchClasses()

End

End

patchStyles()

patchEvents()

End

Has

No

Yes

children?

add?

mountDOM()

Remove?

destroyDOM()

End

patchChildren()

For

each

Move?

Move node

Noop?

Figure 8.7

Checking whether the old and new nodes are equal in the algorithm’s flow chart Listing 8.6

Comparing two nodes for equality (nodes-equal.js)

import { DOM_TYPES } from './h'

export function areNodesEqual(nodeOne, nodeTwo) {

if (nodeOne.type !== nodeTwo.type) {

Nodes of

return false

different types

}

are never equal.

if (nodeOne.type === DOM_TYPES.ELEMENT) {

const { tag: tagOne } = nodeOne

const { tag: tagTwo } = nodeTwo

return tagOne === tagTwo

Element nodes

}

require their tag

names to be equal.

8.2

Patching the DOM

175

return true

}

Exercise 8.2

Paste the areNodesEqual() function into the browser’s console (don’t forget to include the DOM_TYPES constant) and test it with the following cases:

 Two text nodes with the same text

 Two text nodes with different text

 An element node and a text node

 A <p> element node and a <div> element node

 Two <p> element nodes with different text content

What are the results? Find the solution at .

Now that you have a function to check whether two nodes are equal, you can use it in the algorithm to decide whether to destroy the old node and mount the new one. The second question to answer is why you have to destroy the whole subtree, and I’ll answer it in the next section.

8.2.3

Subtree change

Destroying the whole subtree and re-creating it when you detect that the node changed sounds like going back to when your framework removed the whole DOM

tree and mounted a new one. Isn’t that what we’re trying to avoid with the reconciliation algorithm? Couldn’t you compare their children to check whether at least the subtrees are the same so that you need to re-create only the nodes that are different (figure 8.8)?

The top node changes its tag.

Old vdom

New vdom

<form>

The subtrees are the same.

<div>

<input>

<p>

<button>

<input>

<p>

<button>

Text

Text

Text

Text

"..."

"..."

"..."

"..."

Figure 8.8

The top node’s tag changed, but the subtrees are the same.

176

CHAPTER 8

The reconciliation algorithm: Patching the DOM

In figure 8.8, the top nodes—the ones you’re comparing—are different, but their subtrees are the same. You could patch only the top node and leave the subtrees alone.

If the children are different, you can patch them with the help of the arraysDiffSequence() function.

In general, when you detect that a node has changed, it usually means that a new part of the view entered the scene while the old one left. This new part of the view typically has a different subtree below it, one that’s unrelated to the old subtree. If you decide to patch the top node and then start comparing the two subtrees, you’d find many differences because the new subtree is different from the old one. You’d end up doing a lot of comparisons and DOM operations that are equivalent to destroying the whole subtree and re-creating it from scratch, only using many more operations.

A more realistic situation than the one shown in figure 8.8 is the one shown in figure 8.9: when a parent node changes, its subtrees are likely to be different. When you go from having a <form> to having a <div>, you probably can anticipate that something different will be below it. If, however, the parent node changes but the subtree is the same, destroying and re-creating the whole subtree is a price you’re willing to pay for the simplicity of the algorithm.

The top node changes its tag.

Old vdom

New vdom

<form>

The subtrees are also different.

<div>

<input>

<p>

<button>

<section>

<footer>

Text

Text

<h1>

Text

"..."

"..."

"..."

Figure 8.9

A more realistic case in which the top nodes are different and so are their subtrees Now that I’ve illustrated why it’s better to destroy the whole subtree and re-create it when the top virtual nodes being compared are different, let’s implement the algorithm, starting by writing the patchDOM() function, which is the main entry point of the algorithm. We’ll add more cases to the function as we go, but for now, we’ll implement only the case in which the top nodes are different—the easiest one to implement.

8.2

Patching the DOM

177

First, find the index of the old node’s element (referenced by the el property of the virtual node) in its DOM’s parent node (referenced by the parentEl property), destroy it, and then mount the new node in the same place. To find this index, you can use the indexOf() method of the parent element’s list of child nodes (parentEl

.childNodes), passing it the old node’s DOM element. One case to bear in mind, though, is when the index returned by indexOf() is -1, which means that the old node’s DOM element isn’t among the parent children. When the old node is a fragment and the el property references the parent element, we can’t find an element inside itself. In this case, we want to use a null index, which means that the new vdom will be appended at the end of the parent node’s list of child nodes. Figure 8.10 highlights this step in the algorithm’s flow chart.

Start

Old node

New node

Destroying the old DOM and

mounting the new one

No

Yes

Equal?

Te t

x

El ment

e

Fr gment

a

no e?

d

destroyDOM()

no e?

d

no e?

d

mountDOM()

patchText()

patchAttrs()

patchClasses()

End

End

patchStyles()

patchEvents()

End

Has

No

Yes

children?

Add?

mountDOM()

Remove?

destroyDOM()

End

patchChildren()

For

each

Move?

move node

Noop?

Figure 8.10

Replacing the old DOM with the new DOM

178

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Create a new file called patch-dom.js, and write the code in the following listing.

Listing 8.7

Patching the DOM when the top nodes are different (patch-dom.js)

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

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

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

Finds the

index in the

export function patchDOM(oldVdom, newVdom, parentEl) {

parent node

if (!areNodesEqual(oldVdom, newVdom)) {

where the old

const index = findIndexInParent(parentEl, oldVdom.el)

node is

destroyDOM(oldVdom)

Destroys the old

mountDOM(newVdom, parentEl, index)

node and its

subtree

return newVdom

}

Mounts the new node

}

and its subtree

function findIndexInParent(parentEl, el) {

const index = Array.from(parentEl.childNodes).indexOf(el)

if (index < 0) {

return null

}

return index

}

Great—you’ve implemented the first case of the algorithm. Let’s continue with the case in which the top nodes are text nodes.

8.2.4

Patching text nodes

Patching text nodes is easy: you set the nodeValue property of the DOM node to the new text when the text is different. If you find that the text content in both virtual nodes is the same, you don’t need to do anything.

As you can imagine, the first thing the patchDOM() function does—right after the areNodesEqual() comparison from section 8.2.3—is to check whether the nodes are text nodes. If so, call a function to patch the text node, patchText(), which you need to implement. You can use a switch statement to check the type of the nodes and act accordingly. Figure 8.11 shows patching a text node in the algorithm’s flow chart.

If you remember, when you implemented the mountDOM() function, you saved the reference to the DOM element representing the virtual node in the el property of the virtual node. Now, in the patchDOM() function, when the nodes aren’t created but simply patched, you need to save this reference from oldVdom to newVdom so that you don’t lose it (figure 8.12).

8.2

Patching the DOM

179

Start

Patching a text node

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

patchStyles()

patchEvents()

End

Has

No

Yes

children?

Add?

mountDOM()

Remove?

destroyDOM()

End

patchChildren()

For

each

Move?

Move node

Noop?

Figure 8.11

Patching a text node

Copy the DOM el reference from

the old node to the new one.

Old vdom

New vdom

<form>

<form>

el

el

<input>

<button>

<input>

<button>

el

el

Text

Text

el

"..."

"..."

Figure 8.12

Copying the DOM element reference from the old virtual node to the new one

180

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Modify the patchDOM() function, adding the code shown in bold in the following listing.

Listing 8.8

The case of a text node (patch-dom.js)

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

import { DOM_TYPES } from './h'

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

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

export function patchDOM(oldVdom, newVdom, parentEl) {

if (!areNodesEqual(oldVdom, newVdom)) {

const index = findIndexInParent(parentEl, oldVdom.el)

destroyDOM(oldVdom)

mountDOM(newVdom, parentEl, index)

return newVdom

}

Saves the reference

to the DOM element

in the new node

newVdom.el = oldVdom.el

switch (newVdom.type) {

Calls the function to

case DOM_TYPES.TEXT: {

patch the text node

patchText(oldVdom, newVdom)

return newVdom

}

Returns the new

virtual node

}

return newVdom

}

Let’s write the patchText() function, which compares the texts in the nodeValue property of the old and new virtual nodes. If the texts are different (that is, have changed), set the nodeValue property of the DOM element to the new text. Inside the patch-dom.js file, write the code in the following listing.

Listing 8.9

Patching a text node (patch-dom.js)

function patchText(oldVdom, newVdom) {

const el = oldVdom.el

const { value: oldText } = oldVdom

const { value: newText } = newVdom

if (oldText !== newText) {

el.nodeValue = newText

}

}

Note that in the first line of the function, you’re extracting the DOM element from the oldVDom virtual node’s el property. You also could have used the newVDom virtual node’s reference because before calling this function, patchDOM() has already saved it there. (At this point, oldVdom.el === newVdom.el must be true.)

8.2

Patching the DOM

181

Let’s move on to the case in which the top nodes are element nodes. This case is the most interesting one, as well as the most complex.

8.2.5

Patching element nodes

Figure 8.13 shows the patching of an element node in the algorithm’s flow chart. As you can see, patching an element node requires a few steps—four, to be precise.

Start

Patching an element node

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

patchStyles()

patchEvents()

End

Has

No

Yes

children?

Add?

mountDOM()

Remove?

destroyDOM()

End

patchChildren()

For

each

Move?

Move node

Noop?

Figure 8.13

Patching an element node

Let’s start by including the appropriate switch case in the patchDOM() function.

Then I’ll explain how to write the patchElement() function—the one that does all the work. Add the code in bold to patch-dom.js as shown in the following listing. As you can see, the work of patching element nodes is delegated to the patchElement() function.

182

CHAPTER 8

The reconciliation algorithm: Patching the DOM

Listing 8.10

The case of an element node (patch-dom.js)

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

import { DOM_TYPES } from './h'

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

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

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

}

}

return newVdom

}

Назад: 3 Rendering and the virtual DOM
Дальше: 8.2.6 Patching child nodes