Pull to refresh

Автоматизируем клиентскую оптимизацию

Client optimization *

Предыстория

Как известно, перед тем, как выложить сайт в нет, мы его разрабатываем. И делаем мы это, как ни странно, на машине разработчика. И давно замечено, что javascript, а в некоторых случаях и css удобнее при разработке держать в нескольких файлах.Проблема в том, что, согласно принципам, описанным в статье Best Practices for Speeding Up Your Web Site (перевод доступен на сайте webo.in), для ускорения загрузки сайта нам нужно произвести следующие манипуляции над javascript и css файлами:
  1. Слить весь javascript в один файл, причем, желательно так, чтобы сохранился нужный порядок — т.е., скажем, библиотека jQuery — была ближе к началу, а функции и объекты, которые ее используют — после нее.
  2. Слить весь css в один файл
  3. Сжать эти большие файлы с помощью какой-нибудь утилиты вроде yui-compressor (за исключением css-файлов, название которых начинается, скажем, с префикса ie_, которые содержат data:URL, и поэтому критично относятся к переходам со строки на строку, так что их для собственного спокойствия лучше не сжимать)
  4. Расположить их в таком порядке — css-файл как можно ближе к открывающему тэгу head, а js-файл — как можно ближе к закрывающему тэгу body.
  5. Выставить HTTP-заголовок expires на подольше, чтобы браузер пользователя их закешировал. Ну а для того, чтобы при следующем билде у пользователя обновился js и css надо этим файлам дать какое-нибудь уникальное имя.
  6. Перед отдачей файлов клиенту сжимать их с помощью gzip

К чему это я?

Пункты 5 и 6 уже подробно расписаны в других местах.
Я же хочу рассмотреть в этой статье вопрос автоматизации пунктов 1,2,3,4. А точнее, я хочу предложить инструмент, с помощью которого одним (ну, максимум — двумя-тремя :) нажатием кнопки можно выполнить пункты 1, 2, 3, 4 настоящего списка и получить готовые к заливке на сервер javascript и css файлы.

Инструментарий

  • Apache Ant в качестве сборщика. Выбор пал на него за скорость работы, доступность, кроссплатформенность, а также то соображение, что получившийся скрипт легко включить в более общий скрипт выкладывания проекта на production, который будет выполнять какой-нибудь CI-tool, скажем, teamcity.
  • YUI Compressor в качестве утилиты сжатия js и css файлов. Взят за кроссплатформенность, адекватность, хорошую скорость работы
  • JSLint4Java (порт JSLint на java) в качестве валидатора javascript. Мы же не хотим выкладывать нерабочий код на продакшен, верно? Кстати, фраза, написанная на офф. сайте, «JSLint may hurt your feelings» очень даже справедлива :)

Алгоритм работы скрипта

  1. Скачиваем и распаковываем JSLint4Java и YUI Compressor, кладем их в папочку tools внутри проекта. Правильнее, конечно, ложить ее куда-нибудь в место, определенное системной переменной, что-нибудь вроде $TOOLS_LOCATION, но в демонстрационном скрипте и так сойдет, а уж вы для себя поправьте скрипт, как вам нужно.
  2. Натравливаем JSLint4Java на все js-файлы. Если JSLint находит какую-нибудь ошибку, выводим ее на экран и останавливаем выполнение скрипта.
  3. Сливаем все js-файлы, за исключением тех, в имени которых есть фраза test, в один файл с уникальным именем, при этом сохраняем порядок следования файлов, который мы где-нибудь в другом месте определим. В качестве уникального имени давайте возьмем такую конструкцию: main.hh.dd.MM.yy.js, где hh, dd, MM, yy, соответственно, текущие час, день, месяц, год.
  4. Сливаем все css-файлы, за исключением тех, имя которых начинается с ie_ в один файл с уникальным именем (имя такое же, как и в предыдущем пункте, только расширение сменится на `css`). Порядок следования в данном случае не важен.
  5. Натравливаем на получившиеся файлы YUI Compressor. Если при сжатии произошла ошибка, выводим на экран ошибку и останавливаем выполнение скрипта.
  6. В html-темплейте, который подключает все файлы стилей, удаляем все тэги link, кроме тех, в src которых прописаны файлы ie_ и тех, которые содержат правила стилей, а не подключают внешний css-файл при помощи атрибута src.
  7. В том же темплейте удаляем все тэги скрипт, кроме тех, которые содержат javascript-код (а не подключают внешний файл скрипта через атрибут src).
  8. В том же темплейте прописываем получившийся css-файл как можно ближе к открывающему тэгу head.
  9. В том же темплейте прописываем получившийся js-файл как можно ближе к закрывающему тэгу body или открывающему тэгу script получившийся js-файл… Такое странное поведение нужно вот для чего: предположим, что мы в js-файле прописали какие-то библиотечные функции, а прямо в html-файле инициализируем прямо в server-side коде js-объекты какими-то данными. Вот для этого-то нам и нужно сохранить script-тэг, а также подключить получившийся js-файл до него.
  10. Выкладываем получившиеся js, css, html файлы в какую-нибудь директорию.

