[Перевод] Air Berlin: реализация Progressive Web App

Привет, Хабр! На прошедшей 18 мая в Калифорнии юбилейной конференции для разработчиков Google I/O было много всего. Серьёзные штуки для Android, монументальные изменения и интеграции в продуктах Firebase, да и просто множество анонсов и классных технологий. Но кое-что ещё мы пока не обсуждали. Речь идёт о Progressive Web App«ах (современных веб-приложенях) — сайтах, написанных так, словно это современные мобильные приложения: удобные, простые, интуитивно понятные и комфортные для использования на сенсорном дислпее.

Поэтому в ближайшие два месяца мы собираемся не только публиковать статьи по теме PWA, но и провести тематическую онлайн конференцию 11 октября — Progressive Web Apps Day. Пока же предлагаем вашему вниманию реальный кейс использования PWA от AirBerlin.

03e6b4bdcf256ded9101c77bc76780fb.png

Авиакомпания airberlin первая в мире разработала такой сайт-программу, а Ханс Швагер, глава мобильных разработок и инноваций, поделился своим опытом с посетителями конференции: «Популярность и важность умных мобильных интернет-сервисов в сфере авиаперевозок будет и дальше расти, и мы точно знаем, что нашим пассажирам, вне зависимости от того, где они находятся, важно легко и быстро проверять информацию и получать посадочные талоны непосредственно с их мобильных устройств. Именно поэтому мы привлекли подразделение перспективных разработок к созданию Progressive Web App«а — гибрида, сочетающего лучшие стороны мобильных приложений и интернет-сайтов».

Технология современных веб-приложений позволяет пассажирам airberlin получать доступ к посадочным талонам и информации об их путешествии в любой время, даже если последний раз он заходили на наш сайт через Wi-Fi отеля или дома, а в аэропорту внезапно остались без связи. Это позволяет нам предоставлять клиентам очень простой и понятный сервис, повысить удобство и на маленький шажок приблизить будущее мобильной разработки.

Что такое «современное веб-приложение»?


Если говорить по-простому, то это вебсайт, который выглядит и ощущается, как мобильное приложение. После первого захода доступен (частично или полностью) оффлайн благодаря кешированию, поддерживает браузеры Chrome и Firefox с версии 40, а также актуальные сборки Opera. Такое «приложение», в отличие от обычной мобильной версии сайта, хорошо работает на медленном интернет-соединении (например, в перегруженном бесплатном Wi-Fi в аэропорту), тратит минимум трафика, может быть добавлено на рабочий стол, как обычная иконка, имеет доступ к системе уведомлений смартфона и не требовательно к ресурсам смартфона.

Как мы создали PWA airberlin


Рассказывают Marian Pöschmann, главный по тому-то и тому-то, и Axel Michel, ответственный за то-то и то-то.

Основные технологии


Под капотом PWA скрываются простые вещи, основная задача — собрать всё правильным образом.

Web Components
Идея проста: всё, кроме интерфейса прогрессивного веб-приложения — это компоненты. Мы использовали Polymer 1.0, создали отдельные компоненты для слайдера, которые включают всевозможные формы, детали и элементы, из которых строится результат: «виртуальный билет», который увидит пользователь.

Custom Events
Для взаимодействия между компонентами, мы написали основной скрипт, который централизованно управляет асинхронными запросами, историей, данными, используемыми в приложении, их кеширование и предоставление.

HistoryAPI
Наше прогрессивное приложение, фактически, состоит из одной страницы. Так как service worker или кэширование не умеет работать с хэшем в url«ах, мы решили использовать GET«ы для различения статусов и различных «экранов» приложения. В принципе, решение нормальное, но у него есть некоторые проблемы с оффлайн-работой. На будущее — не используйте только значения, отсылайте сразу пары параметр — значение, если вы хотите, чтобы ваши запросы нормально обрабатывались. Ну или пересоздавайте эти запросы, исходя из информации, которую закодируете в URL (об этом будет отдельно сказано чуть дальше, в примерах кода).

Service worker
Как мы уже отмечали, наше приложение, фактически — сайт-одностраничник с одним-единственным юзкейсом. Добавить к такому проекту service worker для оффлайн-работы — проще простого. Сам интерфейс кешируется в процессе первичной «установки», данные и дополнительные файлы закачиваются по первому запросу. Больше проблем доставило удаление нужных данных в нужное время. Также через service worker мы интегрировали push-уведомления для реализации check-in«а в два тапа.

