А ваш AngularJS умеет работать на 3.5Mb ОЗУ?

в 21:01, , рубрики: AngularJS, javascript, Блог компании ABBYY, Клиентская оптимизация

А ваш AngularJS умеет работать на 3.5Mb ОЗУ? - 1
В начале весны ABBYY LS совместно с Xerox запустили сервис для перевода документов Xerox Easy Translator Service. Изюминкой этого сервиса является приложение, запускаемое на МФУ Xerox и позволяющее отсканировать необходимое количество документов, дождаться перевода на один из выбранных 38 языков, произвести печать перевода — и все это не отходя от МФУ.

Приложение запускается на определенной серии МФУ Xerox на основе технологии Xerox ConnectKey с сенсорным экраном 800x480 точек. Аппаратная начинка МФУ зависит от конкретной модели, например, наша тестовая малютка Xerox WorkCentre 3655 имеет на борту 1Ghz Dual Core процессор и 2Gb оперативной памяти. Как ни удивительно, но МФУ имеет встроенный webkit-браузер, а наше приложение — это обычное html-приложение, разработанное на AngularJS 1.3.15.

О самом проекте мы писали в блоге раньше, а эта статья посвящена одному из увлекательных этапов проекта, а именно оптимизации AngularJS под работу на МФУ Xerox. Как оказалось на деле, платформа МФУ практически не накладывает никаких серьезных ограничений на разработку приложений, и они работают практически так же, как и на десктопных webkit-браузерах, за исключением одного НО — html-приложению для исполнения JS выделятся всего 3.5 Mb оперативной памяти (на данный момент Xerox уже выпустили обновление для своей платформы, подняв порог выделяемой памяти до 10 Mb). AngularJS эти 3.5 Mb съедал за несколько минут работы в приложении, а сборщик мусора встроенного браузера МФУ не успевал за такой прожорливостью и просто выбивал наше приложение на главный экран МФУ. Вдобавок у Xerox нет средств для анализа и дебага приложений, запущенных на МФУ.

Сначала казалось, что сделать ничего не получится (тем более не понаслышке зная прожорливость современных браузеров), но грамотно оценив ситуацию, мы все же решились попробовать укротить AngularJS и заставить приложение потреблять минимально возможное количество памяти. Начав с 220kb скомпилированного (минимизированного, не gzip) JS кода приложения, мы закончили 97kb (AngularJS занимает 56kb, все остальное – наш код), по максимуму удалив весь незадействованный код, либо видоизменив его для наименьшего потребления памяти. Результат – стабильная работа приложения в течение нескольких десятков минут на платформе с 3.5 Mb памяти и полная неубиваемость на новой платформе с 10 Mb. Что же мы сделали?

Http-запросы

Основная проблема, с которой мы столкнулись сразу же, — это “тяжелые” http-запросы. Их “тяжесть” измеряется не в количествах или объемах передаваемых данных, а в создаваемом при каждом запросе новом объекте XmlHttpRequest под капотом $http сервиса AngularJS. Официальная информация в секции рекомендаций SDK Xerox указывала на то, что крайне желательно использовать в приложении всего один объект XmlHttpRequest и все запросы последовательно выполнять, используя только один объект.

Примеры из SDK носили весьма простой характер — буквально пару запросов на все приложение, что, в принципе, никак не усложняет использование одного объекта XmlHttpRequest в голом виде с применением нативных коллбеков этого объекта. В нашем же приложении организована весьма хитрая логика синхронизации заказов пользователя, oauth авторизация, запросы к soap-сервисам МФУ для запуска сканирования или печати. К тому же, запросы к МФУ выполнялись с использованием кода из SDK Xerox, который создавал свой объект XmlHttpRequest, тянул за собой методы для работы с xml-ответом soap-сервисов и в целом создавал дополнительную сложность при парсинге этого xml-ответа и приводил к ситуации написания не Angular-way кода.

