Создаём для современных браузеров и постоянно совершенствуем, как в 2003 году.
Опубликовано: 29 июня 2020 г.
В марте 2003 года Ник Финк и Стив Чэмпеон поразили мир веб-дизайна концепцией прогрессивного улучшения — стратегии веб-дизайна, которая сначала загружает основное содержимое веб-страницы, а затем постепенно добавляет более сложные и технически строгие уровни представления и функции поверх него. В 2003 году прогрессивное улучшение предполагало использование современных функций CSS, ненавязчивого JavaScript и даже масштабируемой векторной графики. В 2020 году и далее прогрессивное улучшение подразумевает использование возможностей современных браузеров .

Современный JavaScript
Говоря о JavaScript, стоит отметить отличную поддержку браузерами новейших основных функций JavaScript ES 2015. Новый стандарт включает в себя обещания, модули, классы, шаблонные литералы, стрелочные функции, let
и const
, параметры по умолчанию, генераторы, деструктурирующее присваивание, rest и spread, Map
/ Set
, WeakMap
/ WeakSet
и многое другое. Поддерживаются все эти функции .

Асинхронные функции, функция ES 2017 и одна из моих любимых, могут использоваться во всех основных браузерах. Ключевые слова async
и await
позволяют писать асинхронное поведение на основе промисов более чётко, избегая необходимости явно настраивать цепочки промисов.

И даже совсем недавние нововведения в языке ES 2020, такие как опциональное связывание и объединение по нулевому значению, получили поддержку очень быстро. Что касается основных функций JavaScript, то здесь всё просто замечательно.
Например:
const adventurer = {
name: 'Alice',
cat: {
name: 'Dinah',
},
};
console.log(adventurer.dog?.name);
// Expected output: undefined
console.log(0 ?? 42);
// Expected output: 0

Пример приложения: Fugu Greetings
Для этого документа я использую PWA под названием Fugu Greetings ( GitHub ). Название этого приложения — дань уважения проекту Fugu 🐡, стремлению предоставить вебу все возможности Android, iOS и настольных приложений. Подробнее о проекте можно узнать на его целевой странице .
Fugu Greetings — это приложение для рисования, позволяющее создавать виртуальные поздравительные открытки и отправлять их близким. Оно воплощает в себе основные принципы PWA . Оно надёжно и полностью работает в автономном режиме, поэтому вы можете использовать его даже без подключения к сети. Его также можно установить на главный экран устройства, и оно легко интегрируется с операционной системой как отдельное приложение.

