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

Я рад сообщить, что в этой части будет гораздо больше кода, чем в предыдущей, и я объясню, как все работает, на примерах кода. Но что мы строим в последней части? Мы следим за тем, чтобы наша форма для добавления видео работала без подключения к сети. Мы можем реализовать эту функцию с помощью I ndexedDB и фоновой синхронизации.

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

IndexedDB: Что это такое?

IndexedDB - это база данных NoSQL, которая полностью работает внутри браузера. Давайте рассмотрим некоторые функции, которые есть в IndexedDB:

  1. IndexedDB является транзакционным
  2. IndexedDB - база данных хранилища объектов
  3. IndexedDB имеет индексы только для выборки объектов, соответствующих этому значению индекса (немного похоже на предложение WHERE в SQL)
  4. IndexedDB работает в браузере
  5. Разрешено использование нескольких баз данных (в большинстве случаев вы будете использовать только одну)
  6. База данных может иметь несколько хранилищ объектов
  7. Хранилище объектов - это пара ключ-значение, где значение может быть практически любым, что JavaScript позволяет в качестве переменной.
  8. IndexedDB имеет политику одинакового происхождения, поэтому каждый веб-сайт имеет разные базы данных, и ваши данные не будут переопределены.

В IndexedDB есть один важный паттерн, который будет использоваться почти всегда:

Откройте базу данных → Запустите транзакцию в хранилище объектов → Откройте это хранилище объектов → Выполните действия в хранилище объектов (Добавить, Обновить, Удалить, Получить) → Закройте транзакцию.

Чтобы подготовить среду к разработке, вы можете проверить этот тег git: PT3_Starting-Point. Затем вы можете запустить сервер и локальный веб-сервер, как описано в части 1.

Фреймворк IndexedDB на основе небольших обещаний

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

Чтобы запустить нашу небольшую платформу, создайте новый файл JavaScript с именем Promise-based-indexedDB.js в папке js клиента.

Первый метод, который мы напишем, - это открытие базы данных.

const DB_VERSION = 1;
const DB_NAME = "vid-voter";

const openDB = () => {
    return new Promise((resolve, reject) => {
        if (!window.indexedDB) {
            reject("IndexedDB not supported");
        }

        const request = window.indexedDB.open(DB_NAME, DB_VERSION);

        request.onerror = (event) => {
            reject("DB error: " + event.target.error);
        };

        request.onupgradeneeded = (event) => {
            const db = event.target.result;

            if (!db.objectStoreNames.contains("votes")) {
                db.createObjectStore("votes", {keyPath: "id"});
            }
        };

        request.onsuccess = (event) => {
            resolve(event.target.result);
        };
    });
};

В этом методе мы сначала проверяем, доступен ли IndexedDB на устройстве. Если это не так, мы отклоняем обещание. Затем мы открываем базу данных с именем и версией с помощью метода window.indexedDB.open. Затем у нас есть 3 обратных вызова: onerror, onsuccess, onupgradeneeded. onerror срабатывает при возникновении ошибки. onsuccess срабатывает, когда база данных открывается, и мы выполняем обещание с помощью объекта базы данных. onupgradeneeded запускается, когда база данных открывается с более поздней версией. В этом обратном вызове вы можете выполнять сценарии для создания хранилищ объектов. Мы создали новое хранилище объектов videos с помощью метода объекта базы данных createObjectStore.

Далее идет метод открытия транзакции и хранилища объектов. Это единственный метод, который не возвращает обещание.

const openObjectStore = (db, name, transactionMode) => {
    return db.transaction(name, transactionMode).objectStore(name);
};

Ничего особенного, мы открываем транзакцию с помощью метода transaction, после чего открываем хранилище объектов с помощью метода objectStore.

Следующий метод, который мы реализуем, - это добавление объекта в хранилище объектов.

