데브코스

[Day 28] TodoList Drag&Drop

라다디 2022. 11. 23. 17:27

TodoList Drag&Drop

더보기
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Drag&Drop</title>
</head>
<body>
  <main class="App"></main>
  <script src="/src/main.js" type="module"></script>
</body>
</html>
더보기
import App from "./App.js";

const $target = document.querySelector(".App");

new App({ $target });
더보기
const API_END_POINT = "...";

export const request = async (url, options) => {
  try {
    const res = await fetch(`${API_END_POINT}${url}`, options);

    if (!res.ok) {
      throw new Error("API 호출 오류");
    }

    return await res.json();
  } catch (e) {
    alert(e.message);
  }
};
더보기
import { request } from "./api.js";
import TodoList from "./TodoList.js";
import TaskQueue from "./TaskQueue.js";
import SyncTaskQueue from "./SyncTaskQueue.js";

export default function App({ $target }) {
  // const tasks = new TaskQueue();
  const tasks = new SyncTaskQueue();

  this.state = {
    todos: [],
  };

  const handleTodoDrop = async (todoId, updateValue) => {
    const nextTodos = [...this.state.todos];
    const todoIndex = nextTodos.findIndex((todo) => todo._id === todoId);

    nextTodos[todoIndex].isCompleted = updateValue;
    this.setState({
      ...this.state,
      todos: nextTodos,
    });

    /* tasks.addTask(async () => {
      await request(`/${todoId}/toggle`, {
        method: "PUT",
      }); 
    }); */

    tasks.addTask({
      url: `/${todoId}/toggle`,
      method: "PUT",
    });
  };

  const handleTodoRemove = (todoId) => {
    const nextTodos = [...this.state.todos];
    const todoIndex = nextTodos.findIndex((todo) => todo._id === todoId);
    nextTodos.splice(todoIndex, 1);

    this.setState({
      ...this.state,
      todos: nextTodos,
    });

    tasks.removeTasks(`/${todoId}`);
    tasks.addTask({
      url: `/${todoId}`,
      method: "DELETE",
    });
  };

  const incompletedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료되지 않은 일들",
      todos: [],
    },
    onDrop: (todoId) => handleTodoDrop(todoId, false),
    onRemove: handleTodoRemove,
  });

  const completedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료된 일들",
      todos: [],
    },
    onDrop: (todoId) => handleTodoDrop(todoId, true),
    onRemove: handleTodoRemove,
  });

  this.setState = (nextState) => {
    this.state = nextState;

    const { todos } = this.state;

    incompletedTodoList.setState({
      ...incompletedTodoList.state,
      todos: todos.filter((todo) => !todo.isCompleted),
    });

    completedTodoList.setState({
      ...completedTodoList.state,
      todos: todos.filter((todo) => todo.isCompleted),
    });
  };

  const fetchTodos = async () => {
    const todos = await request("");

    this.setState({
      ...this.state,
      todos,
    });
  };

  fetchTodos();

  const $button = document.createElement("button");
  $button.textContent = "변경내용 동기화";

  $target.appendChild($button);

  $button.addEventListener("click", () => tasks.run());
}
더보기
export default function TodoList({ $target, initialState, onDrop, onRemove }) {
  const $todoList = document.createElement("div");
  $todoList.setAttribute("droppable", "true");
  $target.appendChild($todoList);

  this.state = initialState;

  this.setState = (nextState) => {
    this.state = nextState;
    this.render();
  };

  this.render = () => {
    const { title, todos = [] } = this.state;
    $todoList.innerHTML = `
      <h2>${title}</h2>
      <ul>
        ${todos.map((todo) =>
          `<li data-id="${todo._id}" draggable="true">${todo.content}
             <button>x</button>
           </li>`
          ).join("")
         }
      </ul>
      ${todos.length === 0 ? "설정된 일이 없습니다." : ""}
    `;
  };

  this.render();

  $todoList.addEventListener("dragstart", (e) => {
    const $li = e.target.closest("li");
    e.dataTransfer.setData("todoId", $li.dataset.id);
  });

  $todoList.addEventListener("dragover", (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";
  });

  $todoList.addEventListener("drop", (e) => {
    e.preventDefault();
    const droppedTodoId = e.dataTransfer.getData("todoId");

    // 현재 TodoList의 Todo가 아닌 경우 상위 컴포넌트에 알림
    const { todos } = this.state;
    if (!todos.find((todo) => todo._id === droppedTodoId)) {
      onDrop(droppedTodoId);
    }
  });

  $todoList.addEventListener("click", (e) => {
    if (e.target.tagName === "BUTTON") {
      const $li = e.target.closest("li");

      if ($li) {
        onRemove($li.dataset.id);
      }
    }
  });
}
더보기
export default function TaskQueue() {
  const tasks = [];

  this.addTask = (task) => {
    tasks.push(task);
  };

  this.run = async () => {
    if (tasks.length > 0) {
      const task = tasks.shift();
      await task();
      this.run();
    }
  };

  this.hasTask = () => tasks.length > 0;
}
더보기
import { request } from "./api.js";

