7 июня 2022 г.

GCC: продвинутые оптимизации

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

Продвинутый режим оптимизации google closure compiler включается опцией –compilation_level ADVANCED_OPTIMIZATIONS.

Слово «продвинутый» (advanced) здесь, пожалуй, не совсем подходит. Было бы более правильно назвать его «супер-агрессивный-ломающий-ваш-неподготовленный-код-режим». Кардинальное отличие применяемых оптимизаций от обычных (simple) – в том, что они небезопасны.

Чтобы им пользоваться – надо уметь это делать.

Основной принцип продвинутого режима

  • Если в обычном режиме переименовываются только локальные переменные внутри функций, то в «продвинутом» – на более короткие имена заменяется все.
  • Если в обычном режиме удаляется недостижимый код после return, то в продвинутом – вообще весь код, который не вызывается в явном виде.

Например, если запустить продвинутую оптимизацию на таком коде:

// my.js
function test(node) {
  node.innerHTML = "newValue"
}

Строка запуска компилятора:

java -jar compiler.jar --compilation_level ADVANCED_OPTIMIZATIONS --js my.js

…То результат будет – пустой файл. Google Closure Compiler увидит, что функция test не используется, и с чистой совестью вырежет её.

А в следующем скрипте функция сохранится:

function test(n) {
  alert( "this is my test number " + n );
}
test(1);
test(2);

После сжатия:

function a(b) {
  alert("this is my test number " + b)
}
a(1);
a(2);

Здесь в скрипте присутствует явный вызов функции, поэтому она сохранилась.

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

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

Иначе говоря, продвинутый режим (ADVANCED_OPTIMIZATIONS), в отличие от простого (SIMPLE_OPTIMIZATIONS – по умолчанию), вообще не заботится о доступности кода извне и сохранении ссылочной целостности относительно внешних скриптов.

Единственное, что он гарантирует – это внутреннюю ссылочную целостность, и то – при соблюдении ряда условий и практик программирования.

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

То есть, продвинутый режим – это не просто «улучшенный обычный», а принципиально другой, небезопасный и обфусцирующий подход к сжатию.

Этот режим является «фирменной фишкой» Google Closure Compiler, недоступной при использовании других компиляторов.

Для того, чтобы эффективно сжимать Google Closure Compiler в продвинутом режиме, нужно понимать, что и как он делает. Это мы сейчас обсудим.

Сохранение ссылочной целостности

Чтобы использовать сжатый скрипт, мы должны иметь возможность вызывать функции под теми именами, которые им дали.

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

Существует два способа сохранения внешней ссылочной целостности: экстерны и экспорты. Мы в подробностях рассмотрим оба, но перед этим необходимо упомянуть о модулях – другой важнейшей возможности GCC.

Модули

При сжатии GCC можно указать одновременно много JavaScript-файлов. "Эка невидаль, " – скажете вы, и будете правы. Да, пока что ничего особого.

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

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

Для такой сборки используется флаг компилятора --module имя:количество файлов.

Например:

java -jar compiler.jar --js base.js --js main.js  --js admin.js --module
first:2 --module second:1:first

Эта команда создаст модули: first.js и second.js.

Первый модуль, который назван «first», создан из объединённого и оптимизированного кода первых двух файлов (base.js и main.js).

Второй модуль, который назван «second», создан из admin.js – это следующий аргумент --js после включённых в первый модуль.

Второй модуль в нашем случае зависит от первого. Флаг --module second:1:first как раз означает, что модуль second будет создан из одного файла после вошедших в предыдущий модуль (first) и зависит от модуля first.

А теперь – самое вкусное.

Ссылочная целостность между всеми получившимися файлами гарантируется.

Если в одном функция doFoo заменена на b, то и в другом тоже будет использоваться b.

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

Экстерны

Экстерн (extern) – имя, которое числится в специальном списке компилятора. Он должен быть определён вне скрипта, в файле экстернов.

Компилятор никогда не переименовывает экстерны.

Например:

document.onkeyup = function(event) {
  alert(event.type)
}

После продвинутого сжатия:

document.onkeyup = function(a) {
  alert(a.type)
}

Как видите, переименованной оказалась только переменная event. Такое переименование заведомо безопасно, т.к. event – локальная переменная.

Почему компилятор не тронул остального? Попробуем другой вариант:

document.blabla = function(event) {
  alert(event.megaProperty)
}

После компиляции:

document.a = function(a) {
  alert(a.b)
}

Теперь компилятор переименовал и blabla и megaProperty.

Дело в том, что названия, использованные до этого, были во внутреннем списке экстернов компилятора. Этот список охватывает основные объекты браузеров и находится (под именем externs.zip) в корне архива compiler.jar.

Компилятор переименовывает имя списка экстернов только когда так названа локальная переменная.

Например:

