È ora di utilizzare il nodo 8?

Pubblicato: 2022-03-11

Il nodo 8 è fuori! In effetti, il Nodo 8 è ora disponibile da abbastanza tempo per vedere un solido utilizzo nel mondo reale. È arrivato con un nuovo motore V8 veloce e con nuove funzionalità, inclusi async/await, HTTP/2 e hook asincroni. Ma è pronto per il tuo progetto? Scopriamolo!

Nota del redattore: probabilmente sei consapevole che anche il nodo 10 (nome in codice Dubnium ) è fuori. Abbiamo scelto di concentrarci sul nodo 8 ( Carbon ) per due motivi: (1) il nodo 10 sta appena entrando nella sua fase di supporto a lungo termine (LTS) e (2) il nodo 8 ha segnato un'iterazione più significativa rispetto al nodo 10 .

Prestazioni nel nodo 8 LTS

Inizieremo dando un'occhiata ai miglioramenti delle prestazioni e alle nuove funzionalità di questa straordinaria versione. Una delle principali aree di miglioramento è nel motore JavaScript di Node.

Che cos'è esattamente un motore JavaScript, comunque?

Un motore JavaScript esegue e ottimizza il codice. Potrebbe essere un interprete standard o un compilatore JIT (just-in-time) che compila JavaScript in bytecode. I motori JS utilizzati da Node.js sono tutti compilatori JIT, non interpreti.

Il motore V8

Node.js ha utilizzato il motore JavaScript Chrome V8 di Google, o semplicemente V8 , sin dall'inizio. Alcune versioni di Node vengono utilizzate per la sincronizzazione con una versione più recente di V8. Ma fai attenzione a non confondere V8 con il Nodo 8 mentre confrontiamo le versioni V8 qui.

Questo è facile da inciampare, dal momento che in contesti software usiamo spesso "v8" come slang o anche abbreviazione ufficiale per "versione 8", quindi alcuni potrebbero confondere "Node V8" o "Node.js V8" con "NodeJS 8" ”, ma abbiamo evitato questo in tutto questo articolo per mantenere le cose chiare: V8 significherà sempre il motore, non la versione di Node.

V8 versione 5

Il nodo 6 utilizza V8 versione 5 come motore JavaScript. (Anche le prime versioni del nodo 8 utilizzano la versione 5 V8, ma utilizzano una versione del punto V8 più recente rispetto al nodo 6.)

compilatori

Le versioni V8 5 e precedenti hanno due compilatori:

  • Full-codegen è un compilatore JIT semplice e veloce ma produce codice macchina lento.
  • Crankshaft è un compilatore JIT complesso che produce codice macchina ottimizzato.
Fili

In fondo, V8 utilizza più di un tipo di thread:

  • Il thread principale recupera il codice, lo compila, quindi lo esegue.
  • I thread secondari eseguono il codice mentre il thread principale sta ottimizzando il codice.
  • Il thread del profiler informa il runtime sui metodi non performanti. L'albero a gomiti ottimizza quindi questi metodi.
  • Altri thread gestiscono la raccolta dei rifiuti.
Processo di compilazione

Innanzitutto, il compilatore Full-codegen esegue il codice JavaScript. Durante l'esecuzione del codice, il thread del profiler raccoglie i dati per determinare quali metodi ottimizzerà il motore. Su un altro thread, Crankshaft ottimizza questi metodi.

Questioni

L'approccio sopra menzionato presenta due problemi principali. In primo luogo, è architettonicamente complesso. In secondo luogo, il codice macchina compilato consuma molta più memoria. La quantità di memoria consumata è indipendente dal numero di volte in cui il codice viene eseguito. Anche il codice che viene eseguito una sola volta occupa anche una quantità significativa di memoria.

V8 versione 6

La prima versione Node a utilizzare il motore V8 release 6 è Node 8.3.