Прогрессивное улучшение
Разобравшись с этим, пора поговорить о прогрессивном улучшении . В глоссарии MDN Web Docs эта концепция определяется следующим образом:
Прогрессивное улучшение — это философия дизайна, которая обеспечивает базовый уровень необходимого контента и функциональности для максимально возможного числа пользователей, при этом обеспечивая наилучший возможный опыт только для пользователей самых современных браузеров, которые могут выполнять весь необходимый код.
Обнаружение функций обычно используется для определения того, могут ли браузеры обрабатывать более современные функции, в то время как полифиллы часто используются для добавления недостающих функций с помощью JavaScript.
[…]
Прогрессивное улучшение — это полезный метод, позволяющий веб-разработчикам сосредоточиться на разработке наилучших веб-сайтов, одновременно обеспечивая их работоспособность на нескольких неизвестных пользовательских агентах. Постепенное ухудшение качества связано с прогрессивным улучшением, но это не одно и то же, и часто рассматривается как движение в противоположном направлении. На самом деле оба подхода обоснованы и часто дополняют друг друга.
Участники MDN
Создавать каждую поздравительную открытку с нуля может быть очень сложно. Так почему бы не добавить функцию, позволяющую пользователям импортировать изображение и начать с него? При традиционном подходе для этого использовался бы элемент <input type=file>
. Сначала нужно создать элемент, задать его type
'file'
и добавить MIME-типы в свойство accept
, а затем программно «кликнуть» по нему и ожидать изменений. При выборе изображения оно импортируется прямо на холст.
const importImage = async () => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
resolve(input.files[0]);
});
input.click();
});
};
Если есть функция импорта , вероятно, должна быть и функция экспорта , чтобы пользователи могли сохранять свои поздравительные открытки локально. Традиционный способ сохранения файлов — создание якорной ссылки с атрибутом download
и URL-адресом объекта blob в качестве атрибута href
. Вы также программно «кликаете» по ссылке, чтобы запустить загрузку, и, чтобы избежать утечек памяти, желательно не забыть отозвать URL-адрес объекта blob.
const exportImage = async (blob) => {
const a = document.createElement('a');
a.download = 'fugu-greeting.png';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', (e) => {
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
a.click();
};
Но подождите минутку. Вы же не «скачали» открытку, а «сохранили» её. Вместо того, чтобы открыть диалоговое окно сохранения, где можно выбрать, куда сохранить файл, браузер напрямую скачал открытку без взаимодействия с пользователем и поместил её прямо в папку «Загрузки». Это не очень хорошо.
Что, если бы существовал способ получше? Что, если бы можно было просто открыть локальный файл, отредактировать его, а затем сохранить изменения либо в новом файле, либо в исходном, который вы изначально открыли? Оказывается, такой способ есть. API доступа к файловой системе позволяет открывать и создавать файлы и каталоги, а также изменять и сохранять их.
Итак, как определить API? API доступа к файловой системе предоставляет новый метод window.chooseFileSystemEntries()
. Следовательно, мне нужно условно загружать различные модули импорта и экспорта в зависимости от доступности этого метода.
const loadImportAndExport = () => {
if ('chooseFileSystemEntries' in window) {
Promise.all([
import('./import_image.mjs'),
import('./export_image.mjs'),
]);
} else {
Promise.all([
import('./import_image_legacy.mjs'),
import('./export_image_legacy.mjs'),
]);
}
};
Но прежде чем углубляться в детали API доступа к файловой системе, позвольте мне кратко описать прогрессивный подход к улучшению. В браузерах, не поддерживающих API доступа к файловой системе, я загружаю устаревшие скрипты.


Однако в Chrome, браузере с поддержкой API, загружаются только новые скрипты. Это элегантно реализовано благодаря динамическому import()
, который поддерживают все современные браузеры. Как я уже говорил, сейчас трава ещё довольно зелёная.

