Консольный минимизатор скриптов и стилей в Yii

Консоль

На прошлом уроке мы познакомились с консольным режимом в PHP и с консольными командами в Yii. Теперь пришла пора собрать вместе наши знания и перейти к практике. При разработке любых проектов удобно разделять CSS и JavaScript на отдельные файлы, но их обилие в секции HEAD заметно уменьшает скорость загрузки веб-страницы. Итак, поехали!

Вносить изменения в один огромный CSS-файл неудобно, так как приходится постоянно перебегать по строкам кода буквально на несколько метров вверх или вниз. Например, вместо одного файла стилей:

css/all.css

удобно иметь несколько структурированных фрагментов:

css/_system.css
css/_form.css
css/_layout.css
css/_typography.css
css/_portlet.css
css/_pagination.css

Но при такой структуре файлы нужно будет подключать тоже раздельно:

<link type="text/css" rel="stylesheet" href="/css/_system.css" />
<link type="text/css" rel="stylesheet" href="/css/_form.css" />
<link type="text/css" rel="stylesheet" href="/css/_layout.css" />
<link type="text/css" rel="stylesheet" href="/css/_typography.css" />
<link type="text/css" rel="stylesheet" href="/css/_portlet.css" />
<link type="text/css" rel="stylesheet" href="/css/_pagination.css" />

Одновременная загрузка десятка файлов браузером занимает несколько каналов интернет-соединения. Вместо быстрой загрузки единственного файла по быстрому интернет-соединению браузеру приходится запрашивать шесть файлов, причём для доставки каждого открывать новое соединение. В итоге при задержке на каждый файл по половине секунды тратится приличное время. Именно поэтому эффективнее склеить всю группу файлов в один all.css:

<link type="text/css" rel="stylesheet" href="/css/all.css" />

Для этой манипуляции в простейшем случае подходит простая команда:

cat _system.css _form.css _layout.css _typography.css _portlet.css _pagination.css > all.css

Аналогично можно выполнять склейку JavaScript файлов. Но перед этим необходимо вручную добавить в конце каждого файла переносы, чтобы случайно последнее слово из предыдущего файла не оказалось на одной строке с первым из следующего.

Также имеет смысл не только склеивать, но и облегчать результирующие файлы консольными минимизаторами. Если в своих проектах использовать SASS, то задача склейки стилей легко решается его средствами.

Ещё один подход предполагает вызов PHP-скрипта, который динамически склеивает стили либо по имеющемуся в нём списку:

<link type="text/css" rel="stylesheet" href="/css/style.php?v=123" />

либо непосредственно по указанным именам:

<link type="text/css" rel="stylesheet" href="/css/style.php?files[]=_system.css&files[]=_form.css&files[]=_layout.css&files[]=_typography.css&files[]=_portlet.css&files[]=_pagination.css&v=123" />

Скрипт style.php функцией header() при этом может подменить Content-type и сообщить браузеру время кэширования, что может избавить от повторной загрузки одних и тех же файлов при переходе со страницы на страницу сайта.

Без параметра v=123 браузер закэширует вызов файла по данному адресу на указанное время (например, на неделю или на месяц). Если стили в эти дня изменятся, то браузер не узнает, что их нужно загрузить снова по этому же адресу и будет отображать сайт со стилями недельной давности. Поэтому лучший выход – это менять URL при каждом изменении какого-либо файла. Для изменения адреса можно добавить GET-параметр и менять его значение вручную или автоматически.

Об этом мы поговорим далее, а пока перейдём к написанию нашей команды.

Команда минимизации

Нам нужен алгоритм, который считывает контент из списка переданных файлов, минимизирует его и записывает в результирующий файл. Для начала нам нужны фукнции работы с файлами. Для этих целей мы можем использовать стандартные file_get_contents и file_put_contents:

<?php
 
$source_files = array (
    '../css/_layout.css',
    '../css/_typography.css',
    '../css/_form.css',
    '../css/_custom.css',
);
 
$target_file = '../css/all.css';
 
echo 'Start' . PHP_EOL;
 
$text = '';
foreach ($source_files as $file);
    echo 'Read ' . $file . PHP_EOL;
    $text .= file_get_contents(dirname(__FILE__) . '/' . $file);
}
 
