JavaScript: implementare un'app Todo List senza librerie

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:

  1. fornire un modulo per la creazione di nuove voci
  2. fornire un modo per filtrare le voci
  3. fornire un modo per ordinare le voci sia in ordine crescente che decrescente tramite la data di creazione di ciascuna voce
  4. fornire un modo per rimuovere le voci
  5. i dati devono essere persistenti.

Ogni voce deve avere:

  1. un campo di descrizione
  2. un campo per la data e l'ora nel formato locale corrente
  3. 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">&times;</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">&times;</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 = '&times;';
        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:

  1. ridondanza
  2. assenza di modularità
  3. assenza di scalabilità
  4. difficile manutenibilità
  5. scarsa testabilità
  6. mancanza di uno standard comune nel design
  7. scarsa affidabilità.

Ecco perché si dovrebbe utilizzare una libreria.

Demo

Todo App

Torna su