6º gennaio 2021

Moduli, introduzione

Quando la nostra applicazione cresce di dimensione, vogliamo dividerla in diversi file, chiamati “moduli”(modules). Un modulo solitamente contiene una classe o una libreria di funzioni.

Per molto tempo JavaScript è esistito senza una vera sintassi per i moduli nel linguaggio. Questo non era un problema, dato che inizialmente gli script erano piccoli e semplici, e quindi non ce n’era esigenza.

Ma gli script man mano diventarono più grandi e complessi, di conseguenza la comunità inventò vari sistemi per organizzare il codice in moduli, come librerie speciali che gestivano il caricamento di moduli su richiesta.

Per esempio:

  • AMD – uno dei più vecchi sistemi per la gestione di moduli, inizialmente implementato dalla libreria require.js.
  • CommonJS – il sistema per la gestione di moduli creato per node.js server.
  • UMD – un’altro sistema di gestione di moduli, che è stato suggerito come metodo universale, compativile sia con AMD sia con CommonJS.

Ormai tutti questi sistemi vengono lentamente abbandonati, anche se ancora possono essere trovati in vecchi script.

Il sistema per la gestione dei moduli nel linguaggio è stato standardizzato nel 2015, e si è gradualmente evoluto da quel momento in poi. Ora è supportato da tutti i browser principali e all’interno di node.js, da adesso in poi sarà questo il sistema che studieremo.

Che cos’è un modulo?

Un modulo è semplicemente un file. Uno script è un modulo.

I moduli possono caricarsi a vicenda e utilizzare speciali direttive export e import per scambiarsi funzionalità, chiamando le funzioni da un modulo all’altro:

  • export contrassegna variabili e funzioni che devono essere accessibili dall’esterno del modulo.
  • import permette d’importare funzionalità da altri moduli.

Ad esempio, se abbiamo un file sayHi.js possiamo rendere utilizzabile all’esterno la funzione(esportarla) in questo modo:

// 📁 sayHi.js
export function sayHi(user) {
  alert(`Ciao, ${user}!`);
}

…Successivamente un’altro file può importarla e usarla in questo modo:

// 📁 main.js
import {sayHi} from './sayHi.js';

alert(sayHi); // function...
sayHi('John'); // Ciao, John!

La direttiva import carica il modulo presente al percorso ./sayHi.js, relativamente al file corrente, e assegna la funzione esportata sayHi alla variabile corrispondente.

Ora proviamo ad utilizzare l’esempio all’interno del browser.

Dato che i moduli utilizzano parole chiavi e funzionalità speciali, dobbiamo comunicare al browser che lo script deve essere trattato come un modulo, utilizzando l’attributo <script type="module">.

In questo modo:

Risultato
say.js
index.html
export function sayHi(user) {
  return `Ciao, ${user}!`;
}
<!doctype html>
<script type="module">
  import {sayHi} from './say.js';

  document.body.innerHTML = sayHi('John');
</script>

Il browser recupera ed elabora automaticamente il modulo importato (e i suoi import se necessario), e infine esegue lo script.

Modules work only via HTTP(s), not in local files

Se provate ad aprire una pagina web in locale, tramite il protocollo file://, scoprirete che le direttive import/export non funzionano. Per questo vanno utilizzati dei web-server localum come static-server oppure utilizzando la funzionalità “live server” dell’editor di codice, come quello di VS Code Live Server Extension per testare i moduli.

Funzionalità principali dei moduli

Cosa c’è di diverso nei moduli rispetto ai “normali” script?

Ci sono delle funzionalità aggiunte, valide sia per codice JavaScript all’interno dei browser sia per quello eseguito lato server.

Hanno sempre “use strict”

I moduli utilizzano sempre use strict, automaticamente. Ad esempio assegnare un valore ad una variabile non dichiarata genera un’errore.

<script type="module">
  a = 5; // error
</script>

Visibilità delle variabili (scope) all’interno dei moduli

Ogni modulo ha la propria visibilità delle variabili di massimo livello. In altre parole le variabili dichiarate a livello maggiore all’interno di un modulo non sono visibili negli altri script.

Nell’esempio seguente, due script sono stati importati, hello.js prova ad utilizzare la variabile user dichiarata in user.js, ma fallisce:

Risultato
hello.js
user.js
index.html
alert(user); // La variabile non esiste (ogni modulo ha variabili indipendenti)
let user = "John";
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>

I moduli devono esportare con export quello che vogliono rendere accessibile all’esterno e devono importare con import quello di cui hanno bisogno.

Quindi dobbiamo importare user.js all’interno di hello.jse prendere la funzionalità che ci servono da esso senza basarci sulle variabili globali.