const addObject = (storeName, object) => {
    return new Promise((resolve, reject) => {
        openDB().then(db => {
            openObjectStore(db, storeName, "readwrite")
                .add(object)
                .onsuccess = resolve;
        }).catch(reason => reject(reason));
    });
};

Сначала мы открываем базу данных, затем открываем хранилище объектов и вызываем для него метод add. Мы используем обратный вызов onsuccess, чтобы выполнить обещание.

Чтобы обновить методы, нам нужно еще немного поработать, вот код:

const updateObject = (storeName, id, object) => {
    return new Promise((resolve, reject) => {
        openDB().then(db => {
            openObjectStore(db, storeName, "readwrite")
                .openCursor().onsuccess = (event) => {
                const cursor = event.target.result;
                if (!cursor) {
                    reject(`No object store found for '${storeName}'`)
                }

                if (cursor.value.id === id) {
                    cursor.update(object).onsuccess = resolve;
                }

                cursor.continue();
            }
        }).catch(reason => reject(reason));
    });
};

Мы снова открываем базу данных и хранилище объектов. Затем мы используем openCursor, чтобы начать итерацию по элементам в хранилище объектов. Если курсора нет, мы отклоняем обещание красивым сообщением. Но если есть курсор, мы проверяем, совпадает ли значение идентификатора курсора с идентификатором объекта, который нам нужно обновить. Если это то же самое, мы обновляем объект с помощью метода update и разрешаем обещание с помощью обратного вызова onsuccess. Если идентификаторы не совпадают, мы вызываем метод continue () для объекта курсора. Этот метод указывает курсору перейти к следующему объекту в магазине.

Метод удаления почти такой же, как и метод добавления:

const deleteObject = (storeName, id) => {
    return new Promise((resolve, reject) => {
        openDB().then(db => {
            openObjectStore(db, storeName, "readwrite")
                .delete(id)
                .onsuccess = resolve;
        }).catch(reason => reject(reason));
    });
};

Начнем снова с открытия базы данных и хранилища объектов. Затем в хранилище объектов мы вызываем метод delete и используем обратный вызов onsuccess для выполнения обещания.

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

const getVideos = () => {
    return new Promise(resolve => {
        openDB().then(db => {
            const store = openObjectStore(db, "videos", "readwrite");
            const videos = [];
            store.openCursor().onsuccess = (event) => {
                const cursor = event.target.result;
                if (cursor) {
                    videos.push(cursor.value);
                    cursor.continue();
                } else {
                    if (videos.length > 0) {
                        resolve(videos);
                    } else {
                        getVideosFromServer().then((videos) => {
                            for (const video of videos) {
                                addObject("videos", video);
                            }
                            resolve(videos);
                        });
                    }
                }
            }
        }).catch(function() {
            getVideosFromServer().then((videos) => {
                resolve(videos);
            });
        });
    });
};

const getVideosFromServer = () => {
    return new Promise((resolve) => $.getJSON("http://localhost:3000/videos", resolve));
};

Когда мы можем открыть хранилище объектов и у нас есть курсор, мы перебираем все видео. На каждой итерации мы возвращаемся в один и тот же обратный вызов, и итерация выполняется, когда нет курсора. Если длина массива видео больше нуля, мы возвращаем результаты из IndexedDB. Если у нас нет результатов, мы проверим сервер на наличие всех видео. Когда сервер возвращает видео, мы также сохраняем их в хранилище объектов, чтобы поддерживать синхронизацию браузера и сервера.

Важно, чтобы при создании новых объектов вы создавали их в IndexedDB и на стороне сервера.

Весь этот код можно найти под тегом git: PT3_1-indexedDB-promises.

Рендеринг видео из IndexedDB

В настоящее время мы все еще рендерим видео, которые получаем с сервера. Но, как мы видели в методе getVideos, мы сохраняем все видео, полученные с сервера, в IndexedDB. Так что давайте воспользуемся этим и сделаем отсюда рендеринг видео.

Прежде всего нам нужно добавить тег script, чтобы включить нашу небольшую структуру. Добавьте следующий текст в voter.html над тегом скрипта voter.js.