window.resetNode = function(node) {
  var innerHTML = "test";
  node.innerHTML = innerHTML;
}

На выходе:

window.a = function(a) {
  a.innerHTML = "test"
};

Как видите, внутренняя переменная innerHTML не просто переименована – она заинлайнена (заменена на значение). Так как переменная локальна, то любые действия внутри функции с ней безопасны.

А свойство innerHTML не тронуто, как и объект window – так как они в списке экстернов и не являются локальными переменными.

Это приводит к следующему побочному эффекту. Иногда свойства, которые следовало бы сжать, не сжимаются. Например:

window['User'] = function(name, type, age) {
  this.name = name
  this.type = type
  this.age = age
}

После сжатия:

window.User = function(a, b, c) {
  this.name = a;
  this.type = b;
  this.a = c
};

Как видно, свойство age сжалось, а name и type – нет. Это побочный эффект экстернов: name и type – в списке объектов браузера, и компилятор просто старается не наломать дров.

Поэтому отметим ещё одно полезное правило оптимизации:

Названия своих свойств не должны совпадать с зарезервированными словами (экстернами). Тогда они будут хорошо сжиматься.

Для задания списка экстернов их достаточно перечислить в файле и указать этот файл флагом –externs <файл экстернов.js>.

При перечислении объектов в файле экстернов – объявляйте их и перечисляйте свойства. Все эти объявления никуда не идут, они используются только для создания списка, который обрабатывается компилятором.

Например, файл myexterns.js:

var dojo = {}
dojo._scopeMap;

Использование такого файла при сжатии (опция –externs myexterns.js) приведёт к тому, что все обращения к символам dojo и к dojo._scopeMap будут не сжаты, а оставлены «как есть».

Экспорт

Экспорт – программный ход, основанный на следующем правиле поведения компилятора.

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

Например, window[„User“] превратится в window.User, но не дальше.

Таким образом можно «экспортировать» нужные функции и объекты:

function SayWidget(elem) {
  this.elem = elem
  this.init()
}
window['SayWidget'] = SayWidget;

На выходе:

function a(b) {
  this.a = b;
  this.b()
}
window.SayWidget = a;

Обратим внимание – сама функция SayWidget была переименована в a. Но затем – экспортирована как window.SayWidget, и таким образом доступна внешним скриптам.

Добавим пару методов в прототип:

function SayWidget(elem) {
  this.elem = elem;
  this.init();
}

SayWidget.prototype = {
  init: function() {
    this.elem.style.display = 'none'
  },

  setSayHandler: function() {
    this.elem.onclick = function() {
      alert("hi")
    };
  }
}

window['SayWidget'] = SayWidget;
SayWidget.prototype['setSayHandler'] = SayWidget.prototype.setSayHandler;

После сжатия:

function a(b) {
  this.a = b;
  this.b()
}
a.prototype = {b:function() {
  this.a.style.display = "none"
}, c:function() {
  this.a.onclick = function() {
    alert("hi")
  }
}};
window.SayWidget = a;
a.prototype.setSayHandler = a.prototype.c;

Благодаря строке

SayWidget.prototype['setSayHandler'] = SayWidget.prototype.setSayHandler

метод setSayHandler экспортирован и доступен для внешнего вызова.

Сама строка экспорта выглядит довольно глупо. По виду – присваиваем свойство самому себе.

Но логика сжатия GCC работает так, что такая конструкция является экспортом. Справа переименование свойства setSayHandler происходит, а слева – нет.

Планируйте жизнь после сжатия

Рассмотрим следующий код:

window['Animal'] = function() {
  this.blabla = 1;
  this['blabla'] = 2;
}

После сжатия:

window.Animal = function() {
  this.a = 1;
  this.blabla = 2
};

Как видно, первое обращение к свойству blabla сжалось, а второе (как и все аналогичные) – преобразовалось в синтаксис через точку. В результате получили некорректное поведение кода.

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

Если где-то возможно обращение к свойствам через квадратные скобки по полному имени – такое свойство должно быть экспортировано.

goog.exportSymbol и goog.exportProperty

В библиотеке Google Closure Library для экспорта есть специальная функция goog.exportSymbol. Вызывается так:

goog.exportSymbol('my.SayWidget', SayWidget)

Эта функция по сути работает также, как и рассмотренная выше строка с присвоением свойства, но при необходимости создаёт нужные объекты.

Она аналогична коду:

window['my'] = window['my'] || {}
window['my']['SayWidget'] = SayWidget

То есть, если путь к объекту не существует – exportSymbol создаст нужные пустые объекты.

Функция goog.exportProperty экспортирует свойство объекта:

goog.exportProperty(SayWidget.prototype, 'setSayHandler', SayWidget.prototype.setSayHandler)

Строка выше – то же самое, что и:

SayWidget.prototype['setSayHandler'] = SayWidget.prototype.setSayHandler

