星期六, 4月 04, 2026

[JS] localStorage

在 GAS 上實作待辦事項,並使用 localStorage 來存取資料,該筆記有下列重點
  • localStorage 只能儲存字串資料,該練習資料來源是陣列和物件,會使用 JSON.stringify() 轉成 json 後才進行儲存,讀取資料則是透過 JSON.parse() 轉回
  • render 時會透過自訂 attribute 來儲存識別資料
  • render 資料時,並不是逐一註冊每一個 li click 事件,而是網頁 Loading 時註冊 ul click 事件,再透過 event.target.closest 來確定抓到 li 並進行刪除資料

Index.html
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <base target="_top">
  <meta charset="utf-8">
  <?!= include('Stylesheet'); ?>
</head>
<body>

  <div class="container">
    <h2 class="header-title">GAS LocalStorage 練習</h2>
    
    <div class="input-group">
      <input type="text" id="todoInput" class="todo-input" placeholder="請輸入待辦事項...">
      <button type="button" id="addBtn" class="btn-submit">新增</button>
    </div>

    <ul id="todoList" class="todo-list">
    </ul>
  </div>

  <script>
    /**
     * 從 localStorage 讀取並解析待辦事項
     * @returns {Array<{id: number, text: string}>}
     */
    const getTodos = () => {
      const todosData = localStorage.getItem('my_todos');
      return todosData ? JSON.parse(todosData) : [];
    };

    /**
     * 將待辦事項陣列轉換為 JSON 字串並儲存至 localStorage
     * @param {Array<{id: number, text: string}>} todos 
     */
    const saveTodos = (todos) => {
      localStorage.setItem('my_todos', JSON.stringify(todos));
    };

    /**
     * 負責將資料渲染至畫面 (讀取)
     */
    const renderTodos = () => {
      const todos = getTodos();
      const listContainer = document.getElementById('todoList');
      
      listContainer.innerHTML = ''; 

      todos.forEach(todo => {
        const liElement = document.createElement('li');
        liElement.className = 'todo-item';
        liElement.setAttribute('data-id', todo.id);
        
        const textSpan = document.createElement('span');
        textSpan.textContent = todo.text;
        
        const hintSpan = document.createElement('span');
        hintSpan.className = 'hint-text';
        hintSpan.textContent = '刪除';

        liElement.appendChild(textSpan);
        liElement.appendChild(hintSpan);

        listContainer.appendChild(liElement);
      });
    };

    /**
     * 處理新增待辦事項邏輯 (新增)
     */
    const handleAddTodo = () => {
      const inputElement = document.getElementById('todoInput');
      const textValue = inputElement.value.trim();

      if (!textValue) {
        alert('請先輸入待辦事項內容!');
        return;
      }

      const todos = getTodos();
      const newTodo = {
        id: Date.now(), 
        text: textValue
      };

      todos.push(newTodo);
      saveTodos(todos);
      
      inputElement.value = ''; 
      renderTodos(); 
    };

    /**
     * 處理刪除待辦事項邏輯 (刪除)
     * @param {number} id
     */
    const deleteTodo = (id) => {
      const todos = getTodos();
      const filteredTodos = todos.filter(todo => todo.id !== id);
      saveTodos(filteredTodos);
      renderTodos(); 
    };

    /**
     * 處理待辦清單的點擊事件
     * @param {Event} event 
     */
    const handleListClick = (event) => {
      const clickedItem = event.target.closest('.todo-item');
      if (clickedItem) {
        const todoId = Number(clickedItem.getAttribute('data-id'));
        deleteTodo(todoId);
      }
    };

    document.addEventListener('DOMContentLoaded', () => {
      const addBtn = document.getElementById('addBtn');
      const todoInput = document.getElementById('todoInput');
      const todoList = document.getElementById('todoList');

      addBtn.addEventListener('click', handleAddTodo);

      todoInput.addEventListener('keypress', (event) => {
        if (event.key === 'Enter') {
          handleAddTodo();
        }
      });
      
      todoList.addEventListener('click', handleListClick);

      renderTodos();
    });
  </script>
</body>
</html>

Stylesheet.html
<style>
  body {
    font-family: sans-serif;
    background-color: #f4f7f6;
    color: #333;
    padding: 20px;
  }
  .container {
    width: 500px; 
    margin: 0 auto;
    background: #ffffff;
    padding: 24px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  }
  .header-title {
    text-align: center;
    color: #2c3e50;
    margin-top: 0;
  }
  .input-group {
    display: flex;
    gap: 10px; 
    margin-bottom: 20px;
  }
  .todo-input {
    flex-grow: 1;
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 16px;
  }
  .btn-submit {
    padding: 10px 20px;
    background-color: #3498db;
    color: #ffffff;
    border: none;
    border-radius: 4px;
    font-size: 16px;
    cursor: pointer;
  }
  .btn-submit:hover {
    background-color: #2980b9;
  }
  .todo-list {
    list-style-type: none;
    padding: 0;
    margin: 0;
  }
  .todo-item {
    background-color: #f8f9fa;
    padding: 12px;
    margin-bottom: 8px;
    border-radius: 4px;
    border-left: 4px solid #3498db;
    display: flex;
    justify-content: space-between;
    align-items: center;
    cursor: pointer;
  }
  .todo-item:hover {
    background-color: #e9ecef;
  }
  .hint-text {
    font-size: 12px;
    color: #e74c3c;
  }
</style>

Code.gs
/**
 * 處理 Web App 的 GET 請求,透過模板引擎渲染前端 HTML 頁面
 * @param {Object} e - GET 請求的事件物件
 * @returns {GoogleAppsScript.HTML.HtmlOutput} 回傳渲染後的 HTML 視圖
 */
function doGet(e) {
  // 使用 createTemplateFromFile 才能解析 HTML 檔案中的 GAS 標籤
  return HtmlService.createTemplateFromFile('Index')
    .evaluate()
    .setTitle('待辦事項練習')
}

/**
 * 將指定的 HTML 檔案內容載入到當前頁面中 (用於抽離 CSS 或 JS)
 * @param {string} filename - 要載入的檔案名稱
 * @returns {string} 檔案內容字串
 */
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

觀察儲存在 localStorage 上的資料

沒有留言:

張貼留言