Analisi e miglioramento di un sistema di paginazione in JavaScript

In questo articolo analizzeremo nel dettaglio un'implementazione basilare di un sistema di paginazione client-side scritto in JavaScript vanilla, pensato per suddividere le righe di una tabella HTML in pagine navigabili tramite due pulsanti. Si tratta di un'implementazione didattica e funzionante, ma che presenta numerose criticità dal punto di vista delle performance, della manutenibilità e della robustezza generale. Esamineremo ogni singola parte del codice originale e, per ciascuna, proporremo una versione migliorata spiegando le ragioni delle scelte effettuate.

Il codice originale

Il codice di partenza è racchiuso in una IIFE (Immediately Invoked Function Expression) con 'use strict' e si occupa di trovare una tabella nel documento, leggerne le righe del tbody, suddividerle in gruppi da dieci elementi e mostrare/nascondere le righe in base alla pagina corrente. Vediamolo nella sua interezza:

'use strict';
(function () {
  const PER_PAGE = 10;
  let page = 1;
  function _pages(items = []) {
    return Math.ceil(items.length / PER_PAGE);
  }
  function _items(container = null, selector = '') {
    if (!container || !selector) return [];
    return container.querySelectorAll(selector);
  }
  function paginate(items = [], p = 1) {
    if (items.length === 0) return;
    const start = (p - 1) * PER_PAGE;
    const end = start + PER_PAGE;
    const selection = Array.prototype.slice.call(items, start, end);
    for (const item of items) {
      item.classList.add('hidden');
    }
    for (const sel of selection) {
      sel.classList.remove('hidden');
    }
  }
  function watch(totalPages = 1) {
    const previous = document.querySelector('.pagination .previous');
    const next = document.querySelector('.pagination .next');
    setInterval(() => {
      if (page > 1) {
        previous.removeAttribute('disabled');
      } else {
        previous.setAttribute('disabled', 'disabled');
      }
      if (page === totalPages) {
        next.setAttribute('disabled', 'disabled');
      } else {
        next.removeAttribute('disabled');
      }
    }, 100);
  }
  function events(items = []) {
    document.addEventListener(
      'click',
      (evt) => {
        const target = evt.target;
        if (target.classList.contains('next')) {
          page++;
          paginate(items, page);
        }
        if (target.classList.contains('previous')) {
          page--;
          paginate(items, page);
        }
      },
      false,
    );
  }
  function create(target = null) {
    if (!target) return;
    const nav = document.createElement('nav');
    nav.className = 'pagination';
    nav.innerHTML = `
            <button type="button" disabled="true" class="btn previous">&larr;</button>
            <button type="button" class="btn next">&rarr;</button>
        `;
    target.insertAdjacentHTML('afterend', nav.outerHTML);
  }
  function init() {
    const table = document.querySelector('table');
    const items = _items(table.querySelector('tbody'), 'tr');
    const pages = _pages(items);
    events(items);
    create(table);
    paginate(items);
    watch(pages);
  }
  document.addEventListener('DOMContentLoaded', init, false);
})();

Come dicevamo, il codice funziona, ma è un'implementazione basilare. Passiamo ora in rassegna le singole parti mettendone in luce i limiti e proponendo soluzioni più solide.

Lo stato globale e la configurazione

La prima criticità si manifesta già nelle prime righe: la variabile page è dichiarata con let nello scope della IIFE e viene modificata direttamente dai gestori di eventi. Questo stato condiviso e mutabile rende il codice difficile da estendere (per esempio a più tabelle nella stessa pagina) e difficile da testare. Inoltre la costante PER_PAGE è cablata nel codice e non è configurabile dall'esterno.

Una soluzione più pulita consiste nell'incapsulare lo stato in una classe o in una factory function, accettando le opzioni come parametro:

class Paginator {
  constructor(table, options = {}) {
    // Opzioni configurabili con valori predefiniti sensati
    this.perPage = options.perPage ?? 10;
    this.hiddenClass = options.hiddenClass ?? 'hidden';
    this.table = table;
    this.currentPage = 1;
    this.items = [];
    this.totalPages = 1;
    this.nav = null;
    this.prevButton = null;
    this.nextButton = null;
  }
}

