리엑트 JS로 만들기 - 최근 검색어 구현

42804 단어 TILReactsprintReact

최근 검색어 이름, 검색일자, 삭제 버튼이 목록 형태로 탭 아래 위치한다

최근 검색어 데이터를 저장하고 있는 historyData를 받아오는 getter와 가장 최근 시간대가 최상위 리스트로 올라가게 하기 위한 sort()를 하나 만들어주자!!

 getHistoryList() {
    // TODO
    return this.storage.historyData.sort(this._sortHistory);
  }

  _sortHistory(history1, history2) {
    // 날짜를 역순으로 정렬해주기 위해서 만듬
    return history2.date > history1.date;
  }

state 에 최근 검색어를 다룰 state값을 추가해주고, componentDidMount()에서도 최근 검색어 데이터를 받아오도록 하자!!

constructor() {
    super();

    this.state = {
      searchKeyword: "",
      searchResult: [],
      submitted: false,
      selectedTab: TabType.KEYWORD,
      keywordList: [],
      // TODO
      historyList: [],
    };
  }

  componentDidMount() {
    const keywordList = store.getKeywordList();
    const historyList = store.getHistoryList();
    this.setState({ keywordList, historyList });
  }

이제 render()에 첨부해줄 historyList라는 엘리먼트를 생성한다.
formatRelativeDate(date)helper.js에서 만들어놓은 시간을 계산하는 메소드이다.

나중에 다시 사용할 때가 많은 것같으니, 첨부해두겠다.

// 1
export function formatRelativeDate(date = new Date()) {
  const TEN_SECOND = 10 * 1000;
  const A_MINUTE = 60 * 1000;
  const A_HOUR = 60 * A_MINUTE;
  const A_DAY = 24 * A_HOUR;

  const diff = new Date() - date;

  if (diff < TEN_SECOND) return `방금 전`;
  if (diff < A_MINUTE) return `${Math.floor(diff / 1000)}초 전`;
  if (diff < A_HOUR) return `${Math.floor(diff / 1000 / 60)}분 전`;
  if (diff < A_DAY) return `${Math.floor(diff / 1000 / 60 / 24)}시간 전`;
  return date.toLocaleString("ko-KR", {
    hour12: false,
    dateStyle: "medium",
  });
}

