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> 型別為 date,其 id 分別為 start-dateend-date

表單沒有 method 或 action。相反,我們使用 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() 方法重置表單

驗證使用者輸入

我們檢查日期是否無效。我們進行最少的錯誤檢查。我們確保日期都不是 null,這應該由 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 live regions 來提醒輔助技術使用者注意錯誤。

本地儲存

我們正在使用 Web Storage API,特別是 window.localStorage,將開始和結束日期對儲存在字串化的 JSON 物件中。

LocalStorage 有一些限制,但足以滿足我們應用的需求。我們使用 localStorage 使此應用簡單且僅在客戶端執行。這意味著資料將僅儲存在一個裝置上的一個瀏覽器中。清除瀏覽器資料也會丟失所有本地儲存的週期。對於許多應用來說可能看起來是限制,但對於這個應用來說可能是一個優勢,因為月經週期數據是私密的,此類應用的使用者可能非常關心隱私。

對於更健壯的應用,其他 客戶端儲存選項,如 IndexedDB (IDB) 和稍後討論的服務工作執行緒,具有更好的效能。

localStorage 的限制包括

  • 有限的資料儲存:localStorage 每 origin 的資料限制為 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 object 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) => 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) => 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 所需的功能,包括 manifest 檔案安全連線service worker

首先,我們建立 CycleTracker 的 manifest 檔案,包括我們的 CycleTracker PWA 的身份、外觀和圖示。