API доступа к файловой системе
Итак, разобравшись с этим, пора рассмотреть реализацию на основе API доступа к файловой системе. Для импорта изображения я вызываю метод window.chooseFileSystemEntries()
и передаю ему свойство accepts
, в котором указываю, что мне нужны файлы изображений. Поддерживаются как расширения файлов, так и типы MIME. В результате получается дескриптор файла, из которого я могу получить сам файл, вызвав getFile()
.
const importImage = async () => {
try {
const handle = await window.chooseFileSystemEntries({
accepts: [
{
description: 'Image files',
mimeTypes: ['image/*'],
extensions: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
},
],
});
return handle.getFile();
} catch (err) {
console.error(err.name, err.message);
}
};
Экспорт изображения практически аналогичен, но на этот раз мне нужно передать параметр типа 'save-file'
методу chooseFileSystemEntries()
. В результате появляется диалоговое окно сохранения файла. При открытом файле это не было необходимости, так как 'open-file'
используется по умолчанию. Я устанавливаю параметр accepts
аналогично предыдущему, но на этот раз ограничиваюсь только изображениями в формате PNG. Я снова получаю дескриптор файла, но вместо получения файла я создаю поток для записи, вызывая createWritable()
. Затем я записываю в файл blob-объект, представляющий собой изображение моей поздравительной открытки. Наконец, я закрываю поток для записи.
Всё может пойти не так: на диске может закончиться место, может произойти ошибка записи или чтения, а может быть, пользователь просто отменил диалог открытия файла. Поэтому я всегда заключаю вызовы в блок try...catch
.
const exportImage = async (blob) => {
try {
const handle = await window.chooseFileSystemEntries({
type: 'save-file',
accepts: [
{
description: 'Image file',
extensions: ['png'],
mimeTypes: ['image/png'],
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error(err.name, err.message);
}
};
Благодаря прогрессивным улучшениям API доступа к файловой системе я могу открыть файл, как и раньше. Импортированный файл отображается прямо на холсте. Я могу внести необходимые изменения и сохранить их с помощью полноценного диалогового окна сохранения, где можно выбрать имя и место хранения файла. Теперь файл готов к вечному хранению.



API Web Share и Web Share Target
Помимо возможности хранить открытку вечно, возможно, я действительно хочу поделиться ею. Web Share API и Web Share Target API позволяют мне это сделать. Мобильные устройства, а в последнее время и настольные операционные системы, обзавелись встроенными механизмами для обмена.
Например, панель «Поделиться» в Safari на компьютере macOS открывается, когда пользователь нажимает кнопку «Поделиться статьёй в моём блоге» . Вы можете поделиться ссылкой на статью с другом через приложение «Сообщения» в macOS.
Для этого я вызываю метод navigator.share()
и передаю ему необязательные title
, text
и url
в объекте. Но что, если я хочу прикрепить изображение? API Web Share уровня 1 пока не поддерживает эту функцию. Хорошая новость заключается в том, что в Web Share уровня 2 добавлены возможности обмена файлами.
try {
await navigator.share({
title: 'Check out this article:',
text: `"${document.title}" by @tomayac:`,
url: document.querySelector('link[rel=canonical]').href,
});
} catch (err) {
console.warn(err.name, err.message);
}
Позвольте мне показать, как это работает на примере приложения с поздравительной открыткой Fugu. Сначала мне нужно подготовить объект data
с массивом files
, состоящим из одного двоичного объекта (BLOB), а затем title
и text
. Далее, в качестве рекомендуемой практики, я использую новый метод navigator.canShare()
, который делает то, что следует из его названия: он сообщает, может ли браузер технически поделиться объектом data
, которым я пытаюсь поделиться. Если navigator.canShare()
сообщает, что данные могут быть переданы, я готов вызвать navigator.share()
, как и раньше. Поскольку всё может потерпеть неудачу, я снова использую блок try...catch
.
const share = async (title, text, blob) => {
const data = {
files: [
new File([blob], 'fugu-greeting.png', {
type: blob.type,
}),
],
title: title,
text: text,
};
try {
if (!(navigator.canShare(data))) {
throw new Error("Can't share data.", data);
}
await navigator.share(data);
} catch (err) {
console.error(err.name, err.message);
}
};
Как и прежде, я использую прогрессивное улучшение. Только если в объекте navigator
присутствуют оба атрибута 'share'
и 'canShare'
, я перехожу к загрузке share.mjs
с помощью динамического import()
. В браузерах, таких как мобильный Safari, которые удовлетворяют только одному из двух условий, я не загружаю эту функциональность.
const loadShare = () => {
if ('share' in navigator && 'canShare' in navigator) {
import('./share.mjs');
}
};
В Fugu Greetings при нажатии кнопки «Поделиться» в поддерживающем браузере, например, Chrome на Android, открывается встроенная панель для отправки сообщений. Например, я могу выбрать Gmail, и появится виджет для создания письма с прикреплённым изображением.


API выбора контактов
Далее я хочу поговорить о контактах, то есть об адресной книге устройства или приложении для управления контактами. При написании поздравительной открытки не всегда легко правильно написать чьё-то имя. Например, у меня есть друг Сергей, который предпочитает, чтобы его имя было написано кириллицей. Я использую немецкую раскладку QWERTZ и понятия не имею, как набрать его имя. Эту проблему может решить API выбора контактов . Поскольку мой друг сохранён в приложении контактов на телефоне, с помощью API выбора контактов я могу получить доступ к своим контактам из интернета.
Сначала мне нужно указать список свойств, к которым я хочу получить доступ. В данном случае мне нужны только имена, но в других случаях мне могут быть интересны номера телефонов, адреса электронной почты, значки аватаров или физические адреса. Затем я настраиваю объект options
и устанавливаю multiple
в true
, чтобы можно было выбрать несколько записей. Наконец, я могу вызвать navigator.contacts.select()
, который возвращает оптимальные свойства для выбранных пользователем контактов.
const getContacts = async () => {
const properties = ['name'];
const options = { multiple: true };
try {
return await navigator.contacts.select(properties, options);
} catch (err) {
console.error(err.name, err.message);
}
};
И к настоящему моменту вы, вероятно, уже усвоили схему: я загружаю файл только тогда, когда API действительно поддерживается.
if ('contacts' in navigator) {
import('./contacts.mjs');
}
В приветствии Fugu, когда я нажимаю кнопку «Контакты» и выбираю двух лучших друзей, Сергея Михайловича Брина и劳伦斯·爱德华·"拉里"·佩奇, вы видите, что выбор контактов ограничен: отображаются только их имена, но не адреса электронной почты и другая информация, например, номера телефонов. Затем их имена отображаются на моей поздравительной открытке.


API асинхронного буфера обмена
Далее — копирование и вставка. Одна из наших любимых операций, как разработчиков программного обеспечения, — это копирование и вставка. Как автор поздравительных открыток, я иногда хочу сделать то же самое. Мне может понадобиться вставить изображение в открытку, над которой я работаю, или скопировать её, чтобы продолжить редактирование в другом месте. API асинхронного буфера обмена поддерживает как текст, так и изображения. Позвольте мне рассказать вам, как я добавил поддержку копирования и вставки в приложение Fugu Greetings.
Чтобы скопировать что-либо в системный буфер обмена, мне нужно записать туда данные. Метод navigator.clipboard.write()
принимает в качестве параметра массив элементов буфера обмена. Каждый элемент буфера обмена — это, по сути, объект со значением типа blob и ключом типа blob.
const copy = async (blob) => {
try {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
} catch (err) {
console.error(err.name, err.message);
}
};
Для вставки мне нужно перебрать элементы буфера обмена, полученные с помощью вызова navigator.clipboard.read()
. Это связано с тем, что в буфере обмена может находиться несколько элементов в разных представлениях. У каждого элемента буфера обмена есть поле types
, в котором указаны MIME-типы доступных ресурсов. Я вызываю метод getType()
элемента буфера обмена, передавая ему MIME-тип, полученный ранее.
const paste = async () => {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
try {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
return blob;
}
} catch (err) {
console.error(err.name, err.message);
}
}
} catch (err) {
console.error(err.name, err.message);
}
};
И это уже почти само собой разумеется. Я делаю это только в поддерживаемых браузерах.
if ('clipboard' in navigator && 'write' in navigator.clipboard) {
import('./clipboard.mjs');
}
Как это работает на практике? Я открываю изображение в приложении «Предпросмотр» на macOS и копирую его в буфер обмена. Когда я нажимаю «Вставить» , приложение Fugu Greetings спрашивает, хочу ли я разрешить ему видеть текст и изображения в буфере обмена.

Наконец, после получения разрешения изображение вставляется в приложение. Обратный вариант тоже работает. Давайте скопируем поздравительную открытку в буфер обмена. Когда я открываю «Предварительный просмотр» и нажимаю «Файл» , а затем «Создать из буфера обмена» , поздравительная открытка вставляется в новое изображение без названия.

API бейджинга
Ещё один полезный API — это API Badging . Fugu Greetings, будучи устанавливаемым PWA, конечно же, имеет значок приложения, который пользователи могут разместить на панели приложений или на главном экране. Интересный способ продемонстрировать API — использовать его в Fugu Greetings в качестве счётчика взмахов пера. Я добавил прослушиватель событий, который увеличивает счётчик взмахов пера при каждом событии pointerdown
, а затем устанавливает обновлённый значок значка. При очистке холста счётчик сбрасывается, а значок удаляется.
let strokes = 0;
canvas.addEventListener('pointerdown', () => {
navigator.setAppBadge(++strokes);
});
clearButton.addEventListener('click', () => {
strokes = 0;
navigator.setAppBadge(strokes);
});
Эта функция представляет собой прогрессивное улучшение, поэтому логика загрузки остается обычной.
if ('setAppBadge' in navigator) {
import('./badge.mjs');
}
В этом примере я нарисовал цифры от одного до семи, используя один росчерк пера на каждую цифру. Счётчик значков на значке теперь показывает семь.


API периодической фоновой синхронизации
Хотите начинать каждый день с чего-то нового? Удобная функция приложения Fugu Greetings заключается в том, что оно может вдохновлять вас каждое утро новым фоновым изображением для начала поздравительной открытки. Для этого приложение использует API периодической фоновой синхронизации .
Первый шаг — зарегистрировать периодическое событие синхронизации в сервис-воркере. Оно прослушивает тег синхронизации 'image-of-the-day'
с минимальным интервалом в один день, чтобы пользователь мог получать новое фоновое изображение каждые 24 часа.
const registerPeriodicBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready;
try {
registration.periodicSync.register('image-of-the-day-sync', {
// An interval of one day.
minInterval: 24 * 60 * 60 * 1000,
});
} catch (err) {
console.error(err.name, err.message);
}
};
Второй шаг — прослушивание события periodicsync
в сервис-воркере. Если тег события — 'image-of-the-day'
, то есть изображение дня, зарегистрированное ранее, функция getImageOfTheDay()
извлекает изображение дня, и результат распространяется всем клиентам, чтобы они могли обновить свои холсты и кэши.
self.addEventListener('periodicsync', (syncEvent) => {
if (syncEvent.tag === 'image-of-the-day-sync') {
syncEvent.waitUntil(
(async () => {
const blob = await getImageOfTheDay();
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
image: blob,
});
});
})()
);
}
});
Опять же, это действительно прогрессивное улучшение: код загружается только при поддержке API браузером. Это относится как к клиентскому коду, так и к коду сервис-воркера. В браузерах, не поддерживающих API, ни один из них не загружается. Обратите внимание, что в сервис-воркере вместо динамического метода import()
(который пока не поддерживается в контексте сервис-воркера) я использую классический importScripts()
.
// In the client:
const registration = await navigator.serviceWorker.ready;
if (registration && 'periodicSync' in registration) {
import('./periodic_background_sync.mjs');
}
// In the service worker:
if ('periodicSync' in self.registration) {
importScripts('./image_of_the_day.mjs');
}
В Fugu Greetings нажатие кнопки «Обои» открывает поздравительную открытку дня, которая обновляется каждый день с помощью API периодической фоновой синхронизации.

