Tworzenie prostego pluginu dla Webpacka 4

Dzisiaj zamiast przemyśleń trochę konkretnej roboty. Pokażę jak stworzyć plugin do Webpacka w wersji 4 i jakie zmiany w API zostały poczynione względem poprzedniej wersji. Let’s go!

O Webpacku wspominałem już na blogu podczas tworzenia projektu VRath (ten konkretny wpis można znaleźć tutaj), ale w ramach przypomnienia kilka zdań się przyda.

Webpack to nic innego jak manager zasobów, pozwalający na łączenie ze sobą wielu zewnętrznych zasobów w tzw. „graf zależności”. Innymi słowy, jesteśmy w stanie dzielić złożone aplikacje na wiele plików i nie musimy się martwić o samodzielne ładowanie tychże zależności, ponieważ tym zajmie się właśnie Webpack. Co ważne, nie muszą to być tylko pliki z kodem JS, Webpack świetnie sobie radzi także chociażby z plikami CSS czy też różnymi formatami obrazów. Mógłbym zaryzykować stwierdzenie, że właściwie każdy zasób jaki może być użyty na stronach internetowych może być przez Webpacka włączony do naszego wynikowego zasobu. Tak rozbudowane możliwości działania na zasobach dostarczane są poprzez dwa mechanizmy: loaderów i pluginów. Zarówno loadery jak i pluginy to nic innego jak zewnętrzne biblioteki pozwalające na rozszerzanie konfiguracji bazowej Webpacka. Te pierwsze służą głównie do ładowania różnych rodzajów plików np. ze stylami SASS, te drugie pozwalają np. na modyfikację kodu na różnych etapach kompilacji zasobów.

To tyle słowem wstępu, pora dowiedzieć się jak stworzyć własny plugin i jak załączyć go do gotowej konfiguracji Webpacka.



Pluginy, pluginy wszędzie

Zadania jakie wykonują pluginy mogą być na prawdę różne. Począwszy od logowania informacji na ekranie, poprzez np. zaawansowaną optymalizację kodu. Sam Webpack dostarcza listę oficjalnych pluginów, którą można przejrzeć tutaj. Wśród nich są tak popularne wtyczki jak HtmlWebpackPlugin pozwalający na dynamiczne tworzenie plików HTML, czy też UglifyjsWebpackPlugin służący m.in. do minifikacji naszego kodu JS. Oprócz oficjalnych pluginów, mamy także możliwość dołączenia pluginów tworzonych przez społeczność, a tych są już setki, jeśli nie tysiące. Wystarczy w wyszukiwarce wpisać kontekst tego co chcemy uzyskać i połączyć to z „webpack plugin” i możemy się pozytywnie zaskoczyć gdy okaże się, że funkcjonalność której potrzebujemy jest już gotowa w postaci wygodnej w użyciu wtyczki. Nie byłoby to możliwe gdyby nie proste API, które zostało przygotowane przez zespół Webpacka.

Tworzenie prostego pluginu

Cała koncepcja pluginu opiera się na utworzeniu nowej klasy posiadającej metodę apply. Podstawowy kod wygląda więc następująco:

class TestWebpackPlugin {
  apply(compiler) {
    ...
  }
}

Metoda apply jest wywoływana automatycznie przez wewnętrzny mechanizm Webpacka, podczas kompilacji zasobów. Otrzymuje ona w parametrze obiekt compiler, który udostępnia programiście kilka ciekawych obiektów pozwalających „dobrać się” do przetwarzanego kodu. Najciekawszym z nich jest hooks pozwalający na podpięcie się pod tzw. „cykl życia” procesu tworzenia zasobu. Możemy zasubskrybować funkcje zwrotne na różnego rodzaju „hooki”, a ich lista jest dostępna tutaj. O to kilka z nich:

  • failed – wywoływany w momencie błędu kompilacji.
  • done – wywoływany po pomyślnym zakończeniu kompilacji.
  • emit – wywoływany przed emitowaniem zasobu do folderu docelowego.
  • compile – wywoływany przed rozpoczęciem procesu kompilacji.

Część z hooków jest odpalana raz w trakcie trwania kompilacji, inne mogą być uruchamiane wielokrotnie tak jak np. emit. Spróbujmy więc podpiąć się pod hook failed, który wywoła się gdy kompilacja się nie powiedzie:

class TestWebpackPlugin {
  apply(compiler) {
    compiler.hooks.failed.tap('TestWebpackPlugin', (error) => {
      console.log('Oops!');
      console.log(error);
    });
  }
}

Powyższy plugin wyloguje nam na konsoli enigmatyczne „Oops!” i obiekt błędu gdy kompilacja zakończy się błędem.

Schemat podpinania się pod konkretny hook wygląda następująco:
compiler.hooks.nazwa-hooka.metoda [tap/tapAsync/tapPromise](nazwa-pluginu, funkcja)

Nie jest to więc nic skomplikowanego. Oczywiście pod nazwa-hooka musimy podstawić jeden z hooków compilera, natomiast metoda to jedna z trzech funkcji pozwalająca zarejestrować naszą funkcję zwrotną. Część hooków jest natury asynchronicznej i pozwalają na podpięcie się własną funkcją asynchroniczną za pomocą metod tapAsync i tapPromise. Natomiast wszystkie hooki (także te asynchroniczne) posiadają przede wszystkim standardową metodę tap i jej użyłem w swoim przykładzie. Wybór odpowiedniej metody zależy oczywiście od tego co nasz plugin ma robić. Jeżeli chcemy logować na konsoli zasoby za pomocą hooka emit, możemy to uczynić za pomocą np. tapAsync i zadanie będzie wykonywało się „tak jakby” w osobnym wątku. Jeżeli natomiast chcemy aby nasz plugin tworzył np. katalogi i kolejność wykonywania jest dla nas bardzo istotna, warto wtedy podpiąć się pod zwykłą metodę tap.
To co łączy wszystkie te metody to parametry, w których pierwszy jest zazwyczaj nazwą naszego plugina (powinnna to być unikalna nazwa i niezmienna pomiędzy różnymi hookami) a drugi funkcją zwrotną, która zostanie wywołana poprzez określony hook.

