URL e URLSearchParams: costruire e leggere querystring senza concatenare

Prima o poi, in ogni progetto, compare una riga così:
const url = base + "/cerca?q=" + encodeURIComponent(q) + "&pagina=" + page;
Funziona finché base non finisce con uno slash di troppo, finché non devi aggiungere un
parametro solo in certi casi, e finché nessuno cerca una frase con uno spazio dentro. Il bug è
quasi sempre uno dei due: un secondo ? nella stringa, oppure un carattere codificato nel modo
sbagliato.
Il browser ha due oggetti che fanno esattamente questo lavoro, e Node li espone come globali
dalla versione 10: URL e URLSearchParams. Non serve installare niente.
new URL(path, base): il percorso relativo risolto per te
Il costruttore accetta un URL assoluto, oppure un riferimento relativo più un base su cui
risolverlo. La documentazione è esplicita su un punto che conviene leggere due volte: «When a
base is specified, the resolved URL is not simply a concatenation of url and base». Non è
una concatenazione, è la risoluzione dei riferimenti relativi come la fa il browser quando
incontra un href in una pagina:
new URL("/en-US/docs", "https://developer.mozilla.org/fr-FR/toto");
// => 'https://developer.mozilla.org/en-US/docs'
new URL("/a", "https://example.com/?query=1");
// => 'https://example.com/a'
new URL("http://www.example.com");
// => 'http://www.example.com/'
Nel primo caso il percorso del base è stato scartato perché il riferimento parte da /; nel
terzo lo slash finale è comparso da solo, perché un URL normalizzato ha sempre un path. Sono
entrambe cose che la concatenazione non fa.
Se l’URL non è valido, il costruttore solleva un TypeError. Da settembre 2024 esiste
l’alternativa che non lancia niente, URL.parse(), che «returns a newly created URL object»
oppure null se i parametri non si risolvono in un URL valido. Comoda quando l’URL arriva da
fuori e un try/catch intorno a due righe è sproporzionato:
const url = URL.parse(input, "https://example.com");
if (!url) return;
searchParams: leggere la querystring già decodificata
URL espone la querystring come oggetto URLSearchParams in sola lettura sulla proprietà
searchParams, con i valori già decodificati:
const params = new URL("https://example.com/?name=Jonathan%20Smith&age=18").searchParams;
params.get("name"); // "Jonathan Smith"
parseInt(params.get("age"), 10); // 18
Nessun decodeURIComponent() da ricordare. I metodi che servono nel 90% dei casi sono quattro:
get(name)restituisce il primo valore associato a quel nome;getAll(name)restituisce tutti i valori, come array;set(name, value)imposta il valore e, se ce n’erano diversi, «deletes the others»;append(name, value)aggiunge una coppia senza toccare quelle che c’erano già.
La differenza tra set e append è l’unica cosa da avere chiara: set è “questo parametro vale
X”, append è “aggiungi anche X”. Per i filtri multipli (?tag=php&tag=docker) serve append, e
si rileggono con getAll.
Anche delete() ha due forme, e la seconda è meno conosciuta: accetta un valore opzionale e
cancella solo le coppie che corrispondono a nome e valore.
const params = new URLSearchParams("foo=1&bar=2&foo=3&foo=1");
params.delete("foo", "1");
params.toString(); // "bar=2&foo=3"
L’iterazione (entries(), keys(), values(), forEach()) segue l’ordine in cui le coppie
appaiono nella querystring. Se ti serve un ordine stabile — per esempio per usare l’URL come
chiave di cache — c’è sort(), che ordina per i code unit UTF-16 delle chiavi ed è stabile, cioè
non cambia l’ordine relativo tra coppie con la stessa chiave:
const searchParams = new URLSearchParams("c=4&a=2&b=3&a=1");
searchParams.sort();
searchParams.toString(); // "a=2&a=1&b=3&c=4"
Nota che a=2 è rimasto prima di a=1.
La trappola: lo spazio diventa +, non %20
Questo è il dettaglio che fa perdere un pomeriggio, e vale la pena saperlo prima.
URLSearchParams usa la codifica application/x-www-form-urlencoded, quella dei form: codifica
tutto tranne alfanumerici ASCII, *, -, . e _, e lo spazio diventa +. La proprietà
search di URL, invece, codifica lo spazio come %20.
Due serializzazioni diverse dello stesso URL. La conseguenza è che una modifica apparentemente
innocua ai searchParams riscrive la querystring, perché — come dice MDN — «When updating this
URLSearchParams, the URL’s search is updated with its serialization». L’esempio è loro:
const url = new URL("https://example.com/?a=b ~");
url.href; // "https://example.com/?a=b%20~"
url.searchParams.toString(); // "a=b+%7E"
url.searchParams.sort(); // dovrebbe essere un no-op
url.href; // "https://example.com/?a=b+%7E"
Un sort() su un solo parametro non ha niente da ordinare, e l’URL è cambiato comunque. Lo
stesso vale per una delete() di un parametro che non c’entra:
const url2 = new URL("https://example.com?search=1234¶m=my%20param");
url2.search; // "?search=1234¶m=my%20param"
url2.searchParams.delete("search");
url2.search; // "?param=my+param"
Non è un bug: + e %20 sono entrambi spazi validi in una querystring, e qualunque server che
legge i parametri come un form li interpreta allo stesso modo. Diventa un problema solo se da
qualche parte confronti le due stringhe — una firma HMAC calcolata sull’URL, un if (url === in un test, una chiave di cache. In quel caso scegli una delle due serializzazioni e
expected)
usa sempre quella.
Errori da evitare
Passare un URL intero al costruttore di URLSearchParams. Non lo fa a pezzi: non è un parser
di URL, lavora solo sulla querystring. Un leading ? lo tollera — «strips an initial leading
? off a string, if present» — ma new URLSearchParams("https://example.com/?q=1") ti dà una
chiave che si chiama https://example.com/?q. Se hai un URL completo, parti da new URL() e usa
.searchParams.
Interpolare un valore che contiene un +. Il costruttore legge il + come uno spazio: se
costruisci la querystring a mano e poi la dai in pasto a URLSearchParams, un + legittimo
(pensa a un numero di telefono) si trasforma in spazio. Usa append() o set() e passa il valore
così com’è, senza codificarlo tu.
Continuare a chiamare encodeURIComponent() sui valori. Se usi set() o append() la
codifica la fa l’oggetto: codificare prima significa codificare due volte, e %20 diventa
%2520. Vale la pena ricordare anche cosa encodeURIComponent() non codifica —
A–Z a–z 0–9 - _ . ! ~ * ' ( ) — mentre la RFC 3986 riserva !, ', (, ) e *. È un’altra
buona ragione per non gestire la codifica a mano.
Distinguere ?foo= da ?foo. Non puoi: URLSearchParams non fa differenza tra i due, in
entrambi i casi il valore è la stringa vuota. Se nella tua API “parametro presente ma vuoto” e
“parametro assente” significano cose diverse, usa has() e cambia il nome del parametro, perché
dalla querystring quella differenza non si recupera.
Due oggetti nativi, zero dipendenze, e un ? di troppo in meno nei log.
Se nel tuo progetto la gestione degli URL è cresciuta per stratificazione e nessuno si fida più a
toccarla, scrivici: guardiamo il codice e ti diciamo cosa vale
la pena sistemare.
