In questo articolo vedremo come implementare un'app Todo List senza usare framework o librerie JavaScript.
Innanzitutto, iniziamo con le specifiche. La nostra app deve:
- fornire un modulo per la creazione di nuove voci
- fornire un modo per filtrare le voci
- fornire un modo per ordinare le voci sia in ordine crescente che decrescente tramite la data di creazione di ciascuna voce
- fornire un modo per rimuovere le voci
- i dati devono essere persistenti.
Ogni voce deve avere:
- un campo di descrizione
- un campo per la data e l'ora nel formato locale corrente
- un campo ID univoco.
Si tenga presente che non possiamo usare Angular, React, Vue, Svelte o qualsiasi altra cosa che possa ricordare una libreria JavaScript. Anche un semplice strumento per la gestione delle date non è consentito. Quanto ai CSS, framework come Bootstrap o Tailwind non sono ammessi. Dobbiamo quindi rimboccarci le maniche e lavorare con il DOM e i CSS. Non tratteremo l'implementazione CSS perché il nostro obiettivo principale è solo l'implementazione JavaScript. Iniziamo.
Non possiamo utilizzare moduli o componenti, quindi la struttura HTML e DOM della nostra app sarà contenuta all'interno di una singola pagina HTML.
<form action="" method="post" class="todo-form">
<div>
<input type="text" class="todo-description">
<button type="submit">Add</button>
</div>
</form>
<div class="todo-actions hidden">
<div class="todo-sort">
<ul>
<li data-order="ASC" class="active">ASC</li>
<li data-order="DESC">DESC</li>
</ul>
</div>
<form class="todo-search" method="get" action="">
<div>
<input type="text" class="todo-search-term" placeholder="Search...">
<button type="button" class="todo-reset-search">×</button>
<input type="submit" class="hidden" value="Find">
</div>
</form>
</div>
<ul class="todo-list"></ul>
<script src="app.js"></script>
Un requisito ci dice che i dati devono essere persistenti. Ciò significa che non possiamo
semplicemente manipolare un array di oggetti ma dobbiamo anche usare il Web Storage
per rendere disponibili le nostre voci dopo il reload della pagina.
La struttura del codice JavaScript nel file app.js
sarà la seguente:
'use strict';
(function() {
// Implementazione
document.addEventListener('DOMContentLoaded', () => {
// Elementi del DOM
const todoList = document.querySelector('.todo-list');
const todoForm = document.querySelector('.todo-form');
const todoInput = todoForm.querySelector('.todo-description');
const todoSort = document.querySelector('.todo-sort');
const todoSearch = document.querySelector('.todo-search');
const todoResetSearchBtn = document.querySelector('.todo-reset-search');
// Inizializzazione
}, false);
})();
La prima differenza evidente quando si lavora senza una libreria è che dobbiamo
fare riferimento manualmente a ogni singolo elemento DOM con cui vogliamo lavorare. Qui
abbiamo dovuto scrivere sei variabili per creare quei riferimenti. Una cosa da tenere a mente
è che se uno di questi elementi non esiste nell'albero del DOM, avremo
un riferimento a null
. Questo problema può essere in realtà
mitigato se usiamo TypeScript.
Per avere un'idea di come dovrebbe apparire una voce, possiamo creare una classe e trattarla come un modello.
class Todo {
constructor(id, description, date) {
this.id = id;
this.description = description;
this.date = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
}
I campi date
e description
sono stringhe, ma il campo id
dovrebbe contenere una stringa univoca. Poiché non possiamo utilizzare una libreria per generare stringhe univoche evitando collisioni (come gli UUID), dobbiamo farlo da zero:
const createTodoID = () => 'todo-' + Math.random().toString().substring(2);
Qui ci basiamo sul formato che assume un numero casuale compreso tra 0 e 1 quando viene
convertito in stringa, vale a dire 0.
seguito da una
serie di cifre. Questa soluzione è lungi dall'essere pienamente accettabile, ma
funziona e poiché non possiamo utilizzare una libreria esterna e non possiamo spendere troppo
tempo in questa fase preliminare, dobbiamo usare questa soluzione.
Ora possiamo iniziare con l'implementazione vera e propria. Innanzitutto, definiamo l'array principale per le nostre voci:
let todos = [];
I nostri todo devono essere persistenti, quindi dobbiamo usare l'oggetto
localStorage
salvando l'array come stringa JSON e quindi
riconvertirlo in un array JavaScript. Questa routine deve essere eseguita
all'inizio della nostra fase di inizializzazione.
const initStorage = () => {
if(localStorage.getItem('todos') === null) {
localStorage.setItem('todos', JSON.stringify(todos));
} else {
todos = JSON.parse(localStorage.getItem('todos'));
}
};
Quindi possiamo invocarlo come segue:
// Inizializzazione
initStorage();
Prima di proseguire, abbiamo bisogno di una funzione generica che inserisca un elenco di todo nell'elemento HTML corrispondente:
const displayTodos = (items = [], target = null) => {
if(items.length === 0 || target === null) {
return false;
}
let content = '';
for(const todo of items) {
content += `<li class="todo" id="${todo.id}">${todo.description} ${todo.date} <button type="button" class="todo-remove">×</button></li>`;
}
target.innerHTML = content;
};
Quindi abbiamo anche bisogno di una funzione che trasformi l'array di todo in un elenco di elementi che creerà il contenuto HTML della lista.
const getTodos = (target = null) => {
if(target === null || todos.length === 0) {
return false;
}
displayTodos(todos, target);
handleTodoActions(document.querySelector('.todo-actions'));
};
I componenti di ordinamento e ricerca sono inizialmente nascosti, perché se non ci sono
todo non ha senso mostrarli. Per gestire questo scenario, dobbiamo
creare una funzione specializzata che rimuova la classe CSS hidden
se ci sono più di 1 todo nell'array.
const handleTodoActions = (target = null) => {
if(target === null) {
return false;
}
if(todos.length > 1) {
target.classList.remove('hidden');
} else {
target.classList.add('hidden');
}
};
Abbiamo anche bisogno di una funzione per salvare il nostro array nel web storage:
const saveStorage = () => {
localStorage.setItem('todos', JSON.stringify(todos));
};
La creazione di un nuovo todo avviene in due diversi
passaggi: per prima cosa, dobbiamo aggiungere il nuovo elemento all'array todos
e
salvarlo nel web storage, quindi dobbiamo creare un nuovo elemento dell'elenco e aggiungerlo
alla struttura DOM dell'elenco HTML.
const addTodo = todo => {
if(!todo || !todo instanceof Todo) {
return false;
}
todos.push(todo);
saveStorage();
handleTodoActions(document.querySelector('.todo-actions'));
};
const createTodo = (target = null, content = '', input = null) => {
if(target === null) {
return false;
}
const todo = document.createElement('li');
const id = createTodoID();
const todoObj = new Todo(id, content, new Date());
todo.id = id;
todo.className = 'todo';
todo.innerText = content + ' ' + todoObj.date;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'todo-remove';
remove.innerHTML = '×';
todo.appendChild(remove);
target.appendChild(todo);
addTodo(todoObj);
if(input !== null) {
input.value = '';
}
return true;
};
La creazione di un nuovo todo è stata eseguita utilizzando una procedura DOM standard
invece di aggiungere semplicemente una stringa HTML perché
la descrizione del todo viene inserita direttamente dall'utente, il che ci esporrebbe ad attacchi
XSS. Invece, abbiamo usato la proprietà innerText
perché non possiamo
utilizzare una libreria esterna per filtrare l'input dell'utente.
A questo punto dobbiamo associare queste routine al form HTML principale:
const handleSubmit = (form = null) => {
if(form === null) {
return false;
}
form.addEventListener('submit', e => {
e.preventDefault();
const input = form.querySelector('.todo-description');
const value = input.value;
if(value && !/^\s+$/.test(value)) {
return createTodo(document.querySelector('.todo-list'), value, input);
}
}, false);
};
Poiché non possiamo utilizzare una libreria esterna per convalidare i campi del modulo e i loro valori, siamo costretti a controllare manualmente se il valore dato ha una lunghezza maggiore di zero e non è composto solo da spazi.
Finora abbiamo implementato la creazione di un nuovo todo e ora è il momento
di gestirne la rimozione. Per fare ciò, per prima cosa rimuoviamo uno specifico todo dall'array
e salviamo l'array risultante nel web storage. Quindi associamo un evento
a ciascun pulsante con la classe todo-remove
nella lista HTML.
const removeTodo = id => {
if(todos.length === 0) {
return false;
}
todos = todos.filter(td => td.id !== id);
saveStorage();
handleTodoActions(document.querySelector('.todo-actions'));
};
const handleTodoRemove = (target = null, input = null) => {
if(target === null) {
return false;
}
target.addEventListener('click', e => {
const element = e.target;
if(element.matches('.todo-remove')) {
element.parentNode.remove();
removeTodo(element.parentNode.id);
if(input !== null) {
input.value = '';
}
}
}, false);
};
Il parametro target
è un riferimento al pulsante corrente
e poichè i pulsanti sono inseriti dinamicamente dobbiamo implementare
una semplice forma di event delegation per rimuovere un elemento dal DOM.
event.target
punta al pulsante su cui si fa clic
nella lista. Se il selettore CSS corrisponde (matches()
) a
.todo-remove
, rimuoviamo il suo nodo genitore (la voce della lista) e usiamo l'ID
del nodo padre per rimuovere l'elemento todo corrispondente
dall'array todos
.
Ora, come possiamo implementare l'ordinamento? Dobbiamo convertire ciascuna proprietà
date
dei nostri todo in timestamp Unix e ordinare
l'array risultante in ordine decrescente o crescente in base
al valore scelto dall'utente tramite il blocco .todo-sort
.
const sortTodos = (order = 'ASC') => {
const list = todos.map(todo => {
let dateTime = todo.date;
let dateParts = dateTime.split(' ');
let dt = dateParts[0].split('/').reverse().join('-');
let ts = new Date(dt + ' ' + dateParts[1]).getTime();
return {...todo, ts};
}).sort((a, b) => {
if(order === 'DESC') {
return -1;
}
if(order === 'ASC') {
return 1;
}
return 0;
});
displayTodos(list, document.querySelector('.todo-list'));
};
const toggleSorting = (target = null) => {
if(target === null) {
return false;
}
target.addEventListener('click', () => {
const active = target.querySelector('.active');
const sibling = active.nextElementSibling ? active.nextElementSibling : active.previousElementSibling;
if(sibling) {
sibling.className = 'active';
active.className = '';
}
sortTodos(target.querySelector('.active').dataset.order);
}, false);
};
L'array todos
iniziale viene trasformato dal metodo
map()
aggiungendo a ciascun elemento la proprietà ts
che contiene un timestamp Unix creato dalla stringa della data originale. Quindi
l'array viene passato al metodo sort()
che esegue l'effettiva
routine di ordinamento.
Ora è il momento di implementare la funzionalità di ricerca. Per farlo, abbiamo bisogno
di trovare una corrispondenza nel campo description
di ciascun todo
eseguendo una ricerca testuale senza distinguere tra caratteri maiuscoli e minuscoli.
const searchTodos = (term = '') => {
initStorage();
const results = todos.filter(todo => {
return todo.description.toLowerCase().includes(term.toLowerCase());
});
if(results.length > 0) {
displayTodos(results, document.querySelector('.todo-list'));
}
};
const handleSearch = (form = null) => {
if(form === null) {
return false;
}
form.addEventListener('submit', e => {
e.preventDefault();
const term = form.querySelector('.todo-search-term').value;
searchTodos(term);
}, false);
};
Il metodo filter()
restituirà un array contenente solo gli
elementi la cui descrizione contiene il termine di ricerca. Se non ci sono
risultati, non procederemo oltre perché gli utenti visualizzerebbero solo una lista vuota
e questo causerà una user experience davvero negativa.
Infine, possiamo implementare il reset completo dei risultati della ricerca:
const handleResetSearch = (btn = null, target = null) => {
if(btn === null || target === null) {
return false;
}
btn.addEventListener('click', () => {
getTodos(target);
btn.previousElementSibling.value = '';
}, false);
};
Conclusione
I principali difetti di una soluzione implementata da zero sono:
- ridondanza
- assenza di modularità
- assenza di scalabilità
- difficile manutenibilità
- scarsa testabilità
- mancanza di uno standard comune nel design
- scarsa affidabilità.
Ecco perché si dovrebbe utilizzare una libreria.