经期跟踪器：JavaScript 功能
===================

* 上一页
* Overview: CycleTracker
* 下一页

在上一节中，我们编写了经期跟踪器（CycleTracker）的 HTML 和 CSS，创建了我们的 Web 应用的静态版本。在本节中，我们将编写将静态 HTML 转换为功能完全 Web 应用所需的 JavaScript。

如果你还没有这么做，复制 HTML 和 CSS 并将它们保存到名为 `index.html` 和 `styles.css` 的文件中。

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

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

JavaScript 任务
-------------

当用户访问页面时，我们检查他们是否在 local storage 中保存有现有数据。用户首次访问页面时，不会有任何数据。当新用户选择两个日期并提交表单时，我们需要：

1. 创建一个“`<h2>Past perious</h2>`”标题
2. 创建一个 `<ul>`
3. 用关于该经期的信息填充一个 `<li>` 到 `<ul>` 中
4. 将数据保存到 local storage 中

对于每个后续的表单提交，我们需要：

1. 将新的经期添加到当前列表中
2. 按日期顺序对列表进行排序
3. 用新列表重新填充 `<ul>`，每个经期一个 `<li>`
4. 将数据追加到我们保存的 local storage 中

现有用户在 local storage 中将有现有的数据。当用户使用相同的浏览器在相同的设备上返回我们的网页时，我们需要：

1. 从 local storage 中检索数据
2. 创建一个“`<h2>Past periods</h2>`”标题
3. 创建一个 `<ul>`
4. 用在 local storage 中保存的每个经期的 `<li>` 填充 `<ul>`。

这是一个初级级别的演示应用程序。目标是教授将 web 应用程序转换为 PWA 的基础知识。该应用程序不包含表单验证、错误检查、编辑或删除功能等必要特性。你可以自由扩展所涵盖的特性，并根据你的学习目标和应用程序需求定制课程和应用程序。

表单提交
----

页面包含一个 `<form>`，用于选择每个经期的开始日期和结束日期。日期选择器是类型为 日期 的 `<input>`，其 `id` 分别为 `start-date` 和 `end-date`。

表单没有方法或动作。取而代之的是，我们使用 `addEventListener()` 为表单添加了一个事件监听器。当用户尝试提交表单时，我们阻止表单提交，存储新的经期，渲染这个经期以及之前的经期，然后重置表单。


```js
// 创建表单和表单控件的常量
const newPeriodFormEl = document.getElementsByTagName("form")[0];
const startDateInputEl = document.getElementById("start-date");
const endDateInputEl = document.getElementById("end-date");

// 监听表单提交。
newPeriodFormEl.addEventListener("submit", (event) => {
  // 阻止表单向服务器提交
  // 因为所有操作都在客户端进行。
  event.preventDefault();

  // 从表单中获取开始日期和结束日期。
  const startDate = startDateInputEl.value;
  const endDate = endDateInputEl.value;

  // 检查日期是否无效
  if (checkDatesInvalid(startDate, endDate)) {
    // 如果日期无效，退出。
    return;
  }

  // 将新的经期存储在我们的客户端存储中。
  storeNewPeriod(startDate, endDate);

  // 刷新 UI。
  renderPastPeriods();

  // 重置表单。
  newPeriodFormEl.reset();
});
```


在使用 `preventDefault()` 阻止表单提交后，我们：

1. 验证用户输入；如果无效则退出，
2. 通过检索、解析、追加、排序、字符串化和重新存储数据在 localStorage 中存储新的经期，
3. 渲染表单数据以及过去月经经期的数据和标题部分，并且
4. 使用 HTMLFormElement 的 `reset()` 方法重置表单。
### 验证用户输入

我们检查日期是否无效。我们做了最小的错误检查。我们确保两个日期都不是空值，`required` 属性应该可以防止这种情况发生。我们还检查开始日期是否大于结束日期。如果有错误，我们会清除表单。


```javascript
function checkDatesInvalid(startDate, endDate) {
  // 检查结束日期是否在开始日期之后，两个日期都不是空值
  if (!startDate || !endDate || startDate > endDate) {
    // 为了使验证健壮，我们可以：
    // 1. 根据错误类型添加错误消息
    // 2. 通知无障碍技术用户错误信息
    // 3. 将焦点移动到错误位置
    // 但是，为了简单起见，如果两个日期中一个
    // 或两个无效，我们清除日期
    newPeriodFormEl.reset();
    // 由于日期无效，我们返回 true
    return true;
  }
  // 否则
  return false;
}
```


