An illustration of a tree sawed at the trunk and in small pieces, with a handsaw nearby.

JavaScript Responsabile, Parte II

Supponiamo che insieme a tutto il dev team abbiate fatto pressione con entusiasmo per un remake totale dell’architettura del sito web aziendale, che comincia a mostrare i suoi anni. Le vostre preghiere sono state ascoltate dal management – su su fino alla C-suite – che ha dato il via libera. Tutti euforici, voi e il team avete cominciato subito a lavorare con i team di design, copy e IA e nel giro di poco tempo, avete sfornato il nuovo codice.

L’articolo prosegue sotto

È cominciato tutto con un innocente npm install qui e un npm install là. Prima che ve ne rendeste conto, però, stavate installando delle dependencies in produzione come uno studente della triennale che fa keg stand senza curarsi di cosa succederà il giorno dopo.

Poi avete lanciato.

In questo caso però, a differenza delle conseguenze delle peggiori bevute, l’agonia non inizia la mattina dopo. Oh no. Succede mesi dopo, sotto l’agghiacciante forma di debole nausea e mal di testa da parte dei product owner e del middle management che si chiedono come mai le conversion e le revenue si sono entrambe abbassate dal momento del lancio. Poi si tocca la frenesia quando il CTO di ritorno da un week-end nei boschi si chiede come mai il sito si caricasse così lentamente sul suo telefono – ammesso che si caricasse.

Prima erano tutti contenti. Adesso nessuno è contento. Benvenuti al vostro primo hangover da JavaScript.

Non è colpa vostra#section1

Quando vi ritrovate con un terribile hangover, vi meritereste un bel “Ve l’avevo detto”, anche se potrebbe scaturirne una litigata… Ammesso che riusciate a litigare nel miserabile stato in cui vi trovate.

Quando abbiamo a che fare con un hangover da JavaScript, c’è un sacco di colpa da distribuire. Ma puntare il dito è una perdita di tempo. Il panorama del web attuale richiede iterazioni più rapide di quelle dei nostri competitor. Questo tipo di pressione significa che probabilmente dovremo trarre vantaggio da qualunque mezzo a nostra disposizione per essere il più produttivi possibile. Ciò significa che è più probabile creare app con più overhead e magari usare pattern che possono ledere la performance e l’accessibilità, ma che non sarà necessariamente un insuccesso.

Lo sviluppo web non è facile. È un lungo lavoro duro che raramente facciamo bene al primo tentativo. La parte migliore del lavorare sul web, però, è che non dobbiamo essere perfetti fin dall’inizio. Possiamo fare dei miglioramenti dopo il lancio e questo è proprio l’argomento di questa seconda parte di questa serie. La perfezione è molto lontana. Per ora, liberiamoci dell’hangover di JavaScript migliorando la scriptuation a breve termine del vostro sito.

Radunate i soliti sospetti#section2

Potrebbe sembrare un futile esercizio, ma vale la pena scorrere l’elenco delle ottimizzazioni di base. Non è insolito che i grandi team di sviluppo, particolarmente per quelli che lavorano su molti repository o che non usano boilerplate ottimizzati, le ignorino.

Scuotete quegli alberi#section3

Per prima cosa, assicuratevi che la vostra toolchain sia configurata per fare tree shaking. Se il tree shaking è un concetto nuovo per voi, l’anno scorso ho scritto una guida sull’argomento. In poche parole, il tree shaking è un processo nel quale gli export non utilizzati nella vostra codebase non vengono impacchettati nel vostro bundle in produzione.

Il tree shaking è disponibile “out of the box” con i moderni bundlers come webpack, Rollup o Parcel. Grunt o gulp, che non sono bundlers ma piuttosto task runner, non lo faranno per voi. Un task runner non crea un dependency graph come invece fa un bundler. Al contrario, fanno dei task discreti sui files che date loro in pasto con un qualsiasi numero di plugin. I task runner possono essere estesi con plugin che usino dei bundler per processare JavaScript. Se estendere così i task runner è per voi un problema, probabilmente dovrete fare un audit manuale e rimuovere il codice inutilizzato.

Perché il tree shaking sia efficace, devono essere vere le seguenti condizioni:

  1. La logica della vostra app e i package che installate nel vostro progetto devono essere creati come moduli ES6. Fare tree shaking di moduli CommonJS non è praticamente possibile.
  2. Il vostro bundler non deve trasformare i moduli ES6 in un altro formato di modulo durante il build time. Se succede in una toolchain che usa Babel, @babel/preset-env configuration deve specificare modules: false per impedire che il codice ES6 venga convertito a CommonJS.

