Blog

22 sierpnia 2014 Krzysztof Winiarski

Automatyzacja za pomocą Grunta

Automatyzacja za pomocą Grunta

W niniejszym artykule postaram się przedstawić zasady działania narzędzia służącego do automatyzacji zadań jakim jest Grunt. W ostatnim czasie stał się on bardzo popularny wśród front-end developerów, wyznaczając niejako standard automatyzacji typowych zadań z jakimi boryka się na co dzień frontendowiec. Grunt pozwala stworzyć bardzo przyjazną przestrzeń roboczą, która umożliwia uruchamianie środowiska developerskiego, build aplikacji czy też deploy kodu.

Cel automatyzacji

Automatyzacja jest ważnym aspektem pracy nie tylko programisty, ale w zasadzie każdej dziedziny życia. Najczęściej procesowi automatyzacji poddajemy częste i powtarzalne czynności, których sami nie chcemy wykonywać. Parzenie kawy lub robienie prania to przykłady czynności, które automatyzujemy w życiu codziennym za pomocą odpowiednich urządzeń. Ekspres do kawy czy też pralka nie tylko upraszczają nam życie, pozwalając zająć się tym co naprawdę dla nas ważne, ale powodują też sporo oszczędności. Nie inaczej jest w codziennej pracy programisty. Możemy procesowi automatyzacji poddać bardzo wiele czynności które wykonać musimy, ale niekoniecznie chcemy to robić osobiście. Dlatego też zamiast ręcznie scalać pliki, uruchamiać minifikatory, testy jednostkowe, itp. możemy po prostu zatrudnić Grunta.

Grunt od środka

Grunt napisany jest w JavaScript i działa w oparciu o Node.js. Dostępny jest on tym samym z repozytorium npm. Grunt składa się w rzeczywistości z 2 pakietów: “grunt-cli” oraz “grunt”. Pierwszy z nich jest pakietem instalowanym globalnie, co umożliwia nam używanie Grunta z linii poleceń w dowolnym miejscu. Aby “grunt-cli” mógł wykonać swoje zadanie, potrzebna jest jeszcze instalacja pakietu “grunt” w katalogu projektu, w którym Grunta chcemy używać, a także utworzenie w tym samym katalogu głównego pliku konfiguracyjnego Gruntfile.js. Reasumując, poprawna instalacja Grunta w naszym projekcie będzie wymagała uruchomienia następujących poleceń:

npm install -g grunt-cli

W głównym katalogu naszego projektu:

npm install grunt --save-dev

Następnie należy utworzyć plik Gruntfile.js. Plik ten jest modułem Node.js, którego najprostsza postać może wyglądać w następujący sposób:

module.exports = function (grunt) {
    grunt.loadNpmTasks("grunt-contrib-uglify");
    grunt.initConfig({
        uglify: {
            oneFileFromAll: {
                files: {
                    "dest/lib.min.js": ["lib/**/*.js"]
                }
            }
        }
    });
}

To co widzimy w poniższym przykładzie to deklaracja modułu Node.js, który w tym przypadku jest funkcja przyjmująca jeden parametr, pod którym przekazywana jest instancja Grunta. W ten sposób otrzymujemy dostęp jego API. W przykładzie mamy wykorzystane dwie metody, pierwszą odpowiedzialną za załadowanie zewnętrznego pluginu, który został zainstalowany za pomocą polecenia “npm”. Następnie tworzona i uruchamiana jest konfiguracja dla wcześniej załadowanego pluginu. Nieco więcej na temat ładowania pluginów jak i ich konfiguracji zostanie napisane w dalszej części artykułu. Na chwilę obecną mamy zainstalowanego Grunta i skonfigurowane pierwsze zadanie, które po wykonaniu utworzy nam w katalogu “dest” plik “lib.min.js”, który będzie zawierał zminifikowany i scalony kod wszystkich plików “*.js” które znajdują się w katalogu “lib” jak i wszystkich jego podkatalogach “**”.

Aby uruchomić powyższe zadanie należy zainstalować w katalogu projektu rozszerzenie:

npm install grunt-contrib-uglify --save-dev

a następnie uruchomić polecenie:

grunt uglify

Pluginy i zadania

