Jak CommonJS zwiększa Twoje pakiety

Dowiedz się, jak moduły CommonJS wpływają na drżenie drzew Twojej aplikacji

W tym poście wyjaśnimy, czym jest standard CommonJS i dlaczego zmienia Twoje pakiety JavaScript na większe, niż jest to konieczne.

Podsumowanie: aby mieć pewność, że pakiet może skutecznie zoptymalizować Twoją aplikację, unikaj stosowania modułów CommonJS i używaj składni modułu ECMAScript w całej aplikacji.

Co to jest CommonJS?

CommonJS to standard z 2009 roku, który wprowadza konwencje dla modułów JavaScript. Początkowo był przeznaczony do użytku poza przeglądarką, głównie do aplikacji po stronie serwera.

CommonJS pozwala definiować moduły, eksportować z nich funkcje i importować je w innych modułach. Na przykład poniższy fragment kodu definiuje moduł, który eksportuje pięć funkcji: add, subtract, multiply, divide i max:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Później inny moduł może zaimportować niektóre lub wszystkie z tych funkcji oraz korzystać z nich:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

Wywołanie funkcji index.js za pomocą funkcji node spowoduje wyświetlenie w konsoli liczby 3.

Z powodu braku ujednoliconego systemu modułów w przeglądarce na początku 2010 r. CommonJS stał się popularnym formatem modułu również dla bibliotek JavaScript po stronie klienta.

Jak CommonJS wpływa na ostateczny rozmiar pakietu?

Rozmiar Twojej aplikacji JavaScript po stronie serwera nie jest tak krytyczny jak w przeglądarce, dlatego CommonJS nie został zaprojektowany z myślą o zmniejszeniu rozmiaru pakietu produkcyjnego. Jednocześnie analiza wskazuje, że rozmiar pakietu JavaScript nadal jest głównym powodem powolnego działania aplikacji przeglądarki.

Narzędzia do tworzenia pakietów i minimalizowania JavaScriptu, np. webpack i terser, przeprowadzają różne optymalizacje, aby zmniejszyć rozmiar aplikacji. Analizując aplikację w trakcie kompilacji, starają się usunąć jak najwięcej z nieużywanego kodu źródłowego.

Na przykład w powyższym fragmencie kodu końcowy pakiet powinien zawierać tylko funkcję add, ponieważ jest to jedyny symbol z tabeli utils.js zaimportowany w index.js.

Utwórzmy aplikację, korzystając z tej konfiguracji webpack:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

Określamy, że chcemy wykorzystać optymalizacje w trybie produkcyjnym i użyć index.js jako punktu wejścia. Jeśli po wywołaniu funkcji webpack sprawdzisz rozmiar output, zobaczysz coś takiego:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

Zwróć uwagę, że pakiet ma 625 KB. W danych wyjściowych znajdują się wszystkie funkcje z utils.js i wiele modułów z lodash. Chociaż nie wykorzystujemy lodash w index.js, jest on częścią danych wyjściowych, co nadaje mu większą wagę.

Teraz zmień format modułu na moduły ECMAScript i spróbuj ponownie. Tym razem utils.js wygląda tak:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

Z kolei index.js importuje dane z przeglądarki utils.js, korzystając ze składni modułu ECMAScript:

import { add } from './utils.js';

console.log(add(1, 2));

Używając tej samej konfiguracji webpack, możemy utworzyć aplikację i otworzyć plik wyjściowy. Ma teraz 40 bajtów i następuje ten wynik:

(()=>{"use strict";console.log(1+2)})();

Zwróć uwagę, że końcowy pakiet nie zawiera żadnych nieużywanych funkcji utils.js ani śladu z lodash. Co więcej, funkcja terser (narzędzie do minifikowania JavaScriptu, której używa webpack) wbudowała funkcję add w tagu console.log.

Warto zapytać, dlaczego użycie CommonJS powoduje,że pakiet danych wyjściowych jest prawie 16 000 razy większy. Jest to oczywiście przykład zabawki, w rzeczywistości różnica w rozmiarze może nie być aż tak duża, ale istnieje możliwość, że CommonJS w dużym stopniu wpływa na kompilację produkcyjną.

Moduły CommonJS są trudniejsze do optymalizacji pod względem ogólnym, ponieważ są znacznie bardziej dynamiczne niż moduły ES. Aby mieć pewność, że narzędzie do pakietów i minifikator będzie mogło skutecznie zoptymalizować Twoją aplikację, unikaj stosowania modułów CommonJS i używaj składni modułu ECMAScript w całej aplikacji.

Zwróć uwagę, że nawet jeśli w index.js używasz modułów ECMAScript, a używany moduł jest modułem CommonJS, zmniejsza się rozmiar pakietu aplikacji.

Dlaczego CommonJS powiększa Twoją aplikację?

