Статьи Архив статей

Автор: Мациевский Николай aka sunnybear
Опубликована: 27 июня 2009

Автоматическое объединение текстовых файлов

На тему автоматической «склейки» стилей и скриптов написано уже довольно много статей, но нигде не было описано полное решение, учитывающее подводные камни, связанные с браузерами, и различными способами использования указанных файлов. Ниже я хочу рассмотреть то практическое решение, которое реализовано в Web Optimizer и обкатано уже на нескольких сотнях сайтов.

Объединение CSS-файлов

Несмотря на более простой и поистине академический синтаксис CSS-файлы довольно сложно объединять в силу разных причин. Тут и различные атрибуты media (указывающие на устройства, для которых предназначен данный файл), и возможность сделать «вложенную» загрузку стилей при помощи @import и т. д. Для начала рассмотрим процесс получения ссылок и содержимого самих файлов из исходной структуры веб-страницы.

Получаем код

Если в CMS у нас предусмотрена возможность вставки CSS-файла как отдельного объекта в head-страницы, то это ограждает от множества проблем по «вычленению» этих объектов из готового HTML-кода. В противном случае нам придется использовать примерно следующий вариант:

/* регулярное выражение для нахождения всех <link rel="stylesheet"> и <style type="text/css"> внутри head-секции */
$regex = "!(<link[^>]+rel\\s*=\\s*(\"stylesheet\"|'stylesheet'|stylesheet)([^>]*)>|<style\\s+type\\s*=\\s*(\"text/css\"|'text/css'|text/css)([^>]*)>(.*?)</style>)!is";
preg_match_all($regex, $this->head, $matches, PREG_SET_ORDER);
if (!empty($matches)) {
    foreach($matches as $match) {
	$file = array();
	$file['tag'] = 'link';
	$file['source'] = $match[0];
/* вырезаем из найденного куска HTML-кода обрамляющие теги, чтобы идентифицировать внутренние стилевые правила */
	$file['content'] = preg_replace("/(<link[^>]+>|<style[^>]*>[\t\s\r\n]*|[\t\s\r\n]*<\/style>)/i", "", $match[0]);
/* определяем все дополнительные атрибуты */
	preg_match_all("@(type|rel|media|href)\s*=\s*(?:\"([^\"]+)\"|'([^']+)'|([\s]+))@i", $match[0], $variants, PREG_SET_ORDER);
	if(is_array($variants)) {
	    foreach($variants AS $variant_type) {
		$variant_type[1] = strtolower($variant_type[1]);
		$variant_type[2] = !isset($variant_type[2]) ? (!isset($variant_type[3]) ? $variant_type[4] : $variant_type[3]) : $variant_type[2];
		switch ($variant_type[1]) {
/* выставляем источник для файла стилей */
		    case "href":
			$file['file'] = trim($this->strip_querystring($variant_type[2]));
			$file['file_raw'] = $variant_type[2];
			break;
		    default:
/* пропускаем media="all|screen" для предотвращения некорректного поведения Safari при @media all{} или @media screen{} */
			if ($variant_type[1] != 'media' || ($variant_type[1] == 'media' && !preg_match("/all|screen/i", $variant_type[2]))) {
			    $file[$variant_type[1]] = $variant_type[2];
			}
			break;
		}
	    }
	}
	$this->initial_files[] = $file;
    }
}

Подавая на вход данного алгоритма код head-секции нашего документа ($this->head), на выходе мы получаем готовый массив $this->initial_files. Стоит сразу отметить, что в массиве для файлов стилей атрибуте media не выставляется, если он равен all (в этом случае он просто бесполезен) либо screen (по умолчанию у нас все стилевые правила применяются для отображения сайтов на мониторах, поэтому данное значение также можно безболезненно опустить).

Разбираем вложенность

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