Таким образом, мы столкнулись с действительно серьезными проблемами: отсутствие нормальных примеров реального использования одного объекта XmlHttpRequest, широкого спектра использования запросов и полу-legacy кодом из SDK. Несмотря на всю сложность, выход из ситуации оказался прост – написать свой $http сервис, отказаться от кода из Xerox SDK и написать свои Angular-сервисы для поддержки сканирования и печати.

Одной из главных трудностей было еще и то, чтобы наш кастомный сервис должен иметь такой же программный интерфейс, как и ангуларовский $http сервис, чтобы сохранить уже работающий и протестированный код наших контроллеров и зависимых от $http сервисов. Так как в приложении использовались только get и post запросы, в простой аннотации $http.get(...) и $http.post(...), то сам сервис выглядит вот так:

function ($q) {

   var queue = [];

   // execute request
   function query() {
       var request = queue[0];
       var defer = request.defer;
       xhr.open(request.method, request.url, true);
       // set headers
       var headers = request.headers;
       for (var i in headers) {
           xhr.setRequestHeader(i, headers[i]);
       }
       // load callback
       xhr.onreadystatechange = function () {
           if (xhr.readyState == 4 && !defer.promise.$$state.status) {
               var status = xhr.status;
               var data = JSON.parse(xhr.response);
               (200 <= status && status < 300 ? defer.resolve : defer.reject)({
                   data: data,
                   status: status
               });
               queue.shift();
               if (queue.length) {
                   query();
               }
           }
       };
       // send data
       xhr.send(request.data);
   }

   // add request to queue
   function push(method, url, data, headers) {
       var defer = $q.defer();
       queue.push({
           data: typeof data === "string" ? data : JSON.stringify(data),
           defer: defer,
           headers: headers,
           method: method,
           url: url
       });
       if (queue.length == 1) query();
       return defer.promise;
   }

   return {
       // get request
       get: function (url, data, headers) {
           return push("GET", url, data, headers);
       },

       // post request
       post: function (url, data, headers) {
           return push("POST", url, data, headers);
       }
   };
}

Это минимальный вид нашего сервиса, который, используя один объект XmlHttpRequest, способен выполнять последовательно любое количество http-запросов без угрозы агрессивного потребления памяти МФУ. В конечном результате данный сервис содержит функциональность http interceptor’ов (без возможности внесения изменений в конечный ответ запроса, правильнее было бы назвать http listeners, используем для логирования ошибок), отмены очереди запросов $http.cancel(), плюс дополнительные свойства результирующего объекта, которые позволяют понять, что запрос был отменен пользователем или отвалился по таймауту (30 секунд на запрос), например:

$http.get(...).catch(function (response) { if (response.canceled) { ... } });

Следующий этап – обернуть вызовы soap-сервисов МФУ в соответствующие Angular-сервисы. Основная проблема здесь состоит в том, что ответ от МФУ мы получаем в виде громоздкой soap’овской xml, а реально необходимые данные занимают всего несколько байт. Чтобы упростить этот этап, из исходной xml (которая нам пришла в виде строки) мы с помощью регулярного выражения “вынимаем” только тот тег, который нам интересен:

var parser = new DOMParser();

function toXml (xml, tag) {
   if (tag) {
       var node = new RegExp('((<|<)[\w:]*' + tag + '(>|>|\s).*/[\w:]*' + tag + '(>|>))', 'g').exec(xml);
       return node && node.length ? parse(node[1]) : null;
   } else {
       return parse(xml);
   }
}

