-
Notifications
You must be signed in to change notification settings - Fork 29
Solution to exercises
To add some nice CSS, first create a styles.css file in the examples/ch02 directory and load it from the HTML file's <head>
tag, like so:
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
++ <link rel="stylesheet" href="styles.css" />
<script type="module" src="todos.js"></script>
<title>My TODOs</title>
</head>
Then, add the following CSS rules to the styles.css file to use the Roboto font:
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400&display=swap');
body {
font-family: 'Roboto', sans-serif;
width: 500px;
margin: 2em auto;
}
Add some CSS rules to center the application, add some padding to the input field and the button, and to place the label on top of the input field, displayed in italic font:
body {
font-family: 'Roboto', sans-serif;
width: 500px;
margin: 2em auto;
}
label {
display: block;
font-style: italic;
margin-bottom: 0.25em;
}
h1 {
font-size: 3.5rem;
}
button,
input {
font-family: inherit;
padding: 0.25em 0.5em;
font-size: 1rem;
}
Feel free to experiment and add your own styles to the application. The result of using the styles above is shown below.
Looking much nicer, don't you think?
To add a transition to the button, for when it gets enabled or disabled, you can use the following CSS rules:
button:disabled {
transition: color 0.5s ease-out;
}
button:enabled {
transition: color 0.5s ease-out;
}
If you start typing in the input field, once you've typed three characters, you'll see how the button's text color changes from gray to black. That's the transition you've just added. When you remove the characters from the input field, the button's text color smoothly transitions back to gray.
Implementing the crossed-out to-dos feature is a bit more challenging that it appears at first sight. You need to change how the state is stored to keep track of whether a to-do is done or not:
// State of the app
const todos = [
{ description: 'Walk the dog', done: false },
{ description: 'Water the plants', done: false },
{ description: 'Sand the chairs', done: false },
]
Then you need to change the code in all the places where the sate is used.
For example, inside the renderTodoInReadMode()
you need to change the following line:
-- span.textContent = todo
++ span.textContent = todo.description
And in the renderTodoInEditMode()
function, you need to change the following line:
-- input.value = todo
++ input.value = todo.description
You also need to edit the code in the addTodo()
and updateTodo()
functions.
I leave this part as an exercise for you to do.
Then, when the Done button is clicked, the removeTodo()
function needs to set the done
property of the to-do to true
instead of removing it from the array:
function removeTodo(index) {
-- todos.splice(index, 1)
-- todosList.childNodes[index].remove()
++ todos[index].done = true
}
And lastly, you need to modify the renderTodoInReadMode()
so that, when a to-do is done, it adds a class to the <span>
element to cross out the text, doesn't include the Done button, and doesn't allow the user to edit the to-do:
function renderTodoInReadMode(todo) {
const li = document.createElement('li')
const span = document.createElement('span')
span.textContent = todo.description
++ if (todo.done) {
++ span.classList.add('done')
++ }
++ if (!todo.done) {
span.addEventListener('dblclick', () => {
const idx = todos.indexOf(todo)
todosList.replaceChild(
renderTodoInEditMode(todo),
todosList.childNodes[idx]
)
})
++ }
li.append(span)
++ if (!todo.done) {
const button = document.createElement('button')
button.textContent = 'Done'
button.addEventListener('click', () => {
const idx = todos.indexOf(todo)
removeTodo(idx)
})
li.append(button)
++ }
return li
}
And add the following CSS rule to the styles.css file (or inside a <style>
tag in the document's <head>
) to cross out the text:
.done {
text-decoration: line-through;
}
You can see the result below.
Now, when a to-do is done, it's crossed out and the Done button is removed.
To prevent the user from adding the same to-do multiple times, you need to check if the to-do already exists in the array before adding it. The comparison should be done against the trimmed and lowercased version of the to-do's description. In case the to-do already exists, you can show an alert to the user:
function addTodo() {
const description = addTodoInput.value
++ if (todoExists(description)) {
++ alert('Todo already exists')
++ return
++ }
todos.push(description)
const todo = renderTodoInReadMode(description)
todosList.append(todo)
addTodoInput.value = ''
addTodoButton.disabled = true
}
++ function todoExists(description) {
++ const cleanTodos = todos.map((todo) => todo.trim().toLowerCase())
++ return cleanTodos.includes(description.trim().toLowerCase())
++ }
If you try now to add a to-do that's already in your list, you'll see an alert like the one below.
You can use the Web Speech API to read out loud the to-dos when they're added to the list.
To read a text out loud, you need to create a [https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance](SpeechSynthesisUtterance
object) and set its text
property to the text you want to read.
Then you need to set the voice
property to one of the voices available in the browser.
You can get the list of voices available in the browser using the https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis/getVoices method.
Last, call the speakingSynthesis.speak()
method to read the text out loud:
function addTodo() {
const description = addTodoInput.value
todos.push(description)
const todo = renderTodoInReadMode(description)
todosList.append(todo)
addTodoInput.value = ''
addTodoButton.disabled = true
++ readTodo(description)
}
++ function readTodo(description) {
++ const message = new SpeechSynthesisUtterance()
++ message.text = description
++ message.voice = speechSynthesis.getVoices()[0]
++ speechSynthesis.speak(message)
++ }
Enjoy!
Given the following HTML:
<div id="app">
<h1>TODOs</h1>
<input type="text" placeholder="What needs to be done?">
<ul>
<li>
<input type="checkbox">
<label>Buy milk</label>
<button>Remove</button>
</li>
<li>
<input type="checkbox">
<label>Buy eggs</label>
<button>Remove</button>
</li>
</ul>
</div>
The virtual DOM diagram for it would be the following:
Given the following HTML:
<h1 class="title">My counter</h1>
<div class="container">
<button>decrement</button>
<span>0</span>
<button>increment</button>
</div>
Here's how you can use the hFragment()
and h()
functions to create its virtual DOM:
hFragment([
h('h1', { class: 'title' }, ['My counter']),
h('div', { class: 'container' }, [
h('button', {}, ['decrement']),
h('span', {}, ['0']),
h('button', {}, ['increment'])
])
])
Here's the lipsum()
function:
function lipsum(n) {
const text = `Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi
ut aliquip ex ea commodo consequat.`
return hFragment(
Array(n).fill(h('p', {}, [text]))
)
}
Note that I had to split the lines of the text
variable to make it fit in the page.
You can keep it in a single line in your code.
Here's the code for the MessageComponent()
:
function MessageComponent({ level, message }) {
return h('div', { class: `message message--${level}` }, [
h('p', {}, [message]),
])
}
You could complement this component with some CSS rules to add colors to the different levels of messages, and a bit of padding and border to make them look nicer:
.message {
padding: 1rem;
border: 1px solid black;
}
.message--info {
background-color: #C5E0EF;
}
.message--warning {
background-color: #FFFAD5;
}
.message--error {
background-color: #F9CBCD;
}
For this exercise, you first need to open your local—or favorite—newspaper website and inspect the DOM.
Locate where the headline of the article is, and select it in the inspector, as in the figure below.
(When you have an element highlighted in the inspector, you can reference it in the console by using the $0
variable.)
Next, open the console and paste the code that you need for the hString()
and mountDOM()
functions to work.
That includes the DOM_TYPES
object and the createTextNode()
function.
Then, create a text virtual node with the text "OMG, so interesting!" and use the mountDOM()
function to insert the text inside the <div>
that contains the headline of the article:
const node = hString('OMG, so interesting!')
mountDOM(node, $0)
You can see the result below.
If head over to the inspector and inspect the <div>
element that contains the headline, you'll see that the text you just inserted is there (see the figure below).
As you can see, the text is inside the <div>
element, beside the <h1>
element that contains the headline.
This exercise's solution is similar to the previous one.
This time you need to find where the paragraphs (<p>
) of the article are located in the DOM, and select that element; that's where you'll mount your virtual nodes.
Then, create the vdom for the section you'll add to the article:
const section = hFragment([
h('h2', {}, ['Very important news']),
h('p', {}, ['such news, many importance, too fresh, wow']),
h(
'a',
{
href: 'https://en.wikipedia.org/wiki/Doge_(meme)',
},
['Doge!'],
),
])
Then, mount the section in the DOM:
mountDOM(section, $0)
Inspecting the DOM, you can see that the section was added to the article (figure below).
For this exercise, you want to print to the console the section
vdom tree that you created in the previous exercise.
Simply type in the browser's console:
section
You'll see something similar to the figure below.
As expected, the fragment's el
reference points to the parent element where the fragment children were mounted (in my case, it was the <div>
element that contains the article's paragraphs).
The el
references of the paragraph and link, naturally point at the <p>
and <a>
elements that were created out of them.
For this exercise, you need to remove the section that you added to the article in the previous exercise.
To do that, you need to use the destroyDOM()
function that you implemented in this chapter.
You'll need to copy/paste the destroyDOM()
function in the console, and then call it with the section
vdom tree as an argument in the console:
destroyDOM(section)
Then, you can inspect the section
vdom again (figure below).
As you can see, the el
references of the virtual nodes have all been removed.
If you check the DOM, you'll see that all the nodes were correctly removed from the DOM, but the <div>
element that was the parent of the fragment was not removed, as it was created by the newspaper website, not by you.
(Remember that the fragment's el
reference points to the parent element where the fragment children were mounted.)
For this simple application, we can use two commands:
-
'increment-count'
— increments the counter by one. -
'decrement-count'
— decrements the counter by one.
These commands are mapped to the browser's click
event on the buttons as illustrated in the figure below.
For the state, we need four things:
- The 3x3 board of the game (a 2D array of
null
,X
, orO
) - Who the current player is (
X
orO
) - Whether the game ended in a draw (
true
orfalse
). - Who the winner of the game is (
X
,O
, ornull
if no one has won yet).
Let's create a function that returns the initial state of the application:
function makeInitialState() {
return {
board: [
[null, null, null],
[null, null, null],
[null, null, null],
],
player: 'X',
draw: false,
winner: null,
}
}
At the start of the game, the board is empty, the current player is X
, there's no draw, and there's no winner yet.
The only way players can interact with the application is by clicking the board, thus we only need one command, which we'll call 'mark'
.
This command's payload will be the coordinates of the cell that the player has clicked (the row and column).
The reducer function for the 'mark'
command will update the board, switch the current player, and set who the winner is, if any.
The reducer function will throw an error if the player clicks on a cell that's already marked or if the player clicks on a cell that doesn't exist—those cases should be prevented by the view.
Then, the reducer creates a new and updated board, switches the current player, and checks if the player who just marked the board is the winner.
If there isn't a winner, the reducer checks if the board is full, in which case the game ends in a draw.
Here's the code for the reducer function:
function markReducer(state, { row, col }) {
if (row > 3 || row < 0 || col > 3 || col < 0) {
throw new Error('Invalid move')
}
if (state.board[row][col]) {
throw new Error('Invalid move')
}
const newBoard = [
[...state.board[0]],
[...state.board[1]],
[...state.board[2]],
]
newBoard[row][col] = state.player
const newPlayer = state.player === 'X' ? 'O' : 'X'
const winner = checkWinner(newBoard, state.player)
const draw = !winner && newBoard.every(
(row) => row.every((cell) => cell)
)
return {
board: newBoard,
player: newPlayer,
draw,
winner,
}
}
There's a function, checkWinner()
, that checks if there's a winner.
We haven't implemented this function for this exercise, but you can attempt to implement it yourself if you feel adventurous.
You'll need it for an exercise in the next chapter.
First of all, copy and paste the code for the Dispatcher
class into the browser's console.
Then, create an instance of the Dispatcher
class and subscribe a handler for a command named 'greet'
.
Lastly, add an after-command handler:
const dispatcher = new Dispatcher()
dispatcher.subscribe('greet', (name) => {
console.log(`Hello, ${name}!`)
})
dispatcher.afterEveryCommand(() => {
console.log('Done greeting!')
})
Now, call the dispatch()
method with the command name 'greet'
and the payload 'John'
:
dispatcher.dispatch('greet', 'John')
You should see the following output in the console:
Hello, John!
Done greeting!
Your Dispatcher
is working perfectly!
You can find the solution in the examples/ch06/counter/ directory. As you can see, for a such a simple application I used just an HTML file named counter.html. Create a similar file for your application and write the following base HTML code:
<html>
<head>
<title>Counter</title>
</head>
<body>
<h1>Counter</h1>
</body>
</html>
Serve the application by running the serve:examples
script:
$ npm run serve:examples
Your browser will open the root of the examples/ directory at http://127.0.0.1:8080/. Navigate to the counter/ directory and open the counter.html file. You should see the title of the application, Counter, in the browser's tab.
Then, add a <script>
tag to the <head>
of the HTML file, and import the framework from the unpkg.com CDN.
Define the state
and reducers
objects:
<html>
<head>
++ <script type="module">
++ import {
++ createApp,
++ h,
++ hString,
++ hFragment,
++ } from 'https://unpkg.com/fe-fwk@1'
++
++ const state = { count: 0 }
++ const reducers = {
++ add: (state) => ({ count: state.count + 1 }),
++ sub: (state) => ({ count: state.count - 1 }),
++ }
++ </script>
<title>Counter</title>
</head>
<body>
<h1>Counter</h1>
</body>
</html>
Next, define the view function:
<html>
<head>
<script type="module">
import {
createApp,
h,
hString,
hFragment,
} from 'https://unpkg.com/fe-fwk@1'
const state = { count: 0 }
const reducers = {
add: (state) => ({ count: state.count + 1 }),
sub: (state) => ({ count: state.count - 1 }),
}
++ function View(state, emit) {
++ return hFragment([
++ h('button', { on: { click: () => emit('sub') } }, ['-']),
++ h('span', {}, [hString(state.count)]),
++ h('button', { on: { click: () => emit('add') } }, ['+']),
++ ])
++ }
</script>
<title>Counter</title>
</head>
<body>
<h1>Counter</h1>
</body>
</html>
Lastly, create the application and mount it to the DOM:
<html>
<head>
<script type="module">
import {
createApp,
h,
hString,
hFragment,
} from 'https://unpkg.com/fe-fwk@1'
const state = { count: 0 }
const reducers = {
add: (state) => ({ count: state.count + 1 }),
sub: (state) => ({ count: state.count - 1 }),
}
function View(state, emit) {
return hFragment([
h('button', { on: { click: () => emit('sub') } }, ['-']),
h('span', {}, [hString(state.count)]),
h('button', { on: { click: () => emit('add') } }, ['+']),
])
}
++ createApp({ state, view: View, reducers }).mount(document.body)
</script>
<title>Counter</title>
</head>
<body>
<h1>Counter</h1>
</body>
</html>
Reload the browser and you should see the counter application working, similar to the figure below.
Try clicking the buttons and make sure the counter is incremented and decremented accordingly.
You can find the solution in the examples/ch06/tic-tac-toe/ directory. This time, since the game logic is a bit more complex, I decided to split the code into three different files:
- game.js: contains the game logic
- tictactoe.js: contains the view function and the code to create and mount the application
- tictactoe.html: contains the HTML code
Go ahead and create those files in the examples/ch06/tic-tac-toe/ directory. Then, add the following code to the tictactoe.html file:
<html>
<head>
<script type="module" src="tictactoe.js"></script>
<title>Tic Tac Toe</title>
</head>
<body>
<h1>Tic Tac Toe</h1>
</body>
</html>
We'll need to come back to this file later to add some CSS styles.
But let's first focus on the game.js file.
In this file, you need to define the state
object and the markReducer()
reducer function that creates a new state object based on the current state and the mark that was played.
You wrote this function in exercise 5.2, so you can copy it from there:
export function makeInitialState() {
return {
board: [
[null, null, null],
[null, null, null],
[null, null, null],
],
player: 'X',
draw: false,
winner: null,
}
}
export function markReducer(state, { row, col }) {
if (row > 3 || row < 0 || col > 3 || col < 0) {
throw new Error('Invalid move')
}
if (state.board[row][col]) {
throw new Error('Invalid move')
}
const newBoard = [
[...state.board[0]],
[...state.board[1]],
[...state.board[2]],
]
newBoard[row][col] = state.player
const newPlayer = state.player === 'X' ? 'O' : 'X'
const winner = checkWinner(newBoard, state.player)
const draw = !winner && newBoard.every((row) => row.every((cell) => cell))
return {
board: newBoard,
player: newPlayer,
draw,
winner,
}
}
What's left is to define the checkWinner()
function.
This function receives the board and the player that just played as arguments, and returns that player if he or she covered a row, column or diagonal with his or her marks, or null
otherwise.
Only the player who just played can win, so we don't need to check the other player.
Write the checkWinner()
function in the game.js file, as follows:
function checkWinner(board, player) {
for (let i = 0; i < 3; i++) {
if (checkRow(board, i, player)) {
return player
}
if (checkColumn(board, i, player)) {
return player
}
}
if (checkMainDiagonal(board, player)) {
return player
}
if (checkSecondaryDiagonal(board, player)) {
return player
}
return null
}
Let's now fill in those missing functions:
function checkRow(board, idx, player) {
const row = board[idx]
return row.every((cell) => cell === player)
}
function checkColumn(board, idx, player) {
const column = [board[0][idx], board[1][idx], board[2][idx]]
return column.every((cell) => cell === player)
}
function checkMainDiagonal(board, player) {
const diagonal = [board[0][0], board[1][1], board[2][2]]
return diagonal.every((cell) => cell === player)
}
function checkSecondaryDiagonal(board, player) {
const diagonal = [board[0][2], board[1][1], board[2][0]]
return diagonal.every((cell) => cell === player)
}
Next, let's work on the view of the application.
In the tictactoe.js file, you need to import your framework, and then define the View()
component.
We'll break down the view into two components: the Header()
and Board()
:
import { createApp, h, hFragment } from 'https://unpkg.com/fe-fwk@1'
function View(state, emit) {
return hFragment([Header(state), Board(state, emit)])
}
function Header(state) {
// TODO: implement me
}
function Board(state, emit) {
// TODO: implement me
}
The Header()
component is pretty simple: it just renders current player, or who's won the game, or that the game ended in a draw.
You want to return an <h3>
element in all cases, but with different contents and CSS classes.
You'll use the classes to add different colors to the text: green for the winner and orange for the draw:
function Header(state) {
if (state.winner) {
return h('h3', { class: 'win-title' }, [`Player ${state.winner} wins!`])
}
if (state.draw) {
return h('h3', { class: 'draw-title' }, [\`It's a draw!`])
}
return h('h3', {}, [\`It's ${state.player}'s turn!`])
}
Let's focus on the board now.
We'll use a <table>
element for it where every row is rendered by another component, which we'll call Row()
.
When a player has won or there's a draw, we want to add a CSS class to the <table>
element to prevent the user from clicking the cells:
function Board(state, emit) {
const freezeBoard = state.winner || state.draw
return h('table', { class: freezeBoard ? 'frozen' : '' }, [
h(
'tbody',
{},
state.board.map((row, i) => Row({ row, i }, emit))
),
])
}
Let's implement the missing Row()
component now.
A row is rendered as a <tr>
element (a table's row), and each cell is rendered by another component, which we'll call Cell()
.
Each cell is a <td>
element (a table's cell).
Inside the <td>
is either a <span>
element with the player's mark, or a <div>
element that the user can click on to mark the cell:
function Row({ row, i }, emit) {
return h(
'tr',
{},
row.map((cell, j) => Cell({ cell, i, j }, emit))
)
}
function Cell({ cell, i, j }, emit) {
const mark = cell
? h('span', { class: 'cell-text' }, [cell])
: h(
'div',
{
class: 'cell',
on: { click: () => emit('mark', { row: i, col: j }) },
},
[]
)
return h('td', {}, [mark])
}
Next, you want to import the makeInitialState()
and makeReducer()
functions from the game.js_ file, and then create the application's view:
import { createApp, h, hFragment } from 'https://unpkg.com/fe-fwk@1'
++ import { makeInitialState, markReducer } from './game.js'
function View(state, emit) {
return hFragment([Header(state), Board(state, emit)])
}
Write the Header
component:
function Header(state) {
if (state.winner) {
return h('h3', { class: 'win-title' },
[`Player ${state.winner} wins!`])
}
if (state.draw) {
return h('h3', { class: 'draw-title' }, [`It's a draw!`])
}
return h('h3', {}, [`It's ${state.player}'s turn!`])
}
Write the Board
component:
function Board(state, emit) {
const freezeBoard = state.winner || state.draw
return h('table', { class: freezeBoard ? 'frozen' : '' }, [
h(
'tbody',
{},
state.board.map((row, i) => Row({ row, i }, emit))
),
])
}
function Row({ row, i }, emit) {
return h(
'tr',
{},
row.map((cell, j) => Cell({ cell, i, j }, emit))
)
}
function Cell({ cell, i, j }, emit) {
const mark = cell
? h('span', { class: 'cell-text' }, [cell])
: h(
'div',
{
class: 'cell',
on: { click: () => emit('mark', { row: i, col: j }) },
},
[]
)
return h('td', {}, [mark])
}
Then, create the application instance putting it all together:
createApp({
state: makeInitialState(),
reducers: {
mark: markReducer,
},
view: View,
}).mount(document.body)
Last, let's add some CSS to make the application look nicer.
Inside the tictactoe.html file, add the following <style>
element, with the CSS rules:
<html>
<head>
<script type="module" src="tictactoe.js"></script>
<style>
body {
text-align: center;
}
table {
margin: 4em auto;
border-collapse: collapse;
}
table.frozen {
pointer-events: none;
}
tr:not(:last-child) {
border-bottom: 1px solid black;
}
td {
width: 6em;
height: 6em;
text-align: center;
}
td:not(:last-child) {
border-right: 1px solid black;
}
td.winner {
background-color: lightgreen;
border: 3px solid seagreen;
}
.win-title {
color: seagreen;
}
.draw-title {
color: darkorange;
}
.cell {
width: 100%;
height: 100%;
cursor: pointer;
}
.cell-text {
font-size: 3em;
font-family: monospace;
}
</style>
<title>Tic Tac Toe</title>
</head>
<body>
<h1>Tic Tac Toe</h1>
</body>
</html>
And that's it! Your tic-tac-toe application is ready to be played, and it should look similar to the figure below.
It might not be the first time you implement a tic-tac-toe game, but I'm pretty confident it's the first one you implement using a framework you built yourself. Go ahead and add that to your resume!
The HTML for the old virtual tree is:
<div id="abc" style="color: blue;">
<p class="foo">Hello</p>
<p class="foo bar">World</p>
</div>
And the HTML for the new virtual tree is:
<div id="def" style="color: red;">
<p class="fox">Hi</p>
<span class="foo">there</span>
<p class="baz bar">World!</p>
</div>
To disallow the user to call the mount()
method more than once, you can add a property called isMounted
to the application object and set it to true
when the mount()
method is called.
Then, you can check if the mounted
property is true
before calling the mount()
method.
If so, throw an error:
export function createApp({ state, view, reducers = {} }) {
let parentEl = null
let vdom = null
++let isMounted = false
// -- snip -- //
return {
mount(_parentEl) {
++ if (isMounted) {
++ throw new Error('The application is already mounted')
++ }
parentEl = _parentEl
vdom = view(state, emit)
mountDOM(vdom, parentEl)
++ isMounted = true
},
unmount() {
destroyDOM(vdom)
vdom = null
subscriptions.forEach((unsubscribe) => unsubscribe())
++ isMounted = false
},
}
}
Here are the tests I implemented for the objectsDiff()
function in the project (I'm using the https://vitest.dev/ testing library).
You can find the tests inside the tests/objects.test.js file.
import { expect, test } from 'vitest'
import { objectsDiff } from '../utils/objects'
test('same object, no change', () => {
const oldObj = { foo: 'bar' }
const newObj = { foo: 'bar' }
const { added, removed, updated } = objectsDiff(oldObj, newObj)
expect(added).toEqual([])
expect(removed).toEqual([])
expect(updated).toEqual([])
})
test('add key', () => {
const oldObj = {}
const newObj = { foo: 'bar' }
const { added, removed, updated } = objectsDiff(oldObj, newObj)
expect(added).toEqual(['foo'])
expect(removed).toEqual([])
expect(updated).toEqual([])
})
test('remove key', () => {
const oldObj = { foo: 'bar' }
const newObj = {}
const { added, removed, updated } = objectsDiff(oldObj, newObj)
expect(added).toEqual([])
expect(removed).toEqual(['foo'])
expect(updated).toEqual([])
})
test('update value', () => {
const arr = [1, 2, 3]
const oldObj = { foo: 'bar', arr }
const newObj = { foo: 'baz', arr }
const { added, removed, updated } = objectsDiff(oldObj, newObj)
expect(added).toEqual([])
expect(removed).toEqual([])
expect(updated).toEqual(['foo'])
})
Here's another sequence of operations that transform the [A, B, C]
array into the [C, B, D]
array:
- Move
C
fromi=2
toi=1
. Result:[A, C, B]
- Add
D
ati=3
. Result:[A, C, B, D]
- Remove
A
ati=0
. Result:[C, B, D]
Let's apply to the [A, B, C]
array the following sequence of operations:
[
{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'}
]
- Remove
A
ati=0
. Result:[B, C]
- Move
C
(originally at 2) fromi=1
toi=0
. Result:[C, B]
- Noop
B
ati=1
. Result:[C, B]
- Add
D
ati=2
. Result:[C, B, D]
As expected, the result is the [C, B, D]
array.
Here's the implementation of the applyArraysDiffSequence()
function:
function applyArraysDiffSequence(oldArray, diffSeq) {
return diffSeq.reduce((array, { op, item, index, from }) => {
switch (op) {
case ARRAY_DIFF_OP.ADD:
array.splice(index, 0, item)
break
case ARRAY_DIFF_OP.REMOVE:
array.splice(index, 1)
break
case ARRAY_DIFF_OP.MOVE:
array.splice(index, 0, array.splice(from, 1)[0])
break
}
return array
}, oldArray)
}
Now, if you pass the two arrays described in the exercise to the arraysDiffSequence()
:
const oldArray = ['A', 'A', 'B', 'C']
const newArray = ['C', 'K', 'A', 'B']
const sequence = arraysDiffSequence(oldArray, newArray)
You get the following sequence of operations:
[
{op: 'move', originalIndex: 3, from: 3, index: 0, item: 'C'}
{op: 'add', index: 1, item: 'K'}
{op: 'noop', index: 2, originalIndex: 0, item: 'A'}
{op: 'move', originalIndex: 2, from: 4, index: 3, item: 'B'}
{op: 'remove', index: 4, item: 'A'}
]
Let's pass this sequence to the applyArraysDiffSequence()
function, together with the old array:
const result = applyArraysDiffSequence(oldArray, sequence)
The result is the new array:
['C', 'K', 'A', 'B']
For this exercise, first create an html file with the following content:
<html>
<head>
<title>Exercise 1</title>
</head>
<body>
<div>
<p>One</p>
<p>Three</p>
</div>
</body>
</html>
Then, in the browser's console, you can use the following JavaScript code to insert a new <p>
element between the two existing ones:
const div = document.querySelector('div')
const three = div.querySelectorAll('p').item(1)
const two = document.createElement('p')
two.textContent = 'Two'
div.insertBefore(two, three)
Copy and paste the areNodesEqual()
and the DOM_TYPES
code into the console.
Don't forget that you should leave out the export
keyword when you paste the code into the console.
Then test the cases described in the exercise.
Two text nodes with the same text:
areNodesEqual(
{ type: 'text', value: 'foo' },
{ type: 'text', value: 'foo' },
)
// true
Two text nodes with different text:
areNodesEqual(
{ type: 'text', value: 'foo' },
{ type: 'text', value: 'bar' },
)
// true
An element node and a text node:
areNodesEqual(
{ type: 'element', tag: 'p' },
{ type: 'text', value: 'foo' },
)
// false
An <p>
element node and a <div>
element node:
areNodesEqual(
{ type: 'element', tag: 'p' },
{ type: 'element', tag: 'div' },
)
// false
Two <p>
element nodes with different text content:
areNodesEqual(
{
type: 'element',
tag: 'p',
children: [{ type: 'text', value: 'foo' }],
},
{
type: 'element',
tag: 'p',
children: [{ type: 'text', value: 'bar' }],
},
)
// true
You want to first serve the examples folder. Place your terminal at the project's root folder and run the following command:
$ npm run serve:examples
Let's start with the TODOs application from chapter 6. If you open the Elements tab in Chrome (or the Inspector tab in Firefox) and inspect the DOM modifications that happen when you add a new TODO item, you'll see that the entire page is repainted.
A similar thing happens when you mark a TODO item as completed.
Now open the same application from this chapter and inspect the DOM modifications that happen when you add a new TODO item. You'll see that only the added item is repainted. Hooray! Your reconciliation algorithm is working!
Try marking a TODO item as completed.
In this case, only the <ul>
item that contained the completed item is repainted.
For this exercise, you first want to locate the framework file in the browser's debugger. If you're using chrome, then you want to open the Sources tab and locate the framework file there.
If you're using Firefox, then you want to open the Debugger tab and locate the framework file there.
Next, locate the patchDOM()
function and set a breakpoint in its first line.
Having that breakpoint set allows you to stop the execution every time the patchDOM()
function is called, and then you can compare the old and new virtual DOM trees.
Type a letter in the input field to see the patchDOM()
function being called.
The first time the breakpoint is hit, the old and new virtual DOM trees are the top-level fragment nodes: <h1>
, <div>
and <ul>
.
If you recall, the patchDOM()
function starts from the very top and moves down the tree, comparing the old and new nodes.
You have to click the "Resume execution" button a couple of times until the two compared virtual nodes are the <input>
where you wrote a letter.
As you can see, the value
property of the <input>
node is an empty string in the old virtual DOM tree, and it's the letter you typed in the new virtual DOM tree.
You can step over the lines of code to see how the patchAttrs()
function does its job.
Remove or disable the break point so that you can write a new TODO without interruptions.
Before hitting the Add button, set the breakpoint again.
Click the add button and then click the "Resume execution" button a couple of times until the two compared virtual nodes are the <ul>
node and the <li>
node you just added.
In this case, the old children of the <ul>
node are the three <li>
nodes that were already there, and the new children are the four <li>
nodes, including the one you just added.
I'll leave it as a experiment for you to step over the lines of code and see how the patchChildren()
function does its job.
Take some time to examine how your code is modifying the DOM; this will help you understand how the reconciliation algorithm works.
The code for this exercise component is simple:
const Coffee = defineComponent({
render() {
return hFragment([
h('h1', {}, ['Important news!']),
h('p', {}, ['I made myself coffee.']),
h('button', { on: { click: () => console.log('Good for you') } }, [
'Say congrats',
]),
])
},
})
Now, you need to copy and paste the code for your framework.
The simplest way you can do this is by opening the bundled JavaScript code in the dist/ folder or from unpkg.com.
You have to remember to leave out the export statement at the end of the file (the browser's console will complain if you leave it in).
Then, copy and paste the code for the safeHasOwnProperty()
and defineComponent()
functions into the console.
Finally, copy and paste the code for the Coffee
component into the console.
You can now instantiate the component like so:
const coffee = new Coffee()
Go to the "Elements" (in Chrome) or "Inspector" (in Firefox) tab of your browser's developer tools and look for a good candidate element where to mount your component.
I chose the header of the The Atlantic magazine website.
Click it so that it's highlighted, and you can reference it from the console as $0
.
Then mount the component like so:
coffee.mount($0)
Or at a given index, as I did in the figure below:
coffee.mount($0, 1)
<img src="https://github.com/angelsolaorbaiceta/fe-fwk-book/assets/7513343/118d1ed2-3703-422d-b024-84363f4604c7" alt="Mounting the Coffee component inside The Atlantic magazine" width="8002 />
Click the button. You should see the message "Good for you" in the console. Great!—Now, unmount the component:
coffee.unmount()
The component should disappear from the page, as shown in the figure below.
This exercise is a bit more challenging, but also more fun. Same as in the previous exercise, make sure you copy/paste the code for your framework into the console before you start.
Here are some of the things you'll need to do:
- The state should be based on external props: the window's width and height. This way you can make sure your button isn't rendered outside of the window.
- Generate a random position for the button inside the
state()
function. - Add a
style
prop to the button to make itposition: absolute
, and addleft
andtop
CSS properties to position it. - When the button is clicked, generate a new random position for it and update the state.
Here's how I wrote the code for the FlyingButton
component:
const FlyingButton = defineComponent({
state({ width, height }) {
return {
width,
height,
x: parseInt(Math.random() * width),
y: parseInt(Math.random() * height),
}
},
render() {
const { x, y, width, height } = this.state
return h(
'button',
{
style: {
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
},
on: {
click() {
this.updateState({
x: parseInt(Math.random() * width),
y: parseInt(Math.random() * height),
})
},
},
},
['Move'],
)
},
})
I also chose the The Atlantic magazine website to mount the component.
In this case, I'm mounting it in the <body>
element.
The button is positioned absolutely, so it's not affected by the magazine's layout:
const width = window.innerWidth
const height = window.innerHeight
const flyingButton = new FlyingButton({ width, height })
flyingButton.mount(document.body)
You can see the result in the figure below.
When the button is clicked, it moves to a new random position. Isn't that fun? (Even just moderately?) I should be drinking beers with my friends, but instead I'm making buttons fly over a magazine's website.
The component's offset problem doesn't arise when the component's view is a single root element because, all the child nodes of the element are inside the component's root element. Hence, the child nodes under the root element are always moved around with respect with an element that the component owns. You can see this illustrated in the figure below.
Because the operations returned by the arraysDiffSet()
function are applied relative to the <div>
element that the component owns, the component's offset isn't relevant in this case.
For this exercise, you want to first define a function to fetch a random cocktail from the CocktailDB API:
const url = 'https://www.thecocktaildb.com/api/json/v1/1/random.php'
async function fetchRandomCocktail() {
const response = await fetch(url)
const data = await response.json()
return data.drinks[0]
}
Then, here's how I wrote the code for the RandomCocktail
component:
const RandomCocktail = defineComponent({
state() {
return {
isLoading: false,
cocktail: null,
}
},
render() {
const { isLoading, cocktail } = this.state
if (isLoading) {
return hFragment([
h('h1', {}, ['Random Cocktail']),
h('p', {}, ['Loading...']),
])
}
if (!cocktail) {
return hFragment([
h('h1', {}, ['Random Cocktail']),
h('button', { on: { click: this.fetchCocktail } }, [
'Get a cocktail',
]),
])
}
const { strDrink, strDrinkThumb, strInstructions } = cocktail
return hFragment([
h('h1', {}, [strDrink]),
h('p', {}, [strInstructions]),
h('img', {
src: strDrinkThumb,
alt: strDrink,
style: { width: '300px', height: '300px' },
}),
h(
'button',
{
on: { click: this.fetchCocktail },
style: { display: 'block', margin: '1em auto' },
},
['Get another cocktail']
),
])
},
async fetchCocktail() {
this.updateState({ isLoading: true, cocktail: null })
const cocktail = await fetchRandomCocktail()
setTimeout(() => {
this.updateState({ isLoading: false, cocktail })
}, 1000)
},
})
I won't explain how to mount the component in this case, as we've already done it in the previous exercises. The figure below shows the result of the component before a cocktail is loaded.
Then, when I click the button, I see the loading message, and after a second, the cocktail is loaded.
I'm going to make myself a... Spice Peach Punch before I start writing next chapter. See you there!
This is how you can define the List
and ListItem
components:
const List = defineComponent({
render() {
const { todos } = this.props
return h('ul', {}, todos.map((todo) => h(ListItem, { todo })))
}
})
const ListItem = defineComponent({
render() {
const { todo } = this.props
return h('li', {}, [todo])
}
})
You need to bind the event handler's context to the parent component instance because the event handler is defined in the parent component, and you want to call it with the parent component instance as its context.
A user expects the this
keyword inside the event handler to refer to the parent component instance, where the event handler function is defined, not the child component instance.
This is how you can define the SearchField
component:
const SearchField = defineComponent({
render() {
return h('input', {
on: {
input: debounce((event) => this.emit('search', event.target.value), 500)
}
})
}
})
The first challenge you face in this exercise is setting the breakpoint in the code you paste in the browser's console. I hope you found the solution to this problem by doing a quick search on the internet. In any case, here's how you can do it.
If you're using Chrome:
- In the console, write the name of the function you want to set the breakpoint in, without calling it (e.g.
patchChildren
, without parentheses), and press Enter. - The first lines of the function's code will be displayed in the console. Click it and you'll be taken to the Sources tab, where the function's code is displayed.
If you're using Firefox:
- Open the Debugger tab.
- Click the Search sub tab (in the left column, to the right of Sources and Outline).
- Write the name of the function you want to set the breakpoint in and click in the result that appears below.
After setting a breakpoint in the patchChildren()
function, you can run the mountDOM()
function as indicated in the exercise.
The breakpoint is hit and the execution is paused.
The first time it stops is for the children inside the <ul>
, exactly what you wanted to analyze.
As you can see, the arraysDiffSequence()
wasn't smart enough to know that things moved around, as you can see that it yielded three noop operations.
Let the first noop operation be executed.
You want to dive into the second noop, which is the one that will get into the second <li>
element; the one that changed.
When you reach the patchChildren()
function again, this time the operations are different: one remove operation followed by an add operation.
This means that, when the third element is moved into the second position, the whole tree inside the <li>
is recreated.
In fact, I added log breakpoints in all the children operations to see the exact execution order.
First are two noops, for the first <li>
and its text.
Then is another noop, for the second <li>
element, followed by removing the "Item 2" text that was inside of it.
Next goes creating the new <div>
and all its children inside the second <li>
element; basically recreating the entire tree that was inside the third <li>
element.
Then is the noop for the third <li>
element, followed by removing all its content... what a waste!
Finally, the "Item 2" text is added inside the third <li>
element.
Here's how you could've implemented the MyCounter
component:
const MyCounter = defineComponent({
state() {
return { count: 1 }
},
render() {
const { count } = this.state
return h('div', { style: { 'padding-left': '2em' } }, [
h(
'button',
{
on: { click: () => this.updateState({ count: count + 1 }) },
},
[hString(count)],
),
h('span', {}, [' — ']),
h(
'button',
{
on: { click: () => this.emit('remove') },
},
['Remove'],
),
])
},
})
And here's the complete App
component (I included the instructions of the exercise in the header of the page):
const App = defineComponent({
state() {
return {
counters: 3,
}
},
render() {
const { counters } = this.state
return hFragment([
h('h1', {}, ['Index as key problem']),
h('p', {}, [
'Set a different count in each counter, then remove the middle one.',
]),
h(
'div',
{},
Array(counters)
.fill()
.map((_, index) => {
return h(MyCounter, {
key: index,
on: {
remove: () => {
this.updateState({ counters: counters - 1 })
},
},
})
}),
),
])
},
})
You can now mount this application into the DOM and would get something like the figure below.
Now, if you click the "Remove" button of the second counter, you'll get the result in the figure below.
Definitely now what you wanted. With this exercise you can clearly see the problem of using the index as key.
You can use the same application as in the previous exercise, but making sure that all the MyCounter
components have the same key.
When you repeat the process of setting a different count in each counter and then removing the middle one, you'll get the same result as in the previous exercise.
To understand why this happened, you can set a breakpoint inside the patchChildren()
function and see what operations the arraysDiffSequence()
function yielded.
This is what you'll get:
[
{ op: 'noop', index: 0, originalIndex: 0 },
{ op: 'noop', index: 1, originalIndex: 1 },
{ op: 'remove', index: 2 }
]
These operations are obviously wrong.
The arraysDiffSequence()
function wasn't able to differentiate the elements given in the two lists, because they all have the same key.
It compares one by one, thinking they are all the same items, until it reaches the third element, which is missing in the second list.
You can find the refactored application in the book's repository, in the examples/ch12/todos folder. The answer is a bit long, so I won't include it here.
The order of the console.log()
resulting from running the following code is:
Start
End
Microtask 1
Job
Microtask 2
Timeout
This is what happens:
- The
console.log('Start')
is executed immediately. - The
setTimeout()
function is called, which schedules the callback that prints "Timeout" in the task queue. - The
queueMicrotask()
function is called, which schedules the callback that prints "Microtask 1" in the microtask queue. - The
enqueueJob()
function is called, which schedules the callback that prints "Job" in the microtask queue through the scheduler. - The
queueMicrotask()
function is called again, which schedules the callback that prints "Microtask 2" in the microtask queue. - The
console.log('End')
is executed immediately. - The execution stack is empty, so the event loop starts processing the queues.
- All microtasks are executed in order, so the "Microtask 1", "Job", and "Microtask 2" lines are printed to the console.
- The oldest task in the task queue is executed, so the "Timeout" line is printed to the console.
For this exercise, you can use the locally built version of your framework.
You can compile it by running the npm run build
command inside the packages/runtime folder.
Then, you can create a new directory inside your examples/ folder and reference the built version of the framework (located at packages/runtime/dist/.js) and work out the exercise there.
Serve the exercises in the browser (npm run serve:examples
) and open the developer tools.
Add a "log breakpoint" inside the application's instance mount()
method, like in the figure below.
It's a good idea to add a second "log break point" just after the mountDOM()
function, to make sure the function finishes before the onMounted()
hooks are executed.
Then, reload the page and check the order of the operations logged to the console. It's definitely as expected:
Mounting App component...
Finished mounting
Component mounted with name: Alice
Component mounted with name: Bob
Component mounted with name: Charlie
Component mounted with name: Diana
Component mounted with name: Eve
I'll leave it to you to debug the working of the scheduler.
You can find the solution to this exercise in the exercises/ch13/todos folder. I'll cover here the most important parts of the solution.
Inside the exercises/ch13 folder, create a new folder called todos. There, you can copy/paste the code you wrote in chapter 12, where you refactored the application to use stateful components. In the todos.js file, remember to change the version of the framework to 4:
import {
createApp,
defineComponent,
h,
hFragment,
} from 'https://unpkg.com/fe-fwk@4'
Now create a new file named todos-repository.js. Inside, write the functions to read and write the to-dos to the local storage:
export function readTodos() {
return JSON.parse(localStorage.getItem('todos') || '[]')
}
export function writeTodos(todos) {
localStorage.setItem('todos', JSON.stringify(todos))
}
Add an onMounted()
function inside the App
component that reads the to-dos from the local storage (don't forget to import the functions from the todos-repository.js file):
import {
createApp,
defineComponent,
h,
hFragment,
} from 'https://unpkg.com/fe-fwk@4'
++ import { readTodos, writeTodos } from './todos-repository.js'
const App = defineComponent({
state() {
return {
todos: [],
}
},
++ onMounted() {
++ this.updateState({ todos: readTodos() })
++ },
render() {
// --snip --//
}
})
Now, inside the same component, modify the addTodo()
, removeTodo()
, and editTodo()
methods so that they write the to-dos to the local storage after they update the component's state:
addTodo(text) {
const todo = { id: crypto.randomUUID(), text }
this.updateState({ todos: [...this.state.todos, todo] })
++ writeTodos(this.state.todos)
},
removeTodo(idx) {
const newTodos = [...this.state.todos]
newTodos.splice(idx, 1)
this.updateState({ todos: newTodos })
++ writeTodos(this.state.todos)
},
editTodo({ edited, i }) {
const newTodos = [...this.state.todos]
newTodos[i] = { ...newTodos[i], text: edited }
this.updateState({ todos: newTodos })
++ writeTodos(this.state.todos)
},
And that's all! Write a few to-dos, reload the page, and check that the to-dos are still there.
Here's the order in which the console.log()
calls are executed (tested in Chrome, Firefox, and Safari):
setTimeout() with resolve() as callback...
Microtask 1
Microtask 2
Microtask 44
Microtask 45
Promise {<pending>}
Task 1
Task 2
About to resolve the promise...
Result of flush: 42
Task 22
This is what happens:
- The microtask 1 is queued.
- The task 1 is queued.
- The microtask 2 is queued.
- The task 2 is queued.
- The
flushPromises()
function is called, which creates aPromise
that immediately prints the "setTimeout()..." message, then schedules a task. - The microtask 44 is queued.
- The task 22 is queued.
- The
Promise
returned by theflushPromises()
function is awaited, which will schedule a microtask when thesetTimeout(resolve)
task is executed, and whose callback prints the "Result of flush..." message. (This is the mysteriousPromise {<pending>}
you see logged right after the "Microtask 45" message.)
To understand this last step, recall that using await
would be the equivalent of doing the following:
function doWork() {
// --snip-- //
p.then((value) => {
console.log('Result of flush:', value)
})
}
At this point, the state of the event loop is depicted in the figure below.
All the synchronous code has finished executing, the execution stack is empty, and the event loop is ready to start processing the microtasks:
- The microtask 1 is executed, which prints the "Microtask 1" message.
- The microtask 2 is executed, which prints the "Microtask 2" message.
- The microtask 44 is executed, which prints the "Microtask 44" message and enqueues a new microtask: microtask 45 (figure below).
The microtask 45 is executed, which prints the "Microtask 45" message. This microtask wasn't scheduled before this cycle of the event loop, but as we said, all microtasks are consumed regardless of when they were scheduled.
All microtasks are now consumed, so the event loop is ready to process the oldest task, in this case, task 1. This task prints the "Task 1" message to the console. Now the event loop looks for microtasks, but there aren't any, so it processes the next task, task 2, which prints the "Task 2" message to the console.
Now is the turn of the task created inside the flushPromises()
function.
This task prints the "About to resolve the promise..." message, then resolves the Promise
returned by the flushPromises()
function, which schedules a microtask whose callback prints the "Result of flush..." message.
After this step, the most important one you should understand, the status of the system is depicted in the figure below.
Now, all microtasks are executed; there's only one, the one that was scheduled when the Promise
returned by the flushPromises()
function was resolved.
This one prints the "Result of flush: 42" message to the console.
Last, the next task is executed—the only one that is left in the task queue--which prints the "Task 22" message to the console. That's it.
Wow!—What a ride!