PWA — это просто. Hello Habr

    Продолжаем знакомство с Progressive Web Applications. После теоретической прошлой части самое время перейти к практике.

    Сегодня мы построим простое, но полноценное PWA «Hello Habr».




    Приложение доступно по адресу https://altrusl.github.io/habr-pwa/hello-habr/. При открытии в браузере на мобильном устройстве возможно добавление ярлыка на домашний экран и запуск в полноэкранном режиме.

    Если кто хочет попробовать рассматриваемый пример на своем компьютере, то Chrome позволяет работать локально с простыми PWA приложениями без установки сторонних веб серверов с SSL сертификатами.
    Инструкция для запуска "Hello Habr" локально
    Необходимо установить из интернет-магазина Chrome вот это или аналогичное расширение, являющееся локальным веб-сервером. Без поддержки PHP, естественно.



    Файлы «Hello Habr» можно взять с GitHub-a — https://github.com/altrusl/habr-pwa/tree/master/hello-habr

    Поместите всё в одну директорию и укажите ее веб серверу.


    «Hello Habr» состоит из одной страницы. Он показывает на ней картинку (лого) и анимированную надпись.

    "Hello Habr" code

    index.html


    <html>
        <head>
            <title>Hello Habr</title>
            <script src="hh.js"></script>
            <link rel="stylesheet" href="hh.css" />
            <script type="text/javascript">
                if ('serviceWorker' in navigator) {
                    navigator.serviceWorker.register('/sw.js')
                    .then(function(registration) {
                        console.log('Registration successful, scope is:', registration.scope);
                    })
                    .catch(function(error) {
                        console.log('Service worker registration failed, error:', error);
                    });
                }
            </script>
        </head>
        <body>
            <div class="center">
                <p id="text"></p>
            </div>
            <div id="logo"></div>
        </body>
    </html>

    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.

    sw.js
    // 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
    <ifModule mod_headers.c>
      <FilesMatch (\.css|\.js|sprites\.png)$>
    	Header unset ETag
    	Header unset Expires
    	Header set Cache-Control "no-cache"
      </FilesMatch>
    </IfModule>

    Логика работы 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 подключить манифест приложения:
    manifest.json
    {
      "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": "index.html",
      "background_color": "#3367D6",
      "display": "standalone",
      "scope": "/habr-pwa/hello-habr/",
      "theme_color": "#3367D6"
    }

    Подключается он в index.html:
    <link rel="manifest" href="manifest.json">


    После этого мобильные браузеры (каждый по-своему) предложат создать ярлык для приложения на домашнем экране. При запуске по ярлыку приложение будет открываться в standalone режиме — без браузерных элементов управления. Более подробней об опциях манифеста — на Google Developers.

    Приложение «Hello Habr» в минимальной мере обладает всеми свойствами PWA и является им по сути. Как видно, для того, перевести простой сайт в PWA нужно просто подключить манифест и файл Service Worker-a. Используемый sw.js достаточно универсальный.

    В следующий раз переведем в PWA готовый сайт на CMS Joomla (сайт «из коробки» с изначальными демо-данными). Причем, sw.js останется практически тем же.
    Поделиться публикацией

    Похожие публикации

    Комментарии 16
      +2
      Складывается впечатление, что PWA — это костыль, который исправляет неправильную работу кэша браузера при отключении инета…
        +1
        В таком случае, автомобиль — это костыль, чтобы добраться из пункта А в пункт Б, когда сломался велосипед.
          0
          Ну, не скажите. Я вижу здесь исключительно функцию кеширования. По идее, браузер должен корректно обрабатывать заголовки и сам отдавать все, кроме no-cache, не так ли?
          Более того, у браузера есть такое понятие, как срок кеширования. Как я понимаю, это можно реализовать в Javascript, наверное даже как-то гибче, но, имхо рулить заголовками много легче, чем лезть в логику приложения…
            0
            Я приводил пример в прошлой статье — браузер посылает запрос, Service Worker берет его, распарсивает, посылает на сервер несколько запросов, принимает ответы, лезет в IndexDB, берет оттуда данные, проделывает какие-то операции, апдейтит IndexDB и возвращает некий ответ браузеру.
            Это исключительно функция кэширования?
              0
              Что из этого нельзя сделать «обычным Javascript»? Кроме IndexedDB, конечно, но, имхо, простейшие задачи можно решить на клиенте более простыми способами, не прибегая к IndexedDB.
                0
                У вас на браузере расширения стоят? Пользуетесь? Прикольные штучки? Так вот они как раз реализованы в том числе как прокси. В этом их сила. А так, почти все что они делают, можно сделать на «обычном Javascript».
                  0
                  Большинство расширений (у меня) выполняют функции, не доступные из «обычной» страницы (например, управление настройками браузера, изменение контента любой вкладки, и т.д.), другие чисто удобнее в использовании (контекстное меню, иконка).
                  Вот что из этого невозможно реализовать «обычным» Javascript?
                    +3
                    «Обычным» Javascript-ом можно реализовать всё

                    image
          –1

          Можно, конечно, так говорить.
          Но на самом деле PWA задумывался иначе: принести интерфейс приложения через web фрейморки нативно на разные android/ios.


          Другое дело, что в жизни все пошло не совсем как планировалось (сюрприз) и некоторые производители решили привнести свое/не реализовывать чужое. Получилось, что в угоду совместимости родились 1001 костыль.


          Следует заметить, что это далеко не Java, как по ограниченности применения (нужен браузер и гуи), так и по принципу реализации (нет машины, компиляции и т.п.).


          Если же смотреть на вещи без скепсиса: pwaэто больше концепт чем конкретная реализация. Можно pwaделать на node, а можно через java его "отдавать"...

            +1
            PWA это два файла: manifest.json, который «приносит интерфейс приложения через web фрейморки нативно на разные android/ios.», и service-worker.js, который кэширует оболочку (или что скажут), проксирует запросы и может слать пуши. Всё.
          0
          Уточните, пожалуйста, IOS поддерживает PWA?
          Поскольку попробовал на IOS в нескольких браузерах и ничего не выходит.
          +5

          Скучаю по старой опере когда на диалапе откроешь 10 вкладок. Ждёшь загрузки и резко дисконектишься и не боишься, что одно движение сбросит тебе контент. Ещё и вперёд назад работало в оффлайн режиме. ..

            0
            Я, наверное, сейчас тупость спрошу, но это потому что я не настоящий программист, а самоучка.
            Вот здесь в манифесте:
            "start_url": "/habr-pwa/hello-habr/",
            "scope": "/habr-pwa/hello-habr/",

            почему путь начинается с родительской папки, ведь все файлы лежат в /hello-habr?

            На моем сайте заработало, только когда я сделал
            "start_url": "index.html",
            "scope": "https://mysite.org/path/",

            Автору большое спасибо.
              0
              Да, «start_url» был неправильный. Спасибо.

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

            Самое читаемое