function parse(xml) {
   return parser.parseFromString(xml
       .replace(/amp;/g, '')
       .replace(/</g, '<')
       .replace(/>/g, '>')
       .replace(/"/g, '"')
       .replace(/<w+:/g, '<')
       .replace(/</w+:/g, '</'), 'text/xml').documentElement;
}

В результате мы получаем DOM-дерево, забрать данные из которого уже не составляет труда. К тому же, по DOM-дереву можно искать интересующие нас теги, используя возможности querySelector. Изначально код из SDK Xerox всегда парсил xml ответ целиком, а поиск по DOM-дереву выполнялся путем кастомного обхода дерева до нахождения нужного элемента (что-то вроде куцего самописного XPath в JS). Действительно сложно ответить, какой из подходов лучше и меньше потребляет памяти и системных ресурсов, но лично мы почему-то больше доверяем нативным функциям браузера DomParser.parseFromString, querySelector (querySelectorAll) по работе с DOM деревом, чем ручной обход.

Итого:
Разработана своя функциональность для выполнения http-запросов и простого парсинга xml, в уменьшенном виде занимающие 2.3kb. Из приложения удален весь зависимый код SDK Xerox, в минифицированном виде занимавший 17kb.
Из AngularJS были удалены сервисы $http и $httpBackend.

Роутинг

Изначально в проекте использовался всем известный ui-router версии 0.2.13. Это действительно замечательное, разностороннее и уникальное в своем роде решение для AngularJS. Используя его, мы сделали вполне обычный роутинг приложения, для модальных окон использовались вложенные состояния.

Разумеется, есть менее функциональное и легковесное решение непосредственно от самих разработчиков AngularJS, которое изначально не подходило в своем чистом виде и требовало доработок для модальных окон. Но именно исходный код этого модуля был активно использован для разработки собственного решения. В процессе оптимизации приложения нами было обнаружено, что вся функциональность модуля ui-router нам не нужна, а именно – у нас не было необходимости в url-роутинге (приложение на МФУ открывается на весь экран и нет доступа к адресной строке), вложенных состояниях, resolve и пр. Все, что нам нужно от роутинга это:

1. Возможность простого конфигурирования состояний (экранов и модальных окон) приложения.
2. Сопутствующие директивы и сервисы для кэширования и навигации между экранами и (или) модальными окнами.
3. Корректная подстановка и удаление из DOM-дерева html-шаблонов посещаемых экранов, а также отображение модальных окон поверх исходного экрана (аналог вложенных состояний ui-router, но нам нужен всего один уровень вложенности).

Первый пункт реализуется весьма легко:

xerox.provider("$route", function () {
   ...
   var base = "/";
   var routes = {};
   var start;
   var self = this;

   // add new route
   function add(name, templateUrl, controller, modal) {
       routes[name] = {
           name: name,
           modal: modal,
           controller: controller,
           templateUrl: base + templateUrl + ".html"
       };
       return self;
   }

   // set start state
   self.start = function (name) {
      start = name;
      return self;
   };


   // add modal
   self.modal = function (name, templateUrl, controller) {
       return add(name, templateUrl, controller, true);
   };

   // add state
   self.state = function (name, templateUrl, controller) {
       return add(name, templateUrl, controller, false);
   };

   self.$get = [...];

});

На стадии конфигурирования:

xerox.config(["$routeProvider", function ($routeProvider) {

   $routeProvider
         
      // default state
      .start("settings")
          
      // modals
      .modal("login", "login/login", "login")
      .modal("logout", "login/logout", "logout")
      .modal("processing", "new-order/processing", "processing")
          
      // states
      .state("settings", "new-order/settings", "settings")
      .state("languages", "new-order/languages", "languages");

}]);

Второй пункт реализуется посредством сервисов:

$view

xerox.factory("$view", ["$http", "$locale", "$q", function ($http, $locale, $q) {

   var views = {};

   return {

       // get view
       get: function (url) {
           var self = this;
           if (views[url]) {
               return $q.when(views[url]);
           } else {
               return $http.get(url).then(function (response) {
                   var template = response.data;
                   self.put(url, template);
                   return template;
               });
           }
       },

       // put view
       put: function (url, text) {
           views[url] = text;
       }

   };
}]);

и $route

return {

   // route history
   var history = [];

   // $route interface
   var $route = {
       // current route
       current: null,

       // history back
       back: function () {
           if ($route.current.modal) {
               $rootScope.$broadcast("$routeClose");
           } else {
               $route.go(history.pop() && history.pop());
           }
       },

       // goto route
       go: function (name, params) {
           prepare(name, params);
       }
   };

   // prepare and load route
   function prepare(name, params) {
       var route = routes[name];
       $view.get(route.templateUrl).then(function (template) {
           route.template = template;
           commit(route, params);
       });
   }

   // commit route
   function commit(route, params) {
       route.params = params || {};
       if (!route.modal) {
           history.push(route.name);
       }
       $route.current = route;
       $rootScope.$broadcast("$routeChange");
   }

   // routing start
   prepare(start);

   return $route;
}];

