Tree-shaking: Un ghid de referință

Publicat: 2022-03-10
Rezumat rapid ↬ „Tree-shaking” este o optimizare obligatorie a performanței atunci când combinați JavaScript. În acest articol, ne aprofundăm cum funcționează exact și cum se împletesc specificațiile și practica pentru a face pachetele mai slabe și mai performante. În plus, veți primi o listă de verificare a tremurării copacilor pe care să o utilizați pentru proiectele dvs.

Înainte de a începe călătoria noastră pentru a afla ce este zguduirea copacilor și cum să ne pregătim pentru succes, trebuie să înțelegem ce module sunt în ecosistemul JavaScript.

De la începuturile sale, programele JavaScript au crescut în complexitate și în numărul de sarcini pe care le execută. Necesitatea de a compartimenta astfel de sarcini în domenii închise de execuție a devenit evidentă. Aceste compartimente de sarcini, sau valori, sunt ceea ce numim module . Scopul lor principal este de a preveni repetarea și de a spori reutilizarea. Deci, arhitecturile au fost concepute pentru a permite astfel de tipuri speciale de domeniu de aplicare, pentru a-și expune valorile și sarcinile și pentru a consuma valori și sarcini externe.

Pentru a aprofunda ce sunt modulele și cum funcționează acestea, recomand „ES Modules: A Cartoon Deep-Dive”. Dar pentru a înțelege nuanțele tremurării copacilor și ale consumului de module, definiția de mai sus ar trebui să fie suficientă.

Ce înseamnă de fapt scuturarea copacilor?

Pur și simplu, scuturarea copacilor înseamnă eliminarea codului inaccesibil (cunoscut și ca cod mort) dintr-un pachet. După cum afirmă documentația Webpack versiunea 3:

„Îți poți imagina aplicația ca pe un copac. Codul sursă și bibliotecile pe care le utilizați de fapt reprezintă frunzele verzi și vii ale copacului. Codul mort reprezintă frunzele maro, moarte ale copacului care sunt consumate până în toamnă. Pentru a scăpa de frunzele moarte, trebuie să scuturi copacul, făcându-le să cadă.”

Termenul a fost popularizat pentru prima dată în comunitatea front-end de către echipa Rollup. Dar autorii tuturor limbilor dinamice s-au confruntat cu problema de mult mai devreme. Ideea unui algoritm de scuturare a copacilor poate fi urmărită cel puțin la începutul anilor 1990.

În domeniul JavaScript, tree-shaking a fost posibilă de la specificația modulului ECMAScript (ESM) în ES2015, cunoscută anterior ca ES6. De atunci, tree-shaking a fost activată în mod implicit în majoritatea bundlerilor, deoarece reduc dimensiunea de ieșire fără a modifica comportamentul programului.

Motivul principal pentru aceasta este că ESM-urile sunt statice prin natură. Să analizăm ce înseamnă asta.

Mai multe după săritură! Continuați să citiți mai jos ↓

Module ES vs. CommonJS

CommonJS precede specificația ESM cu câțiva ani. A venit să abordeze lipsa de suport pentru modulele reutilizabile din ecosistemul JavaScript. CommonJS are o funcție require() care preia un modul extern pe baza căii furnizate și îl adaugă la domeniul de aplicare în timpul rulării.

Aceasta require o function ca oricare alta dintr-un program îngreunează evaluarea rezultatului apelului său în timpul compilării. În plus, este și faptul că adăugarea apelurilor require oriunde în cod este posibilă - încapsulată într-un alt apel de funcție, în instrucțiuni if/else, în instrucțiuni switch etc.

Odată cu învățarea și luptele care au rezultat din adoptarea pe scară largă a arhitecturii CommonJS, specificația ESM s-a stabilit pe această nouă arhitectură, în care modulele sunt importate și exportate prin cuvintele cheie respective import și export . Prin urmare, nu mai sunt apeluri funcționale. ESM-urile sunt, de asemenea, permise doar ca declarații de nivel superior — imbricarea lor în orice altă structură nu este posibilă, deoarece sunt statice : ESM-urile nu depind de execuția în timpul execuției.