Questa è la versione corretta:

Risultato
hello.js
user.js
index.html
import {user} from './user.js';

document.body.innerHTML = user; // John
export let user = "John";
<!doctype html>
<script type="module" src="hello.js"></script>

All’interno del browser esiste uno scope di livello massimo all’interno di ogni <script type="module">:

<script type="module">
  // Le variabili saranno visibili sono all'interno di questo modulo
  let user = "John";
</script>

<script type="module">
  alert(user); // Errore: la variabile user non è definita
</script>

Se abbiamo realmente la necessità di dichiarare una variabile globale all’interno del browser possiamo assegnarla a window e accederci attraverso window.user. Questa è un’eccezione che dovrebbe essere usata solo se ci sono delle buone ragioni.

Un modulo viene eseguito solo la prima volta che viene importato

Se uno stesso modulo verrà importato varie volte in più posti, il suo codice verrà eseguito solo la prima volta, successivamente le variabili e le funzioni esportate saranno assegnate a tutti i moduli che lo importano.

Questo ha delle conseguenze importanti. Vediamo degli esempi.

Prima di tutto, se eseguire un modulo ha altri effetti, come far apparire un messaggio, importare quel modulo più volte lo farà apparire solamente una volta, la prima:

// 📁 alert.js
alert("Il modulo è stato eseguito!");
// Importiamo lo stesso modulo in file diversi

// 📁 1.js
import `./alert.js`; // Il modulo è stato eseguito!

// 📁 2.js
import `./alert.js`; // (non appare nulla)

Il pratica, il codice a livello maggiore di un modulo viene principalmente usato per inizializzarlo, creare la struttura dei dati interni e, se vogliamo qualcosa di riutilizzabile, esportarlo con export.

Vediamo adesso un esempio più complesso.

Prendiamo in considerazione un modulo che exporta un oggetto:

// 📁 admin.js
export let admin = {
  name: "John"
};

Nel momento che questo modulo viene importato in più file viene comunque eseguito una sola volta, l’oggetto admin viene creato e poi passato a tutti i moduli che lo hanno importato.

Tutti quindi ottengono esattamente lo stesso oggetto admin:

// 📁 1.js
import {admin} from './admin.js';
admin.name = "Pete";

// 📁 2.js
import {admin} from './admin.js';
alert(admin.name); // Pete

// Entrambi 1.js e 2.js ottengono lo stesso ogetto
// I cambiamenti fatti in 1.js sono visibili in 2.js

Facendo un punto della situazione, il modulo viene eseguito una sola volta. Tutto quello esportato con export viene generato, e successivamente viene condiviso con tutti quelli che lo hanno importato, di conseguenza ogni cambiamento fatto sull’oggetto admin verrà visto anche dagli altri moduli.

Questo comportamento ci permette di configurare i moduli quando vengono importati la prima volta. Possiamo configurare le proprietà una volta, e saranno pronte per tutti gli altri import successivi.

Per fare un esempio, il modulo admin.js può fornire alcune funzionalità ma si aspetta di ricevere le credenziali all’interno dell’oggetto admin dall’esterno:

// 📁 admin.js
export let admin = { };

export function sayHi() {
  alert(`Sono pronto, ${admin.name}!`);
}

All’interno di init.js, il primo script della nostra app, impostiamo admin.name. In questo modo sarà visibile a tutti, comprese le chiamate fatte all’interno di admin.js stesso:

// 📁 init.js
import {admin} from './admin.js';
admin.name = "Pete";

Un’altro modulo può comunque vedere admin.name:

// 📁 other.js
import {admin, sayHi} from './admin.js';

alert(admin.name); // Pete

sayHi(); // Sono pronto, Pete!

import.meta

L’oggetto import.meta contiene le informazioni riguardanti il modulo corrente.

Il suo contenuto dipende dall’ambiente di esecuzione. Nel browser, contiene l’URL dello script o dell’attuale pagina web se inserito all’interno dell’HTML

<script type="module">
  alert(import.meta.url); // script url (l'url della pagina html per gli script in linea)
</script>

All’interno di un modulo, “this” non è definito (undefined)

Questa è una funzionalità minore, ma per completezza dobbiamo menzionarla.

In un modulo, Il this di livello maggiore non è definito (undefined).

Facciamo il confronto con uno script che non è un modulo, dove this è un oggetto globale.

<script>
  alert(this); // window
</script>

<script type="module">
  alert(this); // undefined
</script>

Funzionalità specifiche nel browser

Ci sono diverse funzionalità specifiche dei moduli utilizzati all’interno del browser con type="module".

Potresti voler saltare questa sezione se stai leggedo per la prima volta , oppure se non hai intenzione di usare JavaScript all’interno del browser.