WebSQL
В добавок к оффлайн-обработчику мы хотели улучшить впечатления пользователей от работы без подключения к сети, используя localForage: технологию, которая содержит в себе сразу IndexDB, WebSQL и / или localStorage. Всё взаимодействие между сервером и клиентом описывается JSON«ом, что сильно упрощает дальнейшую разработку.

VanillaJS
Используется для всего остального. Базовые DOM-селекторы, кое-какие асинхронные запросы, в общем всё то, что мы не хотели реализовывать через сторонние библиотеки. Единственный случай подключения готового js — использование moment«а, который полностью обрабатывает расчёт различных часовых поясов и дат: в конце-концов, некоторые рейсы могут и в прошлое / будущее вас отправить. For the rest. Some basic

Manifest and Meta data
Эти штуки нужны, чтобы пользователь мог добавить приложение к себе на домашний экран / рабочий стол. К сожалению, iOS остатёт от Android в плане поддержки оффлайн-возможностей приложения (её попросту нет), мы решили предоставить пользователям правильную иконку, заголовок и цветовую схему на Android и iOS.

Как мы это сделали


Быстрая регистрация на рейс и возможность сесть в самолёт, не устанавливая на телефон вообще ничего отдельного — это круто, поэтому мы хотели, чтобы приложение было быстрым. Поэтому мы загружаем всё, кроме базового css и кое-каких плэйсхолдеров динамически, не блокируя DOM и не ожидая прогрузок.

Базовая HTML-структура:


Стартовый JavaScript:
(function() {
  var raf = window.RequestAnimationFrame  ||
      window.mozRequestAnimationFrame ||
      window.webkitRequestAnimationFrame ||
      window.msRequestAnimationFrame;

  // defer loading of all app relevant javascript
  // and non criticial CSS
  function deferLoad() {
    // JS
    var element = document.createElement("script"),
      l = document.createElement('link');
    element.src = "javascript/app.js";
    document.body.appendChild(element);

    // CSS
    l.rel = 'stylesheet';
    l.href = 'css/main.css';
    document.getElementsByTagName('head')[0].appendChild(l);
  }
  if (raf) {
    raf(deferLoad);
  } else if (window.addEventListener) {
    window.addEventListener("load", deferLoad, false);
  } else if (window.attachEvent) {
    window.attachEvent("onload", deferLoad);
  } else {
    window.onload = deferLoad;
  }
})();

Как это работает?


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

Основной CSS, большая часть нашего скрипта и сторонних библиотек (polymer, moment, local forage) загружаются в фоне, после чего основной сайт подключает различные элементы через polymer. Пол Льюис написал отличную статью по этой теме: aerotwist.com/blog/polymer-for-the-performance-obsessed

По сравнению с обычной мобильной страницей (m.airberlin) общее время загрузки примерно одинаковое — полторы секунды для нашего подхода и две с половиной для классического, при использовании 3G-соединения. Однако отрисовка контента и самого сайта начинается намного раньше: спустя пол секунды после открытия приложения. У мобильного сайта — ужасные 1.2 секунды до появления первых элементов. К тому моменту PWA уже открыт и готов к работе. Мы стремимся сделать загрузку ещё быстрее, но применённые технологии и так минимизировали время загрузки и избавили пользователей от необходимости наблюдать, как скачут элементы по странице в процессе подгрузки стилей и картинок.

Маленькие хитрости


Ещё один трюк, помогающий сократить время ожидания и улучить пользовательский опыт мы подсмотрели у Facebook«а. Дело вот в чём: современные девайсы отличаются и разрешением дисплея, и весьма некислым разбросом показателей — вы можете встретить как шестидюймовую лопату с 720p, так и 5-дюймовый девас с дисплеем 2560×1440 или вообще наткнуться на 4k2k в мобильном девайсе. Для каждого из популярных решений подготовлена своя фоновая картинка, а для того, чтобы пользователь не смотрел на одноцветную подложку, мы применили очень маленькое изображение (60×40 точек) и размытие по гауссу. В итоге пользователь видит всё почти так, как надо, а как только в фоне подгрузится изображение с нужным разрешенем — мы заменяем размытый low-res на актуальную картинку из кэша.

Наш первый обработчик состоял всего из нескольких строк кода. После его активации мы просто подгружали весь статический контент, и тупым образом всё запихивали в кэш. Такой подход устраивал нас, пока мы работали с прототипом приложения, в котором был всего один «полёт», с одним местом назначения, без истории, вообще без чего бы то ни было, что могло бы устареть или потерять актуальность. Разумеется, в реальности от PWA требуется несколько больше: отобразить информацию о полёте, создать посадочный талон. А он, в свою очередь, должен быть уничтожен или забракован после полёта, да и для разных рейсов требуется отображать всякую дополнительную информацию: картинки, текст, что угодно.