API триггеров уведомлений
Иногда, даже при большом вдохновении, нужен толчок, чтобы закончить начатую открытку. Эта функция реализована через API триггеров уведомлений . Как пользователь, я могу указать время, когда я хочу получить толчок, чтобы закончить открытку. Когда наступит это время, я получу уведомление о том, что моя открытка ждёт меня.
После запроса целевого времени приложение планирует отправку уведомления с помощью showTrigger
. Это может быть триггер TimestampTrigger
с ранее выбранной целевой датой. Напоминание будет отправлено локально, без участия сети или сервера.
const targetDate = promptTargetDate();
if (targetDate) {
const registration = await navigator.serviceWorker.ready;
registration.showNotification('Reminder', {
tag: 'reminder',
body: "It's time to finish your greeting card!",
showTrigger: new TimestampTrigger(targetDate),
});
}
Как и все остальное, что я показывал до сих пор, это прогрессивное улучшение, поэтому код загружается только условно.
if ('Notification' in window && 'showTrigger' in Notification.prototype) {
import('./notification_triggers.mjs');
}
Когда я устанавливаю флажок «Напоминание» в Fugu Greetings, появляется запрос, спрашивающий, когда я хочу получить напоминание о необходимости закончить поздравительную открытку.

Когда в Fugu Greetings срабатывает запланированное уведомление, оно отображается так же, как и любое другое уведомление, но, как я уже писал ранее, для него не требуется подключение к сети.