Nella remota possibilità in cui il tree shaking non avvenga durante il build, potrebbe essere utile farlo funzionare. Ovviamente, la sua efficacia varia caso per caso. Dipende anche dal fatto che i moduli che importate introducano degli effetti collaterali, che potrebbero influenzare la capacità del bundler di fare shaking degli export inutilizzati.

Suddividete quel codice#section4

Ci sono delle buone possibilità che stiate usando una qualche forma di code splitting, ma vale la pena rivalutare come lo state facendo. Indipendentemente da come state suddividendo il codice, ci sono due domande che vale sempre la pena porsi:

  1. State de-duplicando il codice comune tra gli entry point?
  2. State facendo lazy loading di tutte le funzionalità che potete ragionevolmente con dynamic import()?

Queste cose sono importanti perché ridurre il codice ridondante è fondamentale per la performance. Anche la funzionalità lazy loading migliora la performance abbassando l’impronta iniziale di JavaScript su una data pagina. Sul fronte della ridondanza, usare un tool di analisi come Bundle Buddy può aiutarvi a scoprire se avete un problema.

The Bundle Buddy utility demonstrating how much code is shared between bundles of JavaScript.
Bundle Buddy esamina le statistiche di compilazione del vostro webpack e determina quanto codice sia condiviso tra i vostri bundle.

Quando c’è in ballo il lazy loading, può essere un po’ difficile sapere da dove cominciare a cercare delle opportunità. Quando le cerco in un progetto esistente, cercherò i punti di interazione dell’utente in tutta la codebase, come gli eventi click e keyboard e candidati simili. Ogni codice che richiede un’interazione con l’utente per girare è un potenziale buon candidato per l’import() dinamico.

Ovviamente, caricare gli script on demand porta con sé la possibilità che l’interattività possa essere sensibilmente ritardata, dal momento che lo script necessario per l’interazione deve essere prima caricato. Se l’uso dei dati non è un problema, prendete in considerazione l’uso del resource hintrel=prefetch per caricare tali script con una priorità minore, così non si contenderanno la banda con le risorse critiche. Il supporto per rel=prefetch è buono, ma non farà danni se non è supportato, dal momento che quei browser che non lo supportano ignoreranno il markup che non comprendono.

Esternalizzate il codice di terze parti hosted#section5

Idealmente, dovreste fare self-hosting di quante più dipendenze del vostro sito possibile. Se per qualche ragione dovete caricare le dipendenze da una terza parte, segnatele come esterne nella configurazione del vostro bundler. Se non riuscite a farlo, potrebbe significare che i visitatori del vostro sito scaricheranno sia il codice ospitato localmente sia lo stesso codice da terze parti.

Osserviamo un’ipotetica situazione in cui questo potrebbe danneggiarvi. Supponiamo che il vostro sito carichi Lodash da un CDN pubblico. Però avete anche installato Lodash nel vostro progetto per lo sviluppo locale. Tuttavia, se non riuscite a marcare Lodash come esterno, il vostro codice di produzione finirà col caricarne una copia di terze parti oltre alla copia bundled, ospitata localmente.

Potrebbe sembrare cosa nota se sapete come muovervi tra i bundler, ma ho visto che viene sottovalutata. Vale la pena controllare due volte.

Se non siete convinti della necessità di ospitare sul vostro hosting le dipendenze di terze parti, allora prendete in considerazione l’opzione di aggiungere per loro dns-prefetch, preconnect o addirittura preload. In questo modo abbasserete il Time to Interactive del vostro sito e, se JavaScript è cruciale per la resa del contenuto, lo Speed Index del vostro sito.

Alternative più piccole per minori overhead#section6

Userland JavaScript è come un negozio di caramelle esageratamente grande e noi sviluppatori siamo stupefatti dall’incredibile quantità di offerte open source. I framework e le librerie ci permettono di estendere le nostre applicazioni per fare tutta una serie di cose che altrimenti richiederebbero tonnellate di tempo e lavoro.

Sebbene io personalmente preferisca minimizzare aggressivamente i framework e le librerie client-side dei miei progetti, il loro valore è inestimabile. Tuttavia, noi abbiamo la responsabilità di essere un po’ aggressivi quando si tratta di decidere cosa installare. Quando abbiamo già realizzato e spedito qualcosa che dipende da un sacco di codice per funzionare, abbiamo accettato il fatto che solo i maintainer di quel codice lo possono gestire in pratica. Giusto?

Forse, o forse no. Dipende dalle dipendenze che abbiamo usato. Per esempio, React è estremamente popolare ma Preact è un’alternativa ultra-piccola che condivide ampiamente la stessa API e mantiene la compatibilità con molti degli add-on di React. Luxon e date-fns sono alternative molto più compatte di moment.js, che non è esattamente piccolo.