Nella versione 6, il team V8 ha creato Ignition e TurboFan per mitigare questi problemi. Ignition e TurboFan sostituiscono rispettivamente Full-codegen e CrankShaft.

La nuova architettura è più semplice e consuma meno memoria.

Ignition compila il codice JavaScript in bytecode anziché in codice macchina, risparmiando molta memoria. Successivamente, TurboFan, il compilatore di ottimizzazione, genera codice macchina ottimizzato da questo bytecode.

Miglioramenti specifici delle prestazioni

Esaminiamo le aree in cui le prestazioni in Node 8.3+ sono cambiate rispetto alle versioni precedenti di Node.

Creazione di oggetti

La creazione di oggetti è circa cinque volte più veloce nel Nodo 8.3+ rispetto al Nodo 6.

Dimensione della funzione

Il motore V8 decide se una funzione deve essere ottimizzata in base a diversi fattori. Un fattore è la dimensione della funzione. Le funzioni piccole sono ottimizzate, mentre le funzioni lunghe no.

Come viene calcolata la dimensione della funzione?

L'albero a gomiti nel vecchio motore V8 utilizza il "conta caratteri" per determinare la dimensione della funzione. Gli spazi bianchi e i commenti in una funzione riducono le possibilità che venga ottimizzata. So che questo potrebbe sorprenderti, ma all'epoca un commento poteva ridurre la velocità di circa il 10%.

Nel nodo 8.3+, caratteri irrilevanti come spazi bianchi e commenti non danneggiano le prestazioni della funzione. Perché no?

Perché il nuovo TurboFan non conta i caratteri per determinare la dimensione della funzione. Invece, conta i nodi dell'albero della sintassi astratta (AST), in modo così efficace da considerare solo le istruzioni di funzione effettive . Usando il Nodo 8.3+, puoi aggiungere commenti e spazi bianchi quanto vuoi.

Argomenti che determinano gli Array

Le funzioni regolari in JavaScript trasportano un oggetto argument simile a Array implicito.

Cosa significa Array -like?

L'oggetto arguments agisce in qualche modo come un array. Ha la proprietà length ma manca dei metodi integrati di Array come forEach e map .

