Перед выкладыванием JavaScript на «боевую» машину – пропускаем его через минификатор (также говорят «сжиматель»), который удаляет пробелы и по-всякому оптимизирует код, уменьшая его размер.
В этой статье мы посмотрим, как работают современные минификаторы, за счёт чего они укорачивают код и какие с ними возможны проблемы.
Современные сжиматели
Рассматриваемые в этой статье алгоритмы и подходы относятся к минификаторам последнего поколения.
Вот их список:
Самые широко используемые – первые два, поэтому будем рассматривать в первую очередь их.
Наша цель – понять, как они работают, и что интересного с их помощью можно сотворить.
С чего начать?
Для GCC:
- Убедиться, что стоит Java
- Скачать и распаковать http://closure-compiler.googlecode.com/files/compiler-latest.zip, нам нужен файл
compiler.jar
. - Сжать файл
my.js
:java -jar compiler.jar --charset UTF-8 --js my.js --js_output_file my.min.js
Обратите внимание на флаг --charset
для GCC. Без него русские буквы будут закодированы во что-то типа \u1234
.
Google Closure Compiler также содержит песочницу для тестирования сжатия и веб-сервис, на который код можно отправлять для сжатия. Но скачать файл обычно гораздо проще, поэтому его редко где используют.
Для UglifyJS:
- Убедиться, что стоит Node.js
- Поставить
npm install -g uglify-js
. - Сжать файл
my.js
:uglifyjs my.js -o my.min.js
Что делает минификатор?
Все современные минификаторы работают следующим образом:
-
Разбирают JavaScript-код в синтаксическое дерево.
Также поступает любой интерпретатор JavaScript перед тем, как его выполнять. Но затем, вместо исполнения кода…
-
Бегают по этому дереву, анализируют и оптимизируют его.
-
Записывают из синтаксического дерева получившийся код.
Как выглядит дерево?
Посмотреть синтаксическое дерево можно, запустив компилятор со специальным флагом.
Для GCC есть даже способ вывести его:
-
Сначала сгенерируем дерево в формате DOT:
java -jar compiler.jar --js my.js --use_only_custom_externs --print_tree >my.dot
Здесь флаг
--print_tree
выводит дерево, а--use_only_custom_externs
убирает лишнюю служебную информацию. -
Файл в этом формате используется в различных программах для графопостроения.
Чтобы превратить его в обычную картинку, подойдёт утилита
dot
из пакета Graphviz:// конвертировать в формат png dot -Tpng my.dot -o my.png // конвертировать в формат svg dot -Tsvg my.dot -o my.svg
Пример кода my.js
:
function User(name) {
this.sayHi = function() {
alert( name );
};
}
Результат, получившееся из my.js
дерево:
В узлах-эллипсах на иллюстрации выше стоит тип, например FUNCTION
(функция) или NAME
(имя переменной). Комментарии к ним на русском языке добавлены мной вручную.
Кроме него к каждому узлу привязаны конкретные данные. Сжиматель умеет ходить по этому дереву и менять его, как пожелает.
Обычно когда код превращается в дерево – из него естественным образом исчезают комментарии и пробелы. Они не имеют значения при выполнении, поэтому игнорируются.
Но Google Closure Compiler добавляет в дерево информацию из комментариев JSDoc, т.е. комментариев вида /** ... */
, например:
/**
* Номер минимальной поддерживаемой версии IE
* @const
* @type {number}
*/
var minIEVersion = 8;
Такие комментарии не создают новых узлов дерева, а добавляются в качестве информации к существующем. В данном случае – к переменной minIEVersion
.
В них может содержаться информация о типе переменной (number
) и другая, которая поможет сжимателю лучше оптимизировать код (const
– константа).
Оптимизации
Сжиматель бегает по дереву, ищет «паттерны» – известные ему структуры, которые он знает, как оптимизировать, и обновляет дерево.
В разных минификаторах реализован разный набор оптимизаций, сами оптимизации применяются в разном порядке, поэтому результаты работы могут отличаться. В примерах ниже даётся результат работы GCC.
- Объединение и сжатие констант
-
До оптимизации:
function test(a, b) { run(a, 'my' + 'string', 600 * 600 * 5, 1 && 0, b && 0) }
После:
function test(a,b){run(a,"mystring",18E5,0,b&&0)};
'my' + 'string'
→"mystring"
.600 * 600 * 5
→18E5
(научная форма числа, для краткости).1 && 0
→0
.b && 0
→ без изменений, т.к. результат зависит отb
.
- Укорачивание локальных переменных
-
До оптимизации:
function sayHi(name, message) { alert(name +" сказал: " + message); }
После оптимизации:
function sayHi(a,b){alert(a+" сказал: "+b)};
- Локальная переменная заведомо доступна только внутри функции, поэтому обычно её переименование безопасно (необычные случаи рассмотрим далее).
- Также переименовываются локальные функции.
- Вложенные функции обрабатываются корректно.
- Объединение и удаление локальных переменных
-
До оптимизации:
function test(nodeId) { var elem = document.getElementsById(nodeId); var parent = elem.parentNode; alert( parent ); }
После оптимизации GCC:
function test(a){a=document.getElementsById(a).parentNode;alert(a)};
- Локальные переменные были переименованы.
- Лишние переменные убраны. Для этого сжиматель создаёт вспомогательную внутреннюю структуру данных, в которой хранятся сведения о «пути использования» каждой переменной. Если одна переменная заканчивает свой путь и начинает другая, то вполне можно дать им одно имя.
- Кроме того, операции
elem = getElementsById
иelem.parentNode
объединены, но это уже другая оптимизация.
- Уничтожение недостижимого кода, разворачивание
if
-веток -
До оптимизации:
function test(node) { var parent = node.parentNode; if (0) { alert( "Привет с параллельной планеты" ); } else { alert( "Останется только один" ); } return; alert( 1 ); }
После оптимизации:
function test(){alert("Останется только один")}
-
Если переменная присваивается, но не используется, она может быть удалена. В примере выше эта оптимизация была применена к переменной
parent
, а затем и к параметруnode
. -
Заведомо ложная ветка
if(0) { .. }
убрана, заведомо истинная – оставлена.То же самое будет с условиями в других конструкциях, например
a = true ? c : d
превратится вa = c
. -
Код после
return
удалён как недостижимый.
- Переписывание синтаксических конструкций
-
До оптимизации:
var i = 0; while (i++ < 10) { alert( i ); } if (i) { alert( i ); } if (i == '1') { alert( 1 ); } else if (i == '2') { alert( 2 ); } else { alert( i ); }
После оптимизации:
for(var i=0;10>i++;)alert(i);i&&alert(i);"1"==i?alert(1):"2"==i?alert(2):alert(i);
- Конструкция
while
переписана вfor
. - Конструкция
if (i) ...
переписана вi&&...
. - Конструкция
if (cond) ... else ...
была переписана вcond ? ... : ...
.
- Инлайнинг функций
-
Инлайнинг функции – приём оптимизации, при котором функция заменяется на своё тело.
До оптимизации:
function sayHi(message) { var elem = createMessage('div', message); showElement(elem); function createMessage(tagName, message) { var el = document.createElement(tagName); el.innerHTML = message; return el; } function showElement(elem) { document.body.appendChild(elem); } }
После оптимизации (переводы строк также будут убраны):
function sayHi(b) { var a = document.createElement("div"); a.innerHTML = b; document.body.appendChild(a) };
- Вызовы функций
createMessage
иshowElement
заменены на тело функций. В данном случае это возможно, так как функции используются всего по разу. - Эта оптимизация применяется не всегда. Если бы каждая функция использовалась много раз, то с точки зрения размера выгоднее оставить их «как есть».
- Инлайнинг переменных
-
Переменные заменяются на значение, если оно заведомо известно.
До оптимизации:
(function() { var isVisible = true; var hi = "Привет вам из JavaScript"; window.sayHi = function() { if (isVisible) { alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); alert( hi ); } } })();
После оптимизации:
(function() { window.sayHi = function() { alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); alert( "Привет вам из JavaScript" ); }; } })();
-
Переменная
isVisible
заменена наtrue
, после чегоif
стало возможным убрать. -
Переменная
hi
заменена на строку.Казалось бы – зачем менять
hi
на строку? Ведь код стал ощутимо длиннее!…Но всё дело в том, что минификатор знает, что дальше код будет сжиматься при помощи gzip. Во всяком случае, все правильно настроенные сервера так делают.
-
Алгоритм работы gzip заключается в том, что он ищет повторы в данных и выносит их в специальный «словарь», заменяя на более короткий идентификатор. Архив как раз и состоит из словаря и данных, в которых дубликаты заменены на идентификаторы.
Если вынести строку обратно в переменную, то получится как раз частный случай такого сжатия – взяли "Привет вам из JavaScript"
и заменили на идентификатор hi
. Но gzip справляется с этим лучше, поэтому эффективнее будет оставить именно строку. Gzip сам найдёт дубликаты и сожмёт их.
Плюс такого подхода станет очевиден, если сжать gzip оба кода – до и после минификации. Минифицированный gzip-сжатый код в итоге даст меньший размер.
- Разные мелкие оптимизации
- Кроме основных оптимизаций, описанных выше, есть ещё много мелких:
- Убираются лишние кавычки у ключей
{"prop" : "val" } => {prop:"val"}
- Упрощаются простые вызовы
Array/Object
a = new Array() => a = []
o = new Object() => o = {}
Эта оптимизация предполагает, что `Array` и `Object` не переопределены программистом. Для включения её в UglifyJS нужен флаг `--unsafe`.
- …И ещё некоторые другие мелкие изменения кода…
Подводные камни
Описанные оптимизации, в целом, безопасны, но есть ряд подводных камней.
Конструкция with
Рассмотрим код:
function changePosition(style) {
var position, test;
with (style) {
position = 'absolute';
}
}
Куда будет присвоено значение position = 'absolute'
?
Это неизвестно до момента выполнения: если свойство position
есть в style
– то туда, а если нет – то в локальную переменную.
Можно ли в такой ситуации заменить локальную переменную на более короткую? Очевидно, нет:
function changePosition(style) {
var a, b;
with (style) { // а что, если в style нет такого свойства?
position = 'absolute';// куда будет осуществлена запись? в window.position?
}
}
Такая же опасность для сжатия кроется в использованном eval
. Ведь eval
может обращаться к локальным переменным:
function f(code) {
var myVar;
eval(code); // а что, если будет присвоение eval("myVar = ...") ?
alert(myVar);
Получается, что при наличии eval
мы не имеем права переименовывать локальные переменные. Причём (!), если функция является вложенной, то и во внешних функциях тоже.
А ведь сжатие переменных – очень важная оптимизация. Как правило, она уменьшает размер сильнее всего.
Что делать? Разные минификаторы поступают по-разному.
- UglifyJS – не будет переименовывать переменные. Так что наличие
with/eval
сильно повлияет на степень сжатие кода. - GCC – всё равно сожмёт локальные переменные. Это, конечно же, может привести к ошибкам, причём в сжатом коде, отлаживать который не очень-то удобно. Поэтому он выдаст предупреждение о наличии опасной конструкции.
Ни тот ни другой вариант нас, по большому счёту, не устраивают.
Для того, чтобы код сжимался хорошо и работал правильно, не используем with
и eval
.
Либо, если уж очень надо использовать – делаем это с оглядкой на поведение минификатора, чтобы не было проблем.
Условная компиляция IE10-
В IE10- поддерживалось условное выполнение JavaScript.
Синтаксис: /*@cc_on код */
.
Такой код выполнится в IE10-, например:
var isIE /*@cc_on =true@*/ ;
alert( isIE ); // true в IE10-
Можно хитро сделать, чтобы комментарий остался, например так:
var isIE = new Function('', '/*@cc_on return true@*/')();
alert( isIE ); // true в IE.
…Однако, с учётом того, что в современных IE11+ эта компиляция не работает в любом случае, лучше избавиться от неё вообще.
В следующих главах мы посмотрим, какие продвинутые возможности есть в минификаторах, как сделать сжатие более эффективным.
Комментарии
<code>
, для нескольких строк кода — тег<pre>
, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)