I moduli sono caricati in modo differito

I moduli vengono sempre reputati script differiti, stesso effetto dell’attributo defer (descritto nel capitolo Scripts: async, defer) sia per gli script esterni che per quelli interni.

In altre parole:

  • Il download di un modulo esterno <script type="module" src="..."> non blocca l’elaborazione dell’HTML, vengono caricati in parallelo insieme alle altre risorse.
  • I moduli attendono fino al momento in cui l’HTML è pronto (anche se sono molto piccoli e possono essere elaborati più velocemente dell’HTML), e poi vengono eseguiti.
  • L’ordine relativo degli script viene mantenuto: gli script che appaiono prima nel documento vengono eseguiti per primi.

Come conseguenza, i moduli “vedono” sempre la pagina HTML completamente caricata, inclusi gli elementi sotto di essi.

Ad esempio:

<script type="module">
  alert(typeof button); // Object: lo script può 'vedere' il bottone sottostante
  // dato che il modulo viene caricato in modo differito, viene eseguito solo dopo che l'intera pagina è stata caricata
</script>

Confrontiamo lo script normale:

<script>
  alert(typeof button); // Error: button is undefined, lo scripr non riesce a vedere il bottone
  // Gli script normali vengono eseguiti immediatamente, prima che il resto della pagina venga processata
</script>

<button id="button">Button</button>

Da notare: il secondo script viene eseguito per primo! Infatti vedremo prima undefined, e dopo object.

Questo accade proprio perché i moduli sono differiti, e quindi attendono che tutto il documento venga processato, al contrario, gli script normali vengono eseguiti immediatamente e di conseguenza vediamo l’output del secondo script per primo.

Quando utilizziamo i moduli, dobbiamo porre attenzione al fatto che la pagina HTML appare mentre viene caricata, e i moduli JavaScript vengono eseguiti successivamente al caricamento, di conseguenza l’utente potrebbe vedere la pagina prima che l’applicazione JavaScript sia pronta. Alcune funzionalità potrebbero in questo modo non funzionare immediatamente. Per questo motivo è opportuno inserire degli indicatori di caricamento, o comunque assicurarci che i visitatori non vengano confusi da questi possibili comportamenti.

Async funziona sui moduli scritti inline

Per gli script normali l’attributo async funziona solamente sugli script esterni, Gli script caricati in modo asincrono (Async) vengono eseguiti immediatamente e indipendentemente dagli altri script e del documento HTML.

Per i moduli async può essere utilizzato sempre.

Ad esempio, lo script seguente è dichiarato asincrono, e quindi non aspetta nulla e viene eseguito.

Esegue l’import (recupera ./analytics.js) e procede quando è pronto, anche se il documento HTML non è completo, o se gli altri script sono ancora in attesa.

Questo comportamento è ottimo per le funzionalità che non dipendono da nulla, come contatori, pubblicità e altro.

<!-- tutte le dipendenze vengono recuperate (analytics.js),e lo script viene eseguito -->
<!-- non aspetta che il documento o altri <script> tag siano pronti -->
<script async type="module">
  import {counter} from './analytics.js';

  counter.count();
</script>

Script esterni

Gli script esterni che vengono segnalati come moduli, type="module", sono diversi sotto due aspetti:

  1. Più script esterni con lo stesso src vengono eseguiti solo una volta:

    <!-- lo script my.js viene recuperato ed eseguito solo una volta -->
    <script type="module" src="my.js"></script>
    <script type="module" src="my.js"></script>
  2. Gli script esterni che vengono recuperati da origini diverse (ad esempio un sito diverso) hanno bisogno delle intestazioni CORS, come descritto nel capitolo Fetch: Cross-Origin Requests. In altre parole, se un modulo viene recuperato da un’altra fonte il server remoto deve fornire un header (intestazione) Access-Control-Allow-Origin dandoci il “permesso” di recuperare lo script.

    <!-- another-site.com deve fornire Access-Control-Allow-Origin -->
    <!-- altrimenti lo script non verrà eseguito -->
    <script type="module" src="http://another-site.com/their.js"></script>

    Questo meccanismo permette di avere una maggiore sicurezza.

Non è possibile usare moduli “bare”

All’interno del browser, import accetta percorsi URL relativi o assoluti. Moduli senza nessun percorso specificato vengono chiamati moduli “bare”. Questi moduli non vengono accettati da import all’interno del browser.

Ad esempio, questo import non è valido:

import {sayHi} from 'sayHi'; // Errore, modulo "bare"
// Il modulo deve avere un percorso, es. './sayHi.js' od ovunque si trovi il modulo

