Featured image of post Потенциал и реализация PWA (Progressive Web Apps) (Сила Service Worker)

Потенциал и реализация PWA (Progressive Web Apps) (Сила Service Worker)

Обзор PWA, от общего представления до жизненного цикла Service Worker, офлайн-кэширования и Push-уведомлений.

1. Введение: Что такое PWA?

Веб-технологии претерпели радикальную эволюцию за последние несколько десятилетий. Начиная с коллекций ссылок на статические HTML-документы, проходя через динамические манипуляции с DOM, асинхронную связь с помощью Ajax и появление SPA (Single Page Application), сегодня стало возможным создавать приложения, обеспечивающие пользовательский опыт (UX), который не уступает нативным приложениям или даже превосходит их. На передовой этой эволюции находятся PWA (Progressive Web Apps).

PWA, проще говоря, это “веб-приложения, которые сочетают в себе доступность интернета с высокой производительностью и UX нативных приложений”. В традиционных веб-приложениях, если вы заходили в автономном режиме, обычно появлялся экран ошибки “Нет подключения к интернету” (например, известная игра с динозавром в Chrome). Однако при правильной реализации технологий PWA можно запускать приложение даже в офлайн-режиме, просматривать кэшированный контент и выполнять синхронизацию данных в фоновом режиме.

В этой статье мы подробно и всесторонне рассмотрим весь спектр PWA: от жизненного цикла Service Worker, который является его ядром, до продвинутых стратегий кэширования, интеграции с IndexedDB и перспектив на будущее.


2. Нативные приложения против PWA

При разработке веб-приложений всегда возникает дискуссия о том, что следует использовать: нативное приложение или PWA. Глубокое понимание преимуществ и недостатков каждого подхода позволяет выбрать наиболее подходящую технологию для вашего проекта.

2.1. Сильные и слабые стороны нативных приложений

Самым большим преимуществом нативных приложений (разработанных на Swift/Objective-C для iOS, Kotlin/Java для Android и т. д.) является полный доступ к API операционной системы. Это позволяет реализовывать продвинутые функции, максимально использующие камеру, GPS, Bluetooth, NFC, различные датчики и многое другое. Кроме того, поскольку они оптимизированы для ОС, производительность рендеринга чрезвычайно высока, что делает нативные приложения намного более предпочтительными для игр с интенсивным использованием сложной анимации и 3D-графики.

С другой стороны, нативные приложения имеют следующие существенные недостатки (проблемы):

  • ** Затраты на разработку и обучение ** : необходимо поддерживать отдельные кодовые базы для iOS и Android (хотя кроссплатформенные фреймворки, такие как React Native или Flutter, могут смягчить эту проблему, они не устраняют ее полностью).
  • ** Проверка в магазинах приложений ** : вы не можете выпустить приложение, не пройдя проверку в Apple App Store или Google Play, и при выпуске обновлений может возникнуть ожидание проверки в течение нескольких дней.
  • ** Барьер привлечения пользователей ** : процесс открытия магазина приложений, поиска, загрузки и установки является серьезным препятствием (трением) для пользователей.

2.2. Проблемы, которые решает PWA

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

  • ** Единый исходный код, множество применений ** : единая кодовая база, разработанная с использованием стандартных веб-технологий (HTML, CSS, JavaScript), работает на всех устройствах (мобильных телефонах, планшетах, настольных компьютерах), оснащенных браузером.
  • ** Мгновенные обновления без проверки ** : поскольку PWA — это просто веб-сайты, им не нужно проходить проверку в магазине приложений. Пользователи всегда могут использовать последнюю версию, просто обновив файлы на сервере.
  • ** Бесшовный опыт без установки ** : пользователи могут начать использовать приложение, просто перейдя по URL-адресу. Если им оно понравится, они могут “добавить на главный экран (Install)” и запускать его с помощью значка приложения, как нативное приложение.
  • ** Возможность обмена ссылками ** : возможность поделиться определенным экраном или состоянием в виде URL-адреса — мощное преимущество, уникальное для интернета.

Конечно, у PWA также есть ограничения. В частности, в среде iOS (Safari) из-за политики Apple реализация веб-API часто запаздывает, поддержка Push-уведомлений до недавнего времени была недостаточной, а фоновая работа строго ограничена. Однако в последние годы Safari также расширяет поддержку PWA, и этот разрыв постепенно сокращается.


3. Три столпа, составляющие PWA

Для реализации PWA необходимы следующие три ключевых технологических элемента.

3.1. HTTPS (Безопасное соединение)