Зачем они нужны, если все можно сделать простым присваиванием?

Основная цель этих функций – во взаимодействии с Google Closure Compiler. Они дают информацию компилятору об экспортах, которую он может использовать.

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

Кроме того, экспорт через эти функции удобен и нагляден.

Если вы используете продвинутый режим оптимизации, то можно взять их из файла base.js Google Closure Library. Можно и подключить этот файл целиком – оптимизатор при продвинутом сжатии вырежет из него почти всё лишнее, так что overhead будет минимальным.

Отличия экспорта от экстерна

Между экспортом и экстерном есть кое-что общее. И то и другое даёт возможность доступа к объектам под исходным именем, до переименования.

Но, в остальном, это совершенно разные вещи.

Экстерн Экспорт
Служит для тотального запрета на переименование всех обращений к свойству. Задумано для сохранения обращений к стандартным объектам браузера, внешним библиотекам. Служит для открытия доступа к свойству извне под указанным именем. Задумано для открытия внешнего интерфейса к сжатому скрипту.
Работает со свойством, объявленным вне скрипта. Вы не можете объявить новое свойство в скрипте и сделать его экстерном. Создаёт ссылку на свойство, объявленное в скрипте.
Если window - экстерн, то все обращения к window в скрипте останутся как есть. Если user экспортируется, то создаётся только одна ссылка под полным именем, а все остальные обращения будут сокращены.

Стиль разработки

Посмотрим, как сжиматель поведёт себя на следующем, типичном, объявлении библиотеки:

(function(window, undefined) {

  // пространство имён и локальная переменная для него
  var MyFramework = window.MyFramework = {};

  // функция фреймворка, доступная снаружи
  MyFramework.publicOne = function() {
    makeElem();
  };

  // приватная функция фреймворка
  function makeElem() {
    var div = document.createElement('div');
    document.body.appendChild(div);
  }

  // ещё какая-то функция
  MyFramework.publicTwo = function() {};

})(window);

// использование
MyFramework.publicOne();

Результат компиляции в обычном режиме:

// java -jar compiler.jar --js myframework.js --formatting PRETTY_PRINT
(function(a) {
  a = a.MyFramework = {};
  a.publicOne = function() {
    var a = document.createElement("div");
    document.body.appendChild(a)
  };
  a.publicTwo = function() {
  }
})(window);
MyFramework.publicOne();

Это – примерно то, что мы ожидали. Неиспользованный метод publicTwo остался, локальные свойства переименованы и заинлайнены.

А теперь продвинутый режим:

// --compilation_level ADVANCED_OPTIMIZATIONS
window.a = {};
MyFramework.b();

Оно не работает! Компилятор попросту не разобрался, что и как вызывается, и превратил рабочий JS-файл в один сплошной баг.

В зависимости от версии GCC у вас может быть и что-то другое.

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

Типичный правильный стиль:

// пространство имён и локальная переменная для него
var MyFramework = {};

MyFrameWork._makeElem = function() {
  var div = document.createElement('div');
  document.body.appendChild(div);
};

MyFramework.publicOne = function() {
  MyFramework._makeElem();
};

MyFramework.publicTwo = function() {};

// использование
MyFramework.publicOne();

Обычное сжатие здесь будет бесполезно, а вот продвинутый режим идеален:

// в зависимости от версии GCC результат может отличаться
MyFrameWork.a = function() {
  var a = document.createElement("div");
  document.body.appendChild(a)
};
MyFrameWork.a();

Google Closure Compiler не только разобрался в структуре и удалил лишний метод – он заинлайнил функции, чтобы итоговый размер получился минимальным.

Как говорится, преимущества налицо.

Резюме

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

Это является принципиальным отличием, по сравнению с другими упаковщиками.

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

Основная проблема этого сжатия – усложнение разработки. Добавляется дополнительный уровень возможных проблем: сжатие. Конечно, можно отлаживать и сжатый код, для этого придуманы Source Maps, но клиентская разработка и без того достаточно сложна.

Поэтому его используют редко.

Как правило, есть две причины для использования продвинутого режима:

  1. Обфускация кода.

    Если в коде после обычного сжатия ещё как-то можно разобраться, то после продвинутого – уже нет. Всё переименовано и заинлайнено. В теории это, конечно, возможно, но «порог входа» в такой код несоизмеримо выше.

    Судя по виду скриптов на сайтах, созданных Google, сам Google жмёт свои скрипты именно продвинутым режимом оптимизации. И библиотека Google Closure Library тоже рассчитана на него.

  2. Хорошие сжатие виджетов, счётчиков.

    Небольшой код, который отдаётся наружу, может быть сжат в продвинутом режиме. Так как он небольшой – все ошибки можно легко исправить, а продвинутый режим гарантирует наилучшее сжатие.

Карта учебника