Alcuni ambienti, come Node.js o tools per creare bundle accettano moduli bare, senza nessun percorso (path), dato che hanno metodologie per trovare e collegare i moduli. Al contrario i browser ancora non supportano i moduli bare.

Compatibilità, “nomodule”

I vecchi browser non comprendono l’attributo type="module". Gli script di una tipologia non conosciuta vengono semplicemente ignorati. Proprio per questo è possibile prevedere uno script di riserva usando l’attributo nomodule:

<script type="module">
  alert("Viene eseguito nei browser moderni");
</script>

<script nomodule>
  alert("I browser moderni conoscono sia type=module sia nomodule, quindi ignorano questo script")
  alert("I browser più vecchi ignorano i tipi di script che non conoscono come type=module, ma eseguono questo");
</script>

Strumenti per il building

Nella realtà, i moduli vengono raramente usati all’interno del browser in modo diretto. Normalmente, vengono uniti insieme con tool specifici come Webpack e portati nel server di produzione.

Uno dei benefici di usare i “bundlers” – ci permettono più controllo su come i moduli vengono gestiti, ad esempio permettendoci di usare moduli “bare” e moduli CSS/HTML.

I tool per il building si comportano nel modo seguente:

  1. Prendono un modulo “principale”, quello che era inteso per essere inserito in <script type="module">.
  2. Analizza tutte le sue dipendenze: che moduli importa, cosa viene importato dai metodi importati etc…
  3. Costruisce un singolo file con tutti i moduli (o più file, può essere impostato), sostituendo le chiamate import con funzioni del bundler. In questo modo può supportare anche moduli “speciali” come quelli CSS/HTML.
  4. Durante il processo altre trasformazioni e ottimizzazioni possono essere eseguite:
    • Parti di codice che non possono essere raggiunte vengono eliminate.
    • export non utilizzati vengono rimossi (“tree-shaking”).
    • Parti di codice tipicamente utilizzati durante lo sviluppo come console e debugger rimosse.
    • Le sintassi più moderne di JavaScript vengono sostituite con funzionalità equivalenti più vecchie e compatibili usando Babel.
    • Il file risultante viene ridotto al minimo (minified), gli spazi superflui rimossi, i nomi delle variabili sostituiti con nomi corti etc…

Quindi se usiamo questa tipologia di strumenti, allora gli script vengono raggruppati in un singolo script (o pochi file), import/export sostituiti con speciali funzioni in modo che lo script finale non contenga più nessun import/export, non richiede l’uso di type="module" e può essere utilizzato come un normale script:

<!-- Assumendo che abbiamo ottenuto bundle.js da un tool come Webpack -->
<script src="bundle.js"></script>

Appurato questo, moduli in modo nativo possono comunque essere usati. Non useremo tools come Webpack qui: se necessario potrai configurarlo successivamente.

Riepilogo

Per ricapitolare, i concetti principali sono:

  1. Un modulo è un file. Per far funzionare import/export, il browser ha bisogno di <script type="module">. I moduli hanno alcune differenze:
    • Vengono eseguiti in modo differito automaticamente
    • Async funziona sui moduli in linea
    • Per caricare moduli esterni provenienti da un’origine diversa (un altro dominio/protocollo/porta), sono necessarie le intestazioni CORS.
    • I moduli esterni duplicati vengono ignorati (un modulo esterno viene eseguito solo la prima volta che viene importato)
  2. I moduli hanno il loro livello di visibilità delle variabili (scope) e si scambiano funzionalità attraverso import/export.
  3. I moduli utilizzano sempre use strict automaticamente.
  4. Il codice di un modulo viene eseguito solamente una volta. Le esportazioni (export) vengono create un’unica volta e condivise con tutti i moduli che le importano.

Quando utilizziamo i moduli, ogni modulo implementa una certa funzionalità e la esporta. Successivamente utilizziamo import per importare quella funzionalità e utilizzarla dove è necessario. I browser caricano es eseguono lo script automaticamente.

In produzione, di solito si tende a usare tool detti “bundlers” come Webpack per unire insieme tutti i mosuli per maggiori prestazioni, compatibilità e altro.

Nel prossimo capitolo vedremo più esempi di moduli, e come le cose possono essere importate ed esportate.

Mappa del tutorial

Commenti

leggi questo prima di lasciare un commento…
  • Per qualsiasi suggerimento - per favore, apri una issue su GitHub o una pull request, piuttosto di lasciare un commento.
  • Se non riesci a comprendere quanto scitto nell'articolo – ti preghiamo di fornire una spiegazione chiara.
  • Per inserire delle righe di codice utilizza il tag <code>, per molte righe – includile nel tag <pre>, per più di 10 righe – utilizza una sandbox (plnkr, jsbin, codepen…)