Мощные функции PWA (Service Worker, Push-уведомления, Geolocation и т. д.) по соображениям безопасности работают только в среде HTTPS (локальная среда разработки localhost разрешена в качестве исключения). Это сделано для предотвращения подделки и злоупотребления этими функциями злоумышленниками с помощью таких методов, как атаки “человек посередине”.

3.2. Web App Manifest

Web App Manifest (manifest.json) — это файл JSON, который предоставляет браузеру метаданные о веб-приложении. Он определяет значок приложения, название, цвет темы, режим отображения и т. д., и управляет внешним видом, подобным нативному приложению, при установке на устройство.

3.3. Service Worker

Service Worker — это волшебная палочка, которая превращает PWA из простого веб-сайта в “приложение”. Это среда JavaScript, которую браузер выполняет в фоновом режиме, работая в потоке, отдельном от веб-страницы. Он может перехватывать (проксировать) сетевые запросы, управлять кэшами и получать Push-уведомления.


4. Подробные настройки Web App Manifest

Web App Manifest — это файл конфигурации, который можно назвать лицом PWA. Он определяет внешний вид и поведение, когда пользователь устанавливает приложение.

Ниже приведен пример типичной настройки manifest.json.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "name": "Progressive Web App Example",
  "short_name": "PWA Example",
  "description": "A comprehensive example of a Progressive Web App.",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0055ff",
  "icons": [
    {
      "src": "/images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "orientation": "portrait",
  "scope": "/"
}

Объяснение основных свойств

  • name и short_name: имена, отображаемые в приглашении к установке и под значком приложения на главном экране. На главном экране, где место ограничено, short_name имеет приоритет.
  • start_url: URL-адрес, который загружается первым, когда пользователь запускает приложение с помощью значка на главном экране. Добавляя параметры отслеживания (например, ?source=pwa), можно идентифицировать доступ из PWA с помощью инструментов веб-аналитики.
  • display: указывает режим отображения приложения.
    • standalone: полностью скрывает пользовательский интерфейс браузера (строку URL-адреса, кнопку “Назад” и т. д.) и отображает его как нативное приложение. Это наиболее рекомендуемая настройка.
    • fullscreen: использует весь экран и скрывает даже строку состояния (идеально подходит для игр и видеоприложений).
    • minimal-ui: отображает только базовый навигационный пользовательский интерфейс.
    • browser: отображается как обычная вкладка браузера.
  • theme_color и background_color: определяют цвет темы приложения и цвет фона экрана-заставки при запуске.
  • icons: массив изображений, используемых в качестве значков приложения. Рекомендуется подготовить несколько размеров (по крайней мере, 192x192 и 512x512) для адаптации к различным разрешениям устройств. Указание purpose: "maskable" позволяет оптимизировать обрезку значков на Android и т. д.

5. Ядро и жизненный цикл Service Worker

Service Worker следует называть “сердцем” PWA. В отличие от JavaScript, выполняемого на традиционных веб-страницах, он не имеет доступа к DOM. Вместо этого он выступает в качестве посредника для сетевых запросов, манипулирует кэшами и выполняет фоновую синхронизацию.

5.1. Жизненный цикл Service Worker

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

Следующая диаграмма Mermaid показывает переходы состояний Service Worker.

  stateDiagram-v2
    direction TB
    "Проанализирован" --> "Установка" : "Регистрация"
    "Установка" --> "Установлен (Ожидание)" : "Успех"
    "Установка" --> "Избыточный" : "Ошибка"
    "Установлен (Ожидание)" --> "Активация" : "Все клиенты закрыты / skipWaiting()"
    "Активация" --> "Активен" : "Успех"
    "Активация" --> "Избыточный" : "Ошибка"
    "Активен" --> "Избыточный" : "Заменен новым SW"
  1. Parsed (Проанализирован): состояние, при котором браузер загрузил скрипт Service Worker и завершил синтаксический анализ.
  2. Installing (Установка): состояние, при котором запускается событие install. Этот этап в основном используется для предварительного кэширования (Pre-caching) статических ресурсов (HTML, CSS, JS, изображения и т. д.), необходимых для работы приложения. Если установка не удалась (например, не удалось сохранить кэш), Service Worker удаляется.
  3. Installed / Waiting (Установлен / Ожидание): установка завершена, но поскольку существующий старый Service Worker все еще активно работает на других вкладках, он ожидает замены. Вы можете перейти к следующему этапу, когда пользователь закроет все вкладки и откроет их снова, или вызвав self.skipWaiting().
  4. Activating (Активация): состояние, при котором запускается событие activate. Этот этап в основном используется для очистки путем удаления ненужных кэшей, созданных старым Service Worker.
  5. Activated (Активен): состояние, при котором он полностью работоспособен и может управлять и обрабатывать события fetch и push со страницы.
  6. Redundant (Избыточный): состояние, при котором не удалась установка, активация или он был заменен новой версией Service Worker.

5.2. Регистрация Service Worker

Чтобы использовать Service Worker, необходимо сначала выполнить процесс регистрации из основного потока JavaScript.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// main.js или внутри <script> в index.html
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("/sw.js", { scope: "/" })
      .then((registration) => {
        console.log("ServiceWorker registration successful with scope: ", registration.scope);
      })
      .catch((error) => {
        console.error("ServiceWorker registration failed: ", error);
      });
  });
}

