Minimalizacja żądań do zewnętrznych zasobów, kompresja kodu wysyłanego do użytkownika, optymalizacja skyptów i styli, buforowanie i cachowanie – jako programista/webdeveloper powinieneś dążyć do tego celu nieustannie i niestrudzenie :-)
W nieniejszej notce zaprezentuję swoją małą bibliotekę do minimalizacji, kompresji i oczyszczania plików CSS – nieodzownych przy budowie każdej nowoczesnej strony internetowej (zgodnej z modelem MVC).
Dlaczego warto?
Niewielu graczy w internetowym półświatku stosuje kompresję i optymalizację stylów CSS. Przekłada się to na dłuższy czas rendowania strony (ponieważ zawartość HEAD dokumentu jest wysyłana przed rozpoczęciem wczytywania elementu BODY). Tracą na tym wszyscy:
- użytkownik musi dłużej czekać – niektórzy zdążą przejść do innej strony,
- serwis generuje więcej transferu – wyższe zapotrzebowanie na transfer oznacza wyższe koszty na serwer,
- wyszukiwarki powoli zaczynają uwzględniać czas wczytywania strony.
Jak sobie z tym poradzić? Wystarczy napisać niezbyt skomplikowaną klasę wykonującą całe zadanie za nas.
Do roboty, zaczynamy!
W notce tej wykorzystamy element pakietu PHP PEAR – Cache_Lite. W niniejszym przykładzie wystarcza w zupełności.
Na początek napiszemy prosty interfejs, CodeCompressor. Przyda się on także przy kolejnym poście, kiedy to opiszę kompresor JavaScript. Pisząc jeszcze inne biblioteki minimalizujące kod, będę mógł oprzeć się na tym interfejsie, także myślę, iż jest to dobry punkt wyjścia.
Plik ten prezentuje się następująco:
1 2 3 4 5 6 7 8 9 10 11 | interface CodeCompressor { public function addFiles($url_files); public function addFile($url_file); public function cleanCode($code); public function compressCode($code); public function showCode(); } |
Zapiszmy go w pliku: interface.CodeCompressor.php
CSS Compressor
Czas na implementację logiki skryptu. Rozpocznijmy od nadania głównego kształtu klasie:
1 2 3 4 5 6 7 8 | require_once 'interface.CodeCompressor.php'; require_once 'Cache/Lite.php'; class CSSCompressor implements CodeCompressor { // ... } |
Zapisujemy to w nowym pliku: class.CSSCompressor.php
Na początku damy tablicę z domyślnymi ustawieniami konfiguracyjnymi klasy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /** * Domyślne ustawienia konfiguracyjne dla klasy **/ private $config = array( 'charset' => 'utf-8', // kodowanie znaków 'clean_code' => true, // czyszczenie kodu 'compress_code' => true, // kompresja kodu 'import_mode' => true, // włączanie wewnętrznych styli (@import) 'gzip_contents' => true, // kompresja gzip 'gzip_level' => 6, // poziom kompresji gzip 'cache_enabled' => true, // buforowanie po stronie serwera 'cache_location' => 'tmp/', // folder dla cache 'use_flush_key' => true, // własnoręczne usuwanie cache, ?flush=FILE_ID 'use_cache_browser' => true, // buforowanie po stronie klienta 'time_cache_browser' => 3600 // czas trzymania w buforze (sekundy) ); |
Wszystko jest ładnie opisane i myślę, że więcej wyjaśniać nie trzeba (w razie wątpliwości później będą rozwinięte te właściwości).
Ustawienia te możemy dowolnie skonfigurować we własnym zakresie, choć wydaje mi się, że te powyższe są całkiem dobre.
Dodajmy kilka innych właściwości klasy, niezbędnych do prawidłowego działania:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | // zmienna przechowująca złączony kod css private $css_code; // tablica z adresami plików do złączenia i optymalizacji private $files_require = array(); // tablica przechowująca adresy plików, które już przetworzono private $files_loaded = array(); // przechowuje importowane pliki (@import) private $files_import = array(); // uchwyt dla klasy buforującej wynik private $cache_lite; // zerowe wielkości atrybutów - różne zapisy private $sizes = array( array(' 0px', ' 0em', ' 0%', ' 0ex', ' 0cm', ' 0mm', ' 0in', ' 0pt', ' 0pc'), array(':0px', ':0em', ':0%', ':0ex', ':0cm', ':0mm', ':0in', ':0pt', ':0pc') ); // wartości css => ich krótsze zamienniki private $shortcuts = array( // specjalne znaki ', ' => ',', ' , ' => ',', ';}' => '}', '; }' => '}', ' ; }' => '}', ' :' => ':', ': ' => ':', ' {' => '{', '; ' => ';', // kolory ':black' => ':#000', ':darkgrey' => ':#666', ':fuchsia' => ':#F0F', ':lightgrey' => ':#CCC', ':orange' => ':#F60', ':white' => ':#FFF', ':yellow' => ':#FF0', ':silver' => ':#C0C0C0', ':gray' => ':#808080', ':maroon' => ':#800000', ':red' => ':#FF0000', ':purple' => ':#800080', ':green' => ':#008000', ':lime' => ':#00FF00', ':olive' => ':#808000', ':navy' => ':#000080', ':blue' => ':#0000FF', ':teal' => ':#008080', ':aqua' => ':#00FFFF' ); // font-weight:name => font-weight:num private $font_weight_to_num = array( 'lighter' => 100, 'normal' => 400, 'bold' => 700, 'bolder' => 900 ); |
Możemy przejść do tworzenia kolejnych metod. Pozostanę przy opisywaniu kodu bezpośrednio w komentarzach języka, gdyż jest to po prostu wygodniejsze.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | public function __construct($config=array()) { // jeśli nie zdefiniowano ustawień - pozostaw domyślne if (!is_array($config)) return false; // przypisz ustawienia konfiguracyjne do właściwości klasy foreach ($config as $name => $type) { if (in_array($name, $this->config)) { $this->config[$name] = $config[$name]; } } // uruchom buforowanie plików $options = array( 'caching' => $this->config['cache_enabled'], 'cacheDir' => $this->config['cache_location'] ); $this->cache_lite = new Cache_Lite($options); // samodzielne czyszczenie buforu // dodaj ?flush=FILE_ID do adresu, aby usunąć cache if ( $this->config['cache_enabled'] and $this->config['use_flush_key'] and !empty($_GET['flush']) ) { $this->flushCache($_GET['flush']); } } |
W samym już konstruktorze klasy dzieje się kilka rzeczy, m. in. przypisywane są ustawienia konfiguracyjne zdefiniowane przy wywoływaniu instancji klasy, definiowana jest możliwość wywoływania metody usuwającej dotychczasowy cache i uruchamiana jest klasa odpowiadająca za cache, wspomniany wcześniej Cache_Lite.
Skoro już wspomniałem o możliwości czyszczenia buforu to utwórzmy metodę wykonującą to zadanie:
1 2 3 | private function flushCache($id_cache) { $this->cache_lite->remove($id_cache); } |
Wykorzystujemy tutaj wcześniej nawiązane połączenie z klasą odpowiadającą za cache i wysyłamy do niej identyfikator pliku cache, którego zamierzamy usunąć – proste.
Następnym krokiem będzie stworzenie metod pozwalających na załączanie kolejnych plików CSS:
1 2 3 | public function addFile($url_file) { array_push($this->files_require, $url_file); } |
oraz wygodniejszej u użyciu:
1 2 3 4 5 6 7 8 | public function addFiles($url_files) { // wczytaj kolejne pliki do złączenia foreach ($url_files as $id => $file) { $this->addFile( trim($file) ); } } |
Tutaj nie trzeba niczego objaśniać, adresy do plików pozostawiamy po prostu we właściwości obiektu – zajmiemy się nimi potem.
Czas przejść do najciekawszej części całej klasy, czyli optymalizacji i minimalizacji kodu CSS!
1 2 3 4 5 6 7 8 9 10 11 12 | public function cleanCode($code) { // wywal komentarze $code = preg_replace('!/*[^*]**+([^/][^*]**+)*/!', null, $code); // wywal niepotrzebne spacje i odstępy $code = str_replace (array("rn", "r", "n", "t", ' ', ' '), null, $code); // zwróć wyczyszczony kod return $code; } |
Tutaj nie dzieje się nic szczególnego. Ciekawiej ma się kompresja kodu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | public function compressCode($code) { // popraw zera na wersje skrócone $code = str_replace($this->sizes[0], ' 0', $code); $code = str_replace($this->sizes[1], ':0', $code); // wywal niepotrzebne spacje z reguł, skróć popularne nazwy kolorów do wartości HEX $code = str_ireplace( array_keys($this->shortcuts), array_values($this->shortcuts), $code ); // Wzorce regex: // 1 => minimalizuj wartości HEX kolorów // 2 => wywal wszystkie cudzysłowy z adresów url // 3 => skróć wartości reguły 'font-weight' do wartości liczbowych $search = array( 1 => '/([^=])#([a-fd])2([a-fd])3([a-fd])4([s;}])/i', 2 => '/url(['"](.*?)['"])/s', 3 => '/(font-weight|font):([a-z- ]*)(normal|bolder|bold|lighter)/ie' ); $replace = array( 1 => '$1#$2$3$4$5', 2 => 'url($1)', 3 => '"$1:$2" . $this->font_weight_to_num["$3"]' ); // wykonaj podmiany $code = preg_replace($search, $replace, $code); // zwróć skompresowany kod return $code; } |
Wywalamy tutaj niepotrzebne spacje w regułach, zmieniamy nadmierne wartości reguł, minimalizujemy wartości kolorów HEX i kilka innych optymalizacji.
Następnym głównym krokiem będzie metoda, która łączy wcześniej napisany kod w spójną całość:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | public function showCode($mode = 'infile') { // wyczyść bufor, jeśli pliki css były modyfikowane $this->checkModifiedFiles(); // utwórz cache, jeśli nie istnieje if ( !($this->css_code = $this->cache_lite->get( $this->_getCacheName() )) ) { // przetwórz kod css $css_code = $this->getCodeCSS($this->files_require); $this->css_code .= '@charset "' . $this->config['charset'] . '";'; $this->css_code .= $this->getImportRules(); $this->css_code .= $css_code; $this->css_code .= "n// generated by CSS Minify (http://blog.kamilbrenk.pl/css-minify/)"; // buforuj kod do pliku $this->cache_lite->save($this->css_code); unset($css_code); } switch ($mode) { // zwróć kod (return) case 'inline': return $this->css_code; break; // generuj kod (echo) case 'infile': $this->outputHeaders(); break; } } |
Dzieje się tutaj kilka ciekawych rzeczy:
- sprawdzamy czy któryś z plików został zmodyfikowany; jeśli tak to odświeżamy plik cache,
- tworzymy plik cache, jeśli tak ustawiono w konfiguracji obiektu,
- generujemy kod css,
- w zależności od kontekstu wywołania metody – zwracamy efekt działania lub przekazujemy do metody odpowiadającej za ustawienia nagłówków i wyświetlamy bezpośrednio w oknie przeglądarki.
Ad. 1) Piszemy metody odpowiedzialne za sprawdzanie, czy któryś z załączonych plików nie był później modyfikowany aniżeli plik cache.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | private function checkModifiedFiles() { // sprawdź czy plik był edytowany (jeśli to możliwe) foreach ($this->files_require as $id => $url_file) { if (file_exists($url_file)) { // liczba sekund od ostatniej aktualizacji $life_file = filemtime($url_file); // jeśli plik był aktualizowant -> wyczyść bufor if ($life_file > $this->_lastModifiedCache()) { return $this->flushCache( $this->_getCacheName() ); } } } } private function _lastModifiedCache() { $this->cache_lite->_setFileName($this->_getCacheName(), 'default'); return $this->cache_lite->lastModified(); } private function _getCacheName() { return md5( serialize($this->config) . implode($this->files_require) ); } |
Ad. 3) Wcześniej załączone pliki były gromadzone w właściwości obiektu o nazwie files_require. Czas pobrać ten kod i go przetworzyć.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | private function getCodeCSS($files) { // zmienna przechowująca kod $full_code = null; // pobierz i zoptymalizuj kod z załączonych plików foreach ($files as $id => $url_file) { // pobierz kod źródłowy - błędy zostaną 'stłumione' i pominięte // zabezpiecz przed wielokrotnym pobieraniem tego samego kodu if ( !in_array($url_file, $this->files_loaded) and $code = @file_get_contents($url_file) ) { // odnotuj dołączenie pliku array_push($this->files_loaded, $url_file); // wywal deklarację charset w dokumencie, o ile posiada // aby nie dublować dla każdego pliku $code = preg_replace('/@charset\s["\']([0-9A-Za-z-]+)["\'];?/', null, $code); // sposób implementowania reguły: http://www.w3.org/TR/CSS21/cascade.html#at-import $code = preg_replace_callback( '/@import\s(?:url\([\'"]?([^\'"]+)[\'"]?\)|["\'](.+)[\'"])\s?([ A-Za-z0-9,]*)?;/', array($this, '_import'), $code ); // wyczyść kod if ($this->config['clean_code']) $code = $this->cleanCode($code); if ($this->config['compress_code']) $code = $this->compressCode($code); // dodaj kod do zwrócenia $full_code .= $code; unset($code); } } } |
Dlaczego każdy kod jest osobno pobierany, przetwarzany, a na sam koniec łączony w całość? Ano między innymi dlatego, że preg_replace, który jest niezbędny do wykorzystania ma pewne ograniczenia.
Więcej przeczytasz w komentarzach oficjalnej dokumentacji języka PHP: http://www.php.net/manual/en/function.preg-replace.php#93840.
Powyższa metoda usuwa wszystkie reguły ustalające kodowanie, ponieważ wg standardu CSS, może być tylko jedna taka reguła na początku dokumentu.
Również reguła @import jest usuwana, ponieważ może być tylko na początku dokumentu CSS. Reguła ta odpowiada za importowanie zewnętrznych plików CSS, jednak nie jest zbyt wydajna. Jeśli w ustawieniach konfiguracyjnych obiektu nakazano włączanie także importowanych plików, nastąpi to w niniejszej metodzie:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | private function _import($result) { // wyciągnij nazwę importowanego pliku css $filename = !empty($result[1]) ? $result[1] : $result[2]; // imprtowanie włączone: zwróć zawartość poprawionego pliku do złączenia if ($this->config['import_mode']) { return $this->getCodeCSS( array($filename) ); } // imprtowanie wyłaczone: przechowaj dane w tablicy $this->files_import += array($filename => $result[3]); } |
Dodam także, że zaimplementowano zabezpieczenie przed nieskończonym wczytywaniem tych samych plików.
plik1.css
1 | @import ('plik2.css') |
plik2.css
1 | @import ('plik1.css') |
Powyższy przykład wczyta tylko jednokrotnie plik1.css oraz plik2.css, po czym zakończy działanie.
Jeśli natomiast wyłączone jest importowanie plików z tej reguły, następuje ich usunięcie ze środka dokumentu i wyświetlenie na samym początku dokumentu CSS (nakazuje tego standard).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | private function getImportRules() { // zmienna przechowująca kod $code = null; // załącz kolejne pliki imprortowane foreach ($this->files_import as $filename => $type) { $code .= '@import url("' . $filename . '") ' . $type . ';'; } // zwróć przetworzony kod return $code; } |
Ad. 4) Wysyłamy nagłówki do przeglądarki oraz kompresujemy css, jeśli jest taka potrzeba. W tym celu posłuży nam ostatnia już metoda:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | protected function outputHeaders() { // standardowe nagłowki header('Content-Type: text/css; charset=' . $this->config['charset']); // buforuj w przeglądarce if ($this->config['use_cache_browser']) { header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $this->_lastModifiedCache()) . ' GMT'); header('Cache-Control: public, must-revalidate, max-age=' . $this->config['time_cache_browser']); header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $this->config['time_cache_browser']) . ' GMT'); } else { header("Cache-Control: no-cache, must-revalidate"); header("Expires: Sat, 26 Jul 1997 05:00:00 GMT"); } // ustaw kompresję gzip if ($this->config['gzip_contents'] and extension_loaded("zlib") and !ini_get('zlib.output_compression')) { $this->css_code = gzencode($this->css_code, $this->config['gzip_level']); header('Content-Encoding: gzip'); } header('Content-Length: ' . strlen($this->css_code)); echo $this->css_code; } |
Dodajemy tutaj nagłówki dla pliku odpowiedzialne m. in. za buforowanie w przeglądarce (po stronie klienta) czy kodowanie znaków.
Ładujemy również kompresję gzip, jeśli tego żądano.
Na sam koniec dodajmy destruktor odwalający brudną robotę – usuwania niepotrzebnych zmiennych.
1 2 3 | public function __destruct() { unset($this->files_code); } |
Mamy zbudowaną klasę. I co dalej?
Teraz wystarczy wywołać instancję klasy i w zależności od potrzeb rozpocząć ładowanie kolejnych plików CSS do optymalizacji:
1 2 3 4 5 6 7 8 9 10 11 12 13 | require_once 'class.CSSCompressor.php'; // utwórz instancję klasy $css_compress = new CSSCompressor(); // dodaj pliki css do złączenia $css_compress->addFile('file1.css'); $css_compress->addFile('file2.css'); $css_compress->addFile('file3.css'); echo '<style>'; echo $css_compress->showCode('inline'); echo '</style'; |
Czego efektem będzie złączenie powyższych trzech plików, dokonanie ich kompresji oraz optymalizacji, dodanie do buforu przeglądarki, zapis cache w folderze tmp/, po czym zwrócenie z wykorzystaniem gzip. Tak działają domyślne ustawienia skryptu.
Skrypt, choć liniowo włączony do strony, jest zbuforowany i nie ma żadnego problemu przy kolejnych odwiedzinach strony – zamiast trzech plików jest wczytywany jeden – zoptymalizowany i często nawet kilkakrotnie mniejszy!
Bibliotekę tą możemy wykorzystać także w bardziej przydatny sposób. Oto przykład:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | require_once 'class.CSSCompressor.php'; // konfiguracja $_config = array( 'charset' => 'utf-8', // kodowanie znaków 'import_mode' => true, // włączanie wewnętrznych styli (@import) 'clean_code' => true, // status czyszczenia kodu 'compress_code' => true, // status kompresji kodu 'cache_enabled' => true, // buforowanie po stronie serwera 'cache_location' => 'tmp/', // folder dla cache 'use_cache_browser' => true, // buforowanie po stronie klienta 'time_cache_browser' => 3600, // czas trzymania w buforze (sekundy) 'gzip_contents' => true, // kompresja gzip 'gzip_level' => 6 // poziom kompresji gzip ); // utwórz instancję klasy $css_compress = new CSSCompressor($_config); // dodaj pliki css do złączenia $css_compress->addFile('file1.css'); $css_compress->addFile('file2.css'); $css_compress->addFile('file3.css'); // wersja 2 $css_compress->showCode('infile'); |
Tworzymy plik z powyższym kodem i zapisujemy, np. css.php.
Teraz na każdej kolejnej stronie możemy odwołać się do tego pliku w następujący sposób:
1 | <link rel="stylesheet" type="text/css" href="css.php" /> |
Jeśli komuś przeszkadza odwoływanie się do pliku CSS bezpośrednio w elemencie link, może za pośrednictwem mod_rewrite przepisać URL na „udający” prawdziwy plik CSS.
Przykład z życia wzięty
Sam najczęściej stosuję się do następującego rozwiązania:
css.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | require_once 'class.CSSCompressor.php'; // utwórz instancję klasy $css_compress = new CSSCompressor(); // dodaj pliki css do złączenia if (!isset($_GET['load'])) die('Nie wczytano żadnych styli.'); // przerób na tablicę $files = explode(',', $_GET['load']); foreach ($files as $id => &$file) { $file = 'style/' . trim($file) . '.css'; } // ustaw do kompresji $css_compress->addFiles($files); // wyświetl wynik $css_compress->showCode('infile'); |
Po czym do plików odwołuję się w poniższy sposób:
1 | <link rel="stylesheet" type="text/css" href="css.php?load=file1,file2,file3" /> |
lub przy pomocy mod_rewrite można upiększyć jeszcze bardziej, np. :
1 | <link rel="stylesheet" type="text/css" href="css.css?load=file1,file2,file3" /> |
Czas na wyniki, analizę – efekty pracy!
Mam nadzieję, że powyższe przykłady prostotę i potęgę optymalizacji plików CSS. Jeśli nadal nie jesteś przekonany, spójrz na wyniki moich optymalizacji:
oryginalny rozmiar plików:
17,03 KB
domyślne ustawienia skryptu:
3,74 KB, oszczędność wynosi 78%, czyli plik zmniejszył się prawie pięciokrotnie!
maksymalna minimalizacja:
- plik jest trzymany po stronie klienta,
- plik jest trzymany na serwerze i ponownie generowany w przypadku aktualizacji któregoś ze arkuszy stylów,
- zamiast odwołań do wielu plików, mamy tylko jedno zapytanie o zewnętrzny zasób; czasem ma to istotny wpływ na wczytywanie strony, o czym więcej przeczytasz we wpisie: Minimalizacja zapytań HTTP.
To by było na tyle. Zapraszam do testów i wytykania błędów.
Mam również nadzieję, że przekonałem Cię do buforowania plików CSS. W następnym wpisie zabierzemy się za JavaScript!
To już lekka przesada dla mnie – te konwersje itd…
Możesz jeszcze dać zamianę color: rgb(X, Y, Z) na # :)
Możliwe, że popłynąłem trochę za bardzo – często mi się to zdarza i zamiast użytecznego i lekkiego narzędzia powstaje zasobożerny potwór ;)
@Kamil – wątpię, żeby to była przesada – o ile jest tylko z tego jakiś zysk to warto nawet przedłużyć czas kompresowania – odzyskamy go z nawiązką później. Kto często modyfikuje pliki CSS? :)
Przy każdej rozbudowie strony i różnych innych poprawkach aktualizuje się także pliki CSS – dodając moduł, usuwając moduł, aktualizując cokolwiek. Przynajmniej u mnie zdarza się to dość często przy stronach nad którymi na bieżąco pracuję i które rozwijam :-)
Niemniej jednak wciąż korzystam z powyższego skryptu i sprawdza się doskonale – nie miałem jeszcze z nim żadnych problemów i niczego nie zmieniałem.