<script src="js/promise-based-indexedDB.js"></script>

Следующее изменение кода предназначено для рендеринга видео из IndexedDB. Это изменение должно произойти внутри voter.js.

Замените loadVideoList следующим:

const loadVideoList = () => {
    getVideos().then(renderVideos);
};

Вместо того, чтобы получать его с сервера, мы просто используем метод из нашей структуры IndexedDB.

Добавить видео

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

Замените addVideo следующим

const addVideo = () => {
    const titleInput = $("#title");
    const urlInput = $("#url");
    const postData = {
        id: Date.now().toString().substring(3, 11),
        title: titleInput.val(),
        link: urlInput.val(),
        points: 0
    };

    titleInput.val('');
    urlInput.val('');

    // Add video to the object store
    addObject("videos", postData)
        .catch(e => console.error(e));

    $.ajax({
       type: 'POST',
       url: 'http://localhost:3000/videos',
       data: JSON.stringify(postData),
       success: renderVideos,
       contentType: 'application/json',
       dataType: 'json'
    });
};

Теперь, если вы добавите видео, мы добавим его в хранилище объектов. Чтобы просмотреть хранилище объектов, перейдите в Инструменты разработчика ChromeВыбрать приложениеОткрыть IndexedDB → Открыть vid-voter. А там вы можете увидеть свои магазины.

Наша конечная цель - использовать Фоновую синхронизацию, и для этого нам нужно использовать нашу небольшую структуру IndexedDB на нашей веб-странице и в сервис-воркере. Но у них разные масштабы. У сервис-воркера нет объекта window. Это то, что мы сейчас используем. Чтобы наш код работал для нашего сервис-воркера, нам нужно изменить каждое window .indexedDB на self .indexedDB .

Нам также необходимо обновить метод getVideosFromServer следующим образом:

const getVideosFromServer = () => {
    return new Promise((resolve) => {
            if (self.$) {
                $.getJSON("http://localhost:3000/videos", resolve)
            } else if (self.fetch) {
                fetch("http://localhost:3000/videos").then((response) => {
                    return response.json();
                }).then(function (videos) {
                    resolve(videos);
                });
            }
        }
    );
};

Мы проверяем, можем ли мы использовать jQuery или fetch api. Теперь мы готовы приступить к реализации фоновой синхронизации.

Весь этот код можно найти под тегом git: PT3_2-use-indexedDB.

Фоновая синхронизация

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

Если активирована Фоновая синхронизация, пользователь не увидит никакой разницы, чем веб-сайт, у которого всегда есть соединение. Но это действительно здорово, если попытаться повторно отправить форму, если она не удалась. Даже если пользователь этого не заметит, ему больше не нужно находиться на веб-сайте. Фоновая синхронизация будет работать в фоновом режиме, пока установлен браузер.

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

Вы всегда можете проверить последний статус здесь: Могу я использовать…

Теперь мы знаем, что делает Фоновая синхронизация, и можем приступить к ее реализации в нашем приложении.

Добавление фоновой синхронизации в наше приложение

Первый метод, который нам нужно изменить, - это addVideo в voter.js. Так теперь выглядит функция:

const addVideo = () => {
    const titleInput = $("#title");
    const urlInput = $("#url");
    const postData = {
        id: +Date.now().toString().substring(3, 11),
        title: titleInput.val(),
        link: urlInput.val(),
        points: 0,
        status: "sending"
    };

    titleInput.val('');
    urlInput.val('');

    // Add video to the object store
    addObject("videos", postData)
        .catch(e => console.error(e));

    if ("serviceWorker" in navigator && "SyncManager" in window) {
        navigator.serviceWorker.ready.then(function(registration) {
            registration.sync.register("sync-videos");
        });
    } else {
        $.ajax({
            type: 'POST',
            url: 'http://localhost:3000/videos',
            data: JSON.stringify(postData),
            success: renderVideos,
            contentType: 'application/json',
            dataType: 'json'
        });
    }
};

