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.

Материалы