Domeniul de aplicare și efectele secundare

Există, totuși, un alt obstacol pe care scuturarea copacilor trebuie să-l depășească pentru a evita balonarea: efectele secundare. Se consideră că o funcție are efecte secundare atunci când modifică sau se bazează pe factori externi domeniului de execuție. O funcție cu efecte secundare este considerată impură . O funcție pură va produce întotdeauna același rezultat, indiferent de context sau de mediul în care a fost rulată.

 const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c

Bundler-urile își servesc scopul evaluând codul furnizat cât mai mult posibil pentru a determina dacă un modul este pur. Dar evaluarea codului în timpul compilarii sau a grupării poate merge doar atât de departe. Prin urmare, se presupune că pachetele cu efecte secundare nu pot fi eliminate în mod corespunzător, chiar și atunci când sunt complet inaccesibile.

Din această cauză, bundlerii acceptă acum o cheie în interiorul fișierului package.json al modulului care permite dezvoltatorului să declare dacă un modul nu are efecte secundare. În acest fel, dezvoltatorul poate renunța la evaluarea codului și poate indica pachetul; codul dintr-un anumit pachet poate fi eliminat dacă nu există nicio importare accesibilă sau dacă nu require legătură cu declarația. Acest lucru nu numai că face un pachet mai slab, dar poate și accelera timpul de compilare.

 { "name": "my-package", "sideEffects": false }

Deci, dacă sunteți un dezvoltator de pachete, utilizați conștiincios sideEffects înainte de a publica și, desigur, revizuiți-l la fiecare lansare pentru a evita orice modificări neașteptate.

În plus față de cheia rădăcină sideEffects , este, de asemenea, posibil să determinați puritatea fișier cu fișier, adnotând un comentariu inline, /*@__PURE__*/ , la apelul dumneavoastră de metodă.

 const x = */@__PURE__*/eliminated_if_not_called()

Consider această adnotare inline a fi o trapă de evadare pentru dezvoltatorul consumator, care trebuie făcută în cazul în care un pachet nu a declarat sideEffects: false sau în cazul în care biblioteca prezintă într-adevăr un efect secundar asupra unei anumite metode.

Optimizarea pachetului web

Începând cu versiunea 4, Webpack a necesitat din ce în ce mai puțină configurație pentru ca cele mai bune practici să funcționeze. Funcționalitatea pentru câteva plugin-uri a fost încorporată în core. Și pentru că echipa de dezvoltare ia foarte în serios dimensiunea pachetului, a făcut ușoară scuturarea copacilor.

Dacă nu sunteți foarte amabil sau dacă aplicația dvs. nu are cazuri speciale, atunci tree-shaking dependențele dvs. este o chestiune de doar o linie.

Fișierul webpack.config.js are o proprietate rădăcină numită mode . Ori de câte ori valoarea acestei proprietăți este de production , va zgudui copacii și va optimiza complet modulele. Pe lângă eliminarea codului mort cu TerserPlugin , mode: 'production' va activa nume deterministe alterate pentru module și bucăți și va activa următoarele plugin-uri:

  • semnalați utilizarea dependenței,
  • steag include bucăți,
  • concatenarea modulelor,
  • nu emite erori.

Nu întâmplător valoarea de declanșare este production . Nu veți dori ca dependențele dvs. să fie complet optimizate într-un mediu de dezvoltare, deoarece va face problemele mult mai dificil de depanat. Așa că aș sugera să procedați cu una dintre cele două abordări.

Pe de o parte, ați putea trece un flag de mode la interfața de linie de comandă Webpack:

 # This will override the setting in your webpack.config.js webpack --mode=production

Alternativ, puteți utiliza variabila process.env.NODE_ENV în webpack.config.js :

 mode: process.env.NODE_ENV === 'production' ? 'production' : development

În acest caz, trebuie să vă amintiți să treceți --NODE_ENV=production în conducta dvs. de implementare.

Ambele abordări sunt o abstractizare peste mult cunoscutul definePlugin din Webpack versiunea 3 și mai jos. Opțiunea pe care o alegeți nu face absolut nicio diferență.