// 2
export function createPastDate(date = 1, now = new Date()) {
  if (date < 1) throw "date는 1 이상입니다";

  const yesterday = new Date(now.setDate(now.getDate() - 1));
  if (date === 1) return yesterday;

  return createPastDate(date - 1, yesterday);
}
 const historyList = (
      <ul className="list">
        {this.state.historyList.map(({ id, keyword, date }) => (
          <li key={id}>
            <span>{keyword}</span>
            {/* <span>{date}</span> */}
            <span> {formatRelativeDate(date)} </span>
            <button className="btn-remove"></button>
          </li>
        ))}
      </ul>
  • 진행시 발생했던 에러 사항
    • 처음에는 <span>{date}</span>을 짚어넣으려고 했다. 그러나, 무지막지한 에러가 터졌는데, 이유는 new Date()가 객체를 반환하기 때문에, 객체는 태그 안에 문자열처럼 짚어넣을 수가 없기 때문이다.

이후에, tabs에서 추천 검색어를 추가했듯이, 추가해주면 된다.

 const tabs = (
      <>
        <ul className="tabs">
          {Object.values(TabType).map((tabType) => (
            <li
              key={tabType}
              className={this.state.selectedTab === tabType ? "active" : ""}
              onClick={() => this.setState({ selectedTab: tabType })}
            >
              {TabLabel[tabType]}
            </li>
          ))}
        </ul>
        {this.state.selectedTab === TabType.KEYWORD && keywordList}
		// 추가된 부분
        {this.state.selectedTab === TabType.HISTORY && historyList}
      </>
    );

목록에서 검색어를 클릭하면 선택된 검색어로 검색 결과 화면으로 이동한다

 const historyList = (
      <ul className="list">
        {this.state.historyList.map(({ id, keyword, date }) => (
   		 // 추가된 부분
          <li key={id} onClick={() => this.search(keyword)}>
            <span>{keyword}</span>
            {/* <span>{date}</span> */}
            <span> {formatRelativeDate(date)} </span>
            <button className="btn-remove"></button>
          </li>
        ))}
      </ul>

onClick={() => this.search(keyword)}을 달아주고, search()에서 state값을 초기화 해주면, 간단히 해결된다.

search(searchKeyword) {
    const searchResult = store.search(searchKeyword);
    this.setState({
      searchKeyword,
      searchResult,
      submitted: true,
    });

목록에서 x 버튼을 클릭하면 선택된 검색어가 목록에서 삭제된다

storage 객체를 관리하는 Store class 안에 선택된 검색어를 삭제해줄 메소드를 만들었다.
=> 처음에는 왜 구지 Class로 만들어서 Data를 관리해야 하나 했는데, storage객체만을 관리해줄 메소드들을 따로 모아놓을 수있어서 가독성 면에서도 사용성 면에서도 좋은 것같다.

removeHistory(keyword) {
    // TODO
    // 강제로 지우는 게 아니라, 필터를 통해서 얘만 빼주고
    // 렌더링을 하는 방법도 있구나
    // storage 값을 새로 세팅함
    this.storage.historyData = this.storage.historyData.filter(
      (history) => history.keyword !== keyword
    );
  }

removeHistory(keyword)라는 메소드를 Store class 안에 새로 만들어줬다.

handleClickRemoveHistory(event, keyword) {
    // 실제 history 데이터는 우리 store가 관리하고 있다.
    // store에 삭제하는 removeHistory(keyword)를 호출하면 되겠다.
    store.removeHistory(keyword);
    const historyList = store.getHistoryList();
    this.setState({ historyList });
  }

🙅 이벤트 전파 차단하기 🙅

  • 발생한 문제

=> 검색 기록이 삭제는 됐지만, 리셋 버튼이 아닌, 검색기록 버튼을 누른 것처럼 작동이 됐다.

이유를 분석해보자!!

<button>에도 이벤트 핸들러가 달려있고, 그 부모 태그인 <li>에도 이벤트 핸들러가 달려있다.
즉, 버튼의 이벤트가 위로 계속 버블링 되어서, <li>에 있는 이벤트 핸들러와 동작하게 되어있다.

그리고 <li>에 달린 이벤트는 클릭시, 해당 검색결과를 보여주는 이벤트이다.
그렇기 때문에, <button>에서는 이벤트가 위로 버블링 되지 않도록, 이벤트 전파를 차단해야 한다.

즉, event.stopPropagation();을 넣어주자!!

handleClickRemoveHistory(event, keyword) {
    // TODO
    // 실제 history 데이터는 우리 store가 관리하고 있죠.
    // store에 삭제하는 removeHistory(keyword)를 호출하면 되겠다.
    // 이벤트 전파를 막는 메소드
    event.stopPropagation();
    store.removeHistory(keyword);
    const historyList = store.getHistoryList();
    this.setState({ historyList });
  }


=> "추천 검색어"로 갖다가 돌아와서 삭제된 검색어 리스트는 유지되있다.


검색시마다 최근 검색어 목록에 추가된다

  • 추천 검색어에서 특정 검색어를 하나 검색하고 나면, 그 검색어가 최근 검색어로 들어가야 된다는 요구사항이다.
  • 검색 시마다, 최근 검색어에 해당 검색어가 등록 되어야한다.

Store.js에서

search(keyword) {
    // TODO
    this.addHistory(keyword);
    return this.storage.productData.filter((product) =>
      product.name.includes(keyword)
    );
  }

검색할 때마다, 검색이력이 추가되도록 this.addHistory(keyword);를 추가했다.

// 에러 방지를 위해, keyword가 없는 경우는 빈문자열로 초기화한다.
addHistory(keyword = "") {
    // 좌우의 공백을 trim()으로 제거한다.
    keyword = keyword.trim();
    if (!keyword) {
      return;
    }
    // 동일한 최근 검색어가 있으면, 옛날 기록은 지우고,
    // 새로 삽입한다.
    const hasHistory = this.storage.historyData.some(
      (history) => history.keyword === keyword
    );
    if (hasHistory) this.removeHistory(keyword);

    // 바로 다음 숫자의 ID를 생성하는 메소드임
    // helper.js에서 생성해놓은 것
    const id = createNextId(this.storage.historyData);
    const date = new Date();
    this.storage.historyData.push({ id, keyword, date });
    // 가장 최근 검색어가 최상단으로 올라가게 정렬해준다.
    this.storage.historyData = this.storage.historyData.sort(this._sortHistory);
  }

addHistory()는 단순히 검색어를 추가하는 것이 아니라, 동일한 검색어를 과거에 검색했다면, 시간대를 최신 시간대로 변경해준다.
또한, 검색어 리스트를 가장 최신 검색어는 최상단에 위치하도록 sort해줬다.

// helper.js

export function createNextId(list = []) {
  // 가장 큰 아이디 값을 찾고, 거기에서 하나 더 증가한 값을 배출한다.
  // 이렇게 되면, id가 낮은 순서대로 오지 않아도, 가장 큰 숫자를 구할 수 있다.
  return Math.max(...list.map((item) => item.id)) + 1;
}

createNextId(list = []) 방식이나 (Array.length)+1으로 id를 추가하는 것이나 큰 차이는 없다.

 _sortHistory(history1, history2) {
   // 날짜의 역순으로 정렬한다.
   // 가장 최근 날짜가 최상단에 위치한다.
    return history2.date - history1.date;
  }

sort() 구현시, 주의사항 ⚠

강사님은 return history2.date > history1.date;으로 구현했다.
그러나, 내 로컬 환경에서는 부등호를 이용하는 방식은 동작하지 않았다.
그래서 부등호(비교 연산) 대신, 산술 연산식(뺄셈식)으로 변경하니 올바르게 정렬이 되었다.
이유를 검색해보니, 브라우저에서 javascript를 해석하는 엔진의 차이였다.
크롬에서는 부등호 연산자로 sort가 되지 않았지만,
강사님께서 사용하신 파이어폭스로 해보니, sort 함수에 비교 연산, 산술 연산 둘 다 올바르게 정렬이 되었다.
=> 결론은, 크롬이나 크로미움 기반의 브라우저에서는 sort 함수 구현시, 산술 연산 방식으로 해야 올바르게 나온다.

// main.js

search(searchKeyword) {
    const searchResult = store.search(searchKeyword);
    // TODO
    const historyList = store.getHistoryList();

    this.setState({
      searchKeyword,
      searchResult,
      // TODO
      // 새로운 최근 검색어 내역을 반영하기 위해
      historyList,
      submitted: true,
    });
  }


바닐라 JS vs React

  • 바닐라 JS
    MVC모델에서 ModelView는 서로 독립적으로 각자의 역할을 수행한다. Model은 모든 데이터를 관리하고 View는 DOM과 밀접하게 움직이면서 화면을 그린다. Controller는 이 둘을 관리하면서 어플리케이션 상태와 이에 따를 UI를 수시로 변화시킨다.
  • React
    Model은 스토리지만 갖고 있고 다른 어플리케이션 상태는 컴포넌트로 위임한다. 컴포넌트의 state로 관리되는 상태는 render() 함수가 반환하는 리액트 엘리먼트와 유기적으로 연결되어 화면에 출력된다. 즉 state의 변화가 UI까지 자동으로 영향을 끼치는 것이다.

    어플리케이션 상태가 변할 때마다 수시로 돔을 조작했던 View와 달리 리액트는 DOM 앞단에 가상돔 계층을 추가해 가상돔을 수정하게끔 한다. 수시로 변경 요청을 받은 가상돔은 최소한의 DOM API만을 사용해 화면을 효율적으로 렌더링한다.

=> 리액트를 사용해 리액티브 프로그래밍과 가상돔을 이용한 성능 개선을 할 수 있었다.


해당 github 링크

좋은 웹페이지 즐겨찾기