function resolve_css_imports($src) {
    $content = file_get_contents($src);
/* удаляем из первоначального содержимого @import внутри комментариев */
    $content = preg_replace("!/\*\s*@import.*?\*/!is", "", $content);
/* выбираем все @import */
    preg_match_all('/@import\\s*(url)?\\s*\\(?([^;]+?)\\)?;/i', $content, $imports, PREG_SET_ORDER);
    if (is_array($imports)) {
	foreach ($imports as $import) {
	    $src = false;
/* очищаем найденный путь к файлу от пробелов и кавычек */
	    if (isset($import[2])) {
		$src = $import[2];
		$src = trim($src, '\'" ');
	    }
	    if ($src) {
/* запускаем рекурсию для обнаруженного файла, чтобы разрешить все @import уже внутри него */
		$content = str_replace($import[0], $this->resolve_css_imports($src), $content);
/* изменяем все пути для CSS-изображений и ресурсов (относительно заданного файла) на абсолютные (относительн корня документа) */
		$content = $this->resolve_relative_paths($src, $content);
	    }
	}
    }
    return $content;
}

Задав полный путь к файлу стилей для функции resolve_css_imports мы полностью разрешим все внутренние включения, чем сведем число HTTP-Запросов к минимуму.

Объединяем

После того, как мы разобрались с массивом файлов и научились получать полное их содержимое, нам нужно корректно их объединить. Как уже описывалось в книге "Разгони свой сайт" для этого лучше всего применять конструкцию @media. Предположим, что в результирующем массиве у нас объект имеет следующий формат:

$this->initial_files = array(
    array(
	'content' => 'полное содержимое файла',
	'media' => 'print|handheld|etc',
	'file_raw' => 'исходный код файла в head-секции'
    ),
    ...
)

Тогда нам нужно просто объединить весь CSS-код в соответствие со спецификацией:

foreach ($this->initial_files as $file) {
    if (!empty($file['media'])) {
	$full_content .= '@media '. $file['media'] . '{';
    }
    $full_content .= $file['content'];
    if (!empty($file['media'])) {
	$full_content .= '}';
    }
}

На выходе мы получим весь CSS-код, обнаруженный внутри head-секции, объединенный в одну строку, которую можно записать в один кэшированный файл. Далее, используя свойство file_raw, удалить исходные файлы и внутренний код из документа и вставить (например, сразу же после <head>) вызов этого кэшированного файла.

Минимизируем

