CycleTracker:JavaScript 功能

在上一节中,我们为 CycleTracker 编写了 HTML 和 CSS,创建了 Web 应用的静态版本。在本节中,我们将编写将静态 HTML 转换为完全功能的 Web 应用程序所需的 JavaScript。

如果您还没有这样做,请复制 HTMLCSS 并将其保存到名为 index.htmlstyle.css 的文件中。

HTML 文件中的最后一行调用了 app.js JavaScript 文件。这是我们在本节中创建的脚本。在本课中,我们将编写客户端 JavaScript 代码来捕获表单提交、本地存储提交的数据以及填充过去周期部分。

在本课结束时,您将拥有一个功能齐全的应用程序。在未来的课程中,我们将逐步增强应用程序,以创建一个完全可安装的 PWA,即使在用户离线时也能正常工作。

JavaScript 任务

当用户访问页面时,我们会检查他们在本地存储中是否有现有的数据。用户第一次访问页面时,将没有任何数据。当新用户选择两个日期并提交表单时,我们需要

  1. 创建一个“<h2>过去的周期</h2>”标题
  2. 创建一个 <ul>
  3. 使用包含该周期信息的单个 <li> 填充 <ul>
  4. 将数据保存到本地存储

对于每个后续表单提交,我们需要

  1. 将新的月经周期添加到当前列表
  2. 按日期顺序对列表进行排序
  3. 使用新的列表重新填充 <ul>,每个周期一个 <li>
  4. 将数据追加到我们保存的本地存储

现有用户将在本地存储中拥有现有数据。当用户使用同一设备上的相同浏览器返回我们的网页时,我们需要

  1. 从本地存储检索数据
  2. 创建一个“<h2>过去的周期</h2>”标题
  3. 创建一个 <ul>
  4. 使用 <li> 为本地存储中保存的每个月经周期填充 <ul>

这是一个初学者级别的演示应用程序。目标是教授将 Web 应用程序转换为 PWA 的基础知识。此应用程序不包含必要的特性,例如表单验证、错误检查、编辑或删除功能等。欢迎您扩展所涵盖的特性,并将本课和应用程序定制到您的学习目标和应用程序需求。

表单提交

页面包含一个 <form>,其中包含日期选择器,用于选择每个月经周期的开始日期和结束日期。日期选择器是 <input> 类型为 dateid 分别为 start-dateend-date

表单没有方法或操作。相反,我们在表单上使用 addEventListener() 添加了一个事件监听器。当用户尝试提交表单时,我们会阻止表单提交,存储新的月经周期,渲染该周期以及之前的周期,然后重置表单。

js
// create constants for the form and the form controls
const newPeriodFormEl = document.getElementsByTagName("form")[0];
const startDateInputEl = document.getElementById("start-date");
const endDateInputEl = document.getElementById("end-date");

// Listen to form submissions.
newPeriodFormEl.addEventListener("submit", (event) => {
  // Prevent the form from submitting to the server
  // since everything is client-side.
  event.preventDefault();

  // Get the start and end dates from the form.
  const startDate = startDateInputEl.value;
  const endDate = endDateInputEl.value;

  // Check if the dates are invalid
  if (checkDatesInvalid(startDate, endDate)) {
    // If the dates are invalid, exit.
    return;
  }

  // Store the new period in our client-side storage.
  storeNewPeriod(startDate, endDate);

  // Refresh the UI.
  renderPastPeriods();

  // Reset the form.
  newPeriodFormEl.reset();
});

在使用 preventDefault() 阻止表单提交后,我们

  1. 验证用户输入;如果无效则退出,
  2. 通过 检索、解析、追加、排序、字符串化和重新存储 localStorage 中的数据来存储新的周期,
  3. 渲染表单数据 以及过去月经周期的数据和一个节标题,以及
  4. 使用 HTMLFormElement reset() 方法重置表单

验证用户输入

我们会检查日期是否无效。我们进行最少的错误检查。我们确保两个日期都不为空,required 属性应该可以防止这种情况发生。我们还会检查开始日期是否大于结束日期。如果出现错误,我们会清除表单。

