TIL - React로 유튜브 클로닝하기

김수지·2019년 12월 14일
2

TILs

목록 보기
18/39
post-custom-banner

Today What I Learned

Javascript를 배우고 있습니다. 매일 배운 것을 이해한만큼 정리해봅니다.


1. Youtube Player Cloning by React

1. 지난 3일 리뷰

  • 지난주는 그래도 간단한 서버를 만들어 보면서 매일 진척사항을 적어 내려갔는데 이번주는 감기 때문에 매일 기록을 못 남겼다. 3일 간 작업 내용을 모아서 리뷰한다.
  • 이번주는 React를 익혔고, 바로 리액트를 이용해 유튜브 플레이어 클로닝 작업을 진행했다.
  • 검색어 기준으로 유튜브 영상들을 api로 받아와서 실행하거나 플레이어 리스트에 넣어두고, 나중에 보기 기능까지 구현하는 것이 목표였다.

2. 중점적으로 익힌 내용

  • 리액트로 클라이언트를 구성하려면 가장 먼저 컴포넌트 구조를 구상하고, 각 컴포넌트의 스트림을 만드는 것이 가장 중요하다. 리액트는 단방향 데이터 흐름을 가지고 있기 때문에 이 특징을 유념하면서 "데이터가 흘러갈 길"을 만드는 것이 중요하다.
  • props는 리액트에서 컴포넌트가 가지는 데이터를 말하는데 위에서 만들어 놓은 "데이터의 길"을 이용해서 데이터를 활용해야 하는 하위 컴포넌트까지 내려줘야 한다. HTML에서 엘리먼트에 속성을 지정해주는 방식(HTML 엘리먼트의 id, class처럼)으로 하위 컴포넌트에 props를 전달할 수 있다.
  • 동적인 페이지를 구현하는 것이기 때문에 변동되는 데이터의 부분도 있는데 이러한 케이스를 state에 담아 관리하고 페이지 내 이벤트 핸들링을 대응해야 한다. state와 이를 변경하는 이벤트 핸들러 함수의 경우에는 같은 상위 컴포넌트에 있으면서 하위 컴포넌트에서 핸들러와 state를 props로 받아 state를 변경해야 하는 경우에는 "state 끌어올리기"를 구현한다.

3. 고전했던 부분

  • 컴포넌트, 프롭스, 스테이트의 개념은 알겠는데 코드를 짜면서 이벤트 핸들러 함수의 적절한 위치는 어디이고 state와 어떻게 연결할 것인지를 가장 많이 고민했던 것 같다.
  • 리액트에서는 DOM을 직접 건드리는 행위를 지양해야 하는데(가상 DOM을 이용해 기존의 내용과 비교하여 변경된 사항만 반영하여 준다는 리액트의 장점을 살리기 위해), 그렇다면 하위 컴포넌트에서의 데이터 입력 상황에서 유효한 데이터를 어디에서 관리할 것인지도 고민이었다. 이 문제는 상위 컴포넌트에서 state로 데이터를 넘겨주는 이벤트 핸들러를 구현하는 방법으로 해결했다.

