Queste sono le note della prima lezione su JavaScript del corso. Non sono un manuale: sono il codice scritto in diretta durante il live coding, riorganizzato un attimo e accompagnato da qualche nota presa da appunti più vecchi, per dare un minimo di contesto a chi non ha seguito la lezione. Per gli approfondimenti fate riferimento alla registrazione e, come sempre, alla documentazione su MDN.
JavaScript (anche noto come JS, ES o ECMAScript) è un linguaggio interpretato, tipizzato dinamicamente, orientato agli oggetti, basato sui prototipi e dotato di funzioni di prima classe. Se questa frase per ora vi dice poco, nessun problema: alla fine del corso ogni pezzo avrà il suo significato.
Prenderemo come riferimento la versione ES6, che ha introdotto numerose funzionalità pensate per risolvere alcune annose lacune del linguaggio. Vedremo però anche qualche costrutto ormai sconsigliato, perché sui progetti già in essere vi capiterà di incontrarlo.
Per provare gli esempi di codice vi suggerisco StackBlitz.
Dichiarazione variabili
Let e Const
La dichiarazione delle variabili in JS può avvenire in due modi:
let a = 5; // Se ho necessità di riassegnare un valore più avanti
const b = 7; // Se voglio evitare che il valore venga riassegnato in futuro
a = 12; // Ok
b = 9; // TypeError: Assignment to constant variable.
In entrambi i casi non serve specificare il tipo di dato che vogliamo memorizzare (intero, stringa, array, …) perché verrà inferito a run-time.
Come vedremo negli esempi successivi, e soprattutto quando parleremo degli oggetti, in JS è preferibile usare sempre const quando non abbiamo una reale necessità di riassegnare un valore alla variabile.
DON’T: Var
Nelle versioni di JS antecedenti a ES6 le variabili si dichiaravano con la keyword var. È ancora supportata, però è preferibile evitarla: lo scope di visibilità delle variabili dichiarate con var non è limitato al blocco di codice in cui vengono dichiarate, bensì si estende all’intera funzione in cui avviene la dichiarazione.
function myFunction() {
var x = 10;
if (x === 10) {
var x = 20;
console.log(x); // 20
}
console.log(x); // 20: il valore di x viene sovrascritto all'interno del blocco :(
}
function myFunction() {
let x = 10;
if (x === 10) {
let x = 20;
console.log(x); // 20
}
console.log(x); // 10 come ci aspettiamo :)
}
DON’T: Non dichiarare le variabili
JavaScript permette di assegnare le variabili senza dichiararle. In questo caso la variabile viene aggiunta come proprietà dello scope globale window (o global se siete su Node.js). E se quella proprietà esiste già, viene sovrascritta.
Nell’esempio qui sotto assegniamo una variabile che si chiama come alert, la funzione che apre la finestra modale del browser.
alert("Hello JS!"); // Viene mostrata la alert
function doNotTryAtHome() {
alert = "Non so dichiarare le variabili";
}
doNotTryAtHome();
alert("Hello JS!"); // ERRORE! Abbiamo sovrascritto una proprietà di window!
Questo comportamento è uno dei motivi per cui esiste la strict mode: aggiungendo "use strict"; in cima al file, o lavorando dentro un modulo ES dove è attiva di default, l’assegnamento a una variabile mai dichiarata non crea la proprietà globale ma genera un ReferenceError. Gli strumenti moderni vi mettono in strict mode senza che ve ne accorgiate, ma è bene sapere che il problema esiste.
Tipi di dato
JavaScript definisce 7 tipi di dati primitivi. Quelli che incontreremo da subito sono:
- boolean, che può assumere i valori true e false
- number, un formato in virgola mobile per rappresentare dati numerici
- string, per rappresentare dati testuali
- undefined, il valore di una variabile dichiarata ma a cui non è ancora stato assegnato nulla
- null, che rappresenta l’assenza intenzionale di un valore
La differenza tra gli ultimi due è sottile ma c’è: undefined è quello che ci dà JS quando un valore non è mai stato assegnato, null è quello che assegniamo noi per dire esplicitamente “qui non c’è niente”.
In aggiunta ai tipi primitivi abbiamo Object che, come vedremo, sarà il tipo di qualsiasi altra entità esistente in JS: array, date, oggetti e anche funzioni, seppur con qualche particolarità.
Essendo un linguaggio dinamico, i tipi vengono assegnati a run-time:
let a = 12;
console.log(typeof a); // number
a = "Pippo";
console.log(typeof a); // string
a = false;
console.log(typeof a); // boolean
a = {};
console.log(typeof a); // object
Il tipo di dato non viene quindi fissato quando si dichiara la variabile, ma viene determinato a ogni assegnamento.
A proposito di typeof, preparatevi a una stranezza storica: typeof null restituisce "object". È un bug presente fin dalla prima versione del linguaggio, che ormai non si può più correggere senza rompere mezzo web.
Tipi e operazioni
Anche gli operatori si comportano in modo diverso in base al tipo degli operandi.
// Entrambi gli operandi number
let a = 5;
let b = 3;
console.log(a + b); // 8
console.log(a - b); // 2
// Un operando number e uno string
a = 5;
b = "3";
console.log(a + b); // 53: il + effettua una concatenazione tra stringhe, convertendo il number in string
console.log(a - b); // 2: il - effettua la sottrazione, convertendo la stringa in number!
In generale è sconsigliato effettuare qualsiasi operazione tra operandi di tipo diverso, per non incorrere in comportamenti indesiderati.
Template literals (o template strings)
Per limitare la confusione si può evitare di concatenare le stringhe con il + e usare invece i template literals.
Un template literal è una stringa definita con il carattere backtick al posto degli apici.
const saluto = `Ciao`;
Può contenere dei placeholder che possiamo sfruttare per comporre stringhe complesse in modo più semplice e sicuro rispetto alla concatenazione con il +.
const nome = "Paolino";
const cognome = "Paperino";
const saluto = `Ciao ${nome} ${cognome}!`;
console.log(saluto); // Ciao Paolino Paperino!
Oggetti
Un oggetto JavaScript è una collezione di coppie chiave-valore, definita nel modo seguente:
const user = {
nome: "Paolino",
cognome: "Paperino",
};
I valori possono essere di qualunque tipo: number, string, altri oggetti e, come vedremo, anche funzioni.
Si può accedere alle proprietà dell’oggetto in due modi:
console.log(user.nome); // Paolino
console.log(user["nome"]); // Paolino
user.nome = "Pippo";
console.log(user.nome); // Pippo
console.log(user["nome"]); // Pippo
Il secondo metodo torna utile, ad esempio, quando bisogna gestire la risposta JSON di un backend che usa il kebab-case per i nomi delle proprietà.
const response = {
"user-name": "Paolino",
"user-surname": "Paperino",
};
console.log(response.user - name); // Niente da fare: per JS questo è "response.user meno name"
console.log(response["user-name"]); // Paolino
const, let e proprietà degli oggetti
Nell’esempio precedente abbiamo modificato una proprietà di un oggetto nonostante fosse stato dichiarato con la keyword const.
const user = {
nome: "Paolino",
cognome: "Paperino",
};
user.nome = "Gastone";
user.cognome = "Paperone";
console.log(`${user.nome} ${user.cognome}`); // Gastone Paperone
Possiamo anche aggiungere proprietà a un oggetto esistente a run-time.
user.city = "Paperopoli";
console.log(`${user.nome} ${user.cognome}, ${user.city}`); // Gastone Paperone, Paperopoli
Quello che const impedisce di fare è riassegnare l’oggetto user in quanto tale.
user = {
nome: "Paperon",
cognome: "De Paperoni",
}; // TypeError: Assignment to constant variable.
Dal momento che un riassegnamento può avvenire anche per errore, è buona pratica dichiarare tutte le variabili inizialmente come const e passarle a let solo quando siamo sicuri di volerle riassegnare.
Coercion e valori “truthy” e “falsy”
Quando JavaScript è stato progettato, si è pensato di rendere più semplice il confronto tra dati di tipo diverso: permettere di confrontare numeri con stringhe, e assegnare valori di verità o falsità a entità come lo 0, la stringa vuota, la stringa non vuota e così via.
Qualche esempio:
console.log(12 == 12); // true
console.log(12 == 3); // false
console.log(12 == "12"); // true
console.log("" == 0); // true
console.log(0 == []); // true
console.log([0] == 0); // true
console.log([1] == 0); // false
Capite che spesso è difficile prevedere se un confronto restituirà true o false, perché l’algoritmo si basa su una tabella di uguaglianza estremamente complessa e, onestamente, molto arbitraria.
Se volete divertirvi a indovinare vi suggerisco:
Per fortuna è stato introdotto anche l’operatore ===, che funziona in modo molto più intuitivo:
- se il tipo degli operandi è diverso, restituisce
false - se è uguale, restituisce
truese anche i valori sono uguali,falsealtrimenti
Esiste anche !==, con le stesse caratteristiche ma per verificare la non uguaglianza.
Truthy e falsy
La coercion non riguarda solo i confronti: qualunque valore, messo in un contesto in cui serve un booleano (la condizione di un if, per esempio), viene convertito in true o false. I valori che diventano false si chiamano falsy e sono pochi, si possono imparare a memoria:
false0e-0(e0n, lo zero dei BigInt)"", la stringa vuotanullundefinedNaN
Tutto il resto è truthy, comprese alcune cose che sorprendono: la stringa "0", la stringa "false", l’array vuoto [] e l’oggetto vuoto {}.
const nome = "";
if (nome) {
console.log(`Ciao ${nome}`);
} else {
console.log("Il nome è vuoto"); // Finiamo qui: la stringa vuota è falsy
}
È un meccanismo comodo e lo useremo spesso, ad esempio per controllare che un campo non sia vuoto prima di usarlo. L’importante è conoscere la lista qui sopra, perché un if (valore) non distingue tra “il valore non c’è” e “il valore è 0”.
Costrutti condizionali
const a = 5;
const b = 3;
if (a > b) {
console.log("A è maggiore di B");
} else {
console.log("A è minore o uguale a B");
}
Quando l’if serve solo a scegliere tra due valori, esiste una forma compatta chiamata operatore ternario: condizione, punto interrogativo, valore se vera, due punti, valore se falsa.
const messaggio = a > b ? "A è maggiore di B" : "A è minore o uguale a B";
console.log(messaggio);
È comodissimo negli assegnamenti, da evitare quando le condizioni iniziano ad annidarsi, perché diventa subito illeggibile.
const a = 5;
const b = 8;
let result;
const op = "ADD";
switch (op) {
case "ADD":
result = a + b;
break;
case "SUB":
result = a - b;
break;
case "MUL":
result = a * b;
break;
case "DIV":
result = a / b;
break;
default:
result = null;
}
Array
Gli array in JS sono collezioni di valori e possono contenere anche tipi eterogenei.
const pizze = ["Margherita", "Marinara", "Quattro Stagioni"];
// DON'T, a meno che non sappiate cosa state facendo
const caos = [12, "Roma", { nome: "Pippo" }];
Si accede agli elementi con un indice che parte da 0, come nella maggior parte dei linguaggi di programmazione.
console.log(pizze[1]); // Marinara
Cicli
const pizze = ["Margherita", "Marinara", "Quattro Stagioni", "Diavola"];
// for "classico"
for (let i = 0; i < pizze.length; i++) {
console.log(pizze[i]);
}
// for in: itera sugli indici
for (let index in pizze) {
console.log(pizze[index]);
}
// for of: raccomandato in quasi tutte le situazioni
for (let pizza of pizze) {
console.log(pizza);
}
// L'output di tutti e tre i cicli sarà lo stesso:
// Margherita
// Marinara
// Quattro Stagioni
// Diavola
Oltre ai for ci sono ovviamente tutti i cicli che avete già visto negli altri linguaggi di programmazione, come il while e il do while.
Funzioni
function saluta(nome, cognome) {
console.log(`Ciao ${nome} ${cognome}`);
}
saluta("Pinco", "Pallo"); // Ciao Pinco Pallo
saluta("Tizio", "Caio"); // Ciao Tizio Caio
saluta("Giorgio", "Giorgi"); // Ciao Giorgio Giorgi
Se chiamiamo una funzione con meno argomenti di quelli previsti, i parametri mancanti valgono undefined. Da ES6 possiamo assegnare ai parametri un valore di default:
function calcolaPrezzo(prezzo, sconto = 0) {
return prezzo - sconto;
}
console.log(calcolaPrezzo(10, 2)); // 8
console.log(calcolaPrezzo(10)); // 10: senza il default avremmo ottenuto NaN (10 - undefined)
Funzioni anonime (approfondimento)
JavaScript permette di definire funzioni senza nome, a patto che vengano invocate immediatamente o assegnate a una variabile.
(function (nome, cognome) {
console.log(`Io ti maledico ${nome} ${cognome}`);
})("Caio", "Mario");
// Io ti maledico Caio Mario
Prima dell’avvento dei moduli, le funzioni anonime invocate immediatamente (IIFE) come la precedente venivano usate per implementare il cosiddetto module pattern. L’obiettivo del pattern era creare una sorta di sistema di namespace, per non “inquinare” l’oggetto globale window con proprietà definite dall’utente.
window.tnv = {};
(function (namespace) {
namespace.alert = message => console.log(message);
})(window.tnv);
alert("Hello JS!"); // Apre l'alert del browser
tnv.alert("Hello JS!"); // Usa la nostra implementazione custom
Oggi lo stesso scopo si raggiunge con i moduli, che vedremo più avanti.
First-class functions
JavaScript possiede quelle che vengono definite first-class functions, ovvero funzioni che possono essere trattate come qualsiasi altra variabile: possono essere assegnate, passate come parametro di funzione, diventare proprietà di un oggetto e così via.
const maledici = function (nome, cognome) {
console.log(`Io ti maledico ${nome} ${cognome}`);
};
maledici("Gastone", "Paperone"); // Io ti maledico Gastone Paperone
Questa definizione è quasi equivalente a
function maledici(nome, cognome) {
console.log(`Io ti maledico ${nome} ${cognome}`);
}
maledici("Gastone", "Paperone"); // Io ti maledico Gastone Paperone
Il “quasi” è il tema del prossimo paragrafo.
Hoisting, function declaration e function expression
L’hoisting è il procedimento con cui il motore JavaScript, prima di eseguire il codice, “solleva” le dichiarazioni nel punto più alto del loro scope. L’effetto più visibile riguarda le funzioni: possiamo invocarne una prima del punto in cui è dichiarata.
maledici("Gastone", "Paperone"); // Io ti maledico Gastone Paperone
function maledici(nome, cognome) {
console.log(`Io ti maledico ${nome} ${cognome}`);
}
Tutto funziona perché la funzione viene “sollevata” in questo modo:
function maledici(nome, cognome) {
console.log(`Io ti maledico ${nome} ${cognome}`);
}
maledici("Gastone", "Paperone"); // Io ti maledico Gastone Paperone
Attenzione però, perché questo vale per le function declaration. Se la funzione viene definita assegnando una funzione anonima a una variabile (una function expression), a essere sollevata è solo la dichiarazione della variabile, mentre l’assegnamento resta dove sta:
let maledici;
maledici("Gastone", "Paperone"); // ReferenceError: Cannot access 'maledici' before initialization
maledici = function (nome, cognome) {
console.log(`Io ti maledico ${nome} ${cognome}`);
};
Per chi vuole il dettaglio in più: anche le variabili let e const vengono sollevate, ma non sono accessibili prima della riga in cui le dichiariamo. La finestra in cui esistono ma non si possono toccare si chiama temporal dead zone, ed è proprio quella che produce l’errore qui sopra.
Questo non significa che un modo di dichiarare le funzioni sia migliore dell’altro: a seconda di cosa dobbiamo fare, entrambi hanno vantaggi e svantaggi. Bisogna però conoscere questo comportamento per evitare di incorrere in funzionamenti imprevisti ed errori.
Arrow Functions
Esiste un terzo modo di esprimere una funzione in JavaScript, e sarà probabilmente quello che useremo di più, perché presenta tutta una serie di vantaggi che vedremo più avanti.
La sintassi delle arrow functions consiste nell’indicare prima i parametri tra parentesi tonde, poi la freccia => (i caratteri uguale e maggiore) e a seguire il corpo della funzione tra parentesi graffe.
const benedici = (nome, cognome) => {
return `Io ti benedico ${nome} ${cognome}`;
};
console.log(benedici("Paolino", "Paperino")); // Io ti benedico Paolino Paperino
Se il corpo della funzione è costituito da una sola espressione, si possono omettere le graffe e la return:
const benedici = (nome, cognome) => `Io ti benedico ${nome} ${cognome}`;
console.log(benedici("Paolino", "Paperino")); // Io ti benedico Paolino Paperino
Altri esempi di arrow functions:
const totalLength = (nome, cognome) => nome.length + cognome.length;
const toUpperCase = (nome, cognome) =>
`${nome.toUpperCase()} ${cognome.toUpperCase()}`;
Riepilogo sulle funzioni
// Function declaration
function saluta(nome, cognome) {
return `Ciao ${nome} ${cognome}`;
}
// Function expression
const maledici = function (nome, cognome) {
return `Io ti maledico ${nome} ${cognome}`;
};
// Arrow function
const benedici = (nome, cognome) => {
return `Io ti benedico ${nome} ${cognome}`;
};
console.log(saluta("Pinco", "Pallo")); // Ciao Pinco Pallo
console.log(maledici("Tizio", "Caio")); // Io ti maledico Tizio Caio
console.log(benedici("Giorgio", "Giorgi")); // Io ti benedico Giorgio Giorgi
Metodi sugli Array
Abbiamo visto come scorrere e stampare gli array usando i cicli:
const pizze = [
{
nome: "Margherita",
prezzo: 9,
ingredienti: ["Farina", "Pomodoro", "Mozzarella"],
},
{
nome: "Marinara",
prezzo: 5,
ingredienti: ["Farina", "Pomodoro", "Aglio", "Origano"],
},
{
nome: "Contadina",
prezzo: 19,
ingredienti: ["Farina", "Melanzane", "Peperoni", "Mozzarella"],
},
];
for (let pizza of pizze) {
console.log(`${pizza.nome} - ${pizza.prezzo}`);
}
// Margherita - 9
// Marinara - 5
// Contadina - 19
Esiste però un altro approccio, preferibile perché ha diversi vantaggi, soprattutto quando vogliamo fare operazioni aggiuntive come filtrare dei dati, ordinare o trasformare i campi di un oggetto.
Ipotizziamo di voler copiare tutte le pizze che costano meno di 10 euro in un nuovo array usando un ciclo for:
let pizzeEconomiche = [];
for (let pizza of pizze) {
if (pizza.prezzo < 10) {
pizzeEconomiche.push(pizza); // Push inserisce un valore in coda a un array
}
}
console.log(pizzeEconomiche);
La sintassi diventa molto imperativa: dobbiamo dichiarare un array in cui aggiungere di volta in volta le pizze, dopo aver controllato che costino meno di 10 euro. Se dovessimo fare altre operazioni, la sintassi si complicherebbe ancora di più.
Possiamo perciò usare i metodi che JS mette a disposizione sugli array. La caratteristica che li accomuna è che ricevono come argomento una funzione, la eseguono per ogni elemento, e restituiscono un nuovo valore (nella maggior parte dei casi un nuovo array) senza modificare l’originale.
Filter
Con il metodo filter la selezione delle pizze economiche diventa una riga, con una sintassi molto più dichiarativa rispetto ai cicli:
// Potete leggere la funzione come "le pizze x tali che il prezzo di x è minore di 10"
const pizzeEconomiche = pizze.filter(x => x.prezzo < 10);
console.log(pizze);
console.log(pizzeEconomiche);
Map
Possiamo anche restituire un nuovo array “mappando” ogni elemento su qualcosa di diverso. Nell’esempio sotto, degli oggetti su una stringa:
const pizzeEconomiche = pizze
.filter(x => x.prezzo < 10) // pizze che costano meno di 10 euro
.map(x => `${x.nome} - ${x.prezzo}`); // trasformiamo gli oggetti in stringhe
console.log(pizzeEconomiche);
Sort
Possiamo ordinare l’array con il metodo sort:
const pizzeEconomiche = pizze
.filter(x => x.prezzo < 10) // pizze che costano meno di 10 euro
.map(x => `${x.nome} - ${x.prezzo}`) // trasformiamo gli oggetti in stringhe
.sort(); // ordiniamo l'array risultante
console.log(pizzeEconomiche);
Un’avvertenza che prima o poi vi risparmierà un pomeriggio di debugging: a differenza di filter e map, sort ordina l’array sul posto, modificandolo. Qui non ci crea problemi perché lo applichiamo all’array appena restituito da map, ma tenetelo a mente quando lo userete su un array che vi serve anche nell’ordine originale.
Seconda avvertenza: senza argomenti, sort converte gli elementi in stringhe e li ordina alfabeticamente. Con le stringhe delle nostre pizze va benissimo, ma sui numeri produce risultati creativi:
const prezzi = [5, 19, 9];
console.log(prezzi.sort()); // [19, 5, 9] perché "19" viene prima di "5" in ordine alfabetico
console.log(prezzi.sort((a, b) => a - b)); // [5, 9, 19]
Per ordinare i numeri bisogna passare a sort una funzione di confronto, come la (a, b) => a - b qui sopra.
Reduce
Altri metodi permettono di “combinare” gli elementi dell’array in un valore solo. Nel caso delle pizze potremmo ad esempio calcolare il totale di un ipotetico ordine:
const totale = pizze.reduce((acc, x) => acc + x.prezzo, 0);
console.log(`Totale: ${totale} euro`);
reduce parte da un valore iniziale (lo 0 passato come secondo argomento) e a ogni passo combina l’accumulatore acc con l’elemento corrente, restituendo alla fine l’ultimo valore accumulato.