export default function SyncTaskManager() {
  let tasks = [];

  this.addTask = (task) => {
    tasks.push(task);
  };

  this.removeTasks = (urlPattern) => {
    tasks = tasks.filter((task) => !task.url.includes(urlPattern));
  };

  this.run = async () => {
    if (tasks.length > 0) {
      const task = tasks.shift();

      await request(task.url, {
        method: task.method || "GET",
      });

      this.run();
    }
  };
}

 

TodoList를 컴포넌트로 만들었기 때문에 안에 있는 데이터만 다르게 해서 여러개를 만들 수 있다.

/* import */
export default function App({ $target }) {
  ...
  const incompletedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료되지 않은 일들",
      todos: [],
    },
    onDrop: (todoId) => handleTodoDrop(todoId, false),
    onRemove: handleTodoRemove,
  });

  const completedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료된 일들",
      todos: [],
    },
    onDrop: (todoId) => handleTodoDrop(todoId, true),
    onRemove: handleTodoRemove,
  });
  ...
}

 

Draggable 속성

draggable="true" 속성을 이용하여 드래그를 할 수 있는 요소로 만든다.

자바스크립트에서 드래그 앤 드롭 기능을 사용하기 위해서는 드래그 하려는 요소에 draggable을, 드롭해야 할 요소에는 droppable 속성을 추가한다.

 

todo들의 영역인 $todoListdroppable속성을 주고, $lidraggable속성을 주었다.

그리고 각 $li를 구분 할 수 있도록 data-id로 todo의 id를 주었다.

export default function TodoList({ $target, initialState, onDrop, onRemove }) {
  const $todoList = document.createElement("div");
  $todoList.setAttribute("droppable", "true");
  ...
  this.render = () => {
    const { title, todos = [] } = this.state;
    $todoList.innerHTML = `
      <h2>${title}</h2>
      <ul>
        ${todos.map((todo) =>
          `<li data-id="${todo._id}" draggable="true">${todo.content}
             <button>x</button>
           </li>`
          ).join("")
         }
      </ul>
      ${todos.length === 0 ? "설정된 일이 없습니다." : ""}
    `;
  };
  
  this.render();
  ...
}

draggable 요소를 마우스로 선택해 droggable 요소로 드래그하고, 마우스 버튼에서 손을 뗌으로써 요소를 드롭한다.

드래그하는 동안 draggable 요소는 반투명한 채로 마우스 포인터를 따라다닌다.

 

Drag Event

드래그 이벤트를 콘솔에 찍으면 드래그한 요소의 위치가 실시간으로 콘솔에 찍히는 것을 볼 수 있다.

  $todoList.addEventListener("drag", (e) => {
    console.log(e);
  });

 

드래그 관련 이벤트는 많다.

그 중 dragstart, dragover, drop을 이용하여 드래그 앤 드롭을 구현하였다.

  • dragstart: 사용자가 객체를 드래그하기 시작할 때 발생
  • dragover: 드래그하면서 마우스가 대상 요소 위에 있을 때 발생
  • drop: 드래그하던 객체를 놓은 droppable 요소에서 발생
export default function TodoList({ $target, initialState, onDrop, onRemove }) {
  ...
  $todoList.addEventListener("dragstart", (e) => {
    const $li = e.target.closest("li");
    e.dataTransfer.setData("todoId", $li.dataset.id);
  });

  $todoList.addEventListener("dragover", (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";
  });

  $todoList.addEventListener("drop", (e) => {
    e.preventDefault();
    const droppedTodoId = e.dataTransfer.getData("todoId");

    // 현재 TodoList의 Todo가 아닌 경우 상위 컴포넌트에 알림
    const { todos } = this.state;
    if (!todos.find((todo) => todo._id === droppedTodoId)) {
      onDrop(droppedTodoId);
    }
  });
  ...
}

브라우저는 HTML 요소에 뭔가를 드롭했을 때 아무 일도 일어나지 않게 하기 때문에 드롭 지역을 만들기 위해서는 해당 요소가 ondragoverondrop 이벤트 핸들러 속성을 가져야 한다.

또한, 각 핸들러는 preventDefault()를 호출해서 추가적인 이벤트(터치, 포인터 등)가 발생하지 않도록 해야 한다.

 

HTML 드래그 앤 드롭 API - Web API | MDN

HTML 드래그 앤 드롭 인터페이스는 파이어폭스와 다른 브라우저에서 어플리케이션이 드래그 앤 드롭 기능을 사용하게 해줍니다. 이 기능을 이용해 사용자는 draggable 요소를 마우스로 선택해 droppa

developer.mozilla.org

 

드래그 이벤트 객체는 dataTrasfer이라는 객체를 가진다.

dataTransfer은 드래그 형태나 데이터, 각 드래그 아이템의 종류와 같은 드래그 이벤트의 상태를 담는다.

getData와 setData 메소드를 이용하여 데이터를 전달 할 수 있다.

 