4. bundler, 비동기 처리, this binding

  • bundler: 의존성을 가지는 js의 여러 파일들을 하나로 묶어주는 기능을 한다. 번들러를 사용하면 사용자(클라이언트)와의 요청/응답 시에 속도가 높아진다
  • 비동기 처리: 자바스크립트는 외부 데이터를 비동기로 받는다. 외부 데이터를 가지고 오는 동안에도 클라이언트의 다른 부분들은 정상적으로 작동해야 하기 때문이다. 비동기 처리를 하는 방법은 크게 3가지가 있다.
    - callback 함수 사용: setTimeout이나 setInterval 안에서 비동기로 받아온 값을 callback 함수에 전달인자로 넘겨서 실행하는 방법
    - promise 사용: promise라는 형태로 값을 받아온 후 .then의 형식으로 함수를 순차적으로 진행하는 방법
    - async & await 사용: 비동기적으로 실행할 함수 앞에 async를 붙이고 함수 내에 비동기로 받아온 값을 await 모드로 가지고 있는다. 그런 다음 추가로 실행해야 하는 내용을 함수에 작성한다.
    이 내용은 노마드코더에서 굉장히 잘 설명해주고 있다..
    리액트 기초 배우기 #16 ReactJS로 웹 서비스 만들기 (React JS Fundamentals #16: Async Await) - https://youtu.be/ckJuFBRWGNY
  • this binding in React : 클래스형 컴포넌트에서 method를 만든 후 이벤트로 인해 해당 method가 실행되면 해당 method가 생성된 class를 기억해주기 위해 this binding이 필요하다. this binding의 대안으로는 arrow function이 있지만 퍼포먼스 상의 이슈가 있다고 한다.

5. 페이지 로딩 시 리액트 라이프사이클

  • 만약 리액트의 method들이 대화를 할 수 있다면 이런 식일 것이다!
    1. constructor
      1-1. state는 여기에 둘게 끝.
    2. Render : state에 인포메이션 있어?
      2-1. 아 뭐 있구나, 그럼 바로 state에 있는 정보 가져갈게. component에 뿌리는 함수(4) 실행시킬게 끝.
      2-2. 뭐 없네? 일단 loading 페이지 render 시킬게 끝.
    3. componentDidMount: render 끝나면 그럼 새롭게 fetch해서 인포메이션에 넣어줄게(5). 끝.
    4. 인포메이션을 component에 뿌리기: state 가져와서 map을 이용해서 component에 뿌려줄게 끝.
    5. 새롭게 fetch해서 인포메이션에 넣기: 나는 순서가 중요한 함수야(async)
      5-1. 새롭게 정보가 필요해? 그럼 fetch하는 함수 불러서 값 가져올게(6) 니가 완료되어야 그 값을 가지고(await) 다음 내용을 실행할 수 있어.
      5-2. 그리고 그 값을 setState 해서 state에 넣어 주겠어(7) 끝.
    6. fetchApi: query나 상세 요청 내용 주면 fetch api로 response 가져다 줄게. 혹시 에러나면 에러 알려줄게. 끝.
    7. response로 정보 받아서 setState하기 : response 전달해주면 setState(8) 할게. 끝.
    8. setState: state가 변경되었어? 그럼 비교해보고 다시 render(2)해야겠다. 끝.

2. Code Review

1. 예시 화면, 구조

  • 구현해야 할 그림은 아래와 같았다.image.png
  • 그리고 내가 만든 구조는 이렇다.image.png

2. 디바운싱

  • 얼추 컴포넌트를 다 실행하고 나서 마주한 문제가 있었는데 검색어 입력에 따른 실시간 검색을 구현하고자 하니 모든 글자마다 fetchAPI를 call하는 것이었다. 대부분의 api는 공짜가 아니니 이런 불필요한 call을 줄여야 했다.
  • 처음에는 setTimeOut을 걸어봤으나 단지 fetch 시간만 지연시킬 뿐 문제는 동일했다. 그 다음 시도한 방식은 underscore에 구현되어 있는 debounce method를 적용하는 것이었다. every second fetch -> 2초를 기다렸다가 fetch 하는 방식으로 변경하였다.

3. 코드와 완료 화면

  • App.js: container 격인 app.js 코드만 우선..
  • 전체 구조와 기능은 이렇다.
    • App > Nav > Search 구조로 흐르는 실시간 검색, fetch API 기능과
    • App > VideoPlayer 구조로 영상 재생 기능
    • App > VideoList > VideoListEntry 구조로 흐르는 비디오 리스팅 기능
    • App > WatchLaterList > WatchLaterListEntry 구조로 흐르는 나중에 보기 기능