Здесь важна область видимости (scope) Service Worker. По умолчанию он перехватывает только запросы к каталогу и ниже, в котором находится файл Service Worker. Другими словами, если это /sw.js, он может перехватывать запросы к / для всего сайта, но если он находится в /js/sw.js, он может перехватывать только запросы к /js/ и ниже.


6. Полное руководство по стратегиям кэширования

Главное преимущество Service Worker — возможность перехватывать сетевые запросы (событие fetch) и реализовывать собственные стратегии кэширования. В зависимости от типа ресурса (изображения, ответы API, HTML) и требований приложения необходимо использовать правильную стратегию кэширования.

6.1. Cache First (Сначала кэш)

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

  flowchart TD
    "Страница" -->|"1. Запрос"| "Service Worker"
    "Service Worker" -->|"2. Проверка кэша"| "Кэш"
    "Кэш" -->|"3a. Попадание в кэш"| "Service Worker"
    "Service Worker" -->|"4a. Ответ"| "Страница"
    "Кэш" -->|"3b. Промах кэша"| "Сеть"
    "Сеть" -->|"4b. Ответ"| "Service Worker"
    "Service Worker" -->|"5b. Сохранить в кэш"| "Кэш"
    "Service Worker" -->|"6b. Ответ"| "Страница"

6.2. Network First (Сначала сеть)

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

  flowchart TD
    "Страница" -->|"1. Запрос"| "Service Worker"
    "Service Worker" -->|"2. Fetch"| "Сеть"
    "Сеть" -->|"3a. Успех"| "Service Worker"
    "Service Worker" -->|"4a. Сохранить в кэш"| "Кэш"
    "Service Worker" -->|"5a. Ответ"| "Страница"
    "Сеть" -->|"3b. Ошибка / Офлайн"| "Service Worker"
    "Service Worker" -->|"4b. Проверка кэша"| "Кэш"
    "Кэш" -->|"5b. Попадание в кэш"| "Service Worker"
    "Service Worker" -->|"6b. Ответ (Fallback)"| "Страница"

6.3. Stale-while-revalidate (Возврат старого кэша при обновлении в фоновом режиме)

Чрезвычайно мощная и современная стратегия, которая балансирует скорость и актуальность. При возникновении запроса он немедленно возвращает кэш (старые, устаревшие данные) для быстрой отрисовки экрана. В то же время он отправляет запрос в сеть в фоновом режиме (while-revalidate), чтобы получить последние данные и обновить кэш. Пользователи увидят последние данные при следующем доступе.

  flowchart TD
    "Страница" -->|"1. Запрос"| "Service Worker"
    "Service Worker" -->|"2. Проверка кэша"| "Кэш"
    "Кэш" -->|"3. Попадание в кэш (Быстрый ответ)"| "Service Worker"
    "Service Worker" -->|"4. Возврат старого ответа"| "Страница"
    "Service Worker" -.->|"5. Fetch (Фон)"| "Сеть"
    "Сеть" -.->|"6. Ответ сети"| "Service Worker"
    "Service Worker" -.->|"7. Обновить кэш"| "Кэш"

6.4. Cache Only / Network Only

  • Cache Only (Только кэш): возвращает ответы исключительно из кэша. Если он не существует, это приведет к ошибке. Используется только для определенных ресурсов, которые гарантированно загружены заранее.
  • Network Only (Только сеть): вообще не смотрит на кэш и всегда запрашивает сеть. Используется для связи, которая не должна кэшироваться, например, API аутентификации или запросы POST.

7. Пример реализации Service Worker (подробное объяснение кода)

Давайте посмотрим на реальный пример реализации sw.js (файла Service Worker), основываясь на жизненном цикле и стратегиях кэширования, упомянутых выше.

7.1. Событие установки и предварительное кэширование