Librerie come Lodash offrono molti metodi utili. tuttavia, alcuni di essi sono facilmente sostituibili con ES6 nativo. Il metodo compact di Lodash, per esempio, è sostituibile con il metodo array filter. Molti altri sono sostituibili senza troppa fatica e senza il bisogno di richiamare una grande libreria di utility.

Quali che siano i vostri tool preferiti, l’idea è la stessa: fate delle ricerche per vedere se ci sono alternative più piccole o se le feature del linguaggio nativo possono andare bene. Potreste rimanere stupiti da quanto poco ci voglia per ridurre seriamente l’overhead della vostra app.

Fate invio differenziato degli script#section7

C’è una buona possibilità che stiate usando Babel nella vostra toolchain per trasformare il vostro sorgente ES6 in codice che possa girare sui browser più vecchi. Questo implica che siete costretti a inviare giganti bundle anche ai browser a cui non servono, finché non ci saranno più i browser più vecchi? Ovviamente no! L’invio in maniera differenziata ci aiuta ad aggirare questo problema generando due diverse build del vostro sorgente ES6:

  • Bundle uno, che contiene tutte le transform e i polyfill richiesti dal vostro sito per funzionare sui browser più vecchi. Probabilmente inviate già questo bundle.
  • Bundle due, che contiene pochi o nessun transform e polyfill perché ha come obiettivo i browser moderni. Questo è il bundle che forse non state ancora inviando.

Realizzare ciò è un po complicato. Ho scritto una guida riguardante un modo per farlo, quindi non c’è bisogno di entrare nei dettagli qui. Il succo della questione è che potete modificare la configurazione della vostra build per generare una versione aggiuntiva ma più piccola del codice JavaScript del sito e inviarla solo ai browser moderni. La parte migliore è che risparmiate senza dover sacrificare alcuna feature o funzionalità che già offrite. A seconda del codice della vostra applicazione, i risparmi potrebbero essere piuttosto significativi.

Un’analisi di webpack-bundle-analyzer di un bundle legacy di un progetto (a sinistra) rispetto a una per un bundle moderno (destra). Vedi immagine a dimensione reali.

Il pattern più semplice per inviare questi bundle alle rispettive piattaforme è breve. Inoltre, funziona benissimo nei browser moderni:

<!-- Modern browsers load this file: -->
/js/app.mjs
<!-- Legacy browsers load this file: -->
/js/app.js

Sfortunatamente, c’è una puntualizzazione riguardo a questo pattern: i browser legacy come IE 11, ma anche quelli relativamente moderni come Edge dalla versione 15 alla 18, scaricheranno entrambe i bundle. Se per voi questa cosa non è un problema, allora siete a posto.

D’altro canto, vi servirà una soluzione temporanea se siete preoccupati delle conseguenze sulla performance del download di entrambe i set di bundle da parte dei browser più vecchi. Ecco una soluzione potenziale che usa lo script injection (invece dei tag script di cui sopra) per evitare un download doppio che danneggi i browser:

var scriptEl = document.createElement("script");

if ("noModule" in scriptEl) {
  // Set up modern script
  scriptEl.src = "/js/app.mjs";
  scriptEl.type = "module";
} else {
  // Set up legacy script
  scriptEl.src = "/js/app.js";
  scriptEl.defer = true; // type="module" defers by default, so set it here.
}

// Inject!
document.body.appendChild(scriptEl);

Questo script deduce che se un browser supporta l’attributo nomodule nell’elemento script, allora comprende type="module". Questo assicura che i browser legacy ottengano solo gli script legacy e i browser moderni ottengano solo quelli moderni. Attenti, però! Gli script inviati dinamicamente si caricano in maniera asincrona di default, quindi impostate l’attributo <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-async">async</a> a false se l’ordine delle dipendenze è di cruciale importanza.

Fate meno transpiling#section8

Non sono qui per devastare Babel. È indispensabile, ma diamine, aggiunge un sacco di altre robe senza che nemmeno lo sappiate. Conviene guardare meglio dietro le quinte per vedere cosa fa. Alcuni cambiamenti minori nelle vostre abitudini di programmazione possono avere un impatto positivo su quello che butta fuori Babel.

https://twitter.com/_developit/status/1110229993999777793

Per intenderci, i parametri di default sono una feature molto pratica di ES6 che probabilmente usate già:

function logger(message, level = "log") {
  console[level](message);
}

