SVG-спрайт в localStorage
02 Июн 2024
Кэшировать SVG-спрайт в браузере сначала кажется задачей на несколько строк. Получил файл, положил строку в localStorage, при следующем запуске достал её обратно.
Сложность начинается с вопроса: как понять, что сохранённая копия ещё актуальна?
В ранней версии iconly я отвечал на него параметром revision. При изменении спрайта нужно было изменить и ревизию. Механизм работал, но требовал помнить о ручном действии при каждом обновлении набора иконок. Как раз от этого я хотел избавиться.
За следующие версии решение прошло через проверку длины SVG и в итоге переехало в IndexedDB.
Как не забыть ревизию
В 1.0.0 логика была прямолинейной. Передаю URL спрайта и его ревизию, а iconly сравнивает её со значением в localStorage:
const { file, revision } = this.options;
if (
this.isLocalStorage
&& localStorage.getItem('inlineSVGrev') === revision
) {
const data = localStorage.getItem('inlineSVGdata');
if (data) {
this.insert(data);
return;
}
}
Если ревизия совпала, можно взять сохранённый SVG и вообще не обращаться к сети. Если нет, библиотека загружает файл и обновляет inlineSVGdata вместе с inlineSVGrev.
Проблема здесь не техническая, а эксплуатационная. Механизм инвалидирования существует вне самого файла. Изменил sprite.svg, забыл изменить revision — получил старую копию из браузера.
Я не хотел держать это правило в голове, поэтому убрал обязательную ревизию и попробовал определить изменение автоматически.
Размер строки вместо отдельной ревизии
Следующий вариант сохранял рядом со спрайтом его длину:
const storedSize = localStorage.getItem('inlineSVGsize');
const response = await fetch(file);
const data = await response.text();
if (storedSize && storedSize === data.length.toString()) {
this.insert(localStorage.getItem('inlineSVGdata'));
} else {
this.insert(data);
localStorage.setItem('inlineSVGdata', data);
localStorage.setItem('inlineSVGsize', data.length.toString());
}
Для API это было удобнее. Пользователю больше не нужно передавать revision, а изменение длины файла автоматически обновляет сохранённую строку.
Но это именно эвристика. Два разных SVG могут иметь одинаковую длину. Кроме того, для сравнения iconly всё равно сначала полностью загружает файл. То есть localStorage в этой реализации не экономит сетевой запрос. Он хранит копию, но для решения о её актуальности уже нужен свежий ответ сервера.
На этом этапе меня такой компромисс устраивал больше ручного revision, но параллельно росли сами спрайты. Хранить всё более крупные SVG-строки в localStorage мне нравилось всё меньше.
Откуда IndexedDB
У перехода было три причины: размер спрайтов, желание получить более надёжное хранилище и эксперимент с IndexedDB.
localStorage хорош для небольших данных в формате ключ/значение, но его API синхронный. IndexedDB устроен иначе: работа с ним асинхронная, данные организованы через базы, хранилища объектов, ключи и транзакции. Для заметно растущего набора данных это выглядело более подходящей основой.
В 1.4.0 iconly открывает одну базу iconlyDB и создаёт хранилище icons:
const request = indexedDB.open('iconlyDB', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('icons')) {
db.createObjectStore('icons', { keyPath: 'version' });
}
};
Вместо двух строк в localStorage теперь хранится объект:
{
version,
data,
}
version становится ключом записи. И вместе с этим меняется публичная конфигурация:
const iconly = new Iconly({
file: './sprite.svg',
version: '1.0',
debug: true,
});
iconly.init();
К этому моменту init() уже вызывался явно после создания экземпляра.
Что на самом деле делает текущий кэш
После перехода на IndexedDB легко сказать: теперь iconly сначала смотрит в кэш и только при необходимости загружает SVG. Но текущий код работает не так.
init() сначала получает файл:
let data = await Iconly.fetchData(file);
const db = await Iconly.dbInstance;
const store = db
.transaction('icons', 'readwrite')
.objectStore('icons');
И только после этого читает запись по version:
const dbVersion = await new Promise((resolve, reject) => {
const request = store.get(version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (!dbVersion) {
await new Promise((resolve, reject) => {
const request = store.put({ version, data });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} else {
data = dbVersion.data;
}
Если записи с такой версией ещё нет, свежий SVG попадает в IndexedDB. Если запись уже существует, iconly использует сохранённые данные.
Это даёт важное ограничение текущей реализации. Сетевой запрос всё ещё происходит при каждом init(). Более того, если содержимое sprite.svg изменилось, а version осталось прежним, сохранённая запись победит только что загруженную строку.
Получается интересный компромисс. До этого я убрал revision, потому что не хотел помнить о её обновлении. С IndexedDB явная версия набора снова стала частью API, теперь уже как ключ записи. Автоматическая проверка длины исчезла.
Я не хочу маскировать это словом “кэш” и приписывать текущей версии свойства, которых у неё нет. На этом этапе задача была другой: перенести хранение SVG из localStorage в более подходящий механизм и разобраться с его моделью работы.
Транзакции оказались отдельной частью задачи
У localStorage нет жизненного цикла транзакции. Вызвал setItem() — операция завершилась синхронно.
С IndexedDB нужно дождаться открытия базы, выполнить get() или put(), а затем корректно обработать завершение транзакции. В первых вариантах после миграции эта часть ещё менялась.
К 1.4.4 ожидание завершения уже обёрнуто в обычный Promise:
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
В этом же релизе исходник переехал с JavaScript на TypeScript. Это хорошо совпало с IndexedDB-кодом, где быстро появляется много объектов со своими типами: IDBDatabase, IDBOpenDBRequest, IDBVersionChangeEvent, транзакции и хранилища.
В 1.5.0 я ещё раз переработал расположение вызовов транзакций. Семантика хранения не изменилась, но код стал ближе к 1.5.1.
Миграция хранилища тем самым оказалась не простой заменой localStorage.setItem() на другой метод. Вместе с IndexedDB в библиотеке появились отдельное открытие базы, схема хранилища, обработчики запросов и жизненный цикл транзакций.
Ещё один побочный эффект: как прятать спрайт
После перехода на новый DOM-контейнер спрайт вставляется в div#iconset. Один из промежуточных вариантов скрывал этот контейнер через display: none:
width: 0px;
height: 0px;
display: none;
После обнаруженной проблемы с видимостью я заменил его на вынесенный за экран нулевой контейнер:
width: 0;
height: 0;
position: absolute;
left: -9999px;
Снаружи API остаётся небольшим:
import Iconly from 'iconly';
const iconly = new Iconly({
file: './sprite.svg',
version: '1.0',
debug: true,
});
await iconly.init();
Кроме file, version и debug можно передать container как CSS-селектор или HTMLElement. По умолчанию спрайт добавляется в document.body.
Текущий вариант не закрыл тему окончательно. Сеть осталась обязательной частью init(), актуальность IndexedDB-записи зависит от переданного version. Зато теперь эти ограничения находятся в модели, которую можно развивать дальше.