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

Сохранение выбранных глав

В предыдущем уроке приложение автоматически готовило XML-страницу. Такой способ удобен для маленькой обязательной части, но плохо подходит для большой библиотеки. Читателю обычно нужны несколько материалов перед поездкой. Дадим ему возможность выбрать Markdown или XML и увидеть, когда сохранение действительно завершилось.

Реестр data/documents.json содержит два объекта с полями article_id, title, url. Используйте договорные ID xml и markdown, адреса /my/LINQ/linq_xml/level7/7_1.php и /articles/markdown-guide.html. Это сокращённые документы нашего offline-lab. Реестр описывает известные материалы; присутствие строки в нём ещё не означает, что документ доступен без сети.

Действие и завершение

Кнопка запускает загрузку, но надпись «Сохранено» появляется только после успешного ответа и записи. Сетевая ошибка, HTML с неправильным типом и неуспешный HTTP-статус дают другое состояние. Мы не делаем вывод о содержимом по одному расширению .php: сервер стенда должен возвращать готовый учебный HTML.

На главной подготовьте контейнер <ul id="chapters"></ul> и подключите /assets/app.js как module. Следующий полный файл создаёт список, проверяет точное наличие документа в кеше и добавляет обработчик сохранения. Заголовки назначаются через textContent; данные реестра не становятся произвольной HTML-разметкой.

Отдельная обычная навигация XML/Markdown уже находится в исходном HTML, вне пустого динамического списка. Поэтому отключённый JavaScript, ошибка модуля или недоступный controller не убирают постоянные переходы. Динамическая часть добавляет управление копиями, а не создаёт единственный путь к материалам.

const CACHE = "pw-offline-v2";
const response = await fetch("/data/documents.json");
if (!response.ok) throw new Error("Реестр недоступен");
const documents = await response.json();
const list = document.querySelector("#chapters");
const cache = await caches.open(CACHE);

for (const item of documents) {
  const url = new URL(item.url, location.origin);
  if (url.origin !== location.origin) throw new Error("Чужой origin");
  const row = document.createElement("li");
  const link = document.createElement("a");
  link.href = url.href;
  link.textContent = item.title;
  const button = document.createElement("button");
  button.type = "button";
  button.textContent = "Сохранить";
  const status = document.createElement("span");
  status.setAttribute("role", "status");
  status.textContent = await cache.match(url.href) ? "Сохранено" : "Не сохранено";
  row.append(link, " ", button, " ", status);
  list.append(row);
  button.addEventListener("click", async () => {
    button.disabled = true;
    status.textContent = "Загружается";
    try {
      const page = await fetch(url.href, {cache: "reload"});
      const type = page.headers.get("content-type") ?? "";
      if (!page.ok || !type.includes("text/html")) {
        throw new Error("Не получен учебный HTML");
      }
      await cache.put(url.href, page);
      status.textContent = "Сохранено";
    } catch (error) {
      status.textContent = "Не удалось обновить копию";
      console.warn(error);
    } finally {
      button.disabled = false;
    }
  });
}

Ожидаемая последовательность понятна без внутреннего устройства браузера: выбор, загрузка, завершение. Повторное нажатие во время одной операции исключено блокировкой кнопки. После ошибки кнопку можно использовать снова. Ранее сохранённая копия не удаляется перед загрузкой новой, поэтому отказ обновления не равен потере старого материала.

Начальный статус получен непосредственно из Cache API. Пока нет отдельного постоянного журнала, который мог бы устареть относительно реального ответа. Позднее появятся закладки и сведения о пакете, но они будут отдельными данными. Запись «сохранён» в таблице сама не создаёт HTML-ответ. Cache.put.

Разберём действие для Markdown по шагам. Реестр даёт постоянный URL и название. Программа создаёт ссылку для обычной навигации и отдельную кнопку для записи. При нажатии ответ проверяется до изменения хранилища. Если сервер сообщает404, cache.put не вызывается. Если ответ подходит, операция записи завершается, и только затем меняется текст статуса. Порядок действий предотвращает распространённую ошибку, когда интерфейс благодарит пользователя сразу после начала fetch.

Для XML действует тот же обработчик, хотя начальная копия уже подготовлена. Повторная загрузка может обновить ответ, а может завершиться ошибкой. Сообщение «Не удалось обновить копию» не утверждает, что прежняя запись отсутствует. Чтобы показать обе стороны результата ещё точнее, будущий интерфейс может повторно проверить cache.match и вывести «Старая копия доступна; обновление не завершено». Это улучшение текста не требует объявлять ошибочный ответ успешным.

