Pull to refresh

Сам себе gzip: сжимаем скрипты на 20% лучше

Reading time 13 min
Views 1.7K
Повторяющиеся ключи

Если посмотреть на скрипт, сжатый Closure Compiler'ом, YUI Compressor'ом или еще чем-нибудь, можно увидеть бесконечные вереницы повторяющихся ключей: .prototype, .length, offsetParent и так далее. Попробуем избавиться от них на примере плагина jQuery UI Sortable. Скажу сразу, что gzip нам не переплюнуть, но когда его нет под рукой или нельзя им воспользоваться (например, на конкурсе 10K Apart), эта техника сжатия может оказаться весьма полезной.

Возьмем файл jquery.ui.sortable.js версии 1.8.2 как один из самых внушительных в комплекте jQuery UI. Исходник весит 38,5 КБ, сжатый Closure Compiler'ом — 23,1 КБ, в gzip'е — 5,71 КБ. Чтобы не загромождать экраны, выберем какой-нибудь характерный метод и представим, что это отдельный плагин. Мне понравился _mouseCapture():
  1. /*!
  2. * Исходный код
  3. */
  4. (function($) {
  5.  
  6. var
  7.     _mouseCapture = function(event, overrideHandle) {
  8.  
  9.         if (this.reverting) {
  10.             return false;
  11.         }
  12.  
  13.         if (this.options.disabled || this.options.type == 'static') return false;
  14.  
  15.         //We have to refresh the items data once first
  16.         this._refreshItems(event);
  17.  
  18.         //Find out if the clicked node (or one of its parents) is a actual item in this.items
  19.         var currentItem = null, self = this, nodes = $(event.target).parents().each(function() {
  20.             if ($.data(this, 'sortable-item') == self) {
  21.                 currentItem = $(this);
  22.                 return false;
  23.             }
  24.         });
  25.         if ($.data(event.target, 'sortable-item') == self) currentItem = $(event.target);
  26.  
  27.         if (!currentItem) return false;
  28.         if (this.options.handle && !overrideHandle) {
  29.             var validHandle = false;
  30.  
  31.             $(this.options.handle, currentItem).find("*").andSelf().each(function() { if (this == event.target) validHandle = true; });
  32.             if (!validHandle) return false;
  33.         }
  34.  
  35.         this.currentItem = currentItem;
  36.         this._removeCurrentsFromItems();
  37.         return true;
  38.  
  39.     };
  40.  
  41. })(jQuery);


Чтобы не путаться, будем называть его тестовым плагином, а весь Sortable — исходным. Тестовый плагин весит ровно 1 КБ, в сжатом виде 576 байт, в gzip 391 байт.

/*!<br> * Исходный код<br> * Оригинальное сжатие, 576 Б<br> */<br>(function(d){var _mouseCapture=function(a,b){if(this.reverting)return false;if(this.options.disabled||this.options.type=="static")return false;this._refreshItems(a);var c=null,e=this;d(a.target).parents().each(function(){if(d.data(this,"sortable-item")==e){c=d(this);return false}});if(d.data(a.target,"sortable-item")==e)c=d(a.target);if(!c)return false;if(this.options.handle&&!b){var f=false;d(this.options.handle,c).find("*").andSelf().each(function(){if(this==a.target)f=true});if(!f)return false}this.currentItem=c;this._removeCurrentsFromItems();return true}})(jQuery);


this self


Для начала избавимся от вездесущего и несжимающегося выражения this. В нашем исходном плагине оно употребляется ни много ни мало 587 раз (что составляет 2,3 КБ или 6% кода). Добавим в начало функции локальную переменную self:
var<br>    self = this;<br>

Теперь заменим везде в теле функции this на self и посмотрим на результаты сжатия.
Было:
this.currentItem=e;this._removeCurrentsFromItems();

Стало:
d.currentItem=e;d._removeCurrentsFromItems();