W świecie Grunta mamy do czynienia z pojęciem rozszerzenia (plugin) oraz zadania (task). Na początek warto wyjaśnić na czym polega różnica, gdyż pojęcia czasem mogą być mylnie używane. Rozszerzenie, jak wskazuje nazwa, jest modułem Node.js który umożliwia Gruntowi wykonanie określonych czynności. Aby mógł być użyty nalezy go wczesniej zainstalowac w projekcie za pomocą polecenia “npm install”, a następnie zainicjalizować w Gruntfile.js za pomocą metody “grunt.loadNpmTask”. Metoda ta rejestruje na obiekcie Grunta nowe zadanie, najczęściej pod taką samą nazwą jak nazwa rozszerzenia, jedynie z pominięciem przedrostka “grunt-” lub “grunt-contrib-“. Jest przyjęte że nazwa rozszerzenia dla Grunta powinna być poprzedzona przedrostkiem “grunt” . “grunt-contrib” jest zarezerwowane dla rozszerzeń dostarczanych przez twórców Grunta. W powyższym przykładzie załadowaliśmy więc rozszerzenie “grunt-contrib-uglify”, które zarejestrowało zadanie o nazwie “uglify”. Konfiguracja dla tego zadania jest definiowana w obiekcie konfiguracji pod kluczem o takiej samej nazwie jak nazwa zadania.

Społeczność Grunta stworzyła pokaźną liczbę rozszerzeń które pozwalają na wykonywanie naprawdę wielu różnych zadań. Specyficzną grupą rozszerzeń jest pakiet contrib, który jest rozwijany i utrzymywany przez autorów Grunta. Pakiet ten udostępnia szereg najbardziej popularnych zadań umożliwiających kasowanie i kopiowanie plików, minifikację JavaScript i CSS, uruchamianie testów jednostkowych i innych narzędzi do sprawdzania jakości kodu. W większości projektów pakiet contrib powinien być w zupełności wystarczający. Wszystkie rozszerzenia z tego pakietu poprzedzone są prefiksem “grunt-contrib-” w nazwie. Pakiet rozszerzeń można zainstalować w całości:

npm i grunt-contrib --save-dev

lub jedynie wybrane rozszerzenia:

npm i grunt-contrib-uglify grunt-contrib-cssmin --save-dev

W powyższym kodzie został użyty alias dla polecenia “npm install”, czyli “npm i”. Dodatkowo użyta jest opcja –save-dev. Powoduje ona zapisanie w pliku package.json w sekcji “devDependencies” informacji o instalowanych przez nas modułach. Możemy też użyć flagi “–save” która zapisze tą samą informacje ale w sekcji “dependencies”. Wszystkie zależności zapisane w pliku package.json są instalowane zawsze kiedy uruchomimy polecenie “npm install” w katalogu projektu. Jeśli chcemy zainstalować tylko zależności niezbędne do działania naszej aplikacji możemy użyć flagi “–production”. Tym sposobem polecenie “npm i –production” zainstaluje zalezności wymienione w sekcji “dependencies” pliku package.json, ale pominie wszystkie zależności deweloperskie “devDependencies”.

Podstawy zadań

W tym miejscu wypada rozszerzyć pojęcie zadania, jakie znamy do tej pory. Po pierwsze Grunt rozróżnia zadania oraz multi-zadania (zadania o wielu celach). Z jakim zadaniem mamy do czynienia, zależy od sposobu jego rejestracji. Zadania o wielu celach rejestrowane są przez metodę registerMultiTask, natomiast zadania proste rejestrowane są poprzez metodę registerTask. Generalnie większość zadań jest definiowana jako multi-zadania co pozwala na ich elastyczne wykorzystanie poprzez przekazanie większej ilości celów. Metoda registerTask jest używana do tworzenia prostych zadań jednokrotnego użycia lub też tzw. zadania zadań, gdzie jako pierwszy parametr przekazujemy nazwę nowego zadania, a parametr drugi stanowi tablica zadań które mają być wykonane we wskazanej kolejności, np.:

grunt.registerTask("build", [
    "clean",
    "uglify",
    "cssmin",
    "copy"
]);

Uruchomienie polecenia “grunt build” wykona zadanie “build” które składa się z 4 innych zadań. Błąd w jakimkolwiek zadaniu składowym spowoduje przerwanie działania zadania “build” i zwrócenie błędu.

Tworzenie multi-zadań zostanie opisane w osobnym artykule.

Zadania i ich cele