Webpack versiunea 3 și mai jos

Merită menționat faptul că scenariile și exemplele din această secțiune s-ar putea să nu se aplice versiunilor recente de Webpack și alte pachete. Această secțiune ia în considerare utilizarea UglifyJS versiunea 2, în loc de Terser. UglifyJS este pachetul din care a fost extras Terser, astfel încât evaluarea codului ar putea diferi între ele.

Deoarece Webpack versiunea 3 și mai jos nu acceptă proprietatea sideEffects din package.json , toate pachetele trebuie să fie complet evaluate înainte ca codul să fie eliminat. Numai acest lucru face ca abordarea să fie mai puțin eficientă, dar trebuie luate în considerare și câteva avertismente.

După cum sa menționat mai sus, compilatorul nu are cum să afle singur când un pachet modifică domeniul global. Dar aceasta nu este singura situație în care omite tremuratul copacilor. Există scenarii mai neclare.

Luați acest exemplu de pachet din documentația Webpack:

 // transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });

Și iată punctul de intrare al unui pachet de consum:

 // index.js import { someVar } from './transforms.js'; // Use `someVar`...

Nu există nicio modalitate de a determina dacă mylib.transform efecte secundare. Prin urmare, niciun cod nu va fi eliminat.

Iată și alte situații cu un rezultat similar:

  • invocând o funcție dintr-un modul terț pe care compilatorul nu o poate inspecta,
  • reexportarea funcțiilor importate de la module terțe.

Un instrument care ar putea ajuta compilatorul să facă tree-shaking să funcționeze este babel-plugin-transform-imports. Acesta va împărți toate exporturile membre și denumite în exporturi implicite, permițând ca modulele să fie evaluate individual.

 // before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';

De asemenea, are o proprietate de configurare care avertizează dezvoltatorul să evite declarațiile de import supărătoare. Dacă sunteți pe Webpack versiunea 3 sau o versiune superioară și ați făcut diligența necesară cu configurația de bază și ați adăugat plugin-urile recomandate, dar pachetul dvs. încă pare umflat, atunci vă recomand să încercați acest pachet.

Ridicarea scopului și timpii de compilare