API блокировки пробуждения
Я также хочу упомянуть API Wake Lock . Иногда нужно просто долго смотреть на экран, пока вдохновение не снизойдет на вас. Худшее, что может произойти в такой ситуации, — экран отключится. API Wake Lock может предотвратить это.
Первый шаг — получение блокировки пробуждения с помощью navigator.wakelock.request method()
. Я передаю ему строку 'screen'
для получения блокировки пробуждения экрана. Затем я добавляю прослушиватель событий, чтобы получать уведомления о снятии блокировки пробуждения. Это может произойти, например, при изменении видимости вкладки. В этом случае я могу повторно получить блокировку пробуждения, когда вкладка снова станет видимой.
let wakeLock = null;
const requestWakeLock = async () => {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
console.log('Wake Lock is active');
};
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('fullscreenchange', handleVisibilityChange);
Да, это прогрессивное улучшение, поэтому мне нужно загружать его только тогда, когда браузер поддерживает API.
if ('wakeLock' in navigator && 'request' in navigator.wakeLock) {
import('./wake_lock.mjs');
}
В Fugu Greetings есть флажок «Бессонница» , при установке которого экран не будет спать.

API обнаружения простоя
Иногда, даже если вы часами смотрите на экран, он просто бесполезен, и вы не можете придумать, что делать с открыткой. API обнаружения простоя позволяет приложению определять время бездействия пользователя. Если пользователь бездействует слишком долго, приложение возвращается в исходное состояние и очищает холст. Этот API ограничен разрешением на уведомления , поскольку многие сценарии использования обнаружения простоя в производственной среде связаны с уведомлениями, например, для отправки уведомления только на устройство, которое пользователь активно использует.
Убедившись, что разрешение на уведомления предоставлено, я создаю детектор бездействия. Я регистрирую прослушиватель событий, который отслеживает изменения состояния бездействия, включая пользователя и состояние экрана. Пользователь может быть активным или бездействующим, а экран может быть разблокирован или заблокирован. Если пользователь бездействует, холст очищается. Я устанавливаю для детектора бездействия пороговое значение в 60 секунд.
const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', () => {
const userState = idleDetector.userState;
const screenState = idleDetector.screenState;
console.log(`Idle change: ${userState}, ${screenState}.`);
if (userState === 'idle') {
clearCanvas();
}
});
await idleDetector.start({
threshold: 60000,
signal,
});
И как всегда, я загружаю этот код только тогда, когда браузер его поддерживает.
if ('IdleDetector' in window) {
import('./idle_detection.mjs');
}
В приложении Fugu Greetings холст очищается, если установлен флажок «Эфемерный» и пользователь слишком долго бездействует.