Jak zostało wyżej napisane, zadania mogą mieć kilka celów. Co to oznacza? Za chwilę wyjaśnię, jednak na początek wróćmy do naszej przykładowej konfiguracji “uglify” i dodajmy do niej pewien zestaw opcji, które zostaną zastosowane podczas procesu minifikacji:

grunt.initConfig({
    uglify: {
        options: {
            sourceMap: true
        },
        oneFileFromAll: {
            files: {
                "dest/lib.min.js" : ["lib/**/*.js"]
            }
        }
    }
});

Dodana opcja spowoduje że do każdego utworzonego pliku powstanie mapa do kodu źródłowego. Teraz załóżmy że chcemy użyć naszego zadania “uglify” do stworzenia 2 plików ze zminifikowanym kodem. Najprościej można to osiągnąć rozbudowując konfigurację w następujący sposób:

grunt.initConfig({
    uglify: {
        options: {
            sourceMap: true
        },
        manyDifferentFiles: {
            files: {
                "dest/lib.min.js" : ["lib/**/*.js"],
                "dest/lib-core.min.js": ["lib/core.js", "lib/core/*.js"]
            }
        }
    }
});

Stworzyliśmy prostą konfigurację multi-zadania o jednym celu. Celem w tym przypadku jest “manyDifferentFiles”. Wykonanie tego celu powoduje wygenerowanie 2 plików używając określonych opcji – takich samych dla obu plików. Co jeśli chcemy jednak wykonać minifikację dla tych samych 2 plików, ale z użyciem różnych opcji? W tym momencie zachodzi potrzeba stworzenia 2 osobnych celów. Każdy z nich będzie używał opcji zdefiniowanych na poziomie celów jako domyślnych. Dodanie właściwości “options” na poziomie celu, spowoduje nadpisanie opcji domyślnych. Chcąc utworzyć mapę tylko dla pliku “dest/lib.min.js” powinniśmy dodać nowy cel, modyfikując naszą konfigurację w następujący sposób:

grunt.initConfig({
    uglify: {
        oneFileWithSourceMap: {
            options: {
                sourceMap: true
            },
            files: {
                "dest/lib.min.js": ["lib/**/*.js"]
            }
        },
        core: {
            files: {
                "dest/lib-core.min.js": ["lib/core.js", "lib/core/*.js"]
            }
        }
    }
});

Ponieważ “sourceMap: false” jest domyślną wartością, opcje na poziomie całego zadania zostały usunięte dla poprawy czytelności. Tym sposobem mamy zadanie o 2 celach. Wykonując teraz polecenie “grunt uglify” zostaną uruchomione oba cele. Aby uruchomić tylko wybrany cel możemy się posłużyć poleceniem “grunt uglify:core”, co spowoduje utworzenie jedynie pliku “dest/lib-core.min.js”.

Zaawansowana konfiguracja

Zaawansowana konfiguracja sprowadza się do 3 zasadniczych kwestii: użycia znaków dopasowania, szablonów oraz dynamicznego budowania listy plików źródłowych i docelowych (“src-dest”).

Szablony

W konfiguracji Grunta mamy możliwość użycia systemu szablonów, identycznego jak ten w bibliotece uderscore czy też lodash. Wykorzystanie szablonów daje nam możliwość skonfigurowania pewnych parametrów w jednym miejscu, a następnie używanie ich poprzez system szablonów jako zmienne. Dla przykładu mamy zdefiniowany katalog do którego chcemy generować pliki ze wszystkich zadań. Aby nie wpisywać za każdym razem ręcznie nazwy tego katalogu, możemy w tym celu zdefiniować nazwę katalogu pod zmienną, a następnie w ścieżkach w których podalibyśmy nazwę tego katalogu użyć szablonów.

{
    "destDir": "dest"
}
grunt.initConfig({
    conf: grunt.file.readJSON("config.json"),
    uglify: {
        oneFileWithSourceMap: {
            options: {
                sourceMap: true
            },
            files: {
                "<%= conf.destDir %>/lib.min.js": ["lib/**/*.js"]
            }
        },
        core: {
            files: {
                "<%= conf.destDir %>/lib-core.min.js": ["lib/core.js", "lib/core/*.js"]
            }
        }
    }
});