class App extends React.Component {
  constructor() {
    super();
    this.state = JSON.parse(localStorage.getItem("state"));
    this.titleClickHandle = this.titleClickHandle.bind(this);
    this.inputChangeHandle = this.inputChangeHandle.bind(this);
    this.searchClickHandle = _.debounce(this.searchClickHandle.bind(this), 2000);
    this.laterClickHandle = this.laterClickHandle.bind(this);
    this.closeClickHandle = this.closeClickHandle.bind(this);
  }

  componentDidMount() {
    const defaultInfo = {
      query: "여락이들",
      max: 5,
      key: YOUTUBE_API_KEY
    };
    searchYouTube(defaultInfo, items => {
      this.setState({
        videos: items,
        currentVideo: items[0]
      });
    });
  }

  // 비디오 제목 클릭 시
  titleClickHandle() {
    const clickedTitle = event.target.textContent;
    let chosenVideo;
    for (let video of this.state.videos) {
      if (video.snippet.title === clickedTitle) {
        chosenVideo = video;
      }
    }
    this.setState({
      currentVideo: chosenVideo
    });
  }

  // watch later 버튼 클릭 시
  laterClickHandle() {
    const clickedVidId = event.target.id;
    let chosenVideo;
    for (let video of this.state.videos) {
      if (video.id.videoId === clickedVidId) {
        chosenVideo = video;
      }
    }

    if (this.state.laterVideos.includes(chosenVideo)) {
      alert("you ALREADY have the video in the Watch Later List!");
    } else {
      this.setState({ laterVideos: [...this.state.laterVideos, chosenVideo] });
    }
  }

  // close 버튼 클릭 시
  closeClickHandle() {
    const clickedVidId = event.target.id;
    let chosenVideo;
    for (let video of this.state.laterVideos) {
      if (video.id.videoId === clickedVidId) {
        chosenVideo = video;
      }
    }
    this.setState({
      laterVideos: this.state.laterVideos.filter(video => {
        return video !== chosenVideo;
      })
    });
  }

  // 검색어 입력 시 - 실시간 검색
  inputChangeHandle() {
    const inputVal = event.target.value;
    this.setState({ currentInput: inputVal });
    this.searchClickHandle();
  }

  // 검색버튼 클릭
  searchClickHandle() {
    if (this.state.currentInput.includes('"')) {
      return null;
    }
    const searchInfo = {
      query: this.state.currentInput,
      max: 5,
      key: YOUTUBE_API_KEY
    };
    searchYouTube(searchInfo, items => {
      if (items.length !== 0) {
        this.setState({
          videos: items,
          currentVideo: items[0]
        });
      }
    });
  }

  render() {
    if (!this.state.currentVideo) {
      console.log("loading");
      return <div>Loading</div>;
    }
    localStorage.setItem("state", JSON.stringify(this.state));
    return (
      <div>
        <Nav
          inputChangeHandle={this.inputChangeHandle}
          searchClickHandle={this.searchClickHandle}
        />
        <div id="contents">
          <div id="video-area">
            <div className="col-md-7">
              <div>
                <VideoPlayer
                  video={this.state.currentVideo}
                  laterClickHandle={this.laterClickHandle}
                />
              </div>
            </div>

            <div className="col-md-5 overflow">
              <div>
                <VideoList
                  videos={this.state.videos}
                  titleClickHandle={this.titleClickHandle}
                  laterClickHandle={this.laterClickHandle}
                />
              </div>
            </div>
          </div>
          <div>
            <div className="watch-later-list">
              <div className="watch-later-list-title">Watch Later</div>
              <WatchLaterList
                laterVideos={this.state.laterVideos}
                titleClickHandle={this.titleClickHandle}
                closeClickHandle={this.closeClickHandle}
              />
            </div>
          </div>
        </div>
      </div>
    );
  }
}

4. 완성한 클라이언트 화면

image.pngimage.png

profile
선한 변화와 사회적 가치를 만들고 싶은 체인지 메이커+개발자입니다.
post-custom-banner

0개의 댓글