5f5f5e3cdd038b39b4151a56259d4661.jpg
Регистрация на рейс стала проще и быстрее: добавили полёт, номер бронирования, нажали на кнопку и получили посадочный талон. Проще не бывает.

Заливать всё это в кэш или хранить всю информацию обо всех направлениях разом как-то не особо progressive, поэтому мы просто уничтожаем всё через 48 часов после посадки самолёта. Так как мы используем WebSQL и локальное хранилище, удалять данные приходится дважды. Сейчас за это отвечает вот такой код:

Code Fragment app.js:

function _isEmpty = function(obj) {
    if ('undefined' !== Object.keys) {
        return (0 === Object.keys(obj).length);
    }
  for(var prop in obj) {
      if(obj.hasOwnProperty(prop)) {
                return false;
            }
  }
  return true;
};

// called whenever a checkin is requested to be displayed
function checkCheckinStatus( checkinID ) {
  var tS = Math.floor(now.getTime() / 1000),
      removeCheckinFromApp = function(cid) {
    // remove from data 
    delete app.data[cid];

    // update local cache
    localforage.setItem('flightData',app.data);

    // trigger event for updating UI elements
    var event = new CustomEvent(
      'updatedData',
      {detail: {modified: cid}}
    );
    document.dispatchEvent(event);
 };

  // no data or no checkin data? - return
  if(_isEmpty(app.data) || !app.data[checkinID]) {
    return false;
  }

  // remove only in case arrival time is min. 48 hours in past
  if((tS - app.data[checkinID].ticket.arrivalTimestamp) < (60 * 60 * 48) ) { return false;}

  if ('serviceWorker' in navigator) {
    // delete cache of flight in service worker...
    app.sendMessage( {
      command: 'deleteCheckin', 
      keyID: checkinID } 
    ).then(function(data) {
      // remove the checkin from app data...
      removeCheckinFromApp(checkinID);
    }).catch(e) {
      // could not remove checkin from service worker
    };
  } else {
    removeCheckinFromApp(checkinID);
  }
}

// send data to the service worker
app.sendMessage = function(message) {
  return new Promise(function(resolve, reject) {
    var messageChannel = new MessageChannel();
    // the onmessage handler
    messageChannel.port1.onmessage = function(event) {
      if (event.data.error) {
        reject(event.data.error);
      } else {
        resolve(event.data);
      }
    };
    
    if(!navigator.serviceWorker.controller){
      return;
    }

    // This sends the message data and port to the service worker.
    // The service worker can use the port to reply via postMessage(), which
    // will he onmessage handler on messageChannel.port1.
    navigator.serviceWorker
      .controller.postMessage(message,[messageChannel.port2]);
  });
}

Code Fragment service-worker.js:
var cacheName = 'v1',
    checkinDataRegex = /applicable\?pnr=([a-zA-Z0-9]+)&lastname=([a-zA-Z]+)/
    ticketRegex = /image\/pnr\/([a-zA-Z0-9]+)\/lastname\/([a-zA-Z]+)\/ticket\/([0-9]+)/;

self.addEventListener('fetch', function(event) {
    var request = event.request,
        matchCheckin = checkinDataRegex.exec(request.url);
    if (matchCheckin) {
        // Use regex capturing to grab only the bit of the URL
        // that we care about (in this case the checkinID)
        var cacheRequest = new Request(match[1]);
        event.respondWith(
            caches.match(cacheRequest).then(function(response) {
                return response || fetch(request).then(function(response) {
                    caches.open(cacheName).then(function(cache) {
                      cache.put(cacheRequest, response);
                    })
                    return response;
                });
            })
        );
    }
    if (ticketRegex) { 
      // disable the image (by replacing it)
      [...]
    }
    [...]
});

// communication between the service worker and the app.js
self.addEventListener("message", function(event) {
 var data = event.data;
 switch(data.command) {
  case 'deleteCheckin':
    // open current cache
    caches.open(cacheName).then(function(cache) {
      // remove the flight data (JSON)
      cache.delete(data.checkinID).then(function(success) {
        event.ports[0].postMessage({
          error: success ? null : 'Item was not found in the cache.'
        });
      )};
    })
    break;
    [...]
 }
});

Работа с кэшем