Весь тестовый плагин:
/*!<br> * Замена this > self<br> * YUI Compressor, 552 Б<br> */<br>(function(b){var a=function(f,g){var d=this;if(d.reverting){return false}if(d.options.disabled||d.options.type=="static"){return false}d._refreshItems(f);var e=null,c=b(f.target).parents().each(function(){if(b.data(this,"sortable-item")==d){e=b(this);return false}});if(b.data(f.target,"sortable-item")==d){e=b(f.target)}if(!e){return false}if(d.options.handle&&!g){var h=false;b(d.options.handle,e).find("*").andSelf().each(function(){if(this==f.target){h=true}});if(!h){return false}}d.currentItem=e;d._removeCurrentsFromItems();return true}})(jQuery);

Неплохо! Кстати, в этом методе в строке 19 уже было точно такое же объявление self = this для передачи контекста в создаваемую функцию, но почему-то не использовалось для оптимизаци. Здесь мы просто вынесли это объявление в начало, а в другой функции можно было бы и добавить, лишние несколько символов окупятся, если в функции используются хотя бы 4 this (а их бывает и по 70).

Ключи и строки


Теперь посмтрим внимательно на оставшийся код. Хорошо видны повторяющиеся выражения options, target, data, "sortable-item" и другие. Большая часть из них — имена свойств объектов или строки, некоторые встречаются в исходном плагине по 50—60 раз. Из компрессоров сжимать эти элементы (менять имена на более короткие) умеет только Closure Compiler в продвинутом режиме, но тут мы наблюдаем почти все свойства на своих местах. Скорее всего, при компиляции библиотеки был задан обширный список экстернов, несжимаемых имен. Возникает естественное желание иправить эту ситуацию, ведь способ оптимизации подсказывает сам синтаксис языка. Сделаем часто используемые ключи локальными переменными:
var<br>    a = {<br>        options: {<br>            visible: true,<br>            mess: 'hi'<br>        }<br>    };

1:
if (a.options.visible) {<br>    alert(a.options.mess);<br>    a.options.visible = false;<br>}

2:
var<br>    o = 'options',<br>    v = 'visible';<br>if (a[o][v]) {<br>    alert(a[o].mess);<br>    a[o][v] = false;<br>}

Во втором варианте нам понадобилось написать visible всего один раз вместо двух, а options один раз вместо трех. В маштабе 40-килобайтного плагина, а тем более всего приложения, это дает ощутимый выигрыш в размере кода (но не в понятности).

