Перейти к содержанию

Локальное хранилище и состояние после остановки worker

Оглавление серии

Worker умеет проверить объект заметки без сохранения. Теперь введём SAVE и LIST, подключим chrome.storage.local и перестанем зависеть от памяти popup или service worker. Результат — каждая заметка получает UUID и дату, запись подтверждается storage, а новый экземпляр интерфейса заново читает сохранённые данные. Это основной переход от временного черновика к постоянному локальному состоянию расширения.

local означает хранение в области данного расширения на этой машине. Это не база сайта и не автоматическая синхронизация между устройствами. Мы выбираем её для собственных заметок без секретов. Удаление расширения или действия пользователя со своим профилем могут удалить данные; резервную копию позже даст экспорт. Обозначение «локально» не означает шифрованное убежище для токенов, паролей или приватной истории аккаунта, которую проект вообще не собирает.

Ключ каждой заметки

Ветка SAVE сначала вызывает validateNote, затем создаёт crypto.randomUUID и ISO-дату new Date().toISOString(). UUID является идентификатором заметки, а createdAt — временем её сохранения в текущем окружении. Эти значения назначает расширение, а не чужая страница. Объект записывается по ключу note:. Такой префикс отделяет записи от будущего settings и помогает выбрать нужные элементы без предположения, что все значения storage являются заметками.

Отдельные ключи уменьшают риск потери при одновременной вставке двух новых записей. Если бы каждый SAVE читал общий массив, добавлял элемент и переписывал весь массив, две операции могли бы сохранить разные версии одного контейнера. Здесь новые UUID дают разные ключи. Это ещё не полноценная транзакционная база для произвольных изменений, но конкретная операция добавления становится проще. Обновление существующей записи потребовало бы отдельного договора, которого в текущем UI нет.

После await storage.local.set возвращается сохранённый объект. Только затем popup показывает «Заметка сохранена» и перечитывает список. Если Promise завершится отказом, успех не объявляется. Отправка команды и подтверждение записи являются разными событиями. Потерянный ответ после успешного set может оставить клиента в неопределённости: в нашем минимальном проекте SAVE не имеет идемпотентного ключа и ручной повтор способен создать второй UUID. Этот предел не следует скрывать общей фразой о надёжном хранении.

Полные файлы шага

Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/browser-extension-lab/lesson-06/.

worker.js

import { validateNote, validateStored } from "./data.js";

const trustedPages = ["popup.html", "options.html"].map(
  (path) => chrome.runtime.getURL(path)
);
const ready = chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });
let queue = Promise.resolve();

async function listNotes() {
  const values = await chrome.storage.local.get(null);
  return Object.entries(values)
    .filter(([key]) => key.startsWith("note:"))
    .map(([, value]) => validateStored(value))
    .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id));
}

async function handle(message) {
  await ready;
  if (message.type === "LIST") return listNotes();
  if (message.type === "SAVE") {
    const value = validateNote(message.payload);
    
    const note = { ...value, id: crypto.randomUUID(), createdAt: new Date().toISOString() };
    await chrome.storage.local.set({ [`note:${note.id}`]: note });
    return note;
  }
  
  
  throw new Error("Неизвестная команда");
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (sender.id !== chrome.runtime.id || !trustedPages.includes(sender.url) ||
      !message || typeof message !== "object" || message.version !== 1 ||
      typeof message.type !== "string") {
    sendResponse({ ok: false, error: "Команда отклонена" });
    return false;
  }
  queue = queue.then(() => handle(message)).then(
    (data) => sendResponse({ ok: true, data }),
    (error) => sendResponse({ ok: false, error: error.message })
  );
  // This literal true keeps sendResponse available for the asynchronous operation.
  return true;
});

popup.js

const status = document.querySelector("#status");
const list = document.querySelector("#notes");
const noteInput = document.querySelector("#note");

async function request(type, payload) {
  const reply = await chrome.runtime.sendMessage({ version: 1, type, payload });
  if (!reply?.ok) throw new Error(reply?.error ?? "Нет ответа расширения");
  return reply.data;
}

function render(notes) {
  list.replaceChildren();
  for (const note of notes) {
    const item = document.createElement("li");
    const title = document.createElement("strong");
    title.textContent = note.title;
    const text = document.createElement("p");
    text.textContent = note.note || note.quote;
    item.append(title, text);
    list.append(item);
  }
}

async function refresh() {
  const notes = await request("LIST");
  
  render(notes);
}

document.querySelector("#save").addEventListener("click", async () => {
  const button = document.querySelector("#save");
  button.disabled = true;
  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (!Number.isInteger(tab?.id)) throw new Error("Нет активной вкладки");
    const results = await chrome.scripting.executeScript({
      target: { tabId: tab.id }, files: ["content.js"]
    });
    const page = results.find((item) => item.frameId === 0)?.result;
    if (!page) throw new Error("Не удалось прочитать страницу");
    await request("SAVE", { ...page, note: noteInput.value });
    noteInput.value = "";
    status.textContent = "Заметка сохранена";
    try { await refresh(); }
    catch (error) { status.textContent = `Заметка сохранена, список не обновлён: ${error.message}`; }
  } catch (error) {
    status.textContent = `Сохранение не завершено: ${error.message}`;
  } finally {
    button.disabled = false;
  }
});

