iT邦幫忙

第 12 屆 iThome 鐵人賽

0
自我挑戰組

花三十天找到 JavaScript 沙漠中的綠洲系列 第 33

番外篇(2)一起來做 To Do List!- 實作篇(1)

  • 分享至 

  • xImage
  •  

上一篇先介紹運用的知識點,這篇會著重在實作時的心路歷程...不是啦,是怎麼把這個網頁寫出來的。先上成品與程式碼,若程式有寫得太過繁瑣的部分,也希望大家多包涵並不吝指教:
github // github page

要寫文章時回頭看整個程式碼,覺得好像也沒用到什麼技術,但剛開始一片空白要寫出東西還真的毫無頭緒。有 bug 卡住的時候,明明覺得答案近在咫尺但就是想不到也很崩潰(不愧是 JS 小菜雞)。因此才想要補充這篇文章,如果你也正因為卡住在找答案的話,希望能幫到忙(不是倒忙)。

釐清需求

在開始前要先釐清需求,我想要做出這些功能:

  • 可以插入日期、時間、代辦事項種類、代辦事項內容
  • 沒有輸入日期、時間、內容的話,要彈視窗警告,且無法新增一條代辦事項
  • 代辦事項的種類用 icon 表示
  • 每個代辦事項都可以按完成或刪除,也可以一次刪除全部的代辦事項
  • 完成的代辦事項會被劃線,跑到最下方
  • 能計算完成多少代辦事項,也能刪除所有已完成的代辦事項的內容及數量
  • 代辦事項預設以時間排列
  • 代辦事項的順序可以被使用者拖拉
  • 超過時間還沒完成的代辦事項以紅色顯示
  • 有進度、月份、種類的篩選器
  • 有分析按鈕,按下去會跳出視窗,顯示種類的圓餅圖(單純是自己想練習 c3.js d3.js)
  • RWD 所以要有手機漢堡選單
  • 即使重新整理或關掉頁面,打過的內容也不會消失

看起來很多,所以先求有再求好,至少先讓內容能在輸入後被加到下方吧!

第一步

當使用者填好資料後,按下加號,要讓 JS 操控 HTML 新增一條 todo 到畫面上,架構應該如下一樣的呈現:

    <ul class="todo-list">
        <li class="todo"> <!--變數名稱 todoLi-->
            <ul class="todo-item"> <!--變數名稱 newTodo-->
                <li class="todo-date">Date</li> dateInput.value 輸入什麼就顯示什麼 <!--變數名稱 newTodoDate-->
                <li class="todo-time">Time</li> timeInput.value 輸入什麼就顯示什麼 <!--變數名稱 newTodoTime-->
                <li class="todo-sort">Sort</li> sortSelect.value 輸入什麼就顯示什麼 <!--變數名稱 newTodoSort-->
                <li class="todo-detail">item detail</li> todoInput.value 輸入什麼就顯示什麼 <!--變數名稱 newTodoDetail-->
            </ul> 
            <div class="todo-btn"> <!--變數名稱 newTodoButton-->
                <button class="complete-btn"></button> <!--變數名稱 completedButton-->
                <button class="complete-btn"></button> <!--變數名稱 trashButton-->
            </div>        
        </li>
    </ul>

之所以要先想好架構,是因為要新增的東西很多,如果邊做邊想很容易搞亂。由此會想到昨天介紹的 createElement() 、 appendChild()。因此便可以照著剛剛想好的架構開始組裝並加上 class 。

除了上述之外,還有幾個小點要注意:

  • 為避免 form 照著原本屬性規定的,直接 submit ,要加 event.preventDefault();
  • 為了達到”沒有輸入日期、時間、內容的話,要彈視窗警告,且無法新增一條代辦事項“的需求,因此要加上 if 判斷式