Ok, ale jak możemy skorzystać z tego pluginu? Ta część jest wręcz banalna, wystarczy do tablicy plugins w configu webpacka dodać instancję naszej wtyczki:

const TestWebpackPlugin = require('ścieżka do pluginu');

module.exports = {
  ...
  plugins: [
    new TestWebpackPlugin()
  ]
  ...
};

I już! Teraz gdy kompilacja się nie powiedzie, nasz plugin nam o tym powie. Byłoby jednak smutno gdyby pluginy nie mogły dostawać żadnych dodatkowych opcji z zewnątrz, na szczęście taka możliwość istnieje. Wystarczy przekazać do konstruktora pluginu parametr w postaci obiektu konfiguracyjnego, np. w ten sposób:

const TestWebpackPlugin = require('ścieżka do pluginu');

module.exports = {
  ...
  plugins: [
    new TestWebpackPlugin({
      text: 'Oh no!'
    })
  ]
  ...
};

Następnie w samym kodzie pluginu dostajemy się do opcji za pomocą parametru options, który trafia do konstruktora:

class TestWebpackPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    compiler.hooks.failed.tap('TestWebpackPlugin', (error) => {
      console.log(this.options.text || 'Oops!');
      console.log(error);
    });
  }
}

W powyższym przykładzie jeżeli zostanie przekazana opcja text to zostanie ona wylogowana na konsoli, a jeżeli nie to zostanie standardowe „Oops!”.

Na zakończenie chciałbym jeszcze wspomnieć o drugim bardzo istotnym obiekcie z którego możemy skorzystać, mowa o obiekcie compilation. Reprezentuje on pojedynczą kompilację zasobów, posiada dostęp do wszystkich modułów i ich zależności a także co ciekawe, oferuje własną listę hooków pod które możemy się podpinać. Pełna lista jest dostępna tutaj, natomiast aby z nich skorzystać, należy wcześniej podpiąć się pod hook compilera o nazwie compilation:

class TestWebpackPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('TestWebpackPlugin', (compilation) => {
      compilation.hooks.optimize.tap('TestWebpackPlugin', () => {
        console.log('Początek optymalizacji!');
      });
    });
  }
}

Różnice w starszych wersjach Webpacka

Wiemy już jak wygląda API do tworzenia pluginów w Webpacku 4, czas spojrzeć nieco w przeszłość i zobaczyć jak to było kiedyś. Po co? A no po to, że jeszcze cała masa wtyczek bazuje na starym API i nie jest kompatybilna z nową wersją Webpacka. Warto więc się orientować jeżeli przyjdzie nam pracować z takim „legacy pluginem”. Poza tym jest mnóstwo fajnych wtyczek porzuconych przez autorów, może więc znajdzie się ktoś kto je przepisze? To może na początek kod pluginu z poprzedniego przykładu „po staremu”:

class TestWebpackPlugin {
  constructor(options) {
    this.options = options;
  }
  apply(compiler) {
    compiler.plugin('failed', (error) => {
      console.log(this.options.text || 'Oops!');
      console.log(error);
    });
  }
}

Jak widać, różnica nie jest duża i dotyczy głównie sposobu podpinania się pod konkretny hook, bo sama klasa jest praktycznie taka sama. Główne skrzypce gra tutaj metoda plugin i jest ona także częścią obiektu compilation. Gorzej jest natomiast z zapisem hooków asynchronicznych, ponieważ musimy zawsze pamiętać o wywołaniu callbacka, którego dostajemy dodatkowo w parametrze. Tak to wygląda dla hooka emit:

class TestWebpackPlugin {
  apply(compiler) {
    compiler.plugin('emit', (compilation, callback) => {
      // jakieś operacje asynchroniczne...
      callback();
    });
  }
}

Robiąc „po nowemu” mamy możliwość wywoływania metod synchronicznych na wszystkich hookach, tutaj niestety musimy pamiętać o callbacku.

Na koniec jeszcze mała uwaga. Szukając informacji w sieci o tworzeniu pluginów można przypadkowo trafić na starą dokumentację Webpacka. Ale teraz gdy już wiecie jak mniej więcej wygląda stary sposób tworzenia wtyczek na pewno w porę się zorientujecie, że trzeba poszukać świeższych informacji 😉

Podsumowanie

Pluginy pozwalają w łatwy sposób na rozszerzanie bazowej funkcjonalności Webpacka i są jednym z tych elementów, dzięki którym Webpack zawdzięcza swoją popularność. Jeżeli nie znajdziemy w sieci interesującej nas wtyczki, zawsze możemy stworzyć ją samemu, co udowodniłem tym postem.

Mam nadzieję, że ten wpis zachęci przynajmniej jedną osobę do poeksperymentowania z API pluginów i może urodzi się z tego coś zwariowanego! Tworzenie własnych pluginów jest na prawdę proste, a ilość hooków pod które możemy się podpiąć robi wrażenie. Do dzieła!

P.S. Dokumentacja Webpacka w wielu miejscach nadal ssie.