js
function checkDatesInvalid(startDate, endDate) {
  // Check that end date is after start date and neither is null.
  if (!startDate || !endDate || startDate > endDate) {
    // To make the validation robust we could:
    // 1. add error messaging based on error type
    // 2. Alert assistive technology users about the error
    // 3. move focus to the error location
    // instead, for now, we clear the dates if either
    // or both are invalid
    newPeriodFormEl.reset();
    // as dates are invalid, we return true
    return true;
  }
  // else
  return false;
}

在更强大的应用程序版本中,我们至少会包括错误消息,通知用户存在错误。一个好的应用程序会告知用户错误是什么,将焦点放在有问题的表单控件上,并使用 ARIA 实时区域 来提醒辅助技术用户错误。

本地存储

我们使用的是 Web 存储 API,特别是 window.localStorage,以字符串化的 JSON 对象形式存储开始日期和结束日期对。

LocalStorage 有几个限制,但足以满足我们应用程序的需求。我们使用 localStorage 使它变得简单且仅限于客户端。这意味着数据将只存储在单个设备上的一个浏览器中。清除浏览器数据也会丢失所有本地存储的周期。对于许多应用程序来说似乎是一个限制,但在本应用程序的情况下可能是一项优势,因为月经周期数据是私人的,并且此类应用程序的用户可能会非常合理地担心隐私。

对于更强大的应用程序,其他 客户端存储 选项,例如 IndexedDB (IDB) 以及稍后讨论的服务工作者,具有更好的性能。

localStorage 的限制包括

  • 有限的数据存储:localStorage 每个来源限制为 5MB 数据。我们的存储需求远小于此。
  • 仅存储字符串:localStorage 将数据存储为字符串键和字符串值对。我们的开始日期和结束日期将存储为字符串解析的 JSON 对象。对于更复杂的数据,需要更强大的存储机制,例如 IDB。
  • 可能导致性能下降:从本地存储获取和设置数据是在主线程上同步进行的。当主线程被占用时,应用程序将没有响应,并且看起来已冻结。由于此应用程序的局限性,这种用户体验方面的短暂缺陷是微不足道的。
  • 仅对主线程可用:除了占用主线程的性能问题之外,服务工作者无法访问主线程,这意味着服务工作者无法直接设置或获取本地存储数据。

检索、追加、排序和重新存储数据

因为我们使用的是 localStorage,它包含一个字符串,所以我们从本地存储中检索数据的 JSON 字符串,解析 JSON 数据(如果有),将新的日期对推送到现有数组,对日期进行排序,将 JSON 对象解析回字符串,并将该字符串保存回 localStorage

此过程需要创建一些函数

js
// Add the storage key as an app-wide constant
const STORAGE_KEY = "period-tracker";

function storeNewPeriod(startDate, endDate) {
  // Get data from storage.
  const periods = getAllStoredPeriods();

  // Add the new period objet to the end of the array of period objects.
  periods.push({ startDate, endDate });

  // Sort the array so that periods are ordered by start date, from newest
  // to oldest.
  periods.sort((a, b) => {
    return new Date(b.startDate) - new Date(a.startDate);
  });

  // Store the updated array back in the storage.
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(periods));
}

function getAllStoredPeriods() {
  // Get the string of period data from localStorage
  const data = window.localStorage.getItem(STORAGE_KEY);

  // If no periods were stored, default to an empty array
  // otherwise, return the stored data as parsed JSON
  const periods = data ? JSON.parse(data) : [];

  return periods;
}

将数据渲染到屏幕

我们应用程序的最后一步是将过去周期的列表连同标题一起渲染到屏幕上。

在我们的 HTML 中,我们添加了一个 <section id="past-periods"> 占位符,用于包含标题和过去周期的列表。

将容器元素添加到脚本顶部的内容列表中。

js
const pastPeriodContainer = document.getElementById("past-periods");

我们检索解析后的过去周期的字符串,或一个空数组。如果为空,则退出。如果存在过去的周期,我们会清除过去周期容器中的当前内容。我们创建一个标题和一个无序列表。我们遍历过去周期,添加包含格式化日期的列表项。

