[VanillaJs로 만드는 Notion Cloning] 회고 #2

최훈오·2023년 12월 15일
0

데브코스

목록 보기
29/29

리액트 강의를 수강하던 중 멘토님께서 일주일동안 그동안 못했던 일 같은 것들을 매일 꾸준히 시간과 목표를 구체적으로 정해놓고 챌린지형태로 하는게 어떻냐고 제안하셨다. 나는 데브코스 과정중에 가장 아쉬웠던 점이 노션 클로닝 프로젝트를 제대로 하지 못한 것이었는데 항상 해야지 해야지 미루다가 시간이 훌쩍 가버렸었다. 지금 할당량도 많아서 목표를 최대한 현실적으로 잡고 싶었는데 지금 아니면 영영 미루게 될 것 같아서 조금이라도 리팩토링을 하기 위해서 노션 클로닝 리팩토링을 하기로 결정하였다.
요구사항은 마음같아선 많이 하고 싶었지만 역시나 현실적으로.. 딱 두개! 낙관적 업데이트랑, contenteditable -> textarea로 변환이다.

contenteditable -> textarea

어쩌면 노션의 핵심 기능이기도 한 contenteditable을 과감히 포기하였다.
이전 팀에 있을 때는 오기로 contenteditable을 꼭 구현하고자 노력하였는데 커서 위치에 대한 사이드 이펙트가 계속 발생하여서 오히려 textarea만도 못한 결과가 만들어졌었다. 근데 예상외로 textarea로도 충분히 좋은 결과를 낸 과제들이 몇몇 보여서 나도 바꾸기로 하였다.

import revertCursor from '../utils/revertCursor.js';

export default function Editor({
  $target,
  initialState = {
    title: '',
    content: '',
  },
  onEditing,
}) {
  const $editor = document.createElement('div');
  $editor.className = 'editor';
  $editor.innerHTML = `
  <input type="text" name="title" placeholder="제목을 입력해주세요."/>
  <div name="content" contentEditable="true"></div>
  `;
  this.state = initialState;
  $target.appendChild($editor);

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

  this.render = async () => {
    $editor.querySelector('[name=title]').value =
      this.state.title === '새 폴더' ? '' : this.state.title;
    $editor.querySelector('[name=content]').innerHTML = this.state.content;
  };

  $editor.querySelector('[name=title]').addEventListener('keyup', (e) => {
    const nextState = {
      ...this.state,
      title: e.target.value,
    };
    this.setState(nextState);
    onEditing(this.state);
  });

  const $content = $editor.querySelector('[name=content]');

  $content.addEventListener('input', (e) => {
    const nextState = {
      ...this.state,
      content: e.target.innerHTML,
    };
    this.setState(nextState);

    revertCursor($content);
    onEditing(this.state);
  });

  this.render();
}

기존의 contenteditable 코드이다.

// 생략

	$editor.innerHTML = `
  <input type="text" name="title" placeholder="제목을 입력해주세요."/>
  <textarea name="content" class="content"></textarea>
  `;

	// 제목 입력
	const $title = $editor.querySelector("[name=title]");
	$title.addEventListener("keyup", (e) => {
		const nextState = {
			...this.state,
			title: e.target.value,
		};
		this.setState(nextState);
		onEditing(nextState);
	});

	// 내용 입력
	const $content = $editor.querySelector("[name=content]");
	$content.addEventListener("keyup", (e) => {
		const nextState = {
			...this.state,
			content: e.target.value,
		};
		this.setState(nextState);

		onEditing(nextState);
	});


// 생략
}

다음은 수정된 textarea 코드이다. innerHTML가 아닌 그냥 value가 컨텐츠 값이다. 제목과 컨텐츠가 동일한 논리로 업데이트 된다. 근데 보다보니까 강의에서 textarea일 경우 이것을 하나로 통일했던 기억이 나서 다음과 같이 적용하였다.

