Термин PWA появился еще в 2015 году, но из-за браузерных разногласий долгое время был лишь красивой идеей. В 2023 году возникла надежда, что на iOS появятся альтернативные браузерные движки, а это может привести к тому, что для создания почти полноценных аналогов нативных приложений будет достаточно знаний фронтенда.
Весной на HolyJS Никита Дубко показал, что умеют современные PWA: как изменился их внешний вид, насколько они интегрированы в операционные системы и в каких случаях они решают пользовательские задачи не хуже нативных приложений.
Делимся расшифровкой доклада и видеозаписью. Повествование будет от лица Никиты.
Содержание
Что такое PWA
Преимущества PWA
Кому оно надо
Прячем кнопку установки
Красивая установка со скриншотами
Получать файлы
Делиться файлами
Буфер обмена
Нативный UI
Файловая система
Запуск на старте ОС
EyeDropper API
Свой протокол
Приложение вместо ссылки
Шорткаты
Открывать файлы
Своя файловая система
Локальные шрифты
Что такое PWA
Этот термин неоднозначен — о нём ведутся целые дискуссии. В докладе буду использовать следующее определение:
PWA — то, что можно установить как отдельное приложение.
Оно написано с использованием веб-технологий, но это отдельное приложение.
Я открыл для себя PWA в 2016 году: Максим Юзва рассказывал про него на конференции Web Standards Days в Минске. Это была прикольная технология, но сырая — много лет было непонятно, что с ней делать.
На самом деле термин «веб-приложение» ещё в 2007 году предложили в Apple. Но, видимо, Стив Джобс быстро передумал, и только в 2015 году у нас появился настоящий термин «PWA». В Google объяснили, что такое прогрессивное веб-приложение.
Вот его характеристики:
Отзывчивое: выглядит хорошо при любых изменениях размера экрана и на телефонах разных размеров.
Не зависит от сети: у него должен быть какой-то офлайн-режим.
Выглядит как нативное приложение независимо от того, что оно написано с использованием веб-технологий.
Обновляемое: есть возможность постоянно обновлять контент.
Безопасное: в нативных приложениях с этим всё более-менее хорошо, а с вебом надо было что-то придумать.
Его можно найти через поисковики.
Есть вовлекающая механика: разработчики сразу заложили, что это будут пуши.
Устанавливаемое: например, как отдельные иконки на рабочем столе.
На него можно дать ссылку.
Apple отказывается от термина PWA, называя их Home Screen Web Apps — эдакое альтернативное видение таких приложений.
Отсутствие полноценных PWA на iOS — это проблема, которая не даёт PWA развиваться.
На Android вы можете установить какие-то приложения из браузера на главный экран, если они поддерживаются. На iOS (внезапно) тоже можно так сделать, там есть кнопка «На экран „Домой“», но по факту это только создаст ссылку на рабочем столе.
В случае десктопов в Chrome можно нажать иконку рядом с URL при условии, что PWA-режим поддерживается.
Требования к установке:
Приложение не должно быть установлено ранее.
Пользователь должен провести на странице больше 30 секунд.
Пользователь должен где-то кликнуть на странице, то есть это не просто баннер, который он открыл в фоне.
Должен быть HTTPS.
Нужен web app manifest — документ, который прикрепляется к странице, и в нём описан URL, по которому открывается приложение, название и иконки.
Должен быть service worker, который «фетчит» запрос — пустой service worker не прокатит.
Но всё же самое главное: зачем писать PWA, если есть нативные приложения? Я хочу привести аргументы, почему PWA — это тоже здорово.
Преимущества PWA
Гибкость
С одной стороны, мы говорим про PWA, с другой — там всё-таки под капотом браузер, поэтому всё, что вы умеете делать в браузере, вы можете сделать и в PWA и даже больше.
Дистрибуция
В случае нативных приложений можно ещё и дистрибутировать, показывать рекламу. На самом деле, если вы пишете приложение, то довести его до App Store — сложная задача: нужно пройти длительные процедуры, связанные с ревью, ключами и лицензиями. А с PWA намного проще, потому что это обычная ссылка, которая открывается в браузере. И вы можете установить её как нативное приложение, потому что есть такие штуки, как PWA Builder — приложения, которые собирают ваши PWA в нечто устанавливаемое, и вы можете попасть в App Store. Значит, PWA с точки зрения дистрибуции лучше.
Обновление: F5
Если нужно обновить нативное приложение, то пользователь должен зайти в App Store и нажать кнопку «Обновить» явно. Обновления всё ещё проходят длительную процедуру ревью. А для PWA всё проще — нажмите F5 или Command-R в зависимости от ОС.
Когда я готовился к докладу, то спрашивал у знакомых, как они пишут нативные приложения. Оказалось, под капотом там чаще всего веб-технологии. Да, там есть API, которые не будут доступны в вебе. Но по факту это WebView, потому что так удобнее.
API
Есть такой проект Project Fugu. Основные его контрибьюторы, наверное, это Google и Microsoft. В этом проекте предлагаются API, которые есть в нативных приложениях, и рассказывается, как сделать так, чтобы они работали в браузере. Самое сложное здесь — это должна быть качественная интеграция с операционной системой, потому что ОС может не разрешать вам что-то сделать. Например, в iOS, если вы открываете Chrome, то на самом деле вы запускаете WebKit. Поэтому, если операционная система не даёт пользоваться каким-то API, то ничего не сделать. У меня есть отдельный доклад про это.
У ОС действительно большое количество возможностей, которые хочется использовать в приложениях. Например, вы из браузера можете работать с железными устройствами: прямо байтики пересылать, использовать HID-протоколы. Но некий вендор по имени Apple говорит: «Да, вы там развиваетесь, всё здорово. Но я попробую вам помешать».
Но и это было до поры до времени. В феврале 2023 года ЕС сказал Apple, что их разрешение устанавливать только WebKit не очень коррелирует с европейскими законами, и обязал разрешить ставить любые движки.
Я очень жду, когда Apple проиграет в этом суде, да и не только я. Это позитивная история ещё и потому, что Google тоже не сможет так делать. Я поэтому и решил подготовить доклад: кажется, что в будущем, года через два-три у нас появится Chromium для iOS. Вы уже можете его установить, потому что как только появилась эта новость про суд, через неделю появилась эта ссылка.
У Firefox тоже есть сборка для iOS. Вы можете по ней пройти и сами установить, если у вас есть Xcode.
Кому оно надо
Но все же остается вопрос: кому нужны PWA? Нативное приложение же работает лучше. Тем не менее, PWA есть у Spotify, TikTok, Twitter, Pinterest. Почему-то такие крупные бренды вложились в PWA. Кстати, я буквально сегодня оформил карту одного желтого банка, и им, оказывается, тоже надо PWA.
Есть Project Fugu API Showcase, в котором показаны примеры, как можно использовать Fugu API для PWA. Обязательно их полистайте, чтобы вдохновиться и узнать, что умеет веб.
Например, есть приложение Squoosh. Оно работает в офлайне и обрабатывает изображения с помощью WebAssembly.
Есть приложение Excalidraw, оно тоже достаточно удобное для того, чтобы рисовать диаграммы. Оно тоже offline first и не хранит ничего на сервере.
Есть SVGcode — потрясающее приложение, которое превращает растр в вектор — мечта!
Ну и покажу, наконец, своё приложение. Я прошел весь путь написания своего PWA и хочу поделиться им с вами. У нас с друзьями есть священный вечер четверга, когда мы играем в D&D. На работе все знают, что в этот вечер меня нет :)
В игре нам нужны токены — это кружочки с красивыми рамочками. Мы их рисуем чаще всего в Photoshop. Я решил сделать приложение, которое будет с этим всем работать. Когда я готовил доклад про Fugu, сделал страницу, которую можно открыть и посмотреть, какие API в вашем браузере сейчас работают.
Не так давно там было много красных рамок. Красная — это то, что пока не работает. В синюю рамку помещена апишка, про которую невозможно понять, работает она или нет, потому что это манифест. Очень сложно тестировать, работает ли JSON — он читается, но не факт, что применяется.
Я полистал эту страницу и понял: пора использовать все эти API. Вы можете протестировать моё приложение, а я постараюсь рассказать, как оно работает под капотом. Давайте откроем его в браузере.
Выглядит как обычная веб-страница, но есть кнопка «Install PWA». Но её не нужно показывать, когда вы уже в режиме PWA. Как спрятать эту кнопку?
Прячем кнопку установки
Нужно прописать в манифесте, что у вас есть приложение, связанное с вашей страницей. Указываете платформу и URL, по которому лежит манифест.
// app.manifest
"related_applications": [
{
"platform": "webapp",
"url": "https://my.app/app.webmanifest"
}
],
Далее в JavaScript используете метод getInstalledRelatedApps
.
if ('getInstalledRelatedApps' in navigator) {
const relatedApps =
await navigator.getInstalledRelatedApps();
const PWAisInstalled = relatedApps.length > 0;
if (PWAisInstalled) {
installButton.classList.add('hidden');
}
}
Если он что-то вернул, значит, PWA установлено, и повторно его устанавливать не надо. В getInstalledRelatedApps
можно запихнуть и нативные приложения. Эта штука работает только на Android.
Но есть CSS-решение: просто пропишите в стилях, чтобы при нужном display-mode
кнопка пряталась.
@media (display-mode: standalone),
(display-mode: window-controls-overlay) {
.pwa-install-button {
display: none;
}
}
Красивая установка со скриншотами
Хочется сделать установку красивой, чтобы когда я нажимаю «Установить», мне показывались скриншоты с описанием.
Как это сделать? Добавить в манифест поле description
, которое описывает ваше приложение, и добавить скриншоты. Обязательно укажите form_factor
(wide
для десктопа и narrow
для мобильного) и размеры. Если указать неправильные размеры, Chrome не поймет, сколько места резервировать под картинку, и сплющит её.
// app.manifest
"short_name": "D&D Tokenizer",
"description": "Some description.",
"screenshots": [
{
"src": "./screens/desktop.jpg",
"type": "image/jpeg",
"sizes": "800x583",
"form_factor": "wide"
},
{
"src": "./screens/mobile.jpg",
"type": "image/jpeg",
"sizes": "530x700",
"form_factor": "narrow"
}
],
Получать файлы
Скорее всего, когда вы пользуетесь нативными приложениями, то используете нативную кнопку «Share», чтобы что-то передавать. Как это сделать в PWA?
Для получения файлов в манифесте нужно прописать share_target
. Вы указываете, в какой action
пойти, и чтобы получать файлы, нужно обрабатывать POST-запрос. Вы пишете, что в этом запросе вы принимаете файлы, и тип файла — image
.
// app.manifest
"share_target": {
"action": "/?share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "image", "accept":
["image/*"]
}
]
}
},
Также нужно завести service worker. С ним есть нюанс. На самом деле, сначала я скопировал код этой истории из SVGcode у Томаса Штайнера, а потом не понимал, почему он не работает. Но Томас же не может ошибаться — как это возможно? Оказалось, что у service worker есть гонка за ресурсами. Если первый зарегистрировался и обработал что-то в fetch
, то следующий service worker туда не пойдёт.
И мы починили это. Суть в том, что внутри service worker вы точно так же обрабатываете fetch-запрос и проверяете, действительно ли это тот самый URL, указанный в манифесте, и тот ли запрос POST. А дальше вы кладете картинку в кэш. Редирект в конце нужен, чтобы сбрасывать отправку формы и не получать картинку повторно.
// sw.js
self.addEventListener('fetch', (fetchEvent) => {
const url = new URL(fetchEvent.request.url);
if (
url.pathname === '/' && url.searchParams.has('share-target') && fetchEvent.request.method === 'POST'
) {
return fetchEvent.respondWith(
(async () => {
const formData = await fetchEvent.request.formData();
const image = formData.get('image');
const keys = await caches.keys();
const sharedCache = await caches.open(
keys.filter((key) => key.startsWith('share-target'))[0]
);
await sharedCache.put('shared-image', new Response(image));
return Response.redirect('./?share-target', 303);
})()
);
}
});
В самом коде вам нужно проверить, что вы находитесь на странице, на которую произошел редирект, и достать изображение из кэша. Дальше можно работать с blob
или image
напрямую.
// main.js
window.addEventListener('load', async () => {
if (location.search.includes('share-target')) {
const keys = await caches.keys();
const sharedCache = await caches.open(
keys.filter((key) => key.startsWith('share-target'))[0]
);
const image = await sharedCache.match('shared-image');
if (image) {
const blob = await image.blob();
await sharedCache.delete('shared-image');
// do something with blob
}
}
});
В целом не так много, но это тяжело дебажить, потому что придется это делать на мобильных устройствах.
Делиться файлами
Мы научились получать, но теперь надо научиться отправлять. Для этого нужно сделать кнопку и проверить, есть ли у браузера метод canShare
. Далее получаете и генерируете blob
, готовите бинарник, который вы шарите. Создаете файл, указываете его имя и тип. Далее еще раз проверяете canShare
, может ли браузер куда-то отправить такой тип файла. Насколько я понимаю, canShare
проверяет, есть ли обработчики в операционной системе. В конце вы вызываете navigator.share
. После этого появится системное окно, которое позволяет передавать файлы.
const shareButton = document.querySelector('.button');
if (navigator.canShare) {
shareButton.addEventListener('click', async () => {
const blob = getBlobFromImage();
const file = new File([blob], 'token.png', {
type: 'image/png',
});
const data = {
files: [file],
};
if (navigator.canShare(data)) {
try {
await navigator.share(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err.name, err.message);
}
}
}
});
}
Здесь идет интеграция с ОС. Share передает в нее бинарник, а что ОС будет делать дальше — уже неважно. А если пользователь нажал Esc, то лучше это обработать при помощи catch
, чтобы тихонько «свалиться».
Буфер обмена
С буфером обмена мы умеем работать довольно давно, но асинхронного буфера обмена давно не хватало. Его завезли недавно. Посмотрим, как реализовать вставку файла через Command-V.
document.addEventListener('paste', async (e) => {
e.preventDefault();
const clipboardItems = typeof navigator?.clipboard?.read === 'function'
? await navigator.clipboard.read()
: e.clipboardData.files;
for (const clipboardItem of clipboardItems) {
let blob;
if (clipboardItem.type?.startsWith('image/')) {
blob = clipboardItem;
// do something with blob
} else {
const imageTypes = clipboardItem.types?.filter((type) =>
type.startsWith('image/')
);
for (const imageType of imageTypes) {
blob = await clipboardItem.getType(imageType);
// do something with blob
}
}
}
});
Нам нужно смотреть, чтобы был navigator.clipboard.read
— этот тот самый асинхронный метод, который недавно завезли. Раньше для работы с картинками в буфере обмена мы использовали Flash, но от него отказались. С кодом всё просто: читаете item
, смотрите у него imageType
и опять же работаете с blob
.
Нативный UI
PWA должен выглядеть нативно — это одна из его особенностей. Как это сделать? В манифесте нужно указать display
для PWA и display_override
, который говорит, что если ваш браузер умеет, то он разрешит вам играть с «контролами» поверх окна.
// app.manifest
"display": "standalone",
"display_override": ["window-controls-overlay"],
Есть несколько важных вещей в CSS: переменные окружения (env) titlebar-area-x
, titlebar-area-y
, titlebar-area-height
, titlebar-area-width
. Вы можете с ними работать, чтобы понимать, что в окружении есть отступы, которые нужно использовать. Здесь я задаю padding-right
и padding-left
, потому что без них кнопки попали бы под элементы управления.
/* style.css */
@media (display-mode: window-controls-overlay) {
.header {
padding-right: calc(2 * env(titlebar-area-x));
padding-left: env(titlebar-area-x);
height: calc(env(titlebar-area-height) + 10px);
}
}
.header__title {
-webkit-app-region: drag;
}
Чтобы можно было двигать приложение, я использую webkit-app-region
. Он позволяет задать область, за которую я могу передвигать окно в режиме window-controls-overlay
.
Файловая система
Вы, наверное, думаете, что под капотом кнопки «Load image» — input type="file"
. Но нет. Есть API, который говорит, что если поддерживается метод showOpenFilePicker
и вы находитесь не в iframe
, вы можете вызвать его, и браузер остановится — это асинхронный метод. Вы выбираете файл, получаете fileHandle
— точно так же, как с input type="file"
, но разница в том, что это асинхронный метод, поэтому не нужно обвешиваться EventListener
. Если пользователь нажмет Esc, вам надо это обработать «тихо упав».
const supportsFileSystemAccess =
'showOpenFilePicker' in window &&
(() => {
try {
return window.self === window.top;
} catch {
return false;
}
})();
if (supportsFileSystemAccess) {
let fileHandle = undefined;
try {
[fileHandle] = await showOpenFilePicker();
return await fileHandle.getFile();
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err.name, err.message);
}
}
}
Запуск на старте ОС
Это предложение от Microsoft для Windows. Я не ставил эту фичу, потому что было бы странно запускать моё приложение сразу при включении компьютера — я не настолько люблю D&D. Но посмотрим, как это работает:
let promise = navigator.runOnOsLogin.set({
mode: "windowed",
});
promise.then(function() {
// Пользователь дал добро
},
function(reason) {
// Что-то пошло не так
});
Вы просите navigator
установить для runOnOsLogin
какой-то режим. И нужно, чтобы пользователь дал разрешение. Если разрешит, то метод отдаст это в ОС — и дальше она сделает всё сама. Браузер в этом месте — как прокси или верхнеуровневый API.
EyeDropper API
Эту фичу я хотел вставить в приложение, но потом отказался от этой идеи — input type="color"
всё делает за меня. Но вам я всё равно покажу, как он работает.
const eyeDropper = new EyeDropper();
try {
const result = await eyeDropper.open();
// Пользователь выбрал пиксель с цветом:
const colorHexValue = result.sRGBHex;
} catch (err) {
// Пользователь выключил пипетку
}
Если вы вызовете EyeDropper, то у вас появится пипетка, которая выбирает цвет, на который вы кликнете.
Для чего это нужно? Если у вас есть кастомный обработчик работы с цветами, вы можете добавить в него функцию пипетки, которая работает в две строчки. И не забывайте: на всё нужно писатьtry/catch
.
Свой протокол
Я хочу придумать свой протокол, например, чтобы ссылки, которые открываются по web+tokenizer://someURL, перенаправлялись ко мне в PWA. Это тоже делается через манифест:
// app.manifest
"protocol_handlers": [
{
"protocol": "web+tokenizer",
"url": "/?from=%s"
}
],
Приложение вместо ссылки
Как сделать так, чтобы при переходе по ссылке сразу открывалось приложение, а не сайт? Например, Twitter открывает сразу нативное приложение — это удобно. Сделать это можно опять же через манифест.
// app.manifest
"capture_links": "existing_client_event",
"url_handlers": [
{
"origin": "https://dnd-tokenizer-41471e.netlify.app"
}
]
В origin
важно указать адрес вашего приложения.
Шорткаты
Когда вы зажимаете иконки в мобильных ОС, у вас появляются интересные дополнительные действия с приложением. И у нас это тоже можно сделать! Посмотрим, как это выглядит на десктопах:
Шорткаты тоже делаются через манифест:
// app.manifest
"shortcuts": [
{
"name": "Create new token",
"url": "/"
}
],
"launch_handler": {
"client_mode": "focus-existing"
},
Можно указать много шорткатов и добавлять им иконки.
Открывать файлы
Через наше PWA можно открывать другие файлы. И это работает даже на macOS, хотя они обычно подобные интеграции медленно докатывают.
И (удивительно) это делается через манифест:
// app.manifest
"file_handlers": [
{
"action": "/",
"accept": {
"image/*": [
".jpg", ".jpeg", ".webp",
".png", ".avif", ".gif"
]
}
}
],
Но ещё это нужно обработать в JavaScript.
// main.js
if ('launchQueue' in window) {
launchQueue.setConsumer((launchParams) => {
const files = launchParams.files;
for (const file of files) {
const blob = await file.getFile();
blob.handle = file;
// Сделать что-то с файлом
}
});
} else {
console.error('File Handling API is not supported!');
}
Когда вы запускаете приложение, у него есть launchQueue
— очередь файлов, которые вы запускаете, их нужно обработать. Здесь я также работаю через blob
.
Своя файловая система
Я не успел реализовать это в своем приложении, потому что на демку было мало времени, но в PWA можно работать со своей файловой системой. Например, так делает Photopea. В чем суть? В JavaScript проникают подходы из C++ кода. Поэтому нужно привыкнуть к тому, что такое указатели.
const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot
.getFileHandle('file', { create: true });
const directoryHandle = await opfsRoot
.getDirectoryHandle('folder', { create: true });
const nestedFileHandle = await directoryHandle
.getFileHandle('nested file', { create: true });
const nestedDirectoryHandle = await directoryHandle
.getDirectoryHandle('nested folder', { create: true });
Такого кода достаточно, чтобы создать внутреннюю файловую систему, которая работает в рамках текущего браузера. Это отдельное хранилище в песочнице вашей страницы.
Локальные шрифты
Вы могли заметить, что в моем приложении много шрифтов. И вряд ли я их все загружал — иначе бы мы очень долго ждали. Это еще одна особенность PWA — работа с локальными шрифтами.
Делается это просто:
try {
const availableFonts = await window.queryLocalFonts();
const list = document.querySelector('select.fonts');
for (const fontData of availableFonts) {
const option = document.createElement('option');
option.text = fontData.fullName;
option.value = fontData.postscriptName;
if (fontData.fullName === 'Times New Roman') {
option.selected = true;
}
list?.appendChild(option);
}
} catch (err) {
console.error(err.name, err.message);
}
Вы вызываете queryLocalFonts
, пользователю выводится окно с разрешением работать с локальными шрифтами. А затем вы можете в методе availableFonts
работать со шрифтами, к которым пользователь дал доступ. У них есть fullName
и postscriptName
. Далее через JavaScript я создаю CSS-загрузчик, в котором подключаю font-face
с именем, которое я беру из fullName
и postscriptName
. И мне даже не нужно ходить в сеть, это работает локально.
Весь код вы можете найти на Github. Можете находить баги и присылать их мне — вместе докрутим что-нибудь крутое.
Когда я писал это приложение, я даже перехотел смотреть на Flutter. Миф о том, что нативные приложения круче PWA, идет откуда-то из 2015-го года. Но с тех пор многое поменялось, и вы сами увидели, как много вещей можно сделать в PWA, которые умеет делать не каждое нативное приложение.
Спасибо за внимание!
Никита уже не первый год выступает на HolyJS. Сейчас мы готовим осеннюю конференцию, которая пройдет онлайн 2 и 3 ноября, а 12 и 13 ноября — офлайн в Санкт-Петербурге. Программа постепенно дополняется, но уже сейчас на сайте можно и увидеть описания многих докладов, и приобрести билеты.
kirill-pavlovskii15
крутой доклад, спасибо Никита