А что, если мы хотим не только объединить файлы, но и уменьшить их в размере? Gzip-компрессию здесь рассматривать не будем: она достаточно тривиальна в реализации (и может сводиться к нескольким правилам в конфигурационном файле сервера). Нам более интересен вопрос про уменьшение CSS-кода в соответствии с CSS-спецификацией. Здесь разумнее всего воспользоваться один из трех путей:

  • Набор простых регулярных выражений. Он был описан еще в книге "Разгони свой сайт". Ниже приведен его код на Perl
    $data =~ s!\/\*(.*?)\*\/!!g;	# удаляем комментарии
    $data =~ s!\s+! !g;      	# сжимаем пробелы
    $data =~ s!\} !}\n!g;		# добавляем переводы строки
    $data =~ s!\n$!!;		# удаляем последний перевод строки
    $data =~ s! \{ ! {!g;		# удаляем лишние пробелы внутри скобок
    $data =~ s!; \}!}!g;		# удаляем лишние пробелы и синтаксис
    				# внутри скобок
  • CSS Tidy. наиболее мощная библиотека для разбора CSS-правил. Для ее использования необходимо загрузить ее в папку проекта, внести изменения в настройки по умолчанию (находятся в файле class.csstidy.php) и осуществить минимизацию простыми вызовами:
    $css = new csstidy();
    $css->load_template($root_dir . 'css.template.tpl');
    $css->parse($css_code);
    echo $css->print->formatted();
    При этом для максимального сжатия лучше использовать следующий шаблон (css.template.tpl):
    |{||{|||;|}||}||{||
  • YUI Compressor. Эта библиотека требует установленной java на сервере и запускается еще проще. Необходимо из командной строки выполнить:
    java -jar yuicompressor.jar -o output.css input.css
    Результат произведенных действий будет сохранен в файле output.css.

Полный код для CSS Tidy и все аспекты практической реализации можно почерпнуть из исходного кода Web Optimizer

Объединение JavaScript-файлов

Для JavaScript-файлов весь описанный механизм повторяется, за исключением небольших деталей.

Получаем код

Во-первых, получать код мы будем уже немного другим методом, и нам будет не существенен атрибут media:

$regex = "!<script[^>]+type\\s*=\\s*(\"text/javascript\"|'text/javascript'|text/javascript)([^>]*)>(.*?</script>)!is";
preg_match_all($regex, $this->head, $matches, PREG_SET_ORDER);
if (!empty($matches)) {
    foreach($matches as $match) {
	$file = array();
	$file['tag'] = 'script';
	$file['source'] = $match[0];
/* вырезаем из найденного куска HTML-кода обрамляющие теги, чтобы идентифицировать внутренние скрипты */
	$file['content'] = preg_replace("/(<script[^>]*>[\t\s\r\n]*|[\t\s\r\n]*<\/script>)/i", "", $match[0]);
	$file['file'] = '';
	preg_match_all("@(type|src)\s*=\s*(?:\"([^\"]+)\"|'([^']+)'|([\s]+))@i", $match[0], $variants, PREG_SET_ORDER);
	if(is_array($variants)) {
	    foreach($variants AS $variant_type) {
		$variant_type[1] = strtolower($variant_type[1]);
		$variant_type[2] = !isset($variant_type[2]) ? (!isset($variant_type[3]) ? $variant_type[4] : $variant_type[3]) : $variant_type[2];
		switch ($variant_type[1]) {
		    case "src":
			$file['file'] = trim($this->strip_querystring($variant_type[2]));
			$file['file_raw'] = $variant_type[2];
			break;
		    default:
			$file[$variant_type[1]] = $variant_type[2];
			break;	
		}
	    }
	}
	$this->initial_files[] = $file;
    }
}

Объединяем

Тут нас ждет еще одно отличие: разные куски JavaScript-кода лучше объединять через точку с запятой с переводом строки. Ибо предыдущая часть кода может не оканчиваться на точку с запятой, потому мы обязаны как-то ее отделить от последующей.

Далее в ходе объединения было установлено, что файлы библиотек для визуального форматирования кода (в силу своей сложности) мало приспособлены к объединению с другими файлами. Поэтому рекомендуется при объединении избегать следующих файлов: tiny_mce.js и fckeditor.js. Во всем остальном механизм абсолютно тот же самый, что и для CSS-файлов (за исключением отсутствия необходимости разрешить @import и необходимости заменять пути для фоновых изображений и ресурсов).

Минимизируем

Для минимизации JavaScript-кода лучше всего использовать уже имеющиеся на рынке решения: JSMin (который портирован в том числе и на PHP) или YUI Compressor. Про последний уже было написано чуть выше (параметры для запуска те же самые). В случае с JSMin все тоже довольно просто: нам нужно загрузить последнюю версию, подключить ее и просто вызвать минимизацию заданного файла:

require 'jsmin-1.1.1.php';
echo JSMin::minify(file_get_contents('example.js'));

Тут стоит только упомянуть, что классический JSMin не поддерживает условную компиляцию для IE. Поэтому тут нужно воспользоваться модифицированным решением (например, из исходников Web Optimizer).

Заключение

Объединение текстовых файлов способно значительно ускорить загрузку вашего сайта, не причиняя вреда качеству разработки (вы можете разрабатывать отдельно, а на сам сайта выкладывать уже готовые версии файлов в автоматическом режиме). Как показывают данные с webo.in на происзольном сайте в Рунете используется 2,7 файлов стилей (средний размер 5,5Кб) и 5 JavaScript-файлов (средний размер ). Просто их объединение позволит выиграеть 0,5-1с при загрузке страницы. А минимизации (вместе с gzip-сжатием, уменьшающим размер на 85%) — еще 60 Кб, что составит 0,6с при скорости подключения 100Кб/с.

Как мы видим, совершенно простые действия способны значительно ускорить загрузку вашего сайта.

Читать дальше

Все комментарии (habrahabr.ru)