Можно написать небольшой скрипт на любом языке для определения частоты выражений в коде и автоматической замены их на переменные. Например, по такому регулярному выражению: /((\')|(\")|\.)\b([a-z_][\w-]+\w)\b(?(2)\')(?(3)\")/i. Для исходного плагина получилось 175 замен, для тестового — 15:
  1. /*!
  2. * Замена this > self
  3. * Замена ключей и строк
  4. */
  5. (function($) {
  6.  
  7. var
  8.     _reverting = 'reverting', // 3
  9.     _options = 'options', // 51
  10.     _disabled = 'disabled', // 4
  11.     _type = 'type', // 2
  12.     _static = 'static', // 2
  13.     __refreshItems = '_refreshItems', // 2
  14.     _target = 'target', // 4
  15.     _parents = 'parents', // 2
  16.     _each = 'each', // 5
  17.     _data = 'data', // 5
  18.     _sortable_item = 'sortable-item', // 6
  19.     _handle = 'handle', // 2
  20.     _find = 'find', // 2
  21.     _currentItem = 'currentItem', // 52
  22.     FALSE = !1, // 30
  23.     TRUE = !0, // 11
  24.  
  25.     _mouseCapture = function(event, overrideHandle) {
  26.         var
  27.             self = this;
  28.  
  29.         if (self[_reverting]) {
  30.             return FALSE;
  31.         }
  32.  
  33.         if(self[_options][_disabled] || self[_options][_type] == _static ) return FALSE;
  34.  
  35.  
  36.         self[__refreshItems](event);
  37.  
  38.  
  39.         var currentItem = null, nodes = $(event[_target])[_parents]()[_each](function() {
  40.             if($[_data](this, _sortable_item) == self) {
  41.                 currentItem = $(this);
  42.                 return FALSE;
  43.             }
  44.         });
  45.         if($[_data](event[_target], _sortable_item) == self) currentItem = $(event[_target]);
  46.  
  47.         if(!currentItem) return FALSE;
  48.         if(self[_options][_handle] && !overrideHandle) {
  49.             var validHandle = FALSE;
  50.  
  51.             $(self[_options][_handle], currentItem)[_find]("*").andSelf()[_each](function() { if(this == event[_target]) validHandle = TRUE; });
  52.             if(!validHandle) return FALSE;
  53.         }
  54.  
  55.         self[_currentItem] = currentItem;
  56.         self._removeCurrentsFromItems();
  57.         return TRUE;
  58.  
  59.     };
  60.  
  61. })(jQuery);


Объявление переменных выглядит громоздко, но не стоит забывать, что оно будет общим как минимум для всех методов исходного плагина, в теории же можно сделать общее объявление имен для всего приложения. Чем больше «поле деятельности», тем эффективнее будет работать сжатие имен, т.к. каждое из них указывается только один раз в начале модуля, и в дальнейшем везде заменяется своим одно- или двухбуквенным представлением. В сжатом виде тестовый плагин выглядит отлично:
  1. /*!
  2. * Замена this > self
  3. * Замена ключей и строк
  4. * YUI Compressor, 395 Б + JS beautifier
  5. */
  6. (function (c) {
  7.     var b = function (v, w) {
  8.         var t = this;
  9.         if (t[e]) {
  10.             return o
  11.         }
  12.         if (t[m][k] || t[m][r] == f) {
  13.             return o
  14.         }
  15.         t[n](v);
  16.         var u = null,
  17.             s = c(v[i])[g]()[q](function () {
  18.                 if (c[l](this, j) == t) {
  19.                     u = c(this);
  20.                     return o
  21.                 }
  22.             });
  23.         if (c[l](v[i], j) == t) {
  24.             u = c(v[i])
  25.         }
  26.         if (!u) {
  27.             return o
  28.         }
  29.         if (t[m][d] && !w) {
  30.             var x = o;
  31.             c(t[m][d], u)[a]("*").andSelf()[q](function () {
  32.                 if (this == v[i]) {
  33.                     x = h
  34.                 }
  35.             });
  36.             if (!x) {
  37.                 return o
  38.             }
  39.         }
  40.         t[p] = u;
  41.         t._removeCurrentsFromItems();
  42.         return h
  43.     }
  44. })(jQuery);


document, window, etc.


Эти глобальные объекты тоже отлично сжимаются, если присвоить их локальным переменным. В нашем тестовом плагине document и window не используются, поэтому временно обратимся к соседнему методу _mouseDrag():
(function() {<br><br>    if(event.pageY - $(document).scrollTop() < o.scrollSensitivity)<br>        scrolled = $(document).scrollTop($(document).scrollTop() - o.scrollSpeed);<br>    else if($(window).height() - (event.pageY - $(document).scrollTop()) < o.scrollSensitivity)<br>        scrolled = $(document).scrollTop($(document).scrollTop() + o.scrollSpeed);<br>    <br>    if(event.pageX - $(document).scrollLeft() < o.scrollSensitivity)<br>        scrolled = $(document).scrollLeft($(document).scrollLeft() - o.scrollSpeed);<br>    else if($(window).width() - (event.pageX - $(document).scrollLeft()) < o.scrollSensitivity)<br>        scrolled = $(document).scrollLeft($(document).scrollLeft() + o.scrollSpeed);<br><br>})();

Такая картина довольно часто наблюдается в скриптах. Сжимается такой код только за счет удаления пробелов и ненужных символов, так как в нем нет локальных переменных. А теперь посмотрим на сжатие с заменами:
(function () {<br>    var g = document,<br>        f = window,<br>        e = "scrollTop",<br>        c = "scrollLeft",<br>        b = "scrollSpeed",<br>        d = "scrollSensitivity",<br>        h = "pageY",<br>        a = "pageX";<br><br>    if (event[h] - $(g)[e]() < o[d]) {<br>        scrolled = $(g)[e]($(g)[e]() - o[b])<br>    } else {<br>        if ($(f).height() - (event[h] - $(g)[e]()) < o[d]) {<br>            scrolled = $(g)[e]($(g)[e]() + o[b])<br>        }<br>    }<br><br>    if (event[a] - $(g)[c]() < o[d]) {<br>        scrolled = $(g)[c]($(g)[c]() - o[b])<br>    } else {<br>        if ($(f).width() - (event[a] - $(g)[c]()) < o[d]) {<br>            scrolled = $(g)[c]($(g)[c]() + o[b])<br>        }<br>    }<br><br>})();

Это азбука оптимизатора, но почему-то эти простые приемы используются крайне редко, даже в больших библиотеках, где им самое место. Вполне возможно, что если бы разработчики пришли к определенным соглашениям по использованию сокращенных переменных на уровне проекта, сжатый jQuery мог бы весить не 70 КБ, а, допустим, 50.

В предыдущем примере еще лучше было бы создать переменную со ссылкой на объект документа в обертке jQuery:
var<br>    doc = document,<br>    $doc = $(doc);


Лирическое отступление

Довольно часто (да почти всегда) в динамически создаваемых функциях в jQuery требуется элемент в обертке, и приходится делать что-то такое:
$('li').each(function(index, item) {<br>    var<br>        $item = $(item)<br>    /* do something with $item */<br>});

Такие конструкции встречаются и в исходниках библиотеки. А насколько было бы проще, если бы jQuery-объект с элементом передавался бы сразу третьим необязательным параметром:
$('li').each(function(index, item, $item) {<br>    /* do something with $item */<br>});

Красота!

Результаты


Однако, вернемся к нашему тестовому плагину. После оптимизации он стал весить 395 байт против 576, мы выиграли 31,4%:
/*!<br> * Замена this > self<br> * Замена ключей и строк<br> * YUI Compressor, 395 Б<br> */<br>(function(c){var b=function(v,w){var t=this;if(t[e]){return o}if(t[m][k]||t[m][r]==f){return o}t[n](v);var u=null,s=c(v[i])[g]()[q](function(){if(c[l](this,j)==t){u=c(this);return o}});if(c[l](v[i],j)==t){u=c(v[i])}if(!u){return o}if(t[m][d]&&!w){var x=o;c(t[m][d],u)[a]("*").andSelf()[q](function(){if(this==v[i]){x=h}});if(!x){return o}}t[p]=u;t._removeCurrentsFromItems();return h}})(jQuery);


В маштабе исходного плагина картина, разумеется, несколько иная, так как добавляются объявления переменных. В результате всех манипуляций плагин в сжатом виде стал весить 18 КБ ровно, выигрыш составил 5,3 КБ (21,5%). А вот gzip, наоборот, потяжелел на 0,7 КБ (13%), так что эту технику лучше использовать там, где gzp по каким-то причинам недоступен. Сжатый таким образом файл также можно отдавать старым браузерам вроде Safari 2, не поддерживающим gzip.

Размеры исходного плагина в байтах:
Исходник this → self Кэширование свойств
Без сжатия 39 495 40 241 41 148
YUI / CC 23 656 22 185 18 496
gzip 5 851 5 950 6 504

Upd: добавлен пример сжатия глобальных объектов.
Tags:
Hubs:
+38
Comments 51
Comments Comments 51

Articles