Закрытие
Уф, вот это да! Столько API в одном приложении-примере. И помните, я никогда не заставляю пользователя платить за загрузку функции, которую его браузер не поддерживает. Используя прогрессивное улучшение, я гарантирую загрузку только нужного кода. А поскольку запросы в HTTP/2 обходятся недорого, этот шаблон должен хорошо работать для многих приложений, хотя для действительно больших приложений, возможно, стоит рассмотреть вариант с бандлером.

Приложение может выглядеть немного по-разному в разных браузерах, поскольку не все платформы поддерживают все функции, но основные функции остаются неизменными, постепенно расширяясь в соответствии с возможностями конкретного браузера. Эти возможности могут меняться даже в одном и том же браузере в зависимости от того, запущено ли приложение как установленное приложение или во вкладке браузера.



Вы можете создать форк Fugu на GitHub .
Команда Chromium усердно работает над тем, чтобы сделать прогрессивные API Fugu более экологичными. Применяя прогрессивное улучшение при разработке своего приложения, я гарантирую, что все получат хороший и стабильный базовый опыт, а пользователи браузеров, поддерживающих больше API веб-платформ, получат ещё лучший опыт. С нетерпением жду, когда вы сможете увидеть, как прогрессивное улучшение будет реализовано в ваших приложениях.
Благодарности
Я благодарен Кристиану Либелю и Хеманту Х.М. , которые внесли свой вклад в Fugu Greetings. Этот документ был проверен Джо Медли и Кейсом Баскесом . Джейк Арчибальд помог мне разобраться с динамическим import()
в контексте сервис-воркера.