А также директив xrx-back:

xerox.directive("xrxBack", ["$route", function ($route) {
   return {
       restrict: "A",
       link: function (scope, element) {
           element.on(xrxClick, $route.back);
       }
   };
}]);

xrx-sref:

xerox.directive("xrxSref", ["$route", function ($route) {
   return {
       restrict: "A",
       link: function (scope, element, attr) {
           element.on(xrxClick, function () {
               $route.go(attr.xrxSref);
           });
       }
   }
}]);

и scriptDirective (для кэширования text/ng-template):

xerox.directive("script", ["$view", function ($view) {
   return {
       restrict: "E",
       terminal: true,
       compile: function(element, attr) {
           if (attr.type == "text/ng-template") {
               $view.put(attr.id, element[0].text);
           }
       }
   };
}]);

В сервисе $route мы организуем дополнительную логику для модальных окон, а именно: 1) не помещаем их в историю состояний и 2) при попытке вызвать $route.back при открытом модальном окне триггерим событие, что необходимо закрыть модальное окно. На событие подписана директива xrx-view, которая и реализует пункт 3:

xerox.directive("xrxView", ["$compile", "$controller", "$route", function ($compile, $controller, $route) {
   return {
       restrict: "A",
       link: function (scope, element) {

           var stateScope;
           var modalScope;
           var modalElement;
           var targetElement;

           // destroy scope
           function $destroy(scope) { scope && scope.$destroy(); }

           // on route change
           scope.$on("$routeChange", function () {
               var current = $route.current;
               var newScope = scope.$new();

               // prepare scopes and DOM element
               $destroy(modalScope);
               if (current.modal) {
                   modalScope = newScope;
                   // find or create modal container
                   modalElement = element.find(".modals");
                   if (!modalElement.length) {
                       modalElement = xrxElement("<div class=modals>");
                       element.append(modalElement);
                   }
                   targetElement = modalElement;
               } else {
                   $destroy(modalScope);
                   $destroy(stateScope);
                   modalScope = null;
                   stateScope = newScope;
                   targetElement = element;
               }

               // append controller and inject { $scope, $routeParams }
               if (current.controller) {
                   targetElement.data("$ngControllerController", $controller(current.controller, {
                       $routeParams: current.params,
                       $scope: newScope
                   }));
               }

               // append Template to DOM and compile
               targetElement.html(current.template);
               $compile(targetElement.contents())(newScope);
           });

           // on modal close
           scope.$on("$routeClose", function () {
               $destroy(modalScope);
               modalScope = null;
               modalElement.remove();
           });

       }
   };
}]);

На этом всё. Роутинг является максимально легковесным, поддерживает работу как с реальными html-шаблонами, так и их аналогами из <script type=text/ng-template>...</script>, реализует необходимую нам логику модальных окон. Дополнительно имеет схожий с ui-router синтаксис для работы и конфигурирования состояний приложения.

Итого:
Из приложения был исключен ui-router размером 28kb и разработана своя функциональность в минимальном виде занимающий всего 1.8kb.

Из AngularJS были удалены следующие сервисы и директивы:

  • ng-controller
  • ng-include
  • scriptDirective (помещает в $templateCache скрипты c типом text/ng-template)
  • $anchorScroll
  • $location
  • $cacheFactory
  • $templateCache

Локализация приложения