$editor.addEventListener("keyup", (e) => {
  const { target } = e;
  const name = target.getAttribute("name"); // title or content
  if (this.state[name] !== undefined) {
    const nextState = {
      ...this.state,
      [name]: e.target.value,
    };
    this.setState(nextState);
    onEditing(nextState);
  }
});

이렇게 name을 통해 한 번에 제목과 컨텐츠를 업데이트 할 수 있다.

여담이지만 지금 방식은 innerHTML로 구조를 짜놓고 render에서 value를 넣어주는 방식인데 매번 렌더링 시에 querySelector로 접근해야 하니까 성능이 좀 떨어져 보여서 좀 더 좋은 방향으로 수정하는 것이 좋다고 판단하였다.

낙관적 업데이트

결론적으로 적용이 안되었다.. 사실, 지금도 서버에서 데이터가 빨리와서 딱히 적용할 필요는 못 느꼈지만 그래도 연습삼아 해보려 했지만..

import SidebarList from "./SidebarList.js";
import SidebarHeader from "./SidebarHeader.js";
import { request } from "../api/api.js";
import { push } from "../router/router.js";

export default function Sidebar({ $target }) {
	const $sidebar = document.createElement("section");
	$sidebar.className = "sidebar";

	new SidebarHeader({
		$target: $sidebar,
		addDocument: async () => {
			const document = {
				title: "새 폴더",
				parent: null,
			};
			const createdDocument = await request("", {
				method: "POST",
				body: JSON.stringify(document),
			});
			this.setState({ ...this.state, createdDocument });
		},
		goHome: async () => {
			push("/");
		},
	});

	const sidebarList = new SidebarList({
		$target: $sidebar,
		initialState: [],
		delDocument: async (docId) => {
			const deletedDocuments = await request(`/${docId}`, {
				method: "DELETE",
			});
			// 삭제가 제대로 된 경우
			if (deletedDocuments) {
				this.setState();
			}
			// 삭제가 제대로 되지 않은 경우
			else {
				console.log("삭제가 제대로 되지 않았습니다.");
			}
		},

		addDocument: async (id) => {
			const document = {
				title: "새 폴더",
				parent: id,
			};
			const updatedDocuments = await request(``, {
				method: "POST",
				body: JSON.stringify(document),
			});
			// 추가가 제대로 된 경우
			if (updatedDocuments) {
				// 새로 들어온 디렉토리 편집화면
				push(`/posts/${updatedDocuments.id}`);
				this.setState();
			}
		},
	});

	this.setState = async () => {
		const documents = await request("");
		sidebarList.setState(documents);
		this.render();
	};

	this.render = async () => {
		$target.appendChild($sidebar);
	};

	this.render();
}

sidebar의 컴포넌트는 자체적으로 state를 담고 있지 않아서 부모쪽에서 state를 보내주거나 여기서 서버 호출을 통해 state를 받아와야 하는데 전자의 경우는 구조를 아예 바꿔야 할 것 같고 후자의 경우는 애초에 서버에 요청을 보내기 전에 미리 클라이언트 업데이트를 하는 것이 목적인데 이렇게 되버리면 서버에 요청을 두번 하는 꼴이 되어버린다..
내가 아직 능력이 부족해서 분명히 해답을 있을 것 같아서 좀 더 고민해봐야 겠다.. 이걸 적용하신 분이 있을까 해서 참고하려고 다른 분들 코드를 몇개 봤는데 아직까지 찾지 못했다.

이외에도 환경변수 설정, 글 업데이트시 바로 sidebar에 반영되도록 로직 변경, 이미지 오류 수정, 홈 버튼(아직 문제 있음)등을 리팩토링 하였다.

앞으로 수정할 사항은 크게 404 에러 페이지 만들기, api 요청 실패에 대한 에러처리, 로컬스토리지 사용, 글 수정 시에 디바운싱 적용 등이 있어서 꾸준히 수정해나가야겠다.

0개의 댓글