Incapsulare lo stato in un'istanza elimina la variabile globale page, permette di avere più paginatori indipendenti sulla stessa pagina e rende i parametri (numero di elementi per pagina, classe CSS per nascondere le righe) configurabili dall'esterno senza dover toccare il sorgente.

La funzione _items e il carattere di sottolineatura

La funzione _items è un semplice wrapper attorno a querySelectorAll con qualche controllo difensivo. Il carattere di sottolineatura iniziale è una convenzione obsoleta per indicare metodi «privati» e in JavaScript moderno non ha alcun significato reale. Inoltre restituire un array vuoto quando i parametri sono mancanti è una scelta discutibile: nasconde errori di programmazione che sarebbe meglio far emergere.

Una versione più idiomatica elimina il wrapper superfluo e utilizza direttamente querySelectorAll dove serve, convertendo il risultato in array per poter usare i metodi iterativi standard:

collectItems() {
  const tbody = this.table?.querySelector('tbody');
  if (!tbody) {
    throw new Error('Tabella senza tbody: impossibile paginare.');
  }
  // Array.from consente di avere un vero array invece di una NodeList
  this.items = Array.from(tbody.querySelectorAll('tr'));
  this.totalPages = Math.max(1, Math.ceil(this.items.length / this.perPage));
}

Convertire la NodeList in un vero array con Array.from rende superflui costrutti come Array.prototype.slice.call usati nel codice originale. Inoltre lanciare un'eccezione quando manca il tbody segnala subito un problema strutturale invece di fallire silenziosamente.

La funzione paginate e le prestazioni

La funzione paginate originale presenta due problemi rilevanti. Il primo è di natura prestazionale: a ogni cambio pagina itera su tutte le righe della tabella per aggiungere la classe hidden, per poi toglierla solo a quelle della pagina corrente. Su tabelle con centinaia o migliaia di righe questo significa altrettante manipolazioni del DOM, che il browser deve processare e che possono causare reflow costosi. Il secondo problema è che non valida il numero di pagina ricevuto: un valore negativo o superiore al totale porterebbe a una selezione vuota e a una tabella completamente nascosta.

Una versione migliorata traccia la pagina precedente per operare solo sulle righe effettivamente interessate dal cambio, e valida l'input:

goToPage(targetPage) {
  // Normalizziamo il valore all'intervallo valido
  const page = Math.min(Math.max(1, targetPage), this.totalPages);
  if (this.items.length === 0) return;

  const start = (page - 1) * this.perPage;
  const end = start + this.perPage;

  // Raggruppiamo le modifiche al DOM in un frammento logico:
  // nascondiamo tutto una volta sola e poi mostriamo solo la pagina richiesta
  this.items.forEach((item, index) => {
    const shouldShow = index >= start && index < end;
    item.classList.toggle(this.hiddenClass, !shouldShow);
  });

  this.currentPage = page;
  this.updateControls();
}

L'uso di classList.toggle con il secondo argomento booleano evita di eseguire due cicli separati e rende il codice più leggibile. Inoltre la chiamata a updateControls sincronizza immediatamente lo stato dei pulsanti, eliminando la necessità del polling descritto nel prossimo paragrafo.

La funzione watch: il problema del setInterval

Questo è probabilmente il punto più critico dell'intera implementazione. La funzione watch usa setInterval con un ritardo di cento millisecondi per controllare costantemente se i pulsanti devono essere abilitati o disabilitati. Si tratta di un antipattern che ha diverse conseguenze negative: consuma risorse anche quando l'utente non sta interagendo, introduce una latenza percepibile (fino a cento millisecondi) tra il click e l'aggiornamento visivo dei pulsanti, e non viene mai fermato, continuando a eseguire per tutto il ciclo di vita della pagina.

L'approccio corretto è reattivo: aggiornare lo stato dei pulsanti solo quando la pagina cambia effettivamente:

updateControls() {
  if (!this.prevButton || !this.nextButton) return;
  // Aggiorniamo lo stato dei pulsanti solo in risposta a un cambio di pagina
  this.prevButton.disabled = this.currentPage <= 1;
  this.nextButton.disabled = this.currentPage >= this.totalPages;
}

Usare la property disabled invece di setAttribute/removeAttribute è più idiomatico e performante, e restituisce un valore booleano coerente con l'HTMLButtonElement. Eliminando il setInterval il codice diventa più efficiente, più prevedibile e privo di memory leak impliciti.

La gestione degli eventi: delegation e coupling