Один HTML-ответ сохраняется под одним точным адресом. Название и article_id помогают человеку выбрать материал, но ключом Cache API остаётся запрос. Поэтому перестановка строк реестра не меняет адрес сохранения, а изменение URL требует отдельного решения. Мы удерживаем старые пути вместо создания нового адреса для каждой редакционной версии. Версия кеша и версия материала решают другие задачи и не заменяют постоянный URL.

Представьте, что приложение одновременно открыто в двух вкладках. У каждой будут собственные кнопки, а хранилище origin окажется общим. Отключение кнопки в первой вкладке не блокирует действие во второй. Текущий код показывает маленький самостоятельный пример, но не реализует распределённую блокировку. При одинаковом документе последняя успешная запись заменит ответ по тому же ключу. Для согласованных пакетов позднее введём отдельные уникальные имена кандидатов, чтобы две подготовки не смешивали разные ресурсы.

Главная тоже нужна без сети

Сохранённая глава бесполезна, если автономный запуск не открывает список. Новый полный sw.js готовит главную, реестр и оба модуля вместе с запасной страницей. В учебном состоянии главная и главы используют встроенное оформление. Внешние изображения сюда пока не входят. Разные типы обязательных файлов проверяются по своей ожидаемой MIME-группе.

const CACHE = "pw-offline-v2";
const CORE = new Map([
  ["/", "text/html"], ["/offline.html", "text/html"],
  ["/my/LINQ/linq_xml/level7/7_1.php", "text/html"],
  ["/assets/app.js", "javascript"],
  ["/assets/register-worker.js", "javascript"],
  ["/data/documents.json", "application/json"]
]);
self.addEventListener("install", event => {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE);
    for (const [path, type] of CORE) {
      const response = await fetch(path, {cache: "reload"});
      if (!response.ok || !(response.headers.get("content-type") ?? "").includes(type)) {
        throw new Error("Обязательный ресурс недоступен: " + path);
      }
      await cache.put(path, response);
    }
  })());
});
self.addEventListener("fetch", event => {
  const request = event.request;
  const url = new URL(request.url);
  if (request.method !== "GET" || url.origin !== self.location.origin) return;
  if (request.mode !== "navigate" && !CORE.has(url.pathname)) return;
  event.respondWith((async () => {
    try { return await fetch(request); }
    catch {
      const cache = await caches.open(CACHE);
      const saved = await cache.match(request);
      if (saved) return saved;
      if (request.mode !== "navigate") return Response.error();
      const fallback = await cache.match("/offline.html");
      return new Response(fallback ? await fallback.text() : "Недоступно", {
        status: 503, headers: {"Content-Type": "text/html; charset=utf-8"}
      });
    }
  })());
});

Ключ v2 отличается от первоначального v1. Код страницы и worker должны ссылаться на одну выбранную версию. Ожидающий новый worker не обслуживает вкладку автоматически: при постепенном переходе старый controller может оставаться v1. Для начального знакомства используйте отдельное чистое состояние учебного проекта; управляемый переход разберём в седьмом уроке.

Мы расширили только обязательную оболочку. XML входит в начальную подготовку, Markdown добавляется по выбору. Сам факт открытия Markdown по сети не вызывает его сохранение. Это полезное правило для будущих двадцати тысяч страниц: программа не начинает бесконтрольную массовую загрузку на каждом просмотре и не обещает автономность всей библиотеки.

Что означает сохранённая глава

После успешного выбора Markdown точный адрес должен находиться в кеше v2. При сетевой ошибке навигационный обработчик сможет вернуть этот Response. Однако в данном состоянии глава автономна лишь потому, что её оформление встроено, а внешних обязательных ресурсов нет. Если добавить картинку по сети, надпись «Сохранено» уже не доказывает полноту изображения.

Есть ещё семантическая граница: HTTP200 с типом HTML способен содержать серверное сообщение об ошибке. Для наших подготовленных документов заголовок и содержание сверяются отдельно. Универсальный пользовательский URL нельзя принимать как заведомый урок. Поэтому список ограничен реестром собственного проекта, а не строкой свободного ввода.

Поведение fetch не зависит только от выбранного флага cache: он управляет обычным HTTP-кешем запроса, а Cache API хранится отдельно. Service worker способен участвовать в запросе страницы; запасной ответ с503 будет отклонён нашей проверкой ok. Успешная старая копия при сетевой ошибке может быть сохранена повторно, но это не доказательство получения нового выпуска. Версию материала потребуется проверять отдельно. Cache API.

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