Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
452 lines (315 sloc) 20.7 KB

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

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

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

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

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

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

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

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

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

Для GCC:

  1. Убедиться, что стоит Java
  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
  2. Поставить npm install -g uglify-js.
  3. Сжать файл my.js: uglifyjs my.js -o my.min.js

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

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

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

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

  2. Бегают по этому дереву, анализируют и оптимизируют его.

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

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

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

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

  1. Сначала сгенерируем дерево в формате DOT:

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

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

Обычно когда код превращается в дерево -- из него естественным образом исчезают комментарии и пробелы. Они не имеют значения при выполнении, поэтому игнорируются.

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

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

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

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

## Оптимизации

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

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

Объединение и сжатие констант
: До оптимизации:

    ```js
    function test(a, b) {
      run(a, 'my' + 'string', 600 * 600 * 5, 1 && 0, b && 0)
    }
    ```

    После:

    ```js 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`.

Укорачивание локальных переменных
: До оптимизации:

    ```js
    function sayHi(*!*name*/!*, *!*message*/!*) {
      alert(name +" сказал: " + message);
    }
    ```

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

    ```js no-beautify
    function sayHi(a,b){alert(a+" сказал: "+b)};
    ```

- Локальная переменная заведомо доступна только внутри функции, поэтому обычно её переименование безопасно (необычные случаи рассмотрим далее).
- Также переименовываются локальные функции.
- Вложенные функции обрабатываются корректно.

Объединение и удаление локальных переменных
: До оптимизации:

    ```js
    function test(nodeId) {
      var elem = document.getElementsById(nodeId);
      var parent = elem.parentNode;
      alert( parent );
    }
    ```

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

    ```js no-beautify
    function test(a){a=document.getElementsById(a).parentNode;alert(a)};
    ```

- Локальные переменные были переименованы.
- Лишние переменные убраны. Для этого сжиматель создаёт вспомогательную внутреннюю структуру данных, в которой хранятся сведения о "пути использования" каждой переменной. Если одна переменная заканчивает свой путь и начинает другая, то вполне можно дать им одно имя.
- Кроме того, операции `elem = getElementsById` и `elem.parentNode` объединены, но это уже другая оптимизация.

Уничтожение недостижимого кода, разворачивание `if`-веток
: До оптимизации:

    ```js
    function test(node) {
      var parent = node.parentNode;

      if (0) {
        alert( "Привет с параллельной планеты" );
      } else {
        alert( "Останется только один" );
      }

      return;

      alert( 1 );
    }
    ```

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

    ```js no-beautify
    function test(){alert("Останется только один")}
    ```

- Если переменная присваивается, но не используется, она может быть удалена. В примере выше эта оптимизация была применена к переменной `parent`, а затем и к параметру `node`.
- Заведомо ложная ветка `if(0) { .. }` убрана, заведомо истинная -- оставлена.

    То же самое будет с условиями в других конструкциях, например `a = true ? c : d` превратится в `a = c`.
- Код после `return` удалён как недостижимый.

Переписывание синтаксических конструкций
: До оптимизации:

    ```js
    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 );
    }
    ```

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

    ```js 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 ? ... : ...`.

Инлайнинг функций
: *Инлайнинг функции* -- приём оптимизации, при котором функция заменяется на своё тело.

    До оптимизации:

    ```js
    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);
      }
    }
    ```

    После оптимизации (переводы строк также будут убраны):

    ```js
    function sayHi(b) {
      var a = document.createElement("div");
      a.innerHTML = b;
      document.body.appendChild(a)
    };
    ```

- Вызовы функций `createMessage` и `showElement` заменены на тело функций. В данном случае это возможно, так как функции используются всего по разу.
- Эта оптимизация применяется не всегда. Если бы каждая функция использовалась много раз, то с точки зрения размера выгоднее оставить их "как есть".

Инлайнинг переменных
: Переменные заменяются на значение, если оно заведомо известно.

    До оптимизации:

    ```js
    (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 );
        }
      }

    })();
    ```

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

    ```js
    (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](http://www.gzip.org/algorithm.txt) заключается в том, что он ищет повторы в данных и выносит их в специальный "словарь", заменяя на более короткий идентификатор. Архив как раз и состоит из словаря и данных, в которых дубликаты заменены на идентификаторы.

Если вынести строку обратно в переменную, то получится как раз частный случай такого сжатия -- взяли `"Привет вам из JavaScript"` и заменили на идентификатор `hi`. Но gzip справляется с этим лучше, поэтому эффективнее будет оставить именно строку. Gzip сам найдёт дубликаты и сожмёт их.

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

Разные мелкие оптимизации
: Кроме основных оптимизаций, описанных выше, есть ещё много мелких:

- Убираются лишние кавычки у ключей

```js no-beautify
{"prop" : "val" }   =>  {prop:"val"}
```
- Упрощаются простые вызовы `Array/Object`

```js no-beautify
a = new Array()   =>  a = []
o = new Object()  => o = {}
```

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

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

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

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

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

```js no-beautify
function changePosition(style) {
  var position, test;

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

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

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

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

```js no-beautify
function changePosition(style) {
  var a, b;

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

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

```js no-beautify
function f(code) {
  var myVar;

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

  alert(myVar);
```

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

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

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

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

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

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

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

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

В IE10- поддерживалось [условное выполнение JavaScript](http://msdn.microsoft.com/en-us/library/121hztk3.aspx).

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

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

```js run
var isIE /*@cc_on =true@*/ ;

alert( isIE ); // true в IE10-
```

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

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

alert( isIE ); // true в IE.
```

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

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