Pe vremea lui CommonJS, majoritatea bundlerilor pur și simplu înfășurau fiecare modul într-o altă declarație de funcție și le mapau în interiorul unui obiect. Nu este diferit de orice obiect de hartă de acolo:

 (function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")

Pe lângă faptul că este greu de analizat static, acest lucru este fundamental incompatibil cu ESM-urile, deoarece am văzut că nu putem încheia declarațiile de import și export . Deci, în zilele noastre, grupatorii ridică fiecare modul la nivelul superior:

 // moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()

Această abordare este pe deplin compatibilă cu ESM; în plus, permite evaluarea codului să identifice cu ușurință modulele care nu sunt apelate și să le abandoneze. Avertismentul acestei abordări este că, în timpul compilării, este nevoie de mult mai mult timp, deoarece atinge fiecare declarație și stochează pachetul în memorie în timpul procesului. Acesta este un motiv important pentru care performanța grupării a devenit o preocupare și mai mare pentru toată lumea și de ce limbajele compilate sunt utilizate în instrumente pentru dezvoltarea web. De exemplu, esbuild este un bundler scris în Go, iar SWC este un compilator TypeScript scris în Rust care se integrează cu Spark, un bundler scris tot în Rust.

Pentru a înțelege mai bine ridicarea lunetei, recomand cu căldură documentația Parcel versiunea 2.

Evitați transpilarea prematură

Există o problemă specifică care, din păcate, este destul de comună și poate fi devastatoare pentru scuturarea copacilor. Pe scurt, se întâmplă atunci când lucrezi cu încărcătoare speciale, integrând diferite compilatoare în bundler-ul tău. Combinațiile comune sunt TypeScript, Babel și Webpack - în toate permutările posibile.

Atât Babel, cât și TypeScript au propriile lor compilatoare, iar încărcătoarele respective permit dezvoltatorului să le folosească, pentru o integrare ușoară. Și aici se află amenințarea ascunsă.

Aceste compilatoare ajung la codul dvs. înainte de optimizarea codului. Și indiferent dacă sunt implicite sau greșite, aceste compilatoare scot adesea module CommonJS, în loc de ESM-uri. După cum sa menționat într-o secțiune anterioară, modulele CommonJS sunt dinamice și, prin urmare, nu pot fi evaluate în mod corespunzător pentru eliminarea codului mort.

Acest scenariu devine și mai comun în zilele noastre, odată cu creșterea aplicațiilor „izomorfe” (adică aplicații care rulează același cod atât pe partea de server, cât și pe partea clientului). Deoarece Node.js nu are încă suport standard pentru ESM-uri, atunci când compilatoarele sunt direcționate către mediul node , produc CommonJS.

Așadar, asigurați-vă că verificați codul pe care îl primește algoritmul dvs. de optimizare .

Lista de verificare a tremurării copacilor

Acum că cunoașteți dezavantajele modului în care funcționează gruparea și scuturarea copacilor, haideți să ne desenăm singuri o listă de verificare pe care o puteți tipări undeva la îndemână atunci când vă revizuiți implementarea actuală și baza de cod. Sperăm că acest lucru vă va economisi timp și vă va permite să optimizați nu numai performanța percepută a codului dvs., dar poate chiar și timpul de construire a conductei dvs.!

  1. Utilizați ESM-uri și nu numai în propria bază de cod, dar preferați și pachetele care scot ESM ca consumabile.
  2. Asigurați-vă că știți exact care (dacă există) dintre dependențele dvs. nu au declarat sideEffects sau le-ați setat ca true .
  3. Utilizați adnotarea inline pentru a declara apeluri de metodă care sunt pure atunci când consumați pachete cu efecte secundare.
  4. Dacă scoateți module CommonJS, asigurați-vă că vă optimizați pachetul înainte de a transforma declarațiile de import și export.

Crearea pachetelor

Sperăm că până în acest moment suntem cu toții de acord că ESM-urile sunt calea de urmat în ecosistemul JavaScript. Ca întotdeauna în dezvoltarea de software, totuși, tranzițiile pot fi dificile. Din fericire, autorii pachetelor pot adopta măsuri de neîncălcare pentru a facilita migrarea rapidă și fără întreruperi pentru utilizatorii lor.

Cu câteva mici adăugări la package.json , pachetul dvs. va putea spune bundlerilor mediile pe care pachetul le acceptă și cum sunt cel mai bine acceptate. Iată o listă de verificare de la Skypack:

  • Includeți un export ESM.
  • Adăugați "type": "module" .
  • Indicați un punct de intrare prin "module": "./path/entry.js" (o convenție a comunității).

Și iată un exemplu care rezultă atunci când sunt respectate toate cele mai bune practici și doriți să acceptați atât mediile web, cât și mediile Node.js:

 { // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }

În plus, echipa Skypack a introdus un scor de calitate al pachetului ca punct de referință pentru a determina dacă un anumit pachet este configurat pentru longevitate și cele mai bune practici. Instrumentul este open source pe GitHub și poate fi adăugat ca devDependency la pachetul dvs. pentru a efectua verificările cu ușurință înainte de fiecare lansare.

Încheierea

Sper că acest articol ți-a fost de folos. Dacă da, luați în considerare distribuirea acestuia cu rețeaua dvs. Aștept cu nerăbdare să interacționez cu tine în comentarii sau pe Twitter.

Resurse utile

Articole și documentație

  • „Module ES: o scufundare profundă a desenului animat”, Lin Clark, Mozilla Hacks
  • „Tree Shaking”, Webpack
  • „Configurare”, Webpack
  • „Optimizare”, Webpack
  • „Scope Hoisting”, documentația pachetului versiunea 2

Proiecte și instrumente

  • Terser
  • babel-plugin-transform-imports
  • Skypack
  • Webpack
  • Colet
  • Rulează
  • esbuild
  • SWC
  • Verificarea pachetului