Aby odpowiedzieć na to pytanie, przeanalizujemy zachowanie ModuleConcatenationPlugin w webpack, a potem omówimy analitykę statyczną. Łączy ona zakres wszystkich Twoich modułów w jednym zamknięciu, dzięki czemu Twój kod może działać szybciej w przeglądarce. Spójrzmy na przykład:

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

Powyżej znajduje się moduł ECMAScript, który importujemy w programie index.js. Definiujemy też funkcję subtract. Możemy utworzyć projekt przy użyciu tej samej konfiguracji webpack co powyżej, ale tym razem wyłączymy minimalizowanie:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

Przyjrzyjmy się wynikom:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

W powyższych danych wyjściowych wszystkie funkcje znajdują się w tej samej przestrzeni nazw. Aby zapobiec kolizji, pakiet internetowy zmienił nazwę funkcji subtract w index.js na index_subtract.

Jeśli program do minifikacji przetworzy powyższy kod źródłowy:

  • Usuń nieużywane funkcje subtract i index_subtract.
  • Usuń wszystkie komentarze i zbędne odstępy
  • Treść funkcji add jest wbudowana w wywołaniu console.log

Deweloperzy często nazywają to usunięcie nieużywanych importów jako „potrząsanie drzewem”. Drżenie drzew było możliwe tylko dlatego, że pakiet internetowy był w stanie statycznie (w trakcie tworzenia) zrozumieć, które symbole są importowane z usługi utils.js i jakie są eksportowane.

To zachowanie jest domyślnie włączone w przypadku modułów ES, ponieważ są lepiej analizowane statycznie niż CommonJS.

Przeanalizujmy dokładnie ten sam przykład, ale tym razem zmienimy utils.js tak, aby używał CommonJS zamiast ES:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Ta mała aktualizacja znacząco zmieni dane wyjściowe. Film jest za długi, aby umieścić go na tej stronie, więc udostępnię tylko jej niewielki fragment:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

Zwróć uwagę, że końcowy pakiet zawiera pewne „środowisko wykonawcze” funkcji webpack: wstrzyknięty kod, który odpowiada za importowanie/eksportowanie funkcji z pakietów. Tym razem zamiast umieszczać wszystkie symbole z utils.js i index.js w tej samej przestrzeni nazw, wymagamy dynamicznego, w czasie działania, funkcji add korzystającej z __webpack_require__.

Jest to konieczne, ponieważ za pomocą CommonJS możemy uzyskać nazwę eksportu z dowolnego wyrażenia. Na przykład ten kod jest absolutnie prawidłową konstrukcją:

module.exports[localStorage.getItem(Math.random())] = () => { … };

Programista nie ma możliwości poznania podczas kompilacji nazwy wyeksportowanego symbolu, ponieważ wymaga to informacji, które są dostępne tylko w czasie działania, w kontekście przeglądarki użytkownika.

W ten sposób minifikator nie wie, co dokładnie wykorzystuje index.js z zależności, więc nie jest w stanie potrząsnąć drzewem. Dokładnie takie samo działanie obserwujemy w przypadku modułów firm zewnętrznych. Jeśli zaimportujemy moduł CommonJS z node_modules, łańcuch narzędzi do kompilacji nie będzie go prawidłowo optymalizować.

Drżenie drzew z CommonJS

Znacznie trudniej jest analizować moduły CommonJS, ponieważ są one z definicji dynamiczne. Na przykład lokalizacja importu w modułach ES jest zawsze literałem tekstowym, w przeciwieństwie do CommonJS, gdzie jest wyrażeniem.

W niektórych przypadkach, jeśli używana biblioteka jest zgodna z określonymi konwencjami dotyczącymi CommonJS, możesz usunąć nieużywane eksporty podczas kompilacji, korzystając z plugin webpack innej firmy. Chociaż ta wtyczka dodaje obsługę potrząsania drzewami, nie obejmuje wszystkich sposobów użycia CommonJS w zależnościach. Oznacza to, że nie otrzymujesz takich samych gwarancji co w przypadku modułów ES. Wiąże się to z dodatkowym kosztem w ramach procesu kompilacji oprócz domyślnego działania webpack.

Podsumowanie

Aby usługa pakietów mogła skutecznie optymalizować Twoją aplikację, unikaj stosowania modułów CommonJS i używaj składni modułu ECMAScript w całej aplikacji.

Oto kilka przydatnych wskazówek, które pozwolą Ci sprawdzić, czy jesteś na najlepszej drodze:

  • Użyj wtyczki node-resolve Rollup.js i ustaw flagę modulesOnly, by wskazać, że chcesz polegać tylko na modułach ECMAScript.
  • Użyj pakietu is-esm, aby sprawdzić, czy pakiet npm korzysta z modułów ECMAScript.
  • Jeśli używasz Angular, domyślnie zobaczysz ostrzeżenie, jeśli używasz modułów bez możliwości potrząsania drzewem.