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.
Проблема здесь не техническая, а эксплуатационная. revision нужно было обновлять отдельно от самого файла. Изменил 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-строки автоматически обновляло сохранённую копию.
Но это именно эвристика. Два разных 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() уже вызывался явно после создания экземпляра.
Что на самом деле делает текущий кэш
В текущей реализации 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 я ещё раз переработал расположение вызовов транзакций. Семантика хранения не изменилась.
Миграция хранилища тем самым оказалась не простой заменой 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.