La cosa a cui prestare attenzione qui è il parametro level, che ha come default “log”. Questo significa che se vogliamo invocare console.log con questa wrapper function, non dobbiamo specificare level. Ottimo, no? Tranne che quando Babel trasforma questa funzione, l’output appare così:

function logger(message) {
  var level = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "log";

  console[level](message);
}

Questo è un esempio di come, nonostante le nostre migliori intenzioni, la comodità dello sviluppatore possa ritorcerglisi contro. Quello che nel nostro sorgente era poco più di una manciata di byte adesso è stato trasformato in qualcosa di molto più grosso nel codice in produzione. Neanche l’uglification può farci granché, dal momento che gli argomenti non possono essere ridotti. Oh e se state pensando che i rest parameter potrebbero essere un valido antidoto, le trasformazioni che vi opera Babel sono ancora più grosse:

// Source
function logger(...args) {
  const [level, message] = args;

  console[level](message);
}

// Babel output
function logger() {
  for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
    args[_key] = arguments[_key];
  }

  const level = args[0],
        message = args[1];
  console[level](message);
}

Peggio ancora, Babel trasforma questo codice anche per progetti con una configurazione @babel/preset-env indirizzata ai browser moderni, il che significa che saranno interessati anche i bundle moderni nel vostro JavaScript inviato in maniera differenziata! Potreste usare una loose transforms per attutire il colpo – ed è una buona idea, dal momento che sono spesso un po’ più piccoli delle loro controparti più spec-compliant – ma abilitare le loose transform può causare problemi se in seguito rimuoverete Babel dalla vostra build pipeline.

Indipendentemente se decidiate di abilitare le loose transform o meno, ecco un modo per tagliare il cruft (codice progettato male, inutilmente complicato, ndt) dei parametri di default trasnpiled.

// Babel won't touch this
function logger(message, level) {
  console[level || "log"](message);
}

Ovviamente, i parametri di default non sono l’unica feature a cui prestare attenzione. Per esempio, la spread syntax viene trasformata, così come le arrow function e tutto un insieme di altre cose.

Se non volete evitare queste features in toto, ci sono un paio di modi per ridurne l’impatto:

  1. se state scrivendo una libreria, prendete in considerazione l’utilizzo di @babel/runtime insieme a @babel/plugin-transform-runtime per de-duplicare le helper function che Babel mette nel vostro codice.
  2. Per le feature a cui sono applicati polyfill nelle app, potete includerli selettivamente con @babel/polyfill attraverso l’opzione useBuiltIns: “usage” di @babel/preset-env.

È solo la mia opinione, ma io credo che la scelta migliore sia quella di evitare completamente la transpilation nei bundle generati per i browser moderni. Non è sempre possibile, specialmente se usate JSX, che deve essere trasformato per tutti i browser, o se state usando delle feature davvero all’avanguardia che non sono ampiamente supportate. In quest’ultimo caso, potrebbe valere la pena chiedersi se queste feature siano veramente necessarie per realizzare una buona user experience (e raramente lo sono). Se arrivate alla conclusione che Babel deve far parte della vostra toolchain, allora vale la pena osservare più in profondità, di tanto in tanto, per trovare le cose sub-ottimali che Babel sta facendo che potete migliorare.

Il miglioramento non è una gara#section9

Mentre vi massaggiate le tempie chiedendovi quando finirà questo orribile hungover da JavaScript, dovete capire che è esattamente quando ci affrettiamo a mandare fuori qualcosa il prima possibile che la user experience soffre. Mentre la web development community si ossessiona sulle iterazioni sempre più rapide in nome della competizione, vale la pena rallentare un po’. Scoprirete che facendo così potreste non iterare tanto velocemente quanto i vostri competitor, ma il vostro prodotto sarà più veloce del loro.

Dovete sapere che se applicate questi suggerimenti alla vostra codebase, non avanzerete spontaneamente dalla sera alla mattina. Il web development è un lavoro. Il vostro lavoro di maggior impatto verrà fatto quando vi sarete dedicati in maniera ponderata a questo mestiere per un lungo periodo. Concentratevi su miglioramenti continui. Misurate, testate, ripete e la user experience del vostro sito migliorerà e diventerete sempre più veloci.

Un ringraziamento speciale va a Jason Miller per il tech editing di questo articolo. Jason è il creatore e uno dei tanti maintainer di Preact, un’alternativa enormemente più piccola di React, con la stessa API. Se usate Preact, , per favore, prendete in considerazione di supportarlo via Open Collective.

Nessun commento

Hai qualcosa da dire?

Abbiamo disattivato i commenti, ma puoi vedere quello che gli altri hanno detto prima che li disattivassimo.

Altro da ALA