PWA — это просто. Hello Habr
Продолжаем знакомство с Progressive Web Applications. После теоретической прошлой части самое время перейти к практике.
Сегодня мы построим простое, но полноценное PWA «Hello Habr».
Приложение доступно по адресу https://altrusl.github.io/habr-pwa/hello-habr/. При открытии в браузере на мобильном устройстве возможно добавление ярлыка на домашний экран и запуск в полноэкранном режиме.
Если кто хочет попробовать рассматриваемый пример на своем компьютере, то Chrome позволяет работать локально с простыми PWA приложениями без установки сторонних веб серверов с SSL сертификатами.
Файлы «Hello Habr» можно взять с GitHub-a — https://github.com/altrusl/habr-pwa/tree/master/hello-habr
Поместите всё в одну директорию и укажите ее веб серверу.
«Hello Habr» состоит из одной страницы. Он показывает на ней картинку (лого) и анимированную надпись.
index.html
Hello Habr
hh.css
@font-face {
font-family: Zaplyv-Heavy;
src: url(Zaplyv-Heavy.otf);
}
body {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
overflow: auto;
}
.center {
font-family: Zaplyv-Heavy;
font-size: 8vmax;
}
#logo {
background-image: url(logo.jpg);
background-size: 100%;
width: 100px;
height: 100px;
position: absolute;
top: 0;
right: 0;
margin: 10px;
}
hh.js
window.onload = function() {
fetch("hh.txt?mode=nocache").then(data => data.text()).then(data => {
animateText(data)
});
}
function animateText(data) {
var ele = document.getElementById("text"),
txt = data.split("");
var interval = setInterval(function(){
if(!txt[0]){
return clearInterval(interval);
};
ele.innerHTML += txt.shift();
}, 150);
}
hh.txt
Hello Hubr
Также присутствует кастомный шрифт. Итого — минимальный полный набор ресурсов среднего веб сайта. Если открыть index.html в браузере, отобразится картинка и надпись. Надпись загружается javascript-ом через fetch из файла hh.txt — простейшая модель общего PWA приложения.
Если открывать без sw.js, то это будет обычный веб сайт. Добавим к нашим файлам Service Worker.
// Caches
var CURRENT_CACHES = {
font: 'font-cache-v1',
css:'css-cache-v1',
js:'js-cache-v1',
site: 'site-cache-v1',
image: 'image-cache-v1'
};
self.addEventListener('install', (event) => {
self.skipWaiting();
console.log('Service Worker has been installed');
});
self.addEventListener('activate', (event) => {
var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) {
return CURRENT_CACHES[key];
});
// Delete out of date caches
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (expectedCacheNames.indexOf(cacheName) == -1) {
console.log('Deleting out of date cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
console.log('Service Worker has been activated');
});
self.addEventListener('fetch', function(event) {
console.log('Fetching:', event.request.url);
event.respondWith(async function() {
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
console.log("\tCached version found: " + event.request.url);
return cachedResponse;
} else {
console.log("\tGetting from the Internet:" + event.request.url);
return await fetchAndCache(event.request);
}
}());
});
function fetchAndCache(request) {
return fetch(request)
.then(function(response) {
// Check if we received a valid response
if (!response.ok) {
return response;
// throw Error(response.statusText);
}
var url = new URL(request.url);
if (response.status < 400 &&
response.type === 'basic' &&
url.search.indexOf("mode=nocache") == -1
) {
var cur_cache;
if (response.headers.get('content-type') &&
response.headers.get('content-type').indexOf("application/javascript") >= 0) {
cur_cache = CURRENT_CACHES.js;
} else if (response.headers.get('content-type') &&
response.headers.get('content-type').indexOf("text/css") >= 0) {
cur_cache = CURRENT_CACHES.css;
} else if (response.headers.get('content-type') &&
response.headers.get('content-type').indexOf("font") >= 0) {
cur_cache = CURRENT_CACHES.font;
} else if (response.headers.get('content-type') &&
response.headers.get('content-type').indexOf("image") >= 0) {
cur_cache = CURRENT_CACHES.image;
} else if (response.headers.get('content-type') &&
response.headers.get('content-type').indexOf("text") >= 0) {
cur_cache = CURRENT_CACHES.site;
}
if (cur_cache) {
console.log('\tCaching the response to', request.url);
return caches.open(cur_cache).then(function(cache) {
cache.put(request, response.clone());
return response;
});
}
}
return response;
})
.catch(function(error) {
console.log('Request failed for: ' + request.url, error);
throw error;
});
}
Как видно, мы создаем пять кэшей для каждого вида ресурсов. Кэш site — для html файлов. Кэшируются все ресурсы, за исключением тех, у кого в GET query стоит «mode=nocache» —, а это у нас запрос к файлу hh.txt со строкой для надписи.
Иногда можно видеть, что ресурс берется с дискового кэша. Это бывает частой проблемой при разработке приложений с Service Worker-ом, поэтому дисковый кэш (кэш браузера) лучше отключать. И не у себя в браузере, а на сервере — например, в
.htaccess# Cache-Control Headers
Header unset ETag Header unset Expires Header set Cache-Control "no-cache"
Логика работы sw.js простая — «Cache falling back to the network». Сперва запрашиваемый ресурс проверяется в кэше, если он там есть, то берется и возвращается браузеру оттуда. Если нет — получается из сети, возвращается браузеру, а копия ресурса помещается в кэш.
После первого открытия страницы index.html в консоли Chrom-a видны записи об установке и активации Service Worker-а. После второго открытия в хранилище создаются наши кэши и в них помещаются наши ресурсы. Также видно, что при последующих открытиях на веб сервер уходят только запросы к hh.txt, все остальные ресурсы берутся из Service Worker-a.
Хранящиеся локально index.html, hh.css, hh.js, hh.otf, logo.jpg — это и есть тот самый application shell, оболочка статичных ресурсов и данных, выполняющая роль оболочки программы на клиенте. Вся динамическая информация, необходимая для работы сайта, получается javascript запросами на сервер и отображением полученных данных в app shell-e. В нашем случае это запрос к text.txt.
Для того, чтобы называться функционально полноценным PWA, «Hello Habr» не хватает одного — иконки на домашнем экране смартфонов и запуска в полноэкранном режиме.
Для этого необходимо в index.html подключить манифест приложения:
{
"short_name": "Hello Habr",
"name": "Hello Habr - PWA example",
"icons": [
{
"src": "logo3.jpg",
"type": "image/jpg",
"sizes": "192x192"
},
{
"src": "logo2.jpg",
"type": "image/jpg",
"sizes": "512x512"
}
],
"start_url": "/habr-pwa/hello-habr/",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/habr-pwa/hello-habr/",
"theme_color": "#3367D6"
}
Подключается он в index.html:
После этого мобильные браузеры (каждый по-своему) предложат создать ярлык для приложения на домашнем экране. При запуске по ярлыку приложение будет открываться в standalone режиме — без браузерных элементов управления. Более подробней о опциях манифеста — на Google Developers.
Приложение «Hello Habr» в минимальной мере обладает всеми свойствами PWA и является им по сути. Как видно, для того, перевести простой сайт в PWA нужно просто подключить манифест и файл Service Worker-a. Используемый sw.js достаточно универсальный.
В следующий раз переведем в PWA готовый сайт на CMS Joomla (сайт «из коробки» с изначальными демо-данными). Причем, sw.js останется практически тем же.