echo 'Write ' . $target_file . PHP_EOL;
file_put_contents(dirname(__FILE__) . '/' . $target_file, $text);
 
echo 'End' . PHP_EOL;

Но что произойдёт, если какой-нибудь файл не найдётся или будет защищён от записи? Вылетит непоправимая ошибка доступа к файлу и скрипт остановится. Для корректной обработки ошибок мы могли бы заключить процессы чтения и записи файлов в блок try...catch:

try {
    file_put_contents($target_file, $text);
    echo 'Write ' . $target_file . PHP_EOL;
catch (Exception $e) {
    echo 'Error: ' $e->getMessage() . PHP_EOL;
}

Но коварство функций file_get_contents и file_put_contents кроется в том, что они не генерируют стандартное перехватываемое исключение Exception, а возвращяют ошибку уровня ядра. Этот парадокс грозит нам тем, что блок try...catch для этих функций не сработает. Но мир не без добрых людей, поэтому мы можем «свистнуть» решение со StackOverflow и использовать call_user_func_array() для их вызова.

Нам также нужно будет получать полные пути файлов относительно папки protected.

На основе этих требований напишем небольшой драйвер:

interface Driver
{
    public function load($source);
    public function save($target, $content);
}
 
class FileDriver implements Driver
{
    private $path = '';
 
    public function __construct() {
         $this->path = Yii::getPathOfAlias('application');
    }
 
    public function load($file) {
         $path = $this->getFullPath($file);
         $contents = @call_user_func_array('file_get_contents', array($path));
         if ($contents === false) {
                throw new Exception('Failed to open ' . $file);
         } else {
                return $contents;
         }
    }
 
    public function save($file, $text) {
         $path = $this->getFullPath($file);
         $contents = @call_user_func_array('file_put_contents', array($path, $text));
         if ($contents === false) {
                throw new Exception('Failed to write to ' . $file);
         }
    }
 
    private function getFullPath($filename) {
         $file = $this->path . '/' . $filename;
         return preg_replace('#[/\\\\]#', DIRECTORY_SEPARATOR, $file);
    }
}

Мы побороли проблему с необрабатываемыми ошибками и сымитировали возврат нормальных исключений.

Следующий элемент более важен. Это будут сами минимизаторы CSS и JavaScript кода:

interface TextProcessor
{
    public function process($text);
}
 
class CSSProcessor implements TextProcessor
{
    public function process($text) {
        // Меняем переносы, группы пробелов и табуляцию на один пробел
        $text = preg_replace('#[\s\t\r\n]+#s', ' ', $text);
        // Удаляем комментарии /* ... */
        $text = preg_replace('#/\*.*?\*/\s*#s', '', $text);
        // Ставим перенос после закрывающей фигурной скобки
        $text = preg_replace('#\}\s*#s', "}\n", $text);
        // Чистим переносы
        $text = preg_replace('#\n+#s', "\n", $text);
        return $text;
    }
}
 
class JSProcessor implements TextProcessor
{
    public function process($text) {
        $text = preg_replace('#[\r\n]+#s', "\n", $text);
        $text = preg_replace('#\t\n+#s', "\n", $text);
        $text = preg_replace('#\n+#s', "\n", $text);
        return $text;
    }
}

В них и производится вся «магия» удаления лишних пробелов, комментариев и переносов.

Далее вместо использования echo или sprintf для вывода сообщений на экран хотелось бы завести что-то более серьёзное. Какой вывод данных мы хотели бы сделать? Можно придумать вот так:

interface Logger
{
    public function writeln($message = '');
    public function write($message);
    public function writelnSuccess($message = 'OK');
    public function writeSuccess($message = 'OK');
    public function writelnError($message = 'FAIL');
    public function writeError($message = 'FAIL');
    public function writelnNotice($message);
    public function writeNotice($message);
}

Теперь нужно сделать класс, реализующий этот интерфейс. Просто добавим стандартный вывод:

class ConsoleLogger implements Logger 
{
    ....
 
    public function writelnSuccess($message = 'OK') {
        $this->writeSuccess($message);
        echo PHP_EOL;
    }
 
    public function writeSuccess($message = 'OK') {
        echo '[' . $message . '] ';
    }
 
    // ... и так далее
}

Теперь остался лишь наш обработчик, который бы ходил по спискам файлов $sources, минимизировал их содержимое и записывал результат в целевой файл $target:

class Handler
{
    /** @var array */
    private $sources = array();
    /** @var string */
    private $target = '';
    /** @var Driver */
    private $driver;
    /** @var Logger */
    private $log;
 
    public function __construct($sources, $target, Driver $driver, Logger $log)
    {
        $this->sources = $sources;
        $this->target = $target;
        $this->driver = $driver;
        $this->log = $log;
    }
 
    public function processWith(TextProcessor $processor) {
        $success = true;
        try {
            $this->log->writelnNotice('[ ' . get_class($this->processor) . ': ' . $this->target . ' ]');
            $result = '';
            foreach ($this->sources as $source) {
                $this->log->write('Process ' . $source);
                $text = $this->driver->load($source);
                if (strpos($source, '.min.') === false) {
                    $text = $processor->process($text);
                }
                $result .= $text . PHP_EOL;
                $this->log->writelnSuccess();
            }
            $this->log->write('Building ' . $this->target);
            $this->driver->save($this->target, $result);
            $this->log->writelnSuccess('COMPLETED');
        } catch (Exception $e) {
            $this->log->writelnError($e->getMessage());
            $this->log->write('Building ' . $this->target);
            $this->log->writelnError('ABORTED');
            $success = false;
        }
        return $success;
    }
}

Обратим внимание на то, что добавив проверку strpos($source, '.min.') мы не производим повторную обработку уже минимизированных файлов и записываем их в результирующий файл как есть.

Теперь для сжатия CSS нам необходимо:

  1. Создать экземпляр класса Handler, указав ему имена файлов и передав экземпляры FileDriver и ConsoleLogger;
  2. Создать экземпляр нашего преобразователя CSSProcessor;
  3. Запустить обработку с помощью этого преобразователя processWith($processor).

Аналогично будем обрабатывать JavaScript.

Сделаем саму команду protected/MinimizeCommand.php, которая будет это делать:

class MinimizeCommand extends CConsoleCommand
{
    private $driver;
    private $logger;
 
    public function init() {
        $this->driver = new FileDriver();
        $this->logger = new ConsoleLogger();
        parent::init();
    }
 
    public function actionCss() {
        return $this->compileStyles() ? 0 : 1;
    }
 
    public function actionJs() {
        return $this->compileScripts() ? 0 : 1;
    }
 
    public function actionAll() {
        $styles = $this->compileStyles();
        $scripts = $this->compileScripts();
        return $styles && $scripts ? 0 : 1;
    }
 
    private function compileStyles() {
        $success = true;
        $processor = new CSSProcessor();
        foreach (Yii::app()->params['minimize_styles'] as $target=>$sources) {
            $handler = new Handler($sources, $target, $this->driver, $this->logger);
            if (!$handler->processWith($processor)) {
                $success = false;
            }
        }
        return $success;
    }
 
    private function compileScripts() {
        $success = true;
        $processor = new JSProcessor();
        foreach (Yii::app()->params['minimize_scripts'] as $target=>$sources) {
            $handler = new Handler($sources, $target, $this->driver, $this->logger);
            if (!$handler->processWith($processor)) {
                $success = false;
            }
        }
        return $success;
    }
}

И зададим списки стилей и скриптов в конфигурационном файле:

return array(
 
    ...
 
    'params'=>array(
 
        'minimize_styles' => array(
 
            '../css/all.css' => array(
                '../css/system.css',
                '../css/form.css',
            ),
 
            '../themes/classic/css/all.css' => array(
                '../themes/classic/css/layout.css',
                '../themes/classic/css/typography.css',
                '../themes/classic/css/custom.css',
            ),
 
        ),
 
        'minimize_script' => array(
 
            '../themes/classic/js/all.js' => array(
                '../js/system.js',
                '../themes/classic/js/jquery.plugins.js',
                '../themes/classic/js/main.js',
                '../themes/classic/js/shop.js',
            ),
 
        ),
    ),
);

Обратите внимание, что если в проекте используются темы оформления, то в CSS-стилях необходимо следить за корректностью относительных ссылок на фоновые изображения. Именно поэтому мы раздельно собрали две группы CSS файлов.

Что удивительно, мы сделали класс обработчика Handler таким, что ему даже не известно, чем он занимается и какой преобразователь ему передали. Всё, что он делает – пропускает контент файлов через метод process($text) пришедшего к нему объекта.

Дом с игрушками

Если внутри объекта есть какой-нибудь массив и нужно пройтись по нему, то можно столкнуться с некими трудностями.

Предположим, что у нас имеется класс нашего дома с коллекцией игрушек. Но мы их жёстко инкапсулировали модификатором private и не хотим никому отдавать. Мы можем без проблем сказать, сколько их у нас есть:

class Toy
{
    public $weight = 0;
    public $price = 0;
 
    public function __construct($weight, $price) {
        $this->weight = $weight;
        $this->price = $price;
    }
}
 
class Home
{
    private $_toys = array();
 
    public function __construct() {
        $count = rand(1, 10);
        for ($i = 0; $i < $count, $i++) {
            $this->_toys[] = new Toy(rand(100, 1000), rand(50, 2000));
        }
    }
 
    public function getCount() {
        return count($this->_toys);
    }
}

А вот показывать не хотим. Игрушки скрыты от всех ключом нашей двери private, поэтому если кто-то захочет их потрогать или взвесить, то для каждой операции нам придётся добавлять соответствующие методы getTotalWeight и getTotalPrice в сам класс:

class Home
{
    ...
 
    public function getCount() {
        return count($this->_toys);
    }
 
    public function getTotalWeight() {
        $weight = 0;
        foreach ($this->_toys as &$toy) {
          $weight += $toy->weight;
        }
    }
 
    public function getTotalPrice() {
        $price = 0;
        foreach ($this->_toys as &$toy) {
          $price += $toy->price;
        }
    }
 
    ...
}

А если мы не знаем число необходимых операций? Может прийти санэпидстанция для проверки, может посетить сосед и попросить поиграться с каждой... Мало ли что... Такое бывает, например, когда новые операции добавляются в какое-либо приложение с помощью плагинов. Для этого либо нам придётся открывать игрушки для публичного доступа (нарушать инкапсуляцию), либо соседу использовать метапрограммирование, магией убирая стены или приходя когда мы спим (динамически добавлять к классам новые методы или меняя private на public), что не очень честно.

Какой же выход? Добавим в класс своего дома всего один метод visitBy($visitor) для возможности приглашения гостей на свою территорию:

class Home
{
    private $_toys = array();
 
    public function __construct() {
        $count = rand(1, 10);
        for ($i = 0; $i < $count, $i++) {
            $this->_toys[] = new Toy(rand(100, 1000), rand(50, 2000));
        }
    }
 
    public function getCount() {
        return count($this->_toys);
    }
 
    public function visitBy(Visitor $visitor) {
        foreach ($this->toys as $toy) {
            $visitor->process($toy);
        }
    }
}

Теперь при посещении нас гостем мы будем сами показывать свои игрушки и давать их «потрогать» своему посетителю под нашим строгим присмотром.

Посетители бывают разные:

abstract class Visitor
{
    abstract public function process(Toy $toy);
}
 
class WeightVisitor extends Visitor
{
    private $_weight = 0;
 
    public function process(Toy $toy) {
        $this->_weight += $toy->weight;
    }
 
    public function getTotalWeight() {
        return $this->_weight;
    }
}
 
class PriceVisitor extends Visitor
{
    private $_price = 0;
 
    public function process(Toy $toy) {
        $this->_price += $toy->price;
    }
 
    public function getTotalPrice() {
        return $this->_price;
    }
}
 
class AgeVisitor extends Visitor
{
    public function process(Toy $toy) {
        $toy->price -= 10;
    }
}
 
class FriendVisitor extends Visitor
{
    public function process(Toy $toy) {
        if (rand(0, 3) == 1) {
            unset($toy);
        }
    }
}

И они могут посещать нас по-очереди:

$home = new Home();
 
// посчитаем массу игрушек в доме
$visitor = new WeightVisitor();
$home->visitBy($visitor);
echo $visitor->getTotalWeight();
 
// посчитаем цену
$visitor = new PriceVisitor();
$home->visitBy($visitor);
echo $visitor->getTotalPrice();
 
// время жестоко
$visitor = new AgeVisitor();
$home->visitBy($visitor);
 
// проверим свежую цену
$visitor = new PriceVisitor();
$home->visitBy($visitor);
echo $visitor->getTotalPrice();
 
// проведём инвентаризацию
echo $home->getCount();
 
// пришёл друг
$visitor = new FriendVisitor();
$home->visitBy($visitor);
 
// не все друзья одинаково полезны...
echo $home->getCount();

Вот и всё. Проблема доступа к внутренним приватным данным снаружи решена.

Фактически наши преобразователи также являются посетителями обработчика, приходящими внутрь при вызове метода. Такой шаблон проектирования и называется «Посетитель« (Visitor).

Многократное использование

Сейчас работа с нашим обработчиком происходит следующим образом:

$driver = new FileDriver();
$logger = new ConsoleLogger();
 
$processor = new CSSProcessor();
foreach (Yii::app()->params['minimize_styles'] as $target=>$sources) {
    $handler = new Handler($sources, $target, $driver, $logger);
    $handler->processWith($processor);
}
 
$processor = new JSProcessor();
foreach (Yii::app()->params['minimize_scripts'] as $target=>$sources) {
    $handler = new Handler($sources, $target, $driver, $logger);
    $handler->processWith($processor);
}

Компонент Handler получился «одноразовым», так как имена файлов передаются в него через конструктор и для каждой группы файлов нам нужно создавать новый объект. При этом $driver и $log неизменны. Видоизменим класс так, чтобы неизменные параметры передавались через конструктор, а имена файлов можно было бы устанавливать через соответствующие сеттеры:

class Handler
{
    ...
 
    public function __construct(Driver $driver, Logger $log) {
        $this->driver = $driver;
        $this->log = $log;
    }
 
    public function setSources(array $sources) {
        $this->sources = $sources;
    }
 
    public function setTarget($target) {
        $this->target = $target;
    }
 
    ...
}

Теперь у нас нет необходимости создавать для каждой задачи новый экземпляр. Мы можем создать всего один объект и использовать его повторно:

$handler = new Handler(new FileDriver(), new ConsoleLogger());
 
$processor = new CSSProcessor();
foreach (Yii::app()->params['minimize_styles'] as $target=>$sources) {
    $handler->setSources($sources);
    $handler->setTarget($target);
    $handler->processWith($processor);
}
 
$processor = new JSProcessor();
foreach (Yii::app()->params['minimize_scripts'] as $target=>$sources) {
    $handler->setSources($sources);
    $handler->setTarget($target);
    $handler->processWith($processor);
}

Это реализуется теперь тем, что мы отделили неизменную инфраструктуру от изменяемых параметров.

В нашем примере выигрыш от такой экономии не очень существенен, так как класс Handler достаточно мал. Но бывают ситуации, когда в конструкторе класса выполняется много сложной работы (например, происходит подключение к базе данных). В этих случаях гораздо эффективнее многократно использовать один и тот же готовый экземпляр.

Человекопонятный язык

В разобранном примере мы по шагам заполняем объект нужными данными и запускаем результирующий процесс. Похожий пошаговый подход позволяет, например, собирать SQL-запросы, модифицировать изображения:

$posts = Yii::app()->db->createCommand()->select('*')->from('{{post}}')->where('public = 1')->queryAll();
Yii::app()->imageHandler->load($original)->watermark($watermark)->thumb(200, 200)->save($thumb);

...генерировать документы разных форматов.

Сравните по лёгкости восприятия эти красивые предложения со словами load, thumb, watermark и save с нашими не очень понятными командами setSources и setTarget. Какие то у нас источники и цели...

Никто нам не мешает сделать также красиво. Нужно просто переименовать сеттеры и добавить в них return $this для возможности вызывать методы друг за другом по цепочке в одну строку:

class Handler
{
    /** @var array */
    private $sources = array();
    /** @var string */
    private $target = '';
    /** @var \Driver */
    private $driver;
    /** @var \Logger */
    private $log;
    /** @var \TextProcessor */
    private $processor;
 
    public function __construct(Driver $driver, Logger $log) {
        $this->driver = $driver;
        $this->log = $log;
    }
 
    public function from(array $sources) {
        $this->sources = $sources;
        return $this;
    }
 
    public function to($target) {
        $this->target = $target;
        return $this;
    }
 
    public function with(TextProcessor $processor) {
        $this->processor = $processor;
        return $this;
    }
 
    public function process() {
        $this->checkOptions();
        $success = true;
        try {
            $this->log->writelnNotice('[ ' . get_class($this->processor) . ': ' . $this->target . ' ]');
            $result = '';
            foreach ($this->sources as $source) {
                $this->log->write('Process ' . $source);
                $text = $this->driver->load($source);
                if (strpos($source, '.min.') === false) {
                    $text = $this->processor->process($text);
                }
                $result .= $text . PHP_EOL;
                $this->log->writelnSuccess();
            }
            $this->log->write('Building ' . $this->target);
            $this->driver->save($this->target, $result);
            $this->log->writelnSuccess('COMPLETED');
        } catch (Exception $e) {
            $this->log->writelnError($e->getMessage());
            $this->log->write('Building ' . $this->target);
            $this->log->writelnError('ABORTED');
            $success = false;
        }
        return $success;
    }
 
    private function checkOptions() {
        if (!is_array($this->sources))
            throw new CException('Sources are not valid');
        if (empty($this->target))
            throw new CException('Target is empty');
        if ($this->processor === null)
            throw new CException('Processor is empty');
    }
}

Теперь изменим класс нашей команды MinimizeCommand:

class MinimizeCommand extends CConsoleCommand
{
    /** @var Handler */
    private $handler;
 
    public function init() {
        $this->handler = new Handler(new FileDriver(), new ConsoleLogger());
    }
 
    public function actionCss() {
        return $this->compileStyles() ? 0 : 1;
    }
 
    public function actionJs() {
        return $this->compileScripts() ? 0 : 1;
    }
 
    public function actionAll() {
        $styles = $this->compileStyles();
        $scripts = $this->compileScripts();
        return $styles && $scripts ? 0 : 1;
    }
 
    private function compileStyles() {
        $success = true;
        $processor = new CSSProcessor();
        foreach (Yii::app()->params['minimize_styles'] as $target=>$sources) {
            $success = $this->handler->from($sources)->with($processor)->to($target)->process() && $success;
        }
        return $success;
    }
 
    private function compileScripts() {
        $success = true;
        $processor = new JSProcessor();
        foreach (Yii::app()->params['minimize_scripts'] as $target=>$sources) {
            $success = $this->handler->from($sources)->with($processor)->to($target)->process() && $success;
        }
        return $success;
    }
}

Вот и всё. Теперь и мы научились программировать на «человеческом» языке:

$handler->from($sources)->with($processor)->to($target)->process();

При использовании многошагового построителя нужно внимательно следить за корректностью заполнения, ведь программист может случайно пропустить любой из шагов или выполнить их не в той последовательности, которая предполагается. В этих случаях нужно либо использовать значения по умолчанию для пропущенных шагов, либо сообщать об ошибках при любых нестандартных ситуациях. Для этих целей в классе Handler реализован метод checkOptions().

Теперь достаточно зайти в папку protected и запустить нашу команду на выполнение:

php yiic.php minimize all

и увидеть на экране результат:

[ CSSProcessor: ../css/all.css ]
Process ../css/system.css [OK]
Process ../css/form.css [OK]
Building ../css/all.css [COMPLETED]
[ CSSProcessor: ../themes/classic/css/all.css ]
Process ../themes/classic/css/layout.css [OK]
Process ../themes/classic/css/typography.css [OK]
Process ../themes/classic/css/custom.css [OK]
Building ../themes/classic/css/all.css [COMPLETED]
[ JSProcessor: ../themes/classic/js/all.js ]
Process ../js/system.js [OK]
Process ../themes/classic/js/jquery.plugins.js [OK]
Process ../themes/classic/js/main.js [OK]
Process ../themes/classic/js/shop.js [Failed to open ../themes/classic/js/shop.js]
Building ../themes/classic/js/all.js [ABORTED]

Система выведет отчёт и сообщит о всех успехах и, возможно, об ошибках выполнения.

Конечно же мы могли сделать всю обработку, вывод сообщений и открытие фалов внутри всего одного класса консольной команды и не придумывать столько классов. Но у текущей структуры есть свои достоинства. Посмотрите на каждый сопутствующий класс и класс команды. Если бы мы сделали всё внутри MinimizeCommand, то это так и осталось бы работать только в нашем проекте на Yii. Но сейчас только MinimizeCommand имеет специфический для Yii код (а именно считывает настройки из Yii::app()->params и с ними запускает нужные операции). Остальные классы абсолютно независимые и, следовательно, кросплатформенные. Их можно без изменений использовать в любом сайте на Zend Framework, Symfony, Wordpress, Joomla и любой другой системе.

Версионирование URL-адреса

Как мы уже говорили, если подключить наш сгенерированный файл стилей как есть:

<link rel="stylesheet" href="<?php Yii::app()->theme->baseUrl; ?>/css/all.css" />

то браузер может его надолго закэшировать и не будет обновлять когда это нам необходимо. Чтобы принудительно заставить браузеры загружать новый файл нам необходимо менять его адрес. В нашем случае это можно сделать втоматически. Достаточно просто добавить в GET-параметр в качестве номера версии время модификации файла:

<link rel="stylesheet" href="<?php Yii::app()->request->baseUrl; ?>/css/all.css?v=<?php echo @filemtime(Yii::getPathOfAlias('webroot') . '/css/all.css'); ?>" />
<link rel="stylesheet" href="<?php Yii::app()->theme->baseUrl; ?>/css/all.css?v=<?php echo @filemtime(Yii::getPathOfAlias('webroot.themes.classic') . '/css/all.css'); ?>" />

Эта конструкция сгененрирует нам следующий HTML-код:

<link rel="stylesheet" href="/css/all.css?v=1382363642" />
<link rel="stylesheet" href="/themes/classic/css/all.css?v=1382363642" />

Теперь при каждой перегенерации все адреса будут меняться в соответствие со вренем изменения файлов.

Всё бы хорошо, но некоторые браузеры принципиально не кэшируют адреса, содержащие GET-параметры. Большой файл all.css будет в них загружаться вновь и вновь при каждом переходе на новую страницу.

Этого можно избежать небольшим изиенением вида адреса:

<link rel="stylesheet" href="<?php echo Yii::app()->request->baseUrl ?>/css/all-<?php echo @filemtime(Yii::getPathOfAlias('webroot') . '/css/all.css'); ?>.css" />
<link rel="stylesheet" href="<?php echo Yii::app()->theme->baseUrl ?>/js/all-<?php echo @filemtime(Yii::getPathOfAlias('webroot.themes.classic') . '/css/all.css'); ?>.css" />

Теперь вместо реальных файлов будет происходить обращение по псевдоадресам:

<link rel="stylesheet" href="/css/all-1382363642.css" />
<link rel="stylesheet" href="/themes/classic/css/all-1382363642.css" />

Таких файлов у нас нет, поэтому необходимо сделать скрытое перенаправление в конфигурационном файле .htaccess:

RewriteRule ^(.+)/all-[0-9]+\.css /$1/all.css [L,QSA]
RewriteRule ^(.+)/all-[0-9]+\.js /$1/all.js [L,QSA]

или в настройках виртуального хоста сервера Nginx:

location ~ ^(.+)/all-[0-9]+\.css$ {
    rewrite ^(.+)/all-[0-9]+\.css$ $1/all.css last;
}

location ~ ^(.+)/all-[0-9]+\.js$ {
    rewrite ^(.+)/all-[0-9]+\.js$ $1/all.js last;
}

Теперь все браузеры будут воспринимать наши файлы как настоящие и корректно их кэшировать.

Эстетический бонус

При запуске нашей команды yiic minimize all на экран будет выведен отчёт её работы. Всё сейчас выведено однородным малоинформативным стилем. Но возможно легко доработать ConsoleLogger, чтобы пользователям консоли Linux и терминала Cygwin было приятнее:

[ CSSProcessor: ../css/all.css ]
Process ../css/system.css [OK]
Process ../css/form.css [OK]
Building ../css/all.css [COMPLETED]
[ CSSProcessor: ../themes/classic/css/all.css ]
Process ../themes/classic/css/layout.css [OK]
Process ../themes/classic/css/typography.css [OK]
Process ../themes/classic/css/custom.css [OK]
Building ../themes/classic/css/all.css [COMPLETED]
[ JSProcessor: ../themes/classic/js/all.js ]
Process ../js/system.js [OK]
Process ../themes/classic/js/jquery.plugins.js [OK]
Process ../themes/classic/js/main.js [OK]
Process ../themes/classic/js/shop.js [Failed to open ../themes/classic/js/shop.js]
Building ../themes/classic/js/all.js [ABORTED]

Это сделать несложно. Tакой класс можно будет использовать в любой системе на PHP.

Готовый класс как плюшку готов отдать в хорошие руки.

Некоторые вещи для меня слишком ценны, поэтому делюсь ими с постоянными читателями.

В следующий раз мы поговорим о миграциях базы данных.

Другие статьи

Вот и прошёл недавно первый год второй жизни блога. В то время, пока вы делитесь эмоциями в комментариях ко вчерашней статье о миграциях, пора мне выйти из тени и поделиться парой новостей. Итак, если у вас есть какие-нибудь предложения к развитию блога в будущем году, если у вас уже праздничное настроение, то прошу пожаловать под кат.

Три программиста пишут код проекта. И с кодом у них нет проблем. Есть три рабочих машины, центральный репозиторий и главный рабочий сервер, берущий файлы из этого же репозитория. Работа кипит. у каждого на своём компьютере установлен PhpMyAdmin, что им позволяет время от времени вносить изменения в свою базу данных.

Для решения специфических задач часто используются готовые консольные команды. Но намного интереснее не только использовать чужие, но и уметь создавать свои. Это поможет легко автоматизировать любую рутинную работу, на которую обычно тратится довольно много времени. Многие фреймворки имеют встроенные инструменты для написания не только самих веб-приложений, но и для создания инфраструктуры пользовательских консольных команд.

В этот знаменательный день, 15-е ноября, автор данного сайта справляет очень хороший праздник. В общем, у меня отличное настроение и хочется всем дарить подарки. В связи с данным событием на эти два дня объявляю маленький конкурс. Присоединяйтесь!

Комментарии

 

XAKEPEHOK

А какие браузеры не кэшируют css-стили с параметрами?

Ответить

 

Дмитрий Елисеев

Вот в первой попавшейся статье упомянуто про браузеры некоторых версий и большинство прокси-серверов. И у кого-то на Хабре встречал.

Ответить

 

script

Спсибо. Как всегда то что надо.
Вообще у вас наверняка лучший Yii-ориентированный блог в рунете.

Ответить

 

TyVik

А неужели нет никаких стандартных средств, которые можно поставить через composer? Не хотелось бы городить велосипеды, когда есть проверенные инструменты.

Ответить

 

Дмитрий Елисеев

Наверняка есть. Можете посмотреть некоторые по ссылке в первом пункте. Только всё равно придётся делать либо привязку к конфигурации проекта (для PHP-решения), либо составлять небольшой bash-скрипт, запускающий минимизатор с указанием списка файлов.

Ответить

 

Виктор Тыщенко

В общем, их много даже под Yii.

Ответить

 

Дмитрий Елисеев

Кстати да. Но, как вижу, не консольные, а подменяющие CClientScript. Даже два с минимизатором на Java :)

Ответить

 

Роман

Дмитрий, спасибо огромное за материал!
Одна маа-а-ленькая очепятка: все-таки, наверное, не SAAS, а Sass имелся ввиду=)
Удачи Вам и еще больше качественного контента!

Ответить

Оставить комментарий

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>