К тому моменту, когда мы начали проводить полномасштабную оптимизацию приложения, мы уже имели почти полностью локализованное приложение на шести языках – английском, немецком, французском, итальянском, испанском и португальском. Тексты языков хранились по типу ключ-значение в JSON, а подставлялись в приложении с помощью одностороннего биндинга {{::locale.HELLO_HABR}}. В загрузке локализации из JSON все довольно просто и оптимизировать больше нечего:

angular.element(document).ready(function () {
   window.$locale(function () {
       angular.bootstrap(document.body, ["xerox"]);
   });
});

Внутри функции $locale идет определение языка интерфейса и подгружается наиболее подходящий язык из JSON с помощью глобального xhr.

Но вот стадию реалтаймовой локализации приложения можно и нужно оптимизировать, хотя она и использует односторонний биндинг, но все равно это дополнительная работа внутри digest цикла при каждом заходе на страницу. К тому же в локализации есть тексты с версткой, которые требуют применения ng-bind-html, а тот в свою очередь влечет и еще дополнительные проверки сервисом $sanitize. Решение далеко не из лучших, но ничего более удобного сделать, собственно, практически и нельзя было до того момента, пока не был разработан свой роутинг. С появлением собственного сервиса загрузки и кэширования html-шаблонов $view, несомненно, пришла идея использовать его для локализации приложения.

Что же нам для этого пришлось сделать? В принципе совсем немного:

1. Во всех html шаблонах места, требующие локализации, обернуть двойными квадратными скобками, а-ля, было {{::locale.HELLO_HABR}}, стало — [[HELLO_HABR]]
2. Так как такое сочетание квадратных скобок в приложении уникально, то мы можем с помощью регулярного выражения сделать обычный replace, минуя стадию готового DOM и в целом digest цикл, а если быть точнее – производить локализацию до того, как шаблон будет скомпилирован и вставлен в DOM:

2. xerox.factory("$view", ["$http", "$locale", "$q", function ($http, $locale, $q) {

   var views = {};
   // locale inject RegExp
   var localeRegExp = /[[(w+)]]/mg;

   // template localization
   function localization(template) {
       var match;
       while (match = localeRegExp.exec(template)) {
           template = template.replace(match[0], $locale[match[1]]);
       }
       return template;
   }

   return {

       ...

       // put view
       put: function (url, text) {
           views[url] = localization(text);
       }
   };
}]);

Таким образом, локализация срабатывает однократно в момент старта Angular-приложения и в памяти мы уже храним локализованные html-шаблоны.

Итого:
Локализация приложения вынесена из цикла digest на стадию загрузки приложения.
Из AngularJS были удалены следующие сервисы и директивы:

  • ng-bind
  • ng-bind-html
  • ng-bind-template
  • ng-pluralize
  • $$sanitizeUri
  • $sce
  • $sceDelegate

ng-model

Директива ng-model (и остальные директивы для работы с html формами, связанные с ней,) – одна из жемчужин AngularJS, это невероятный инструмент, в который влюбляешься с первого знакомства. Но мало кто знает, что скрыто под капотом ng-model. Это в действительности весьма тяжеловесный код, отслеживающий события на элементе (cut, paste, change, keydown), синхронизирующий реальное значение модельки с отображаемым значением на экране, проверяющий при каждом изменении нашу модельку, предоставляющий интерфейс-контроллер для работы с моделькой в наших директивах.

На деле оказалось, что нам все эти возможности и не нужны. Например, не нужна валидация, так как даже форма авторизации, согласно всем гайдлайнам, выводит ошибку в модальном окне только после неудачного серверного запроса. Кастомные чекбоксы, селектбоксы и списки тоже не требуют проверки, а директивы, которые их реализуют, работают с моделькой в режиме read-write-watch. То есть директива checkbox выглядит как-то так:

xerox.directive("checkbox", function () {
   return {
       restrict: "E",
       scope: {
           xrxModel: "="
       },
       link: function (scope, element) {
           var icon = xrxElement("<div class=checkbox-icon>");
           element.prepend(icon);
           icon.on(xrxClick, function () {
               if (!element.attr(xrxDisabled)) {
                   scope.$apply(function () {
                       scope.xrxModel = !scope.xrxModel;
                   });
               }
           });
           scope.$watch("xrxModel", function (value) {
               element[value ? "addClass" : "removeClass"]("checked");
           });
       }
   };
});

