[Перевод] Air Berlin: реализация Progressive Web App
Поэтому в ближайшие два месяца мы собираемся не только публиковать статьи по теме PWA, но и провести тематическую онлайн конференцию 11 октября — Progressive Web Apps Day. Пока же предлагаем вашему вниманию реальный кейс использования PWA от AirBerlin.
Авиакомпания 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 требуется несколько больше: отобразить информацию о полёте, создать посадочный талон. А он, в свою очередь, должен быть уничтожен или забракован после полёта, да и для разных рейсов требуется отображать всякую дополнительную информацию: картинки, текст, что угодно.
Регистрация на рейс стала проще и быстрее: добавили полёт, номер бронирования, нажали на кнопку и получили посадочный талон. Проще не бывает.
Заливать всё это в кэш или хранить всю информацию обо всех направлениях разом как-то не особо 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, скрипты, картинки и помещаем их в кэш. Далее странится собирается из «базовой» конструкции и обратастает деталями и проработанным оформлением по мере загрузки ресурсов на пользовательское устройство.
Например, в фоне загружается симпатичный фона или дополнительная информация о месте назначения. А основные функции будут работать и без этих красивостей, даже если вы подключены по еле живому 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 до сих пор есть проблемы из-за… скажем так, особенностей закрытой архитектуры и платформы. Вы можете добавить ярлык на рабочий стол, и (из-за определённых механик кеширования браузера) оно может быть даже будет работать оффлайн или при очень плохом соединении…, но в то же время всегда может показать динозавра.
На сегодня на этом всё, но мы ещё вернёмся к теме PWA в ближайшем будущем.