js
function renderPastPeriods() {
  // get the parsed string of periods, or an empty array.
  const periods = getAllStoredPeriods();

  // exit if there are no periods
  if (periods.length === 0) {
    return;
  }

  // Clear the list of past periods, since we're going to re-render it.
  pastPeriodContainer.textContent = "";

  const pastPeriodHeader = document.createElement("h2");
  pastPeriodHeader.textContent = "Past periods";

  const pastPeriodList = document.createElement("ul");

  // Loop over all periods and render them.
  periods.forEach((period) => {
    const periodEl = document.createElement("li");
    periodEl.textContent = `From ${formatDate(
      period.startDate,
    )} to ${formatDate(period.endDate)}`;
    pastPeriodList.appendChild(periodEl);
  });

  pastPeriodContainer.appendChild(pastPeriodHeader);
  pastPeriodContainer.appendChild(pastPeriodList);
}

function formatDate(dateString) {
  // Convert the date string to a Date object.
  const date = new Date(dateString);

  // Format the date into a locale-specific string.
  // include your locale for better user experience
  return date.toLocaleDateString("en-US", { timeZone: "UTC" });
}

在加载时渲染过去的周期

当延迟的 JavaScript 在页面加载时运行时,我们会渲染过去的周期(如果有)。

js
// Start the app by rendering the past periods.
renderPastPeriods();

完整的 JavaScript

您的 app.js 文件应该类似于以下 JavaScript 代码

js
const newPeriodFormEl = document.getElementsByTagName("form")[0];
const startDateInputEl = document.getElementById("start-date");
const endDateInputEl = document.getElementById("end-date");
const pastPeriodContainer = document.getElementById("past-periods");

// Add the storage key as an app-wide constant
const STORAGE_KEY = "period-tracker";

// Listen to form submissions.
newPeriodFormEl.addEventListener("submit", (event) => {
  event.preventDefault();
  const startDate = startDateInputEl.value;
  const endDate = endDateInputEl.value;
  if (checkDatesInvalid(startDate, endDate)) {
    return;
  }
  storeNewPeriod(startDate, endDate);
  renderPastPeriods();
  newPeriodFormEl.reset();
});

function checkDatesInvalid(startDate, endDate) {
  if (!startDate || !endDate || startDate > endDate) {
    newPeriodFormEl.reset();
    return true;
  }
  return false;
}

function storeNewPeriod(startDate, endDate) {
  const periods = getAllStoredPeriods();
  periods.push({ startDate, endDate });
  periods.sort((a, b) => {
    return new Date(b.startDate) - new Date(a.startDate);
  });
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(periods));
}

function getAllStoredPeriods() {
  const data = window.localStorage.getItem(STORAGE_KEY);
  const periods = data ? JSON.parse(data) : [];
  console.dir(periods);
  console.log(periods);
  return periods;
}

function renderPastPeriods() {
  const pastPeriodHeader = document.createElement("h2");
  const pastPeriodList = document.createElement("ul");
  const periods = getAllStoredPeriods();
  if (periods.length === 0) {
    return;
  }
  pastPeriodContainer.textContent = "";
  pastPeriodHeader.textContent = "Past periods";
  periods.forEach((period) => {
    const periodEl = document.createElement("li");
    periodEl.textContent = `From ${formatDate(
      period.startDate,
    )} to ${formatDate(period.endDate)}`;
    pastPeriodList.appendChild(periodEl);
  });

  pastPeriodContainer.appendChild(pastPeriodHeader);
  pastPeriodContainer.appendChild(pastPeriodList);
}

function formatDate(dateString) {
  const date = new Date(dateString);
  return date.toLocaleDateString("en-US", { timeZone: "UTC" });
}

renderPastPeriods();

您可以尝试使用完全功能的 CycleTracker 周期跟踪 Web 应用程序,并在 GitHub 上查看 Web 应用程序源代码。是的,它有效,但还不是 PWA。

接下来

从本质上讲,PWA 是一种可以安装的 Web 应用程序,可以逐步增强以在离线状态下工作。现在我们已经拥有了一个功能齐全的 Web 应用程序,我们将添加将它转换为 PWA 所需的功能,包括 清单文件安全连接 以及 服务工作者

首先,我们将创建 CycleTracker 的清单文件,包括 CycleTracker PWA 的身份、外观和图标。