Пример реализации

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project name="production-build" default="build" basedir=".">
  3.   <!-- место, куда будем складывать свежескачанные yui-compressor и jslint4java -->
  4.   <property name="tools.location" value="tools/"/>
  5.   <!-- какую версию yui compressor'а и откуда качать, а также, какое имя будет у получившегося jar-файла -->
  6.   <property name="yuicompressor-version" value="2.4"/>
  7.   <property name="yuicompressor-zip" value="yuicompressor-${yuicompressor-version}.zip"/>
  8.   <property name="yuicompressor-unzip-dir" value="yuicompressor-${yuicompressor-version}"/>
  9.   <property name="yuicompressor-location" value="http://www.julienlecomte.net/yuicompressor/"/>
  10.   <property name="yuicompressor-jar" value="yuicompressor-${yuicompressor-version}.jar"/>
  11.   <!-- какую версию jslint4java и откуда будем качать, а также, какое имя будет у получившегося jar-файла -->
  12.   <property name="jslint-version" value="1.2.1"/>
  13.   <property name="jslint-location" value="http://jslint4java.googlecode.com/files/"/>
  14.   <property name="jslint-zip" value="jslint4java-${jslint-version}.zip"/>
  15.   <property name="jslint-jar" value="jslint4java-${jslint-version}+rhino.jar"/>
  16.   <property name="jslint-unzip-dir" value="jslint4java-${jslint-version}"/>
  17.   <!-- откуда мы будем брать js-файлы, css-файлы, html-темплейт -->
  18.   <property environment="env"/>
  19.   <property name="js.src" value="js/"/>
  20.   <property name="css.src" value="css/"/>
  21.   <property name="template.name" value="index.html"/>
  22.   <property name="template" value="${template.name}"/>
  23.  
  24.  
  25.   <!-- и куда мы будем их все складывать -->
  26.   <property name="output.dir" value="build/"/>
  27.   <property name="js.out" value="${output.dir}/js/"/>
  28.   <property name="css.out" value="${output.dir}/css/"/>
  29.   <property name="template.out" value="${output.dir}/${template.name}"/>
  30.  
  31.   <!-- порядок конкатенации js-файлов. Указанные файлы будут расположены в начале общего js-файла в указанном порядке -->
  32.   <!-- все оставшиеся файлы будут присоединены в конец файла -->
  33.   <property name="js-required-file-order" value="jquery-1.2.6.js, some_object.js"/>
  34.  
  35.   <!-- эта задача всегда выполнится первой -->
  36.   <target name="init">
  37.     <tstamp>
  38.       <!-- запомним в качестве переменной текущее время в формате mm-hh-MM-dd-yyyy -->
  39.       <format property="build-time" pattern="mm-hh-MM-dd-yyyy"/>
  40.     </tstamp>
  41.     <!-- создаем директорию, содержащую yui compressor и jslint4java -->
  42.     <mkdir dir="${tools.location}"/>
  43.   </target>
  44.  
  45.   <target name="prepare-tools" depends="init">
  46.     <!-- скачаем и распакуем jslint и yui compressor -->
  47.     <antcall target="prepare-yuicompressor"/>
  48.     <antcall target="prepare-jslint"/>
  49.   </target>
  50.  
  51.   <!-- скачаем и подготовим к работе jslint -->
  52.   <target name="prepare-jslint" depends="check-if-jslint-exists" unless="jslint.exist">
  53.     <get src="${jslint-location}${jslint-zip}" dest="${tools.location}${jslint-zip}" verbose="true"/>
  54.     <unzip src="${tools.location}${jslint-zip}" dest="${tools.location}" />
  55.     <copy file="${tools.location}${jslint-unzip-dir}/${jslint-jar}" todir="${tools.location}"/>
  56.     <delete dir="${tools.location}${jslint-unzip-dir}"/>
  57.     <delete file="${tools.location}${jslint-zip}"/>
  58.   </target>
  59.  
  60.   <!-- удостоверимся, что jslint скачан и готов к работе - эта задача выполняется непосредственно перед проверкой js-файлов -->
  61.   <target name="check-if-jslint-exists">
  62.     <condition property="jslint.exist">
  63.       <and>
  64.         <available file="${tools.location}${jslint-jar}"/>
  65.       </and>
  66.     </condition>
  67.   </target>
  68.  
  69.   <!-- скачаем и подготовим к работе jslint -->
  70.   <target name="prepare-yuicompressor" depends="check-if-yuicompressor-exists" unless="yuicompressor.exist">
  71.     <get src="${yuicompressor-location}${yuicompressor-zip}" dest="${tools.location}${yuicompressor-zip}" verbose="true"/>
  72.     <unzip src="${tools.location}${yuicompressor-zip}" dest="${tools.location}" />
  73.     <copy file="${tools.location}${yuicompressor-unzip-dir}/build/${yuicompressor-jar}" todir="${tools.location}"/>
  74.     <delete dir="${tools.location}${yuicompressor-unzip-dir}"/>
  75.     <delete file="${tools.location}${yuicompressor-zip}"/>
  76.   </target>
  77.  
  78.   <!-- удостоверимся, что jslint скачан и готов к работе - эта задача выполняется непосредственно перед сжатием js/css-файлов -->
  79.   <target name="check-if-yuicompressor-exists">
  80.     <condition property="yuicompressor.exist">
  81.       <and>
  82.         <available file="${tools.location}${yuicompressor-jar}"/>
  83.       </and>
  84.     </condition>
  85.   </target>
  86.  
  87.   <!-- валидируем javascript -->
  88.   <target name="validate-javascript" depends="prepare-tools">
  89.     <apply executable="java" parallel="false" failonerror="false">
  90.       <fileset dir="${js.src}">
  91.         <include name="**/*.js"/>
  92.         <!-- файлы библиотек тестировать не нужно, их и другие люди уже оттестировали -->
  93.         <exclude name="**/jquery-1.2.6.js"/>
  94.       </fileset>
  95.       <arg value="-jar" />
  96.       <arg file="${tools.location}${jslint-jar}" />
  97.       <arg value="--bitwise" />
  98.       <arg value="--browser" />
  99.       <arg value="--undef" />
  100.       <arg value="--widget" />
  101.       <srcfile />
  102.     </apply>
  103.   </target>
  104.  
  105.   <!-- сжимаем js/css-файлы -->
  106.   <target name="compress" depends="prepare-tools, concatenate-files">
  107.     <apply executable="java" parallel="false" failonerror="true" dest="${js.out}" verbose="true" force="true">
  108.       <fileset dir="${js.out}" includes="*.js"/>
  109.       <arg line="-jar"/>
  110.       <arg path="${tools.location}${yuicompressor-jar}"/>
  111.       <arg line="--line-break 8000"/>
  112.       <arg line="-o"/>
  113.       <targetfile/>
  114.       <srcfile/>
  115.       <mapper type="glob" from="*.js" to="*.js"/>
  116.     </apply>
  117.     <apply executable="java" parallel="false" failonerror="true" dest="${css.out}" verbose="true" force="true">
  118.       <fileset dir="${css.out}" includes="*.css" excludes="ie_*.css"/>
  119.       <arg line="-jar"/>
  120.       <arg path="${tools.location}${yuicompressor-jar}"/>
  121.       <arg line="--line-break 0"/>
  122.       <srcfile/>
  123.       <arg line="-o"/>
  124.       <mapper type="glob" from="*.css" to="*.css"/>
  125.       <targetfile/>
  126.     </apply>
  127.   </target>
  128.  
  129.   <!-- удаляем все старые css и js файлы в темплейте, а затем вставляем ссылки на наши сжатые файлы -->
  130.   <target name="update-tags" depends="prepare-tools">
  131.     <copy file="${template}" tofile="${template.out}" overwrite="true"/>
  132.  
  133.     <replaceregexp file="${template.out}" match="<script\s+type="text/javascript"\s+src="[A-Za-z0-9._\-/]*"></script>" flags="igm" replace=""/>
  134.  
  135.     <replaceregexp file="${template.out}" match="(<script*|</body>)" flags="im" replace="<script type="text/javascript" src="js/main-${build-time}.js"></script>${line.separator}\1"/>
  136.  
  137.     <replaceregexp file="${template.out}" match="<link[^>]*href="css/[^i>]{1}[^e>]{1}[^_>]{1}[^>]*/>" flags="igm" replace=""/>
  138.  
  139.     <replaceregexp file="${template.out}" match="</head>" flags="im" replace="<link rel="stylesheet" href="css/main-${build-time}.css" type="text/css" /></head>"/>
  140.   </target>
  141.  
  142.   <!-- конкатенация файлов -->
  143.   <target name="concatenate-files" depends="update-tags">
  144.     <concat destfile="${js.out}/main-${build-time}.js" fixlastline="true">
  145.       <filelist dir="${js.src}"
  146.         files="${js-required-file-order}"/>
  147.       <fileset dir="${js.src}"
  148.         includes="**/*.js"
  149.         excludes="${js-required-file-order}"/>
  150.     </concat>
  151.  
  152.     <copy todir="${css.out}">
  153.       <fileset dir="${css.src}" includes="**/ie_*.css"/>
  154.     </copy>
  155.     <concat destfile="${css.out}/main-${build-time}.css" fixlastline="true">
  156.       <fileset dir="${css.src}"
  157.         includes="**/*.css"
  158.         excludes="**/ie_*.css"/>
  159.     </concat>
  160.   </target>
  161.   
  162.   <!-- вызываем цели в нужном порядке -->
  163.   <target name="build">
  164.     <mkdir dir="${output.dir}"/>
  165.     <antcall target="validate-javascript"/>
  166.     <antcall target="compress"/>
  167.   </target>
  168. </project>
* This source code was highlighted with Source Code Highlighter.
На всякий случай, пример: ant1

TODO

  1. Не слишком красивым является указание порядка конкатенации js-файлов прямо в скрипте. Гораздо лучше было бы, если бы мы писали прямо в html что-нибудь вроде <% javascript: { first: jquery, last: initialize } %>. При development build'е скрипт бы заменял эту строку на несколько script-tag'ов (удобно для debug-a), а на продакшене сливал бы файлы в один в указанном порядке.
  2. Скачивание файлов при помощи ant-а не сказать, чтобы очень удобное — а что, если выйдет новая версия или изменится ссылка на скачивание? Гораздо удобнее пользоваться maven'ом для таких случаев.
  3. Дополняйте :)
P.S. кросспост в моем блоге.
Tags:
Hubs:
Total votes 50: ↑48 and ↓2 +46
Views 5.2K
Comments Comments 74