在这个应用程序的一个更健壮的版本中，我们至少要包含错误消息来通知用户存在错误。一个好的应用程序会通知用户错误是什么，将焦点放在有问题的表单控件上，并使用 ARIA 实时区域来通知无障碍技术用户错误信息。

Local storage
-------------

我们使用 Web 存储 API，具体来说是 window.localStorage，以字符串化的 JSON 对象形式存储开始日期和结束日期对。

LocalStorage 有一些局限性，但对我们的应用需求足够了。我们使用 localStorage 是为了让它简单且仅存储在客户端上。这意味着数据将只存储在单个设备上的单个浏览器上。清除浏览器数据也会失去所有存储在 local storage 的经期。对许多应用程序来说这可能看起来是一个局限，但对于这个应用程序来说，月经经期数据是个人的，并且使用此类应用程序的用户可能非常关心隐私。

对于更健壮的应用程序，其他客户端存储选项比如 IndexDB（IDB）和后面讨论的 service worker 具有更好的性能。

`localStorage` 的局限包括：

* 有限的数据存储：每个源的 `localStorage` 限制为 5MB 数据。我们的存储需求远远少于这个数字。
* 仅存储字符串：`localStorage` 将数据存储为字符串键和字符串值对。我们的开始和结束日期将存储为被解析为字符串的 JSON 对象。对于更复杂的数据，需要一个更健壮的存储机制，比如 IDB。
* 可能导致性能问题：获取和设置 local storage 是在主线程上同步完成的。当主线程被占用时，应用程序不会响应，就跟被冻住了一样。考虑到这个应用程序的简单性质，这丁点不好的用户体验可以忽略。
* 只能被主线程访问：除了占用主线程带来的性能问题之外，service worker 无法访问主线程，这意味着 service worker 不能直接设置或获取 local storage 数据。

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

因为我们使用包含单个字符串的 localStorage，所以我们从 local storage 检索 JSON 字符串数据，解析 JSON 数据（如果有的话），将新日期对推入现有数组，对日期排序，再次将 JSON 对象解析为字符串，并将该字符串保存回 `localStorage`。

这个过程需要创建一些函数：


```javascript
// 将存储键添加为应用范围内的常量
const STORAGE\_KEY = "period-tracker";

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() {
  // 从 localStorage 获取经期数据字符串
  const data = window.localStorage.getItem(STORAGE\_KEY);

  // 如果没有存储经期，默认为空数组
  // 否则，将存储的数据作为解析后的 JSON 返回
  const periods = data ? JSON.parse(data) : [];

  return periods;
}
```

### 将数据渲染到屏幕上

我们应用程序的最后一步是将过去经期列表渲染到屏幕上，并添加标题。

在我们的 HTML 中，我们添加了一个 `<section id="past-periods">` 占位符来容纳标题和过去经期列表。

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


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


接着，我们需要检索解析后的过去经期字符串或空数组。如果为空，则退出。如果过去经期存在，会清除 past period 容器的当前内容。然后，创建一个标题和一个无序列表，并循环遍历过去的经期，添加包含格式化的起始日期和终止日期的列表项。


```javascript
function renderPastPeriods() {
  // 获取解析后的经期字符串，或空数组
  const periods = getAllStoredPeriods();

  // 如果没有经期，退出
  if (periods.length === 0) {
    return;
  }

  // 清除过去经期列表，因为我们要重新渲染它
  pastPeriodContainer.innerHTML = "";

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

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

  // 循环渲染所有经期
  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) {
  // 将日期字符串转换为 Date 对象
  const date = new Date(dateString);

  // 将日期格式化为特定于语言环境的字符串
  // 包括你的语言环境以获得更好的用户体验
  return date.toLocaleDateString("en-US", { timeZone: "UTC" });
}
```

加载时渲染过去的经期
----------

当延迟的 JavaScript 在页面加载时运行时，如果有过去的经期的话，渲染它们。


```javascript
// 通过渲染过去的经期启动应用程序
renderPastPeriods();
```

完整的 JavaScript
--------------

你的 `app.js` 文件中的 JavaScript 代码应该类似如下：


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

// 将存储键添加为应用范围内的常量
const STORAGE\_KEY = "period-tracker";

// 监听表单提交
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.innerHTML = "";
  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 所需的功能，包括清单文件、安全连接 和 service worker。

首先，我们为经期跟踪器创建清单文件，包括我们的经期跟踪器 PWA 的标识、外观和图标。

* 上一页
* Overview: CycleTracker
* 下一页