Некоторые элементы polymer«а могут запустить app.js, внутри которого специальный метод проверяет, актуальна ли информация, хранящаяся в кэше, или нет. В случае, если данные устарели, обработчик получает команду «жги», стирает внутренний кэш и удаляет данные из локального хранилища, после чего сообщает всем заинтересованным элементам polymer, что данные изменились.

Приведённый выше код также содержит обработчик загрузки (fetch). Так как URL, с которым взаимодействует обработчик, может меняться (например, появятся дополнительные GET-параметры для firebase analytics), мы написали регулярное выражение, которое просто выдёргивает необходимый набор параметров и помещает его в кэш веб-приложения. Таким образом мы не привязаны к URL«у и легко можем получать из него данные, которые проще обрабатывать и хранить.

Кое-что ещё


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

xFLSDzERKFTWXF-cZmP6Onpm5Ge6p1JPR9CLUYkk
Например, в фоне загружается симпатичный фона или дополнительная информация о месте назначения. А основные функции будут работать и без этих красивостей, даже если вы подключены по еле живому edge-соединению.

К сожалению, волшебной кнопки «сделать зашибись» ещё не придумано, так что просто взять и через какое-нибудь API показать окошко с кнопкой «добавьте наш сайт на домашний экран» не выйдет. Придётся применить смекалку и набор костылей: то есть написать своё сообщение и диалоговое окно, в котором мы расскажем пользователию, как добавить PWA на рабочий стол. В нашем случае к изобретению велосипеда добавились проверка на то, в какой момент высвечивать диалог. Вся информация становится доступной оффлайн только после регистрации на рейс, так что именно после неё мы и предлагаем пользователю создать ярлык. Собственно, тут всё просто:

var deferredPromptEvent; 

window.addEventListener('beforeinstallprompt', function(e) {
    e.preventDefault();
    deferredPromptEvent = e;
    return false;
});

// and in the moment your condition is fulfilled
// check if the prompt had been triggered
if(deferredPromptEvent !== undefined && deferredPromptEvent) {
    // show message
    deferredPromptEvent.prompt();
    // do something on the user choice
    deferredPromptEvent.userChoice.then(function(choiceResult) {
        if(choiceResult.outcome != 'dismissed') {
        }
        // finally remove it
        deferredPromptEvent = null;
    });
}

В нашем случае сообщение появляется после того, как пользователь закроет pop-up с уведомлением о том, что его билет сохранён.

Элементы polymer«а атомарны (сейчас под атомарностью продолжают понимать неделимость, хотя мы-то знаем, что атомы ещё как делятся). Так вот, из атомарности следует, что каждый элемент polymer«а будет нести в себе inline CSS и javascript. Само собой, вы можете добавить и внешние стили / скрипты, но inline реализация быстрее загружается и надёжнее работает. Разумеется, у неё есть свой минус — обслуживать такой код сложнее, особенно CSS. Наше решение — использовать Grunt и встраивать inline именно его, ну и использовать SCSS как прекомпилятор для CSS. Каждый элемент вместе с нормализованным CSS получает собственный SCSS файл с базовыми параметрами (функциями и переменными). Grunt берёт и сгенерированный CSS и внедряет его как inline-стиль, после чего привязывает его к элементу. Само собой, то же самое будет работать с gulp«ом или LESS«ом.

Чему мы научились в процессе создания PWA


Облегчить работу и ускорить загрузку можно разными способами, и один из самых интересных и эффективных — использовать массив объектов как базу данных для объектов polymer«а, но там есть одна маленькая сложность, особенно если вы работаете со вложенными элементами. В нашем случае в приложении был слайдер, который содержит посадочные талоны. Так как мы перевели всю отрисовку талонов с сервера на клиент, то данные для отрисовки, которые клиент получит с сервера, весят очень мало: немного JSON«а, пара бинарников… Всё это улучшает время загрузки, облегчает работу сервера, особено если речь идёт о современных гаджетах. На старых устройствах всё не так однозначно. Иногда проще отдать пре-рендеренный контент, чем данные для его построения, правда, первый запуск PWA будет происходить дольше. Всё это — объект изучения конкретных случаев в конкретных приложениях, цель A/B тестирования и оценки удобства того или иного подхода к решению посталвенной задачи.

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

Dzm2ihKmqQlPZHTx_oruDwXtWnW8lqixbM9_kaD-

На сегодня на этом всё, но мы ещё вернёмся к теме PWA в ближайшем будущем.

Комментарии (0)

© Habrahabr.ru