Изменение заключается в инструкции if. В этом заявлении мы проверяем, есть ли у нас работник службы и есть ли у нас диспетчер синхронизации. Если у нас есть и то, и другое, мы регистрируем новое событие синхронизации под названием sync-videos. Если у нас нет обоих объектов, мы вызываем сервер напрямую, как мы привыкли.

Мы также добавили поле статус к данным публикации. Мы будем использовать это поле, чтобы проверить, нужно ли нам пытаться отправить его повторно или нет.

В Promise-based-indexedDB.js нам также необходимо изменить request.onupgradeneeded на следующее:

request.onupgradeneeded = (event) => {
    const db = event.target.result;
    const upgradeTransaction = event.target.transaction;
    let videoStore;

    if (!db.objectStoreNames.contains("videos")) {
        videoStore = db.createObjectStore("videos", {keyPath: "id"});
    } else {
        videoStore = upgradeTransaction.objectStore("videos");
    }

    if (!videoStore.indexNames.contains("idx_status")) {
        videoStore.createIndex("idx_status", "status", { unique: false });
    }

};

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

Не забудьте увеличить переменную DB_VERSION!

Теперь, когда мы создали этот индекс, мы можем его использовать, измените getVideos на следующее:

const getVideos = (indexName, indexValue) => {
    return new Promise(resolve => {
        openDB().then(db => {
            const store = openObjectStore(db, "videos", "readwrite");
            const videos = [];
            
            const openCursor = indexName && indexValue ?
                store.index(indexName).openCursor(indexValue) :
                store.openCursor();
            
            openCursor.openCursor().onsuccess = (event) => {
                const cursor = event.target.result;
                if (cursor) {
                    videos.push(cursor.value);
                    cursor.continue();
                } else {
                    if (videos.length > 0) {
                        resolve(videos);
                    } else {
                        getVideosFromServer().then((videos) => {
                            for (const video of videos) {
                                addObject("videos", video);
                            }
                            resolve(videos);
                        });
                    }
                }
            }
        }).catch(function (e) {
            console.error(e);
            getVideosFromServer().then((videos) => {
                resolve(videos);
            });
        });
    });
};

Мы добавили 2 необязательных параметра в метод getVideos. Если мы установим оба из них, мы создадим курсор с этим индексом и значением индекса. Это гарантирует, что если мы отправим, например, getVideos ('status', 'sent'), мы выберем только те видео со статусом status отправка .

Теперь для фактической синхронизации добавьте следующий код в service-worker.js под методом self .addEventListener («активировать», функция (событие) {}) :

self.addEventListener("sync", function(event) {
    if (event.tag === "sync-videos") {
        event.waitUntil(syncVideos());
    }
});

В этом фрагменте мы добавляем новый прослушиватель событий для синхронизации, а затем проверяем, совпадает ли тег события с тем, который мы используем для запуска события синхронизации. Если это то же самое, мы пытаемся синхронизировать видео. Обратите внимание на event.waiteUntil,, который мы используем, поэтому наш процесс синхронизации не может быть прерван.

Последний фрагмент кода, который нам нужно добавить, - это метод syncVideos. Вы можете добавить метод в конец файла service-worker.js:

const syncVideos = () => {
    return getVideos("idx_status", "sending").then((videos) => {
        return Promise.all(videos.map((video) => {
                return syncVideo(video)
                    .then((newVideo) => updateObject("videos", newVideo.id, newVideo));
            })
        );
    });
};

const syncVideo = (video) => {
    return fetch('http://localhost:3000/videos', {
        method: 'post',
        body: JSON.stringify(video)
    }).then(function (response) {
        return response.json();
    })
};

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

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

Ознакомьтесь с исходной публикацией блога на веб-сайте Design is Dead: https://designisdead.com/blog/offline-first-with-progressive-web-apps-part-3-3-background-sync