onDrop 콜백을 작성하여 api 호출을 한 다음 화면 반영을 위해 fetchTodos를 호출했다.

/* import */
export default function App({ $target }) {
  ...
  const completedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료된 일들",
      todos: [],
    },
    onDrop: async (todoId) => {
      await request(`/${todoId}/toggle`, {
        method: "PUT",
      });

      await fetchTodos();
    },
  });
  ...
}

 

 

낙관적 업데이트 적용

하지만 토글을 했을 때 UI 업데이트가 빠르게 이뤄지지 않는다.

이를 위해 낙관적 업데이트를 적용했다.

/* import */
export default function App({ $target }) {
  ...
  const completedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료된 일들",
      todos: [],
    },
    onDrop: async (todoId) => {
      const nextTodos = [...this.state.todos];
      const todoIndex = nextTodos.findIndex((todo) => todo._id === todoId);
      
      nextTodos[todoIndex].isCompleted = true;
      this.setState({
        ...this.state,
        todos: nextTodos,
      });

      await request(`/${todoId}/toggle`, {
        method: "PUT",
      });

      await fetchTodos();
    },
  });
  ...
}

incompletedTodoList의 onDrop 코드도 위와 거의 동일하다.

따라서 이런 코드는 별도의 핸들러 함수로 빼고 true, false만 파라미터로 핸들링할 수 있게해서 처리하는 게 좋다.

/* import */
export default function App({ $target }) {
  ...
   const handleTodoDrop = async (todoId, updateValue) => {
    const nextTodos = [...this.state.todos];
    const todoIndex = nextTodos.findIndex((todo) => todo._id === todoId);

    nextTodos[todoIndex].isCompleted = updateValue;
    this.setState({
      ...this.state,
      todos: nextTodos,
    });
  };
  
   const incompletedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료되지 않은 일들",
      todos: [],
    },
    onDrop: (todoId) => handleTodoDrop(todoId, false),
  });

  const completedTodoList = new TodoList({
    $target,
    initialState: {
      title: "완료된 일들",
      todos: [],
    },
    onDrop: (todoId) => handleTodoDrop(todoId, true),
  });
  ...
}

 

TaskQueue

현재 드래그 앤 드롭이 될 때 마다 api 호출이 반복적으로 일어나고 있는데 queue를 이용하여 연속적인 작업을 모아놨다가 한 번에 처리하는 코드를 작성했다.

export default function TaskQueue() {
  const tasks = [];

  this.addTask = (task) => {
    tasks.push(task);
    console.log(tasks);
  };

  this.run = async () => {
    if (tasks.length > 0) {
      const task = tasks.shift();
      await task();
      this.run();
    }
  };

  this.hasTask = () => tasks.length > 0;
}
/* import */
export default function App({ $target }) {
  ...
   const handleTodoDrop = async (todoId, updateValue) => {
    ...
    tasks.addTask(async () => {
      await request(`/${todoId}/toggle`, {
        method: "PUT",
      }); 
    }); 
  };
  ...
  const $button = document.createElement("button");
  $button.textContent = "변경내용 동기화";

  $target.appendChild($button);

  $button.addEventListener("click", () => tasks.run());
}

 

낙관적 업데이트를 통해 화면을 먼저 업데이트 한 뒤 동기화 버튼을 클릭하면 queue에 쌓여있던 작업을 한 번에 처리하고 있다. 

 

requestIdleCallback웹 워커라는 키워드도 알게 되었는데 나중에 추가적으로 공부해야 할 것 같다.

requestIdleCallback은 UI가 바쁘지 않을 때 함수를 실행할 수 있고, 웹 워커를 이용하면 메인 스레드와 별개로 작동되는 스레드를 생성하고 그 안에서 동작시킬 수 있기 때문에 성능에서 더 나은 방법을 제시할 수 있다고 한다. 

 

어떠한 작업을 할 때 DB와 실시간으로 싱크를 하는 경우, 작업 여러가지를 하고 한번에 서버와 통신해서 DB와 싱크하는 경우를 상황에 따라서 잘 선택해야 할 것 같다.

 

TaskQueue를 SyncTaskQueue로 변경했다. (차이점: 태스크 형태)

 

TaskQueue 최적화

이런 작업을 생각해보자.

만일 어떤 todo를 토글 한 뒤 삭제를 한다면? 토글 작업은 필요가 없어지게 된다.

import { request } from "./api.js";

export default function SyncTaskQueue() {
  let tasks = [];
  ...
  this.removeTasks = (urlPattern) => {
    tasks = tasks.filter((task) => !task.url.includes(urlPattern));
  };
  ...
}

filter 함수를 이용하여 url이 똑같은 todo를 제거했다.

 

데이터의 흐름, 네트워크 요청같은 것들을 어떻게 잘 제어할 수 있을지, 어떤 방식으로 좀 더 최적화시킬 수 있을까 고민하는 것이 중요하다.

 


출처: 프로그래머스 프론트엔드 데브코스 

[Day 28] VanillaJS를 통한 자바스크립트 기본 역량 강화 II (5)