Ecco come funziona l'oggetto arguments :

 function foo() { console.log(arguments[0]); // Expected output: a console.log(arguments[1]); // Expected output: b console.log(arguments[2]); // Expected output: c } foo("a", "b", "c");

Quindi, come potremmo convertire l'oggetto arguments in un array? Usando il conciso Array.prototype.slice.call(arguments) .

 function test() { const r = Array.prototype.slice.call(arguments); console.log(r.map(num => num * 2)); } test(1, 2, 3); // Expected output: [2, 4, 6]

Array.prototype.slice.call(arguments) compromette le prestazioni in tutte le versioni di Node. Pertanto, la copia delle chiavi tramite un ciclo for ha prestazioni migliori:

 function test() { const r = []; for (index in arguments) { r.push(arguments[index]); } console.log(r.map(num => num * 2)); } test(1, 2, 3); // Expected output [2, 4, 6]

Il ciclo for è un po' ingombrante, vero? Potremmo usare l'operatore di diffusione, ma è lento nel nodo 8.2 e versioni precedenti:

 function test() { const r = [...arguments]; console.log(r.map(num => num * 2)); } test(1, 2, 3); // Expected output [2, 4, 6]

La situazione è cambiata nel nodo 8.3+. Ora lo spread viene eseguito molto più velocemente, anche più velocemente di un ciclo for.

Applicazione parziale (Currying) e Binding

Currying è la scomposizione di una funzione che accetta più argomenti in una serie di funzioni in cui ogni nuova funzione accetta solo un argomento.

Diciamo che abbiamo una semplice funzione di add . La versione corrente di questa funzione accetta un argomento, num1 . Restituisce una funzione che accetta un altro argomento num2 e restituisce la somma di num1 e num2 :

 function add(num1, num2) { return num1 + num2; } add(4, 6); // returns 10 function curriedAdd(num1) { return function(num2) { return num1 + num2; }; } const add5 = curriedAdd(5); add5(3); // returns 8

Il metodo bind restituisce una funzione curry con una sintassi terser.

 function add(num1, num2) { return num1 + num2; } const add5 = add.bind(null, 5); add5(3); // returns 8

Quindi il bind è incredibile, ma è lento nelle versioni precedenti di Node. Nel nodo bind , il collegamento è molto più veloce e puoi usarlo senza preoccuparti di eventuali colpi di prestazioni.

Esperimenti

Sono stati condotti diversi esperimenti per confrontare le prestazioni del Nodo 6 con il Nodo 8 ad alto livello. Si noti che questi sono stati condotti sul nodo 8.0, quindi non includono i miglioramenti sopra menzionati che sono specifici del nodo 8.3+ grazie al suo aggiornamento V8 versione 6.

Il tempo di rendering del server nel nodo 8 è stato del 25% inferiore rispetto al nodo 6. Nei progetti di grandi dimensioni, il numero di istanze del server potrebbe essere ridotto da 100 a 75. Questo è sorprendente. Testare una suite di 500 test nel Nodo 8 è stato il 10% più veloce. Le build di Webpack sono state il 7% più veloci. In generale, i risultati hanno mostrato un notevole aumento delle prestazioni nel nodo 8.

Funzionalità del nodo 8

La velocità non è stato l'unico miglioramento nel Nodo 8. Ha anche portato diverse nuove utili funzionalità, forse la cosa più importante, async/await .

Asincrono/In attesa nel nodo 8

I callback e le promesse vengono generalmente utilizzati per gestire il codice asincrono in JavaScript. I callback sono noti per la produzione di codice non mantenibile. Hanno causato caos (noto specificamente come callback hell ) nella comunità JavaScript. Le promesse ci hanno salvato dall'inferno delle richiamate per molto tempo, ma mancavano ancora della pulizia del codice sincrono. Async/await è un approccio moderno che consente di scrivere codice asincrono simile a codice sincrono.

E sebbene async/await potesse essere utilizzato nelle versioni precedenti di Node, richiedeva librerie e strumenti esterni, ad esempio una preelaborazione aggiuntiva tramite Babel. Ora è disponibile in modo nativo, pronto all'uso.

Parlerò di alcuni casi in cui async/await è superiore alle promesse convenzionali.

Condizionali

Immagina di recuperare i dati e di determinare se è necessaria una nuova chiamata API in base al payload . Dai un'occhiata al codice qui sotto per vedere come ciò avviene tramite l'approccio delle "promesse convenzionali".

 const request = () => { return getData().then(data => { if (!data.car) { return fetchForCar(data.id).then(carData => { console.log(carData); return carData; }); } else { console.log(data); return data; } }); };

Come puoi vedere, il codice sopra sembra già disordinato, solo da un condizionale in più. Async/await comporta una minore nidificazione:

 const request = async () => { const data = await getData(); if (!data.car) { const carData = await fetchForCar(data); console.log(carData); return carData; } else { console.log(data); return data; } };

Gestione degli errori

Async/await ti garantisce l'accesso per gestire sia gli errori sincroni che quelli asincroni in try/catch. Supponiamo che tu voglia analizzare JSON proveniente da una chiamata API asincrona. Un singolo tentativo/cattura potrebbe gestire sia gli errori di analisi che gli errori dell'API.

 const request = async () => { try { console.log(await getData()); } catch (err) { console.log(err); } };

Valori intermedi

E se una promessa avesse bisogno di un argomento che dovrebbe essere risolto da un'altra promessa? Ciò significa che le chiamate asincrone devono essere eseguite in serie.

Usando le promesse convenzionali, potresti ritrovarti con un codice come questo:

 const request = () => { return fetchUserData() .then(userData => { return fetchCompanyData(userData); }) .then(companyData => { return fetchRetiringPlan(userData, companyData); }) .then(retiringPlan => { const retiringPlan = retiringPlan; }); };

Async/await brilla in questo caso, dove sono necessarie chiamate asincrone concatenate:

 const request = async () => { const userData = await fetchUserData(); const companyData = await fetchCompanyData(userData); const retiringPlan = await fetchRetiringPlan(userData, companyData); };

Asincrono in parallelo

Cosa succede se si desidera chiamare più di una funzione asincrona in parallelo? Nel codice seguente, aspetteremo che fetchHouseData risolva, quindi chiameremo fetchCarData . Sebbene ciascuno di questi sia indipendente dall'altro, vengono elaborati in sequenza. Aspetterai due secondi per la risoluzione di entrambe le API. Questo non è un bene.

 function fetchHouseData() { return new Promise(resolve => setTimeout(() => resolve("Mansion"), 1000)); } function fetchCarData() { return new Promise(resolve => setTimeout(() => resolve("Ferrari"), 1000)); } async function action() { const house = await fetchHouseData(); // Wait one second const car = await fetchCarData(); // ...then wait another second. console.log(house, car, " in series"); } action();

Un approccio migliore consiste nell'elaborare le chiamate asincrone in parallelo. Controlla il codice qui sotto per avere un'idea di come questo si ottiene in async/await.

 async function parallel() { houseDataPromise = fetchHouseData(); carDataPromise = fetchCarData(); const house = await houseDataPromise; // Wait one second for both const car = await carDataPromise; console.log(house, car, " in parallel"); } parallel();

L'elaborazione di queste chiamate in parallelo comporta l'attesa di un solo secondo per entrambe le chiamate.

Nuove funzioni della libreria principale

Il nodo 8 porta anche alcune nuove funzioni di base.

Copia file

Prima del Nodo 8, per copiare i file, creavamo due flussi e convogliavamo i dati dall'uno all'altro. Il codice seguente mostra come il flusso di lettura convoglia i dati al flusso di scrittura. Come puoi vedere, il codice è ingombrante per un'azione così semplice come la copia di un file.

 const fs = require('fs'); const rd = fs.createReadStream('sourceFile.txt'); rd.on('error', err => { console.log(err); }); const wr = fs.createWriteStream('target.txt'); wr.on('error', err => { console.log(err); }); wr.on('close', function(ex) { console.log('File Copied'); }); rd.pipe(wr);

Nel nodo 8 fs.copyFile e fs.copyFileSync sono nuovi approcci per copiare i file con molto meno problemi.

 const fs = require("fs"); fs.copyFile("firstFile.txt", "secondFile.txt", err => { if (err) { console.log(err); } else { console.log("File copied"); } });

Prometti e richiama

util.promisify converte una funzione regolare in una funzione asincrona. Si noti che la funzione immessa deve seguire lo stile di callback comune di Node.js. Dovrebbe richiedere un callback come ultimo argomento, cioè (error, payload) => { ... } .

 const { promisify } = require('util'); const fs = require('fs'); const readFilePromisified = promisify(fs.readFile); const file_path = process.argv[2]; readFilePromisified(file_path) .then((text) => console.log(text)) .catch((err) => console.log(err));

Come puoi vedere, util.promisify ha convertito fs.readFile in una funzione asincrona.

D'altra parte, Node.js viene fornito con util.callbackify . util.callbackify è l'opposto di util.promisify : converte una funzione asincrona in una funzione di stile di callback Node.js.

destroy la funzione per leggibili e scrivibili

La funzione di destroy nel nodo 8 è un modo documentato per distruggere/chiudere/interrompere un flusso leggibile o scrivibile:

 const fs = require('fs'); const file = fs.createWriteStream('./big.txt'); file.on('error', errors => { console.log(errors); }); file.write(`New text.\n`); file.destroy(['First Error', 'Second Error']);

Il codice sopra comporta la creazione di un nuovo file denominato big.txt (se non esiste già) con il testo New text. .

Le funzioni Readable.destroy e Writeable.destroy nel Nodo 8 emettono un evento di close e un evento di error opzionale : destroy non significa necessariamente che qualcosa sia andato storto.

Operatore di diffusione

L'operatore spread (aka ... ) ha funzionato nel nodo 6, ma solo con array e altri iterabili:

 const arr1 = [1,2,3,4,5,6] const arr2 = [...arr1, 9] console.log(arr2) // expected output: [1,2,3,4,5,6,9]

Nel Nodo 8, gli oggetti possono anche utilizzare l'operatore di diffusione:

 const userCarData = { type: 'ferrari', color: 'red' }; const userSettingsData = { lastLoggedIn: '12/03/2019', featuresPlan: 'premium' }; const userData = { ...userCarData, name: 'Youssef', ...userSettingsData }; console.log(userData); /* Expected output: { type: 'ferrari', color: 'red', name: 'Youssef', lastLoggedIn: '12/03/2019', featuresPlan: 'premium' } */

Funzionalità sperimentali nel nodo 8 LTS

Le funzionalità sperimentali non sono stabili, potrebbero diventare obsolete e potrebbero essere aggiornate nel tempo. Non utilizzare nessuna di queste funzionalità in produzione finché non diventano stabili.

Hook asincroni

Gli hook asincroni tengono traccia della durata delle risorse asincrone create all'interno di Node tramite un'API.

Assicurati di aver compreso il ciclo di eventi prima di andare oltre con gli hook asincroni. Questo video potrebbe aiutare. Gli hook asincroni sono utili per il debug di funzioni asincrone. Hanno diverse applicazioni; uno di questi sono le tracce dello stack di errori per le funzioni asincrone.

Dai un'occhiata al codice qui sotto. Si noti che console.log è una funzione asincrona. Pertanto non può essere utilizzato all'interno di hook asincroni. Viene invece utilizzato fs.writeSync .

 const asyncHooks = require('async_hooks'); const fs = require('fs'); const init = (asyncId, type, triggerId) => fs.writeSync(1, `${type} \n`); const asyncHook = asyncHooks.createHook({ init }); asyncHook.enable();

Guarda questo video per saperne di più sugli hook asincroni. In termini di una guida Node.js in particolare, questo articolo aiuta a demistificare gli hook asincroni attraverso un'applicazione illustrativa.

Moduli ES6 nel nodo 8

Il nodo 8 ora supporta i moduli ES6, consentendo di utilizzare questa sintassi:

 import { UtilityService } from './utility_service';

Per utilizzare i moduli ES6 nel nodo 8, è necessario effettuare le seguenti operazioni.

  1. Aggiungi il --experimental-modules alla riga di comando
  2. Rinomina le estensioni dei file da .js a .mjs

HTTP/2

HTTP/2 è l'ultimo aggiornamento del protocollo HTTP non aggiornato e il nodo 8.4+ lo supporta in modo nativo in modalità sperimentale. È più veloce, più sicuro e più efficiente del suo predecessore, HTTP/1.1. E Google ti consiglia di usarlo. Ma cos'altro fa?

Multiplexing

In HTTP/1.1, il server poteva inviare solo una risposta per connessione alla volta. In HTTP/2, il server può inviare più di una risposta in parallelo.

Spinta del server

Il server può inviare più risposte per una singola richiesta client. Perché questo è vantaggioso? Prendi un'applicazione web come esempio. Convenzionalmente,

  1. Il client richiede un documento HTML.
  2. Il client scopre le risorse necessarie dal documento HTML.
  3. Il client invia una richiesta HTTP per ogni risorsa richiesta. Ad esempio, il client invia una richiesta HTTP per ogni risorsa JS e CSS menzionata nel documento.

La funzione server-push sfrutta il fatto che il server conosce già tutte queste risorse. Il server invia tali risorse al client. Quindi, per l'esempio dell'applicazione Web, il server esegue il push di tutte le risorse dopo che il client ha richiesto il documento iniziale. Questo riduce la latenza.

Priorità

Il cliente può impostare uno schema di priorità per determinare l'importanza di ciascuna risposta richiesta. Il server può quindi utilizzare questo schema per dare priorità all'allocazione di memoria, CPU, larghezza di banda e altre risorse.

Perdere le vecchie cattive abitudini

Poiché HTTP/1.1 non consente il multiplexing, vengono utilizzate diverse ottimizzazioni e soluzioni alternative per coprire la bassa velocità e il caricamento dei file. Sfortunatamente, queste tecniche causano un aumento del consumo di RAM e un rendering ritardato:

  • Partizionamento orizzontale del dominio: sono stati utilizzati più sottodomini in modo che le connessioni siano disperse e vengano elaborate in parallelo.
  • Combinazione di file CSS e JavaScript per ridurre il numero di richieste.
  • Mappe sprite: combinazione di file di immagine per ridurre le richieste HTTP.
  • Inlining: CSS e JavaScript vengono inseriti direttamente nell'HTML per ridurre il numero di connessioni.

Ora con HTTP/2 puoi dimenticare queste tecniche e concentrarti sul tuo codice.

Ma come usi HTTP/2?

La maggior parte dei browser supporta HTTP/2 solo tramite una connessione SSL protetta. Questo articolo può aiutarti a configurare un certificato autofirmato. Aggiungi il file .crt generato e il file .key in una directory chiamata ssl . Quindi, aggiungi il codice seguente a un file denominato server.js .

Ricorda di utilizzare il --expose-http2 nella riga di comando per abilitare questa funzione. Ad esempio, il comando di esecuzione per il nostro esempio è node server.js --expose-http2 .

 const http2 = require('http2'); const path = require('path'); const fs = require('fs'); const PORT = 3000; const secureServerOptions = { cert: fs.readFileSync(path.join(__dirname, './ssl/server.crt')), key: fs.readFileSync(path.join(__dirname, './ssl/server.key')) }; const server = http2.createSecureServer(secureServerOptions, (req, res) => { res.statusCode = 200; res.end('Hello from Toptal'); }); server.listen( PORT, err => err ? console.error(err) : console.log(`Server listening to port ${PORT}`) );

Naturalmente, il nodo 8, il nodo 9, il nodo 10, ecc. supportano ancora il vecchio HTTP 1.1: la documentazione ufficiale di Node.js su una transazione HTTP standard non sarà obsoleta per molto tempo. Ma se vuoi usare HTTP/2, puoi approfondire con questa guida di Node.js.

Quindi, dovrei usare Node.js 8 alla fine?

Il nodo 8 è arrivato con miglioramenti delle prestazioni e con nuove funzionalità come async/await, HTTP/2 e altre. Esperimenti end-to-end hanno dimostrato che il nodo 8 è circa il 25% più veloce del nodo 6. Ciò comporta notevoli risparmi sui costi. Quindi per i progetti greenfield, assolutamente! Ma per i progetti esistenti, dovresti aggiornare Node?

Dipende se è necessario modificare gran parte del codice esistente. Questo documento elenca tutte le modifiche sostanziali del Nodo 8 se provieni dal Nodo 6. Ricorda di evitare problemi comuni reinstallando tutti i pacchetti npm del tuo progetto usando l'ultima versione del Nodo 8. Inoltre, utilizza sempre la stessa versione di Node.js sui computer di sviluppo e sui server di produzione. Buona fortuna!

Imparentato:
  • Perché diavolo dovrei usare Node.js? Un tutorial caso per caso
  • Debug di perdite di memoria nelle applicazioni Node.js
  • Creazione di un'API REST sicura in Node.js
  • Codifica della febbre da cabina: un tutorial sul back-end di Node.js