if (todoInput.value == 0 || todoInput.value == undefined || todoInput.value == null){
        alert("內容欄為必填");
    }else if(dateInput.value ==0 || dateInput.value == undefined || dateInput.value == null){
        alert("日期欄為必填");
    }else if(timeInput.value == 0 || timeInput.value == undefined || timeInput.value == null){
        alert("時間欄為必填");
    }else{
  • 因為要使代辦事項的種類用 icon 表示,可在 HTML 中的 option 設 value , JS 加入 if 判斷式搭配 inner.HTML 轉換 value 成相對應的圖示。圖示部分我都是取用 font awesome。
//選什麼種類就秀對應圖案
        if(taskSort.value == "job"){
            newTodoSort.innerHTML += `<i class="fas fa-briefcase"></i>`;
        }else if(taskSort.value == "housework"){
            newTodoSort.innerHTML += `<i class="fas fa-home"></i>`;
        }else if(taskSort.value == "sport"){
            newTodoSort.innerHTML += `<i class="far fa-futbol"></i>`;
        }else if(taskSort.value == "routine"){
            newTodoSort.innerHTML += `<i class="fas fa-hourglass"></i>`;
        }else if(taskSort.value == "others"){
            newTodoSort.innerHTML += `<i class="fas fa-palette"></i>`;
        }else{
            newTodoSort.innerHTML += `<i class="fas fa-times"></i>`;
        };
  • 為晚點要存到本地端鋪路,這部分我們第三步再細談。
//add todo to localstorage
        let saveLocal = [dateInput.value,timeInput.value,taskSort.value,todoInput.value];
        saveLocalTodos(saveLocal);
  • 用 location.reload(); 讓加完之後畫面可以重整一下
  • 最後要讓輸入欄被清空, 因此要讓 value = ""

之後你就會得到:

//selectors
const dateInput = document.querySelector('#date-input');
const timeInput = document.querySelector('#time-input');
const todoInput = document.querySelector('.todo-input');
const addButton = document.querySelector('.addlist-button');
const todoList = document.querySelector('#todoList');
const taskSort = document.querySelector('#task-sort');

//event listeners
addButton.addEventListener('click',addTodo);
todoList.addEventListener('click',deleteCheck);

//functions
function addTodo(event){
    event.preventDefault(); 
    const todoLi = document.createElement('li');
    todoLi.classList.add("todo");
    const newTodo = document.createElement('ul');
    newTodo.classList.add("todo-item");
    todoLi.appendChild(newTodo);

    const newTodoDate = document.createElement('li');
    const newTodoTime = document.createElement('li');
    const newTodoSort = document.createElement('li');
    const newTodoDetail = document.createElement('li');
    newTodoDate.classList.add("todo-date");
    newTodoTime.classList.add("todo-time");
    newTodoSort.classList.add("todo-sort");
    newTodoDetail.classList.add("todo-detail");
    
    if (todoInput.value == 0 || todoInput.value == undefined || todoInput.value == null){
        alert("內容欄為必填");
    }else if(dateInput.value ==0 || dateInput.value == undefined || dateInput.value == null){
        alert("日期欄為必填");
    }else if(timeInput.value == 0 || timeInput.value == undefined || timeInput.value == null){
        alert("時間欄為必填");
    }else{
        newTodoDate.innerText = dateInput.value; 
        newTodoTime.innerText = timeInput.value; 
        newTodoDetail.innerText = todoInput.value;
        if(taskSort.value == "job"){
            newTodoSort.innerHTML += `<i class="fas fa-briefcase"></i>`;
        }else if(taskSort.value == "housework"){
            newTodoSort.innerHTML += `<i class="fas fa-home"></i>`;
        }else if(taskSort.value == "sport"){
            newTodoSort.innerHTML += `<i class="far fa-futbol"></i>`;
        }else if(taskSort.value == "routine"){
            newTodoSort.innerHTML += `<i class="fas fa-hourglass"></i>`;
        }else if(taskSort.value == "others"){
            newTodoSort.innerHTML += `<i class="fas fa-palette"></i>`;
        }else{
            newTodoSort.innerHTML += `<i class="fas fa-times"></i>`;
        };

        newTodo.appendChild(newTodoDate); 
        newTodo.appendChild(newTodoTime); 
        newTodo.appendChild(newTodoSort); 
        newTodo.appendChild(newTodoDetail); 

        let saveLocal = [dateInput.value,timeInput.value,taskSort.value,todoInput.value];
        saveLocalTodos(saveLocal);

        const newTodoButton = document.createElement('div');
        newTodoButton.classList.add("todo-btn");
        todoLi.appendChild(newTodoButton); 
        const completedButton = document.createElement('button');
        completedButton.innerHTML = '<i class="fas fa-check"></i>';
        completedButton.classList.add('complete-btn');
        newTodoButton.appendChild(completedButton); 
        const trashButton = document.createElement('button');
        trashButton.innerHTML = '<i class="fas fa-trash"></i>';
        trashButton.classList.add('danger-btn');
        newTodoButton.appendChild(trashButton); 
        todoList.appendChild(todoLi);
        
        location.reload();
        dateInput.value = "";
        timeInput.value = "";
        todoInput.value = "";
    };
}

第二步

接著是完成和刪除鍵的功能撰寫。

在 To Do List 練習中,將會一直牽涉到 HTML DOM 中的查找,可以善用 console.log 來確定我們要取得的是不是跟我們打的一樣。

按刪除鍵時,想達成的目的是要刪除一整條的 todoLi 。用console.log(e.target);可以發現, e.target 等於我們點的位置的 html 標籤,意即若直接 remove 掉 e.target,移除的會是刪除鈕本身,因此須回到父層再刪除,同理按完成鍵也是一樣概念,因此可將它們放在同一函式中。但要怎麼分別刪除和完成呢?

沒錯,同樣是要運用 DOM 觀念查找,發現可以從 item.classList[0] ,也就是 item 的第一層 class 為 danger-btn 或 complete-btn 來區分,進而執行不同的事。

刪除的部分,我們要幫他加動畫效果 fall ,並用 css 設定 fall 這個動畫的細節。在動畫跑完後,才執行函式將 todo 本身移除。並且要讓本地端儲存的資料一併刪除。但是本地端的部分,讓我們稍後再細談。

完成鍵的部分,要在 todo 這個 div 加上 completed 的 class,藉此設定畫線樣式。讓本地端儲存的資料一併刪除,但紀錄完成了哪些事。

function deleteCheck(e){
    
    const item = e.target;
    const todo = item.parentElement.parentElement; 
    
    //delete btn
    if(item.classList[0] === 'danger-btn'){
        todo.classList.add("fall");
        removeLocalTodos(todo); 
        todo.addEventListener('transitionend',function(){ 
            todo.remove();
        });
    }
    
    //check btn
    if(item.classList[0] === 'complete-btn'){
        todo.classList.add('completed');
        let date = todo.querySelector(".todo-date").innerText;
        let time = todo.querySelector(".todo-time").innerText;
        let detail = todo.querySelector(".todo-detail").innerText;
        let sort = todo.querySelector(".todo-sort").innerHTML;
        if (sort == `<i class="fas fa-briefcase"></i>`){
            sort = "job";
        }else if(sort == `<i class="fas fa-home"></i>`){
            sort = "housework";
        }else if(sort == `<i class="far fa-futbol"></i>`){
            sort = "sport";
        }else if(sort == `<i class="fas fa-hourglass"></i>`){
            sort = "routine";
        }else{
            sort = "others";
        };
        let saveLocalComplete = [date,time,sort,detail];
        saveLocalCompleteTodos(saveLocalComplete);
        removeLocalTodos(todo); 
    }
}

第三步

先停一下,來處理儲存與刪除本地端資料的問題。要儲存的值會有三項:還沒完成的項目、已完成的項目、完成的數目。可以分別將三個 key 命名為 todo 、 complete 和 completeTask ,並分別儲存。網頁重整時,再從本地端提出來。

  • 儲存
    首先要確認本地端有沒有已存在的 todo 和 complete ,如果沒有,則建空陣列。如果有,用 json 把已存在的資料拿來,並建立內容相同的陣列。但不論剛剛有沒有拿到東西,都要把參數 push 進 todos 陣列,並將 todos 資料轉回字串,更新到資料庫中。這個參數會是剛剛在第一和第二步驟設定的格式:[日期,時間,種類,內容],之所以會這樣設定是因為等等提取時也是要這樣依序放回 HTML 中。

為了讓 completeTask 的數量等同目前存在於 complete 陣列中的數, completeTodos.length 派上用場。最後,按完完成鍵時,設定它在一定時間後自動重整頁面。

function saveLocalTodos(todo) {
    let todos;
    if (localStorage.getItem("todos") === null) {
      todos = [];
    } else {
      todos = JSON.parse(localStorage.getItem("todos"));
    }
    todos.push(todo);
    localStorage.setItem("todos", JSON.stringify(todos));
}
function saveLocalCompleteTodos(todo){
    let completeTodos;
    if (localStorage.getItem("complete") === null) {
      completeTodos = [];
    } else {
      completeTodos = JSON.parse(localStorage.getItem("complete"));
    }
    completeTodos.push(todo);
    localStorage.setItem("complete", JSON.stringify(completeTodos));
    if (completeTodos.length != 0){
        completedNum.innerHTML = `已完成 ${completeTodos.length} 項工作!`;
    }else{
        completedNum.innerHTML = `尚未有完成的工作!`;
    }
    localStorage.setItem("completeTask",JSON.stringify(completeTodos.length));
    window.setTimeout(function () { 
        window.location.reload();
    }, 500);
}
  • 提取
    希望在每次畫面重整時,自動提取。因此加上監聽與宣告:
const completedNum = document.querySelector('#completedNum');
const clearCompleteNum = document.querySelector('#clearCompleteNum');
document.addEventListener('DOMContentLoaded',getTodos); 

分別確認本地端有沒有 todos 和 completes ,沒有的就要建立空陣列。排列組合下會寫出四種出來。這時可用 console.log(todos); 檢查,發現 todo 已被分成一條 task 一個陣列的狀態,這時再複製上面 function addTodo ,只是記得將 input 改成 todo[n] / complete[n]。

也在這同步處理完成數量的程式:

if(completes = []){
        completedNum.innerHTML = `尚未有完成的工作!`;
    }else{
        completedTotalNum = JSON.parse(localStorage.getItem('completeTask'));
        completedNum.innerHTML = `已完成 ${completedTotalNum} 項工作!`;
    }

時間排序及過期的設定先不管的話,現在的你應該會得到以下程式:

function getTodos(){
    let todos;
    let completes;
    if(localStorage.getItem('todos') === null && localStorage.getItem('complete') === null){
        todos = [];
        completes = [];
    }else if(localStorage.getItem('todos') === null && localStorage.getItem('complete') !== null){
        todos = [];
        completes = JSON.parse(localStorage.getItem("complete"));
    }else if(localStorage.getItem('complete') === null && localStorage.getItem('todos') !== null){
        todos = JSON.parse(localStorage.getItem('todos'));
        completes = [];
    }else if(localStorage.getItem('complete') !== null && localStorage.getItem('todos') !== null){
        todos = JSON.parse(localStorage.getItem('todos'));
        completes = JSON.parse(localStorage.getItem("complete"));
    }

    todos.forEach(function(todo) {
        const todoLi = document.createElement('li');
        todoLi.classList.add("todo");
        const newTodo = document.createElement('ul');
        newTodo.classList.add("todo-item");
        todoLi.appendChild(newTodo); 
    
        const newTodoDate = document.createElement('li');
        const newTodoTime = document.createElement('li');
        const newTodoSort = document.createElement('li');
        const newTodoDetail = document.createElement('li');
        newTodoDate.classList.add("todo-date");
        newTodoTime.classList.add("todo-time");
        newTodoSort.classList.add("todo-sort");
        newTodoDetail.classList.add("todo-detail");
        newTodoDate.innerText = todo[0]; 
        newTodoTime.innerText = todo[1]; 
        newTodoDetail.innerText = todo[3]; 

        if(todo[2] == "job"){
            newTodoSort.innerHTML += `<i class="fas fa-briefcase"></i>`;
        }else if(todo[2] == "housework"){
            newTodoSort.innerHTML += `<i class="fas fa-home"></i>`;
        }else if(todo[2] == "sport"){
            newTodoSort.innerHTML += `<i class="far fa-futbol"></i>`;
        }else if(todo[2] == "routine"){
            newTodoSort.innerHTML += `<i class="fas fa-hourglass"></i>`;
        }else if(todo[2] == "others"){
            newTodoSort.innerHTML += `<i class="fas fa-palette"></i>`;
        }else{
            newTodoSort.innerHTML += `<i class="fas fa-times"></i>`;
        };

        newTodo.appendChild(newTodoDate); 
        newTodo.appendChild(newTodoTime); 
        newTodo.appendChild(newTodoSort); 
        newTodo.appendChild(newTodoDetail); 
        const newTodoButton = document.createElement('div');
        newTodoButton.classList.add("todo-btn");
        todoLi.appendChild(newTodoButton); 

        const completedButton = document.createElement('button');
        completedButton.innerHTML = '<i class="fas fa-check"></i>';
        completedButton.classList.add('complete-btn');
        newTodoButton.appendChild(completedButton); 
        const trashButton = document.createElement('button');
        trashButton.innerHTML = '<i class="fas fa-trash"></i>';
        trashButton.classList.add('danger-btn');
        newTodoButton.appendChild(trashButton); 
        todoList.appendChild(todoLi);
    });
    completes.forEach(function(complete){
        const todoLi = document.createElement('li');
        todoLi.classList.add("todo");
        todoLi.classList.add("completed");

        const completeTodo = document.createElement('ul');
        completeTodo.classList.add("todo-item");
        todoLi.appendChild(completeTodo); 

        const doneTodoDate = document.createElement('li');
        const doneTodoTime = document.createElement('li');
        const doneTodoSort = document.createElement('li');
        const doneTodoDetail = document.createElement('li');
        doneTodoDate.classList.add("todo-date");
        doneTodoTime.classList.add("todo-time");
        doneTodoSort.classList.add("todo-sort");
        doneTodoDetail.classList.add("todo-detail");
        completeTodo.appendChild(doneTodoDate); 
        completeTodo.appendChild(doneTodoTime); 
        completeTodo.appendChild(doneTodoSort); 
        completeTodo.appendChild(doneTodoDetail);

        doneTodoDate.innerText = complete[0]; 
        doneTodoTime.innerText = complete[1]; 
        doneTodoDetail.innerText = complete[3];
        if(complete[2] == "job"){
            doneTodoSort.innerHTML += `<i class="fas fa-briefcase"></i>`;
        }else if(complete[2] == "housework"){
            doneTodoSort.innerHTML += `<i class="fas fa-home"></i>`;
        }else if(complete[2] == "sport"){
            doneTodoSort.innerHTML += `<i class="far fa-futbol"></i>`;
        }else if(complete[2] == "routine"){
            doneTodoSort.innerHTML += `<i class="fas fa-hourglass"></i>`;
        }else if(complete[2] == "others"){
            doneTodoSort.innerHTML += `<i class="fas fa-palette"></i>`;
        }else{
            doneTodoSort.innerHTML += `<i class="fas fa-times"></i>`;
        };
        todoList.appendChild(todoLi);
    })
    completedTotalNum = JSON.parse(localStorage.getItem('completeTask'));
    if(completedTotalNum == null){
        completedNum.innerHTML = `尚未有完成的工作!`;
    }else{
        completedNum.innerHTML = `已完成 ${completedTotalNum} 項工作!`;
    }
};

順帶一提,因為上面先寫 todos.forEach 再寫 completes.forEach ,還沒完成的會放在上面,已經完成的會被擺到下面。若不太懂我的意思,你可以把兩個順序倒過來試試,就會知道我在說什麼。

  • 刪除
    這個函式會在按完刪除鍵或完成鍵後執行。一樣要先確認本地端有沒有已存在的 todo ,沒有就建立空陣列,有就用 json 把已存在的 todos 拿來,並建立內容相同的陣列 todos。

因為 todos 是陣列包著陣列, 而 todo 是點下去的那段的程式碼,所以把 todo 轉成跟 todos 一樣的陣列方式(有點類似剛剛儲存時轉過來,現在再轉回去)。

接著要用 indexOf 找到他在陣列上是第幾個位置,然後用 slice 把它切掉。剛剛提過了, todos 是陣列中又包著陣列。當要在陣列中包著陣列的形式中尋找特定陣列,使用 indexOf 會找不到,因為 indexOf 是用嚴格模式判斷,例如即使 todos=[[3,0],[1,2]] ,找 todos.indexOf([3,0]) 也找不到。為此需要客製化 indexOf :

既然陣列是從 0 開始數,預設代表陣列位置的變數 i=0 。當 i 小於查找項目的長度,跑下面的迴圈,跑完加一再繼續跑,直到等於長度時停止。從查找陣列的第 0 項開始,當第 0 項陣列中的第 0 個位置的值,跟要找的陣列的第 0 個值相同,就讓 i 顯示 0 返回,藉此得知要找的就在第 0 的位置,依此類推,若都找不到則返回 -1。

終於寫好。依上面寫好的程式代入 todo (被找的父陣列) 和 todoIndex (要找的內容)。1 的位置要填的數字,代表要刪幾個,只刪一個所以填一。最後,把結果傳回本地端。

function removeLocalTodos(todo){
    let todos;
    if(localStorage.getItem('todos') === null){
        todos = [];
    }else{
        todos = JSON.parse(localStorage.getItem('todos'));
    }
    let date = todo.querySelector(".todo-date").innerText;
    let time = todo.querySelector(".todo-time").innerText;
    let detail = todo.querySelector(".todo-detail").innerText;
    let sort = todo.querySelector(".todo-sort").innerHTML;
    if (sort == `<i class="fas fa-briefcase"></i>`){
        sort = "job";
    }else if(sort == `<i class="fas fa-home"></i>`){
        sort = "housework";
    }else if(sort == `<i class="far fa-futbol"></i>`){
        sort = "sport";
    }else if(sort == `<i class="fas fa-hourglass"></i>`){
        sort = "routine";
    }else{
        sort = "others";
    };
    let todoIndex = [date,time,sort,detail];
    
    function indexOfCustom (parentArray, searchElement) {
        for (let i = 0; i < parentArray.length; i++ ) { 
            if ( parentArray[i][0] == searchElement[0] && parentArray[i][1] == searchElement[1] && parentArray[i][2] == searchElement[2] && parentArray[i][3] == searchElement[3]) { 
                return i;
            }
        }
        return -1;
    }
    todos.splice(indexOfCustom(todos,todoIndex),1); 
    localStorage.setItem("todos",JSON.stringify(todos)); 
}

雖然文章和程式很長,但其實可以發現都是一些簡單的觀念重複運用,只有少數幾個小卡關的點而已。而我們沒達成的需求還剩:

  • 可以一次刪除全部的代辦事項
  • 能刪除所有已完成的代辦事項的內容及數量
  • 代辦事項預設以時間排列
  • 代辦事項的順序可以被使用者拖拉
  • 超過時間還沒完成的代辦事項以紅色顯示
  • 有進度、月份、種類的篩選器
  • 有分析按鈕,按下去會跳出視窗,顯示種類的圓餅圖
  • RWD 所以要有手機漢堡選單

上一篇
番外篇(2)一起來做 To Do List!- 知識篇
下一篇
番外篇(2)一起來做 To Do List!- 實作篇(2)
系列文
花三十天找到 JavaScript 沙漠中的綠洲35
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言