В событии install предварительно кэшируется оболочка приложения (базовые HTML, CSS, JS). Это позволяет мгновенно отобразить структуру приложения при последующих доступах или в автономном режиме.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// sw.js
const CACHE_NAME = "pwa-cache-v1";
const PRECACHE_URLS = [
  "/",
  "/index.html",
  "/css/style.css",
  "/js/app.js",
  "/images/logo.png",
  "/offline.html"
];

self.addEventListener("install", (event) => {
  console.log("[ServiceWorker] Install event");
  
  // Вызов self.skipWaiting() пропускает состояние ожидания и немедленно делает его активным.
  self.skipWaiting();

  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log("[ServiceWorker] Pre-caching offline pages");
      return cache.addAll(PRECACHE_URLS);
    })
  );
});

7.2. Событие активации и очистка кэша

Когда вы меняете версию имени кэша (например, с pwa-cache-v1 на v2), вам нужно сэкономить место в хранилище, удалив старые, ненужные кэши. Это делается в событии activate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
self.addEventListener("activate", (event) => {
  console.log("[ServiceWorker] Activate event");
  
  // self.clients.claim() немедленно берет все открытые в данный момент страницы под контроль.
  event.waitUntil(self.clients.claim());

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log("[ServiceWorker] Deleting old cache:", cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

7.3. Обработка события Fetch

Это пример сложной реализации, которая перехватывает событие fetch и переключает стратегии в зависимости от типа ресурса запроса. Обработка разветвляется: для изображений используется Cache First, для запросов навигации HTML — Network First с возвратом (fallback).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
self.addEventListener("fetch", (event) => {
  const request = event.request;
  const url = new URL(request.url);

  // Пропускаем POST-запросы и запросы к внешним доменам в сеть
  if (request.method !== "GET") return;

  // HTML-запросы (переходы по страницам) - стратегия Network First + возврат (fallback) для офлайн-режима
  if (request.mode === "navigate" || request.headers.get("accept").includes("text/html")) {
    event.respondWith(
      fetch(request)
        .then((response) => {
          return caches.open(CACHE_NAME).then((cache) => {
            cache.put(request, response.clone());
            return response;
          });
        })
        .catch(() => {
          // В случае сетевой ошибки (офлайн) получаем из кэша, если нет, возвращаем специальную офлайн-страницу
          return caches.match(request).then((cachedResponse) => {
            return cachedResponse || caches.match("/offline.html");
          });
        })
    );
    return;
  }

  // Статические ресурсы, такие как изображения - стратегия Cache First
  if (url.pathname.match(/\.(png|jpg|jpeg|gif|svg|css|js)$/)) {
    event.respondWith(
      caches.match(request).then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(request).then((networkResponse) => {
          return caches.open(CACHE_NAME).then((cache) => {
            cache.put(request, networkResponse.clone());
            return networkResponse;
          });
        });
      })
    );
    return;
  }

  // Для других API-запросов и т.д. применяем Stale-while-revalidate
  event.respondWith(
    caches.match(request).then((cachedResponse) => {
      const fetchPromise = fetch(request).then((networkResponse) => {
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(request, networkResponse.clone());
          return networkResponse;
        });
      });
      // Если есть кэш, возвращаем его сначала, продолжая процесс fetch в фоновом режиме. Если кэша нет, ждем fetchPromise.
      return cachedResponse || fetchPromise;
    })
  );
});

8. Интеграция с IndexedDB: более сложное управление данными

API caches (Cache Storage) Service Worker очень хорошо подходит для хранения целых HTTP-ответов (HTML-файлы, изображения, CSS и т. д.). Однако этого может быть недостаточно для управления структурированными данными, обрабатываемыми приложением (ответы API в формате JSON, данные пользовательских настроек, текстовые данные, отправленные в автономном режиме, и т. д.).

Здесь на помощь приходит IndexedDB.

IndexedDB — это асинхронная транзакционная база данных NoSQL, встроенная в браузер. В ней можно хранить очень большие объемы данных, а также выполнять сложный поиск по индексам.

8.1. Почему одного только Cache Storage недостаточно?

Например, предположим, вы добавляете новую задачу в приложение ToDo, находясь в автономном режиме. В это время трудно сохранить сам “POST-запрос для добавления задачи” в Cache Storage. Для требований, таких как сохранение действий в автономном режиме и их повторная отправка при восстановлении подключения, вам необходимо временно сохранить данные задачи в IndexedDB, извлечь данные из базы данных во время фоновой синхронизации (описано ниже) и отправить их в API. Такая интеграция необходима.

8.2. Использование IndexedDB в Service Worker