W przykładzie powyżej używamy metody “grunt.file.loadJSON” do wczytania zewnętrznego pliku JSON i podpięcia go pod obiekt konfiguracji, który jest przekazywany do szablonów podczas ich przetwarzania. Odwołanie w szablonie do “conf.destDir” to nic innego jak odwołanie do klucza “conf” obiektu konfiguracyjnego i znajdującej się pod nim właściwości “destDir”, która została wczytana z pliku JSON.

Znaki dopasowania

Grunt używa bibliotek minimatch i node-glob, które pozwalają na podawanie ścieżek do plików za pomocą tzw. znaków dopasowania. Pełną dokumentację możliwości jakie dają biblioteki można znaleźć na stronach obu projektów. Poniżej przedstawię jedynie najbardziej przydatne użycia tych znaków, zaczynając od przykładu jaki został umieszczony w powyższych fragmentach kodu: “lib/**/*.js”. Jak rozumieć taki zapis? Otrzymując tak zapisaną ścieżkę Grunt:

  • otworzy katalog “lib” i zacznie po nim iterować dopasowując kolejne pliki do dalszej ścieżki;
  • wyrażenie “**” akceptuje znaki alfanumeryczne, łącznie ze znakiem “/” co powoduje że akceptowane są wszelkie ścieżki niezależnie od głębokości ich zagnieżdżenia;
  • na końcu dopasowywana jest nazwa pliku i w naszym przypadku może to być dowolny plik z rozszerzeniem “.js”.

A co gdybyśmy chcieli wszystkie pliki “.js” oraz “.json”? Taka możliwość daje nam użycie nawiasu klamrowego:

["lib/**/*.{js,json}"]

Idąc dalej chcemy uwzględnić jedynie pliki bezpośrednio w katalogu “lib” oraz w katalogach bezpośrednio w “lib” (jeden poziom zagnieżdżenia):

["lib/{,*/}*.{js,json}"]

A gdybyśmy z tak stworzonej kolekcji chcieli usunąć konkretny plik? Możemy posłużyć się znakiem zapytania, który neguje cały ciąg dopasowania:

["lib/{,*/}*.{js,json}", "!lib/exclude.js"]

Dynamiczne listy plików

Obok szablonów i znaków dopasowania Grunt daje nam jeszcze jedną możliwość konfigurowania naszych zadań. Są to tzw. dynamiczne listy plików. Są one bardzo pomocne jeśli chcemy przetworzyć w ten sam sposób wiele pojedynczych plików. Przypuśćmy że chcemy zminifikować z osobna każdy plik który mamy w katalogu “lib”. Można to zrobić tak jak w przykładach powyżej, podając nazwę pliku docelowego i pliku źródłowego. Jednak jeśli plików jest wiele to może być to karkołomne i trudne do utrzymania. Pozwólmy więc Gruntowi zbudować tą listę za nas:

grunt.initConfig({
    uglify: {
        separateFiles: {
            expand: true,
            cwd: "lib/",
            src: ["**/*.js"],
            dest: "dest/",
            ext: ".min.js"
        }
    }
});

W powyższym kodzie widzimy że zachodzi dekompozycja wcześniejszej ścieżki do plików źródłowych. Podajemy najpierw katalog w którym Grunt ma szukać plików (“cwd”), a następnie zestaw ścieżek jakie ma dopasować (“src”). Na tej podstawie powstanie lista plików źródłowych. Do każdego pliku źródłowego tworzony jest odnośnik do lokalizacji pliku docelowego. W uproszczeniu odbywa się to przez zamianę katalogu “cwd” w ścieżce do każdego pliku na “dest”, a następnie modyfikację rozszerzenia z “.js” na “.min.js”. Przykładowo dla pliku “lib/exclude.js” zostanie utworzony plik “dest/exclude.min.js”.

Wiedzę z opisanych zagadnień dotyczących konfiguracji zadań można poszerzyć zapoznając się z oficjalną dokumentacją.

Działające przykłady z niniejszego artykułu można pobrać z repozytorium na GitHub.

Co dalej

W niniejszym artykule omówiono w pigułce podstawy działania narzędzia jakim jest Grunt. W kolejnych częściach zostaną omówione następujące tematy:

  • Tworzenie zadań w Grunt, czyli jak krok po kroku stworzyć własne rozszerzenie.
  • Grunt na sterydach, czyli kilka porad na temat tego jak przyspieszyć wykonywanie zadań.
  • Grunt dla zaawansowanych, czyli przykłady zadań i konfiguracji które zmienią sposób w jaki pracujesz.

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....