La funzione events originale registra un listener sul document intero e controlla la classe CSS del target per capire se il click riguarda il pulsante precedente o successivo. Questo approccio ha due problemi: primo, se nella pagina esistono altri elementi con le classi next o previous (magari appartenenti ad altri componenti) si verificheranno collisioni; secondo, ascoltare su document per eventi che riguardano un'area ristretta della pagina è uno spreco.

Meglio delegare l'ascolto al solo elemento di navigazione creato dal paginatore, e riferirsi direttamente ai pulsanti tramite reference salvate:

bindEvents() {
  // Usiamo arrow function e riferimenti diretti invece della delegation globale
  this.prevButton.addEventListener('click', () => {
    this.goToPage(this.currentPage - 1);
  });
  this.nextButton.addEventListener('click', () => {
    this.goToPage(this.currentPage + 1);
  });
}

In questo modo ogni istanza del paginatore ascolta solo i propri pulsanti, evitando conflitti con altri componenti della pagina e rendendo il codice facilmente rimovibile (basta mantenere un riferimento alle funzioni per poterle passare a removeEventListener).

La funzione create e l'uso di innerHTML

La funzione create del codice originale costruisce un elemento nav, vi assegna una stringa tramite innerHTML e poi lo inserisce nel DOM tramite insertAdjacentHTML passando outerHTML. Questo approccio è contorto (crea e serializza l'elemento solo per poi farlo riparsare dal browser) e, soprattutto, perde il riferimento diretto ai pulsanti, costringendo watch a cercarli nuovamente con querySelector.

Inoltre l'attributo disabled="true" è formalmente scorretto: in HTML disabled è un attributo booleano, la sua sola presenza indica lo stato, indipendentemente dal valore.

Una versione migliorata costruisce gli elementi programmaticamente e ne conserva i riferimenti:

buildControls() {
  // Creiamo gli elementi programmaticamente per conservarne il riferimento
  this.nav = document.createElement('nav');
  this.nav.className = 'pagination';
  this.nav.setAttribute('aria-label', 'Navigazione paginazione');

  this.prevButton = document.createElement('button');
  this.prevButton.type = 'button';
  this.prevButton.className = 'btn previous';
  this.prevButton.setAttribute('aria-label', 'Pagina precedente');
  this.prevButton.innerHTML = '&larr;';

  this.nextButton = document.createElement('button');
  this.nextButton.type = 'button';
  this.nextButton.className = 'btn next';
  this.nextButton.setAttribute('aria-label', 'Pagina successiva');
  this.nextButton.innerHTML = '&rarr;';

  this.nav.append(this.prevButton, this.nextButton);
  this.table.insertAdjacentElement('afterend', this.nav);
}

Oltre a essere più pulito, questo approccio aggiunge attributi aria-label che migliorano significativamente l'accessibilità: i lettori di schermo annunceranno «Pagina precedente» e «Pagina successiva» invece delle semplici frecce, che per un utente non vedente sarebbero incomprensibili. L'uso di insertAdjacentElement invece di insertAdjacentHTML inserisce l'elemento già costruito senza passare per la serializzazione.

La funzione init e l'inizializzazione

L'init originale assume che nel documento esista esattamente una tabella, la trova con document.querySelector('table') e non gestisce il caso in cui manchi. Inoltre non ritorna nulla, rendendo impossibile interagire con il paginatore dopo la sua creazione (per esempio per distruggerlo o cambiargli configurazione).

Una versione più robusta richiede esplicitamente l'elemento tabella, valida l'input e restituisce l'istanza creata:

init() {
  this.collectItems();
  this.buildControls();
  this.bindEvents();
  this.goToPage(1);
  return this;
}

// Metodo di distruzione per rimuovere il paginatore in modo pulito
destroy() {
  this.nav?.remove();
  this.items.forEach(item => item.classList.remove(this.hiddenClass));
}

Il metodo destroy è particolarmente utile in applicazioni a pagina singola (SPA) dove i componenti vengono montati e smontati dinamicamente: senza di esso gli event listener rimarrebbero attivi anche dopo la rimozione della tabella, causando memory leak.

Il codice completo migliorato

Mettendo insieme tutti i miglioramenti discussi, otteniamo la seguente implementazione:

'use strict';

class Paginator {
  constructor(table, options = {}) {
    if (!(table instanceof HTMLTableElement)) {
      throw new TypeError('Paginator richiede un elemento table valido.');
    }
    this.table = table;
    this.perPage = options.perPage ?? 10;
    this.hiddenClass = options.hiddenClass ?? 'hidden';
    this.currentPage = 1;
    this.items = [];
    this.totalPages = 1;
    this.nav = null;
    this.prevButton = null;
    this.nextButton = null;
  }

  collectItems() {
    const tbody = this.table.querySelector('tbody');
    if (!tbody) {
      throw new Error('Tabella senza tbody: impossibile paginare.');
    }
    this.items = Array.from(tbody.querySelectorAll('tr'));
    this.totalPages = Math.max(1, Math.ceil(this.items.length / this.perPage));
  }

  buildControls() {
    this.nav = document.createElement('nav');
    this.nav.className = 'pagination';
    this.nav.setAttribute('aria-label', 'Navigazione paginazione');

    this.prevButton = document.createElement('button');
    this.prevButton.type = 'button';
    this.prevButton.className = 'btn previous';
    this.prevButton.setAttribute('aria-label', 'Pagina precedente');
    this.prevButton.innerHTML = '&larr;';

    this.nextButton = document.createElement('button');
    this.nextButton.type = 'button';
    this.nextButton.className = 'btn next';
    this.nextButton.setAttribute('aria-label', 'Pagina successiva');
    this.nextButton.innerHTML = '&rarr;';

    this.nav.append(this.prevButton, this.nextButton);
    this.table.insertAdjacentElement('afterend', this.nav);
  }

  bindEvents() {
    // Conserviamo i riferimenti alle funzioni per poterle rimuovere in destroy
    this.onPrev = () => this.goToPage(this.currentPage - 1);
    this.onNext = () => this.goToPage(this.currentPage + 1);
    this.prevButton.addEventListener('click', this.onPrev);
    this.nextButton.addEventListener('click', this.onNext);
  }

  goToPage(targetPage) {
    const page = Math.min(Math.max(1, targetPage), this.totalPages);
    if (this.items.length === 0) return;

    const start = (page - 1) * this.perPage;
    const end = start + this.perPage;

    this.items.forEach((item, index) => {
      const shouldShow = index >= start && index < end;
      item.classList.toggle(this.hiddenClass, !shouldShow);
    });

    this.currentPage = page;
    this.updateControls();
  }

  updateControls() {
    this.prevButton.disabled = this.currentPage <= 1;
    this.nextButton.disabled = this.currentPage >= this.totalPages;
  }

  init() {
    this.collectItems();
    this.buildControls();
    this.bindEvents();
    this.goToPage(1);
    return this;
  }

  destroy() {
    this.prevButton?.removeEventListener('click', this.onPrev);
    this.nextButton?.removeEventListener('click', this.onNext);
    this.nav?.remove();
    this.items.forEach(item => item.classList.remove(this.hiddenClass));
  }
}

document.addEventListener('DOMContentLoaded', () => {
  // Inizializziamo il paginatore su tutte le tabelle marcate con l'attributo data
  const tables = document.querySelectorAll('table[data-paginate]');
  tables.forEach(table => new Paginator(table, { perPage: 10 }).init());
});

Demo della versione originale

JavaScript Table Pagination

Demo della versione migliorata

JavaScript Table Pagination Improved

Conclusioni

Il codice originale era un'implementazione funzionante ma fragile: usava stato globale, manipolava il DOM in modo inefficiente, si basava su un setInterval per sincronizzare l'interfaccia e trascurava completamente l'accessibilità. La versione riscritta mantiene la stessa funzionalità ma introduce incapsulamento tramite classe, rende configurabili i parametri chiave, elimina il polling in favore di un modello reattivo, riduce le manipolazioni del DOM, migliora l'accessibilità con attributi ARIA, gestisce correttamente i casi limite e fornisce un meccanismo di distruzione pulito.

Questi miglioramenti non sono mere questioni di stile: si riflettono in performance misurabili su tabelle di grandi dimensioni, in una minore probabilità di bug quando il paginatore convive con altri componenti, e in una migliore esperienza per gli utenti che si affidano alle tecnologie assistive. Partendo da un'implementazione basilare come quella iniziale e applicando iterativamente questi principi si arriva a un componente pronto per l'uso in produzione.