К IndexedDB также можно получить доступ из области видимости Service Worker. Поскольку прямое использование IndexedDB API может сделать код сложным, обычно используется легковесная библиотека-обертка под названием idb, предоставляемая Google.

В PWA с продвинутыми офлайн-функциями, которые сохраняют списки статей JSON, полученные из API, в IndexedDB вместо кэш-API для детального управления и запросов, IndexedDB играет важную роль.


9. Push-уведомления и фоновая синхронизация (Background Sync)

Функции, которые больше всего приближают PWA к нативным приложениям, — это Push-уведомления и фоновая работа.

9.1. Web Push API

Web Push — это механизм, который запускает Service Worker с сервера для доставки уведомлений пользователям, даже если приложение не открыто.

  1. ** Подписка (Subscribe)**: браузер запрашивает у пользователя разрешение на уведомления, получает информацию о подписке на сервис Push (конечную точку и ключ шифрования) и сохраняет ее на собственном сервере.
  2. ** Отправка (Push)**: сообщение отправляется с вашего сервера на сервис Push поставщика браузера (FCM или Apple Push Notification service).
  3. ** Получение (Push Event)**: когда сервис Push отправляет данные на устройство, браузер запускает Service Worker в фоновом режиме и инициирует событие push. Service Worker вызывает метод self.registration.showNotification() для отображения пользовательского интерфейса нативных уведомлений ОС.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
self.addEventListener("push", (event) => {
  const data = event.data ? event.data.json() : {};
  const title = data.title || "У вас новое сообщение";
  const options = {
    body: data.body || "Пожалуйста, откройте приложение для проверки.",
    icon: "/images/icons/icon-192x192.png",
    badge: "/images/icons/badge.png",
  };

  event.waitUntil(self.registration.showNotification(title, options));
});

9.2. Background Sync (Фоновая синхронизация)

Предположим, пользователь нажимает кнопку отправки сообщения, находясь в автономном режиме в метро. Обычное веб-приложение выдаст ошибку, но при использовании Background Sync API браузер дожидается момента “восстановления сетевого подключения” и инициирует событие sync для Service Worker.

Приложение временно сохраняет данные в IndexedDB в автономном режиме и регистрирует задачу синхронизации в Service Worker (registration.sync.register('send-messages')). Позже, когда соединение восстанавливается и срабатывает событие sync, оно извлекает данные из IndexedDB и отправляет их на сервер. Это позволяет пользователям продолжать пользоваться приложением, не беспокоясь о состоянии сети.


10. Будущее и проблемы PWA (Эволюция с помощью Project Fugu)

PWA продолжают развиваться и сегодня. В частности, инициатива под названием Project Fugu (Web Capabilities), возглавляемая Google, Microsoft, Intel и другими, еще больше размывает границы между интернетом и нативными приложениями.

Цель Project Fugu — обеспечить безопасный доступ из интернета к мощным функциям ОС, которые ранее были разрешены только для нативных приложений. В результате в браузерах один за другим реализуются новые API, такие как:

  • Web Bluetooth API: прямая связь с устройствами IoT
  • Web USB API / Web Serial API: подключение к специальному аппаратному обеспечению
  • File System Access API: прямое чтение и запись файлов в локальной файловой системе пользователя (важно для IDE и редакторов PWA)
  • Contact Picker API: доступ к данным адресной книги устройства
  • Web Share Target API: регистрация PWA в качестве адресата в “меню общего доступа” ОС

В качестве проблемы по-прежнему упоминается состояние поддержки со стороны Apple (iOS/Safari). Apple с осторожностью относится ко многим API Project Fugu из-за соображений конфиденциальности и безопасности, а также из-за баланса с бизнес-моделью App Store. Однако также фактом является то, что поддержка PWA постепенно расширяется в ответ на настойчивые запросы пользователей, например поддержка Web Push в iOS 16.4.

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


11. Заключение

В этой статье мы рассмотрели PWA на очень глубоком уровне: от базовых концепций до сложного жизненного цикла Service Worker, различных стратегий кэширования, интеграции с IndexedDB и последних тенденций в веб-технологиях.

При первом знакомстве с Service Worker вы можете быть сбиты с толку его асинхронностью и поведением кэширования. Однако, правильно понимая жизненный цикл и выбирая и реализуя соответствующие стратегии кэширования, вы можете создавать невероятно быстрые и отказоустойчивые веб-приложения.

Опыт “работает даже в автономном режиме” — это не просто удобная функция для пользователей, он порождает глубокое доверие и привязанность к приложению. Пожалуйста, попробуйте внедрить технологию PWA в свои проекты и максимально раскройте потенциал интернета.

comments powered by Disqus