Единственное – у нас есть форма авторизации, на которой мы используем текстовые инпуты. Поэтому директива клавиатуры, как и ng-model, отслеживает события cut, change, paste, но в более облегченном виде, не запуская маховик валидации и остальных вкусняшек AngularJS при работе с модельками.

Пару слов о клавиатуре, раз ее затронули, вот ее реальный вид:
А ваш AngularJS умеет работать на 3.5Mb ОЗУ? - 2

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

Итого:
Из AngularJS были удалены следующие директивы:
ng-model
ng-list
ng-change
pattern
required
minlength
maxlength
ng-value
ng-model-options
ng-options
ng-init
ng-form
input
form
select

Скролл

Немало масла в огонь подлили нам и скроллируемые списки:

А ваш AngularJS умеет работать на 3.5Mb ОЗУ? - 3

Оптимизируя потребление памяти, мы отказались от ng-repeat (который создает свой scope для каждого элемента), написали свое легковесное решение и думали, что на этом все, но вот рендеринг списка из 38 языков изрядно притормаживал на МФУ. Дополнительно дело ухудшало еще и то, что МФУ не рисует в браузере системный скролл и приходится его рисовать собственными средствами. Мы перепробовали множество ухищрений, начиная от использования -webkit-scrollbar и заканчивая кастомным скроллингом через element.scrollTop или -webkit-transform: translate(x, y) с использованием overflow: hidden. Попытки понять принцип рендеринга браузера тоже не увенчались успехом. Либо тормозил сам скролл, либо перестроение списка (пользователь выбрал другой Source язык и нужно перестроить список Target языков, который в себе не содержит выбранный Source язык).
Уже практически потеряв надежду, в один из очередных экспериментов, мы заметили, что если в список вставить несколько элементов и менять только их innerHTML, рендеринг не тормозит, а скролл осуществляется плавно и без задержек. Этим нелегким путем в приложении появилась директива для скролла, принцип ее работы прост и хитер одновременно:

1. В контейнер вставляется необходимое количество элементов, чтобы заполнить всю его высоту, например, 7 элементов списка.
2. На основе значения offset'а (отступ от начала массива данных) и html шаблона меняются innerHTML наших элементов.
3. Отлавливаем события нажатия на стрелочки скролла или «таскание» (mouseDown-mouseMove-mouseUp) ползунка, высчитываем offset, меняем позицию ползунка и возвращаемся к пункту 2.
Таким образом, создается ощущение скролла данных, хотя на деле меняется только внутреннее содержимое всех тех же 7 элементов списка.

Итого:
Из AngularJS была удалена директива ng-repeat, так как в ней больше не было ни какого смысла, а всю нужную нам работу выполняла новая директива скролла.

Дополнительно

Дополнительно над AngularJS был произведен еще ряд шаманств:

  • полностью удален функционал анимации ($animate, $$rAF);
  • удалены директивы для работы с классами и атрибутами (ng-class, ng-class-even и пр.);
  • ng-if и ng-switch заменены их оптимизированными аналогами – устанавливается display: none для html-элементов, не удовлетворяющих условию, минуя создание своего scope, как в оригинальных директивах, а ng-show и ng-hide удалены совсем;
  • удалены $filter и $locale, а все необходимые данные подготавливаются непосредственно в наших сервисах на основе небольших самописных решений.

Выводы

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

Мы на своем опыте убедились, что, когда наступает время необходимой оптимизации, её вполне по силам сделать практически любой творческой команде разработчиков. Затраченное время на все описанные оптимизации составило порядка 12-15% от общего времени разработки проекта, что, в принципе, более чем достаточно и мы остались очень довольны достигнутыми результатами.

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

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

Спасибо специалистам "СимбирСофт" за активное участие в работе над проектом.

Автор: ABBYY

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js