document.querySelector("#settings").hidden = true;
document.querySelector("#settings").addEventListener("click", () => {
  chrome.runtime.openOptionsPage();
});

refresh().catch((error) => { status.textContent = error.message; });

Ожидаемый результат

Это ожидаемый договор для самостоятельного исследования; расширение не устанавливалось и не запускалось.

SAVE → новый UUID и запись note:<id>, после set ответ ok=true.
Новый popup/worker → LIST снова читает запись.
Квота или другой отказ set → сообщение об ошибке, успех не объявлен.

Согласование текущих операций

Worker содержит queue=Promise.resolve. Каждый принятый запрос присоединяется через then, поэтому операции своего текущего контекста обрабатываются последовательно. Это полезно для будущего импорта и настроек. Очередь не является сохранённой работой: после завершения worker она исчезнет. Постоянные данные находятся в storage, а очередь отвечает лишь за порядок уже принятых обработчиков во время текущей жизни. Не называем её durable job queue и не обещаем продолжение любой незавершённой команды.

setAccessLevel с TRUSTED_CONTEXTS ограничивает доступ storage собственными доверенными контекстами расширения. Content script не должен самостоятельно читать все записи пользователя, даже если он исполняет наш короткий сборщик выделения. Обещание ready выполняется перед обработкой команд. Эта настройка объясняет minimum_chrome_version 102, документированную нижнюю границу API. Автор не проверял пакет в Chrome 102; поле manifest не заменяет фактический опыт совместимости.

Обработчик сообщений остаётся обычной функцией и возвращает literal true. Реальная работа асинхронна, а результат позднее передаётся sendResponse. Мы сохраняем этот документированный способ, не полагаясь на более новые варианты возврата Promise во всех браузерах выбранного профиля. Известный отказ превращается в ok=false, а следующая команда присоединяется к уже обработанной цепочке. Принимаются только точные страницы popup и options собственного расширения, даже если options пока ещё не введена.

Чтение после нового открытия

LIST получает все значения local, выбирает ключи с note: и валидирует каждую сохранённую форму через validateStored. Это важно: хранилище может содержать данные старой версии или ручного эксперимента. Наш простой договор предпочитает видимый отказ при неправильной записи, а не тихое удаление пользовательского объекта. Миграция данных будет отдельной задачей при изменении схемы. Мы не обещаем, что любой произвольный JSON в storage автоматически становится корректной заметкой.

Сортировка сначала использует createdAt в обратном порядке, затем id для одинаковых дат. Даты создаются единым ISO-форматом, поэтому строковое сравнение соответствует выбранному порядку таких записей. UUID не означает хронологию; он нужен только как дополнительный устойчивый критерий. Импорт приводит разбираемую дату к ISO-формату внутри validateStored. Поэтому даже допустимая исходная запись со смещением не остаётся произвольной строкой для сортировки. Учебный экспорт собственного проекта сохраняет уже нормализованные даты.

Popup строит элементы через createElement и textContent. Название выделяется strong, текст заметки выводится в p. Если note пуст, показывается quote. Отсутствие цитаты и собственной заметки допускается как сохранённая ссылка с заголовком, хотя для учебного сценария лучше заполнить текст. Мы не вставляем содержимое как HTML и не делаем URL произвольным скриптовым действием. Хранение и безопасное отображение остаются двумя отдельными проверенными границами.

Ожидаемая постоянность

Ожидаемый сценарий: читатель сохраняет свою обычную страницу, закрывает popup и снова открывает его. Новый LIST должен вернуть прежнюю запись, даже если worker за это время завершился и запущен заново. Если set отказал из-за квоты, интерфейс должен показать неуспех. Точную вместимость для каждого выпуска браузера не выводим из небольшой заметки: приложение использует ограниченный размер полей и не требует unlimitedStorage. Большие пользовательские коллекции потребуют другой оценки.

Полные worker.js и popup.js на этой странице совпадают с lesson-06. Ни запись, ни остановка worker фактически не выполнялись автором. Для самостоятельного разбора найдите, где находятся постоянные данные, где — временная очередь, а где — DOM текущего окна. Закрытие последних двух не должно само удалять первые. Следующая глава добавит настройки, сохранив один writer и отдельный ключ settings, чтобы изменение начального текста не превратилось в случайную правку уже сохранённых заметок.

Дата создания зависит от часов собственной машины пользователя. Она полезна для интерфейсного порядка, но не является удостоверенным временем внешнего сервера. Поэтому UUID, дата и факт подтверждённого set отвечают на разные вопросы: какой объект, какая подпись времени и была ли завершена локальная запись. Не используем createdAt как секретный токен или доказательство авторства. Для учебной коллекции достаточно нормализованной строки и дополнительного порядка по id; иные гарантии потребовали бы другого источника времени и отдельной модели доверия.

Документация и исходники

Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Заметок читателя» содержит все двенадцать состояний. Установка, запуск и публикация не выполнялись.