en.javascript.info/6-optimize/2-minification/article.md
2015-04-18 01:40:37 +03:00

21 KiB
Raw Blame History

Как работают сжиматели JavaScript

Перед выкладыванием JavaScript на "боевую" машину -- пропускаем его через минификатор (также говорят "сжиматель"), который удаляет пробелы и по-всякому оптимизирует код, уменьшая его размер.

В этой статье мы посмотрим, как работают современные минификаторы, за счёт чего они укорачивают код и какие с ними возможны проблемы. [cut]

Современные сжиматели

Рассматриваемые в этой статье алгоритмы и подходы относятся к минификаторам последнего поколения.

Вот их список:

Самые широко используемые -- первые два, поэтому будем рассматривать в первую очередь их.

Наша цель -- понять, как они работают, и что интересного с их помощью можно сотворить.

С чего начать?

Для GCC:

  1. Убедиться, что стоит [Java](http://java.oracle.com)
  2. Скачать и распаковать [](http://closure-compiler.googlecode.com/files/compiler-latest.zip), нам нужен файл `compiler.jar`.
  3. Сжать файл `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:

  1. Убедиться, что стоит [Node.js](http://nodejs.org)
  2. Поставить `npm install -g uglify-js`.
  3. Сжать файл `my.js`: `uglifyjs my.js -o my.min.js`

Что делает минификатор?

Все современные минификаторы работают следующим образом:

  1. Разбирают JavaScript-код в синтаксическое дерево.

    Также поступает любой интерпретатор JavaScript перед тем, как его выполнять. Но затем, вместо исполнения кода...

  2. Бегают по этому дереву, анализируют и оптимизируют его.
  3. Записывают из синтаксического дерева получившийся код.

Как выглядит дерево?

Посмотреть синтаксическое дерево можно, запустив компилятор со специальным флагом.

Для GCC есть даже способ вывести его:

  1. Сначала сгенерируем дерево в формате [DOT](http://en.wikipedia.org/wiki/DOT_language):
    java -jar compiler.jar --js my.js --use_only_custom_externs --print_tree >my.dot
    

    Здесь флаг --print_tree выводит дерево, а --use_only_custom_externs убирает лишнюю служебную информацию.

  2. Файл в этом формате используется в различных программах для графопостроения.

    Чтобы превратить его в обычную картинку, подойдёт утилита 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 (имя переменной). Комментарии к ним на русском языке добавлены мной вручную.

Кроме него к каждому узлу привязаны конкретные данные. Сжиматель умеет ходить по этому дереву и менять его, как пожелает.

[smart header="Комментарии JSDoc"] Обычно когда код превращается в дерево -- из него естественным образом исчезают комментарии и пробелы. Они не имеют значения при выполнении, поэтому игнорируются.

Но Google Closure Compiler добавляет в дерево информацию из комментариев JSDoc, т.е. комментариев вида /** ... */, например:

*!*
/**
 * Номер минимальной поддерживаемой версии IE
 * @const
 * @type {number}
 */
*/!*
var minIEVersion = 8;

Такие комментарии не создают новых узлов дерева, а добавляются в качестве информации к существующем. В данном случае -- к переменной minIEVersion.

В них может содержаться информация о типе переменной (number) и другая, которая поможет сжимателю лучше оптимизировать код (const -- константа).

[/smart]

Оптимизации

Сжиматель бегает по дереву, ищет "паттерны" -- известные ему структуры, которые он знает, как оптимизировать, и обновляет дерево.

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

Объединение и сжатие констант
До оптимизации:
function test(a, b) {
  run(a, 'my' + 'string', 600 * 600 * 5, 1 && 0, b && 0)
}

После:

//+ no-beautify
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);
}

После оптимизации:

//+ no-beautify
function sayHi(a,b){alert(a+" сказал: "+b)};
  • Локальная переменная заведомо доступна только внутри функции, поэтому обычно её переименование безопасно (необычные случаи рассмотрим далее).
  • Также переименовываются локальные функции.
  • Вложенные функции обрабатываются корректно.
Объединение и удаление локальных переменных
До оптимизации:
function test(nodeId) {
  var elem = document.getElementsById(nodeId);
  var parent = elem.parentNode;
  alert( parent );
}

После оптимизации GCC:

//+ no-beautify
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 );
}

После оптимизации:

//+ no-beautify
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 );
}

После оптимизации:

//+ no-beautify
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-сжатый код в итоге даст меньший размер.

Разные мелкие оптимизации
Кроме основных оптимизаций, описанных выше, есть ещё много мелких:
  • Убираются лишние кавычки у ключей
    //+ no-beautify
    {"prop" : "val" }   =>  {prop:"val"}
    
  • Упрощаются простые вызовы `Array/Object`
    //+ no-beautify
    a = new Array()   =>  a = []
    o = new Object()  => o = {}
    

    Эта оптимизация предполагает, что Array и Object не переопределены программистом. Для включения её в UglifyJS нужен флаг --unsafe.

  • ...И еще некоторые другие мелкие изменения кода...

Подводные камни

Описанные оптимизации, в целом, безопасны, но есть ряд подводных камней.

Конструкция with

Рассмотрим код:

//+ no-beautify
function changePosition(style) {
  var position, test;

*!*
  with (style) {
    position = 'absolute';
  }
*/!*
}

Куда будет присвоено значение position = 'absolute'?

Это неизвестно до момента выполнения: если свойство position есть в style -- то туда, а если нет -- то в локальную переменную.

Можно ли в такой ситуации заменить локальную переменную на более короткую? Очевидно, нет:

//+ no-beautify
function changePosition(style) {
  var a, b;

*!*
  with (style) {          // а что, если в style нет такого свойства? 
    position = 'absolute';// куда будет осуществлена запись? в window.position?
  }
*/!*
}

Такая же опасность для сжатия кроется в использованном eval. Ведь eval может обращаться к локальным переменным:

//+ no-beautify
function f(code) {
  var myVar;

  eval(code); // а что, если будет присвоение eval("myVar = ...") ?

  alert(myVar);

Получается, что при наличии eval мы не имеем права переименовывать локальные переменные. Причём (!), если функция является вложенноой, то и во внешних функциях тоже.

А ведь сжатие переменных -- очень важная оптимизация. Как правило, она уменьшает размер сильнее всего.

Что делать? Разные минификаторы поступают по-разному.

  • UglifyJS -- не будет переименовывать переменные. Так что наличие `with/eval` сильно повлияет на степень сжатие кода.
  • GCC -- всё равно сожмёт локальные переменные. Это, конечно же, может привести к ошибкам, причём в сжатом коде, отлаживать который не очень-то удобно. Поэтому он выдаст предупреждение о наличии опасной конструкции.

Ни тот ни другой вариант нас, по большому счёту, не устраивают.

Для того, чтобы код сжимался хорошо и работал правильно, не используем with и eval.

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

Условная компиляция IE10-

В IE10- поддерживалось условное выполнение JavaScript.

Синтаксис: /*@cc_on код */.

Такой код выполнится в IE10-, например:

//+ run
var isIE /*@cc_on =true@*/ ;

alert( isIE ); // true в IE10-

Можно хитро сделать, чтобы комментарий остался, например так:

//+ run
var isIE = new Function('', '/*@cc_on return true@*/')();

alert( isIE ); // true в IE.

...Однако, с учётом того, что в современных IE11+ эта компиляция не работает в любом случае, лучше избавиться от неё вообще.

В следующих главах мы посмотрим, какие продвинутые возможности есть в минификаторах, как сделать сжатие более эффективным.