Typescript Start Kit_Hacker News 마이그레이션

Yudrey·2022년 5월 18일
0

패스트캠퍼스 강의를 정리한 내용입니다.
"김민태의 프론트엔드 아카데미 : 제 1강 JavaScript & TypeScript Essential"


마이그레이션(migration)
데이터나 소프트웨어를 한 시스템에서 다른 시스템으로 이동하는 것

타입스크립트를 위한 환경 설정

*앞의 강의 예제를 그대로 활용
1) 스크립트 파일로 파일 변환 : app.js → app.ts
2) 타입스크립트 관련 설정 파일 추가 : tsconfig.json
→ 타입스크립트는 트랜스파일러여서 브라우저에서 실행하려면 자바스크립트로 변환을 해야하는데 이때 여러가지 옵션 추가 가능

//tsconfig.json
{
  "compilerOptions": {
    "strict": true,	// 타입스크립트로 변환 시 엄격성
    "target": "ES5", // 컴파일 된 자바스크립트에 사용될 문법 체계
    "module": "CommonJS",
    "alwaysStrict": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "sourceMap": true, // ~.js.map 파일 생성 여부
    "downlevelIteration": true
  }
}

타입스크립트 컴파일 옵션 문서 :
https://www.typescriptlang.org/tsconfig

3) 터미널에 명령어 입력 : parcel index.html
parcel로 실행 시 node_modules, dist, .cache 디렉토리가 생성되며, 이러한 디렉토리와 파일들은 parcel.js가 타입스크립트를 이용해서 앱을 만드는데 필요한 어플리케이션을 자동으로 명령하고 다운로드하여 설정을 끝내놓은 상태라고 볼 수 있음

  • dist 디렉토리: 타입스크립트 컴파일러가 타입스크립트 파일을 변환한 결과를 출력해놓은 타겟 디렉토리
    • ~.js
    • ~.js.map : sourceMap이라는 파일
      → 컴파일 되기 전 원본 파일(타입스크립트)
    • index.html

변수에 타입 작성하기

타입스크립트의 명시적인 타입 지원 기능을 이용한 타입 지정

Javascript Type

Primitive Type

  • 원시 타입
  • Number, String, Null, Undefined, Boolean, Symbol
  • 할당 시 변수에는 실제 값(value)이 저장됨
  • 다른 변수에 할당 시 '값'이 복사되어 전달됨 (pass by value)
  • 변경 불가능한 값으로, 수정 시 새로운 주소에 값이 할당됨

Object Type

  • 객체 타입
  • 할당 시 변수에는 메모리 주소(참조값)가 저장됨
  • 다른 변수에 할당 시 '참조'값이 할당되어 같은 객체를 공유함
  • 수정 가능하므로 property를 동적으로 추가하 또는 삭제 가능

VSCode로 타입 작성

VSCode에서는 특정 부분에 마우스오버 시 타입 관련 정보를 제공함

→인자로 elementId를 받으며 elementId의 타입은 string
→함수 인자 뒤의 콜론(:)은 함수의 반환값 타입을 의미함. 즉 HTMLElement 또는 null 타입

Type Aliases (타입 별칭)

특정 타입이나 인터페이스를 참조할 수 있는 타입 변수를 의미

//예시
// string 타입을 사용할 때
const name: string = 'Yudery';

// 타입 별칭을 사용할 때
type MyName = string;
const name: MyName = 'Yudery';

// interface 레벨의 복잡한 타입에도 별칭 부여 가능
type Developer = {
  name: string;
  skill: string;
}

Type Aliases vs interface

타입 별칭과 인터페이스의 가장 큰 차이점은 타입의 확장 가능 / 불가능 여부
인터페이스는 확장이 가능한데 반해 타입 별칭은 확장이 불가능
따라서, 가능한 type 보다는 interface로 선언해서 사용하는 것을 추천

Object is possibly 'null'
→null이 아닌 경우에만 코드가 동작하도록 수정하라는 경고
→어떤 유형의 값이 두 가지가 들어오는 케이스(그 둘 중 하나는 null)에서 null을 체크하라는 유형의 경고 코드를 '타입 가드'라고 표현함
→아래 예제에서는 updateView() 함수로 해당 경고 코드 대응

//변수로 타입 작성한 예제
<script>
type Store = {
  currentPage: number;
  feeds: NewsFeed[]; //명확히 어떤 타입의 데이터가 들어갈지 명시
}

type NewsFeed = {
  id: number;
  comments_count: number;
  url: string;
  user: string;
  time_ago: string;
  points: number;
  title: string;
  read?: boolean; //콜론(:)과 속성명 사이의 물음표(?)는 데이터가 있을 수도 있고 없을 수도 있는 '선택 속성'을 의미
}

const container: HTMLElement | null = document.getElementById('root');
const ajax: XMLHttpRequest = new XMLHttpRequest();
const content = document.createElement('div');
const NEWS_URL = 'https://api.hnpwa.com/v0/news/1.json';
const CONTENT_URL = 'https://api.hnpwa.com/v0/item/@id.json';
const store: Store = {
  currentPage: 1,
  feeds: [],
};


function getData(url) {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

function makeFeeds(feeds) {
  for (let i = 0; i < feeds.length; i++) {
    feeds[i].read = false;
  }

  return feeds;
}

function updateView(html) {
  if (container != null) {
    container.innerHTML = html;
  } else {
    console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
  }
}

function newsFeed() {
  let newsFeed: NewsFeed[] = store.feeds;
  const newsList = [];
  let template = `
    <div class="bg-gray-600 min-h-screen">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
          <div class="flex justify-start">
            <h1 class="font-extrabold">Hacker News</h1>
          </div>
          <div class="itmes-center justify-end">
            <a href="#/page/{{__prev_page__}}">Prev</a>
            <a href="#/page/{{__next_page__}}">Next</a>
            </div>
          </div>
        </div>
      </div>
      <div class="p-4 text-2xl text-gray-700">
        {{__news_feed__}}
      </div>
    </div>
  `;

  if (newsFeed.length === 0) {
    newsFeed = store.feeds = makeFeeds(getData(NEWS_URL));
  }

  for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
    newsList.push(`
      <div class="p-6 ${
        newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
      } mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
        <div class="flex">
          <div class="flex-auto">
            <a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
          </div>
          <div class="text-center text-sm">
            <div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
          </div>
        </div>
        <div class="flex mt-3">
          <div class="grid grid-cols-3 text-sm text-gray-500">
            <div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
            <div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
            <div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
          </div>
        </div>
      </div>
    `);
  }

  template = template.replace('{{__news_feed__}}', newsList.join(''));
  template = template.replace('{{__prev_page__}}', store.currentPage > 1 ? store.currentPage - 1 : 1);
  template = template.replace(
    '{{__next_page__}}',
    store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
  );

  updateView(template);
}

const ul = document.createElement('ul');

function newsDetail() {
  const id = location.hash.substr(7);
  const newsContent = getData(CONTENT_URL.replace('@id', id));

  let template = `
    <div class="bg-gray-600 min-h-screen pb-8">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
            <div class="flex justify-start">
              <h1 class="font-extrabold">Hacher News</h1>
            </div>
            <div class="items-center justify-end">
            <a href="#/page/${store.currentPage}" class="text-gray-500">
              <i class="fa fa-times"></i>
            </a>
            </div>
          </div>
        </div>
      </div>
      <div class="h-full border rounded-xl bg-white m-6 p-4">
        <h2>${newsContent.title}</h2>
        <div class="text-gray-400 h-20">
          ${newsContent.content}
        </div>

        {{__comments__}}
      </div>
    </div>
  `;

  for (let i = 0; i < store.feeds.length; i++) {
    if (store.feeds[i].id === Number(id)) {
      store.feeds[i].read = true;
      break;
    }
  }

  function makeComment(comments, called = 0) {
    const commentString = [];

    for (let i = 0; i < comments.length; i++) {
      commentString.push(`
        <div style="padding-left: ${called * 40}px;" class="mt-4">
          <div class="text-gray-400">
            <i class="fa fa-sort-up mr-2"></i>
            <strong>${comments[i].user}</strong>${comments[i].time_ago}
          </div>
          <p class="text-gray-700">${comments[i].content}</p>
        </div>
      `);

      if (comments[i].comments.length > 0) {
        //재귀호출
        commentString.push(makeComment(comments[i].comments, called + 1));
      }
    }

    return commentString.join('');
  }

  
  updateView(template.replace('{{__comments__}}', makeComment(newsContent.comments)));
}

function router() {
  const routePath = location.hash;

  //location hash에 #만 들어있는 경우에는 빈값을 반환
  if (routePath === '') {
    newsFeed();
  } else if (routePath.indexOf('#/page/') >= 0) {
    store.currentPage = Number(routePath.substr(7));
    newsFeed();
  } else {
    newsDetail();
  }
}

window.addEventListener('hashchange', router);

router();

</script>

참고 : https://blog.kangho.me/js-primitive/


함수의 규격 작성하기

VSCode 확장프로그램: REST client
API 호출을 브라우저 개발툴로 보지 않고 에디터에서 볼 수 있도록 기능 제공
에디터에서 JSON 결과물 확인 가능

REST client 사용 방법
1) 확장프로그램 설치
2) ~.http 파일 생성
3) ~.http 파일에 ### 입력 후 줄바꿈
4) 호출할 API 주소 작성
5) HTTP 헤더 필드 작성 (예: HTTP/1.1)
6) 작성한 API 주소 좌측 상단의 'Sent Request' 클릭
7) 우측에 Response 화면 생성되어 호출 결과값 확인 가능

Intersection Type

AND(&) 연산자를 사용하며 'A이면서 B이다' 라는 의미의 타입

Generics

입력이 n개의 유형일 때, 출력도 n개의 유형인 것을 정의하는 것
입력이 ABCD 유형 중에 A가 들어오면 출력도 A로, B로 들어오면 출력도 B..로 나가는 것
호출하는 쪽에서 유형을 명시해주면 그 유형을 그대로 받아서 반환 유형으로 사용하는 것

<script>
// 제네릭 사용 전
// getData가 리턴하는 타입은 NewsFeed, NewsDetail 두 가지인데, 호출 함수에서 정확히 어떤 타입을 리턴하는지 명확하지 않음
// 타입 가드 코드로 처리 시 getData가 처리하는 API가 많을 경우 코드가 너무 길어지므로 제네릭 사용하여 해결
function getData(url: string): NewsFeed[] | NewsDetail {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

function newsFeed() {
	...
  if (newsFeed.length === 0) {
    newsFeed = store.feeds = makeFeeds(getData(NEWS_URL));
  }
  ...
}

// 제네릭 사용 후
function getData<T>(url: string): T {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

function newsFeed() {
	...
  if (newsFeed.length === 0) {
     newsFeed = store.feeds = makeFeeds(getData<NewsFeed[]>(NEWS_URL));
  }
  ...
}
</script>

자바스크립트를 타입스크립트 코드로 포팅 완료한 코드

//app.js
<script>
type Store = {
  currentPage: number;
  feeds: NewsFeed[]; //명확히 어떤 타입의 데이터가 들어갈지 명시
}

type News = {
  id: number;
  time_ago: string;
  title: string;
  url: string;
  user: string;
  content: string;
}

// Intersection type
type NewsFeed = News & {
  comments_count: number;
  points: number;
  read?: boolean; //콜론(:)과 속성명 사이의 물음표(?)는 데이터가 있을 수도 있고 없을 수도 있는 '선택 속성'을 의미
}

type NewsDetail = News & {
  comments: NewsComment[];
}

type NewsComment = News & {
  comments: NewsComment[];
  level: number;
}

const container: HTMLElement | null = document.getElementById('root');
const ajax: XMLHttpRequest = new XMLHttpRequest();
const content = document.createElement('div');
const NEWS_URL = 'https://api.hnpwa.com/v0/news/1.json';
const CONTENT_URL = 'https://api.hnpwa.com/v0/item/@id.json';
const store: Store = {
  currentPage: 1,
  feeds: [],
};


function getData<AjaxResponse>(url: string): AjaxResponse {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

function makeFeeds(feeds: NewsFeed[]): NewsFeed[] {
  for (let i = 0; i < feeds.length; i++) {
    feeds[i].read = false;
  }

  return feeds;
}

function updateView(html: string): void {
  if (container != null) {
    container.innerHTML = html;
  } else {
    console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
  }
}

function newsFeed(): void {
  let newsFeed: NewsFeed[] = store.feeds;
  const newsList = [];
  let template = `
    <div class="bg-gray-600 min-h-screen">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
          <div class="flex justify-start">
            <h1 class="font-extrabold">Hacker News</h1>
          </div>
          <div class="itmes-center justify-end">
            <a href="#/page/{{__prev_page__}}">Prev</a>
            <a href="#/page/{{__next_page__}}">Next</a>
            </div>
          </div>
        </div>
      </div>
      <div class="p-4 text-2xl text-gray-700">
        {{__news_feed__}}
      </div>
    </div>
  `;

  if (newsFeed.length === 0) {
    newsFeed = store.feeds = makeFeeds(getData<NewsFeed[]>(NEWS_URL));
  }

  for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
    newsList.push(`
      <div class="p-6 ${
        newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
      } mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
        <div class="flex">
          <div class="flex-auto">
            <a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
          </div>
          <div class="text-center text-sm">
            <div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
          </div>
        </div>
        <div class="flex mt-3">
          <div class="grid grid-cols-3 text-sm text-gray-500">
            <div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
            <div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
            <div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
          </div>
        </div>
      </div>
    `);
  }

  template = template.replace('{{__news_feed__}}', newsList.join(''));
  template = template.replace('{{__prev_page__}}', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
  template = template.replace(
    '{{__next_page__}}',
    String(store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
  ));

  updateView(template);
}

const ul = document.createElement('ul');

function newsDetail(): void {
  const id = location.hash.substr(7);
  const newsContent = getData<NewsDetail>(CONTENT_URL.replace('@id', id));

  let template = `
    <div class="bg-gray-600 min-h-screen pb-8">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
            <div class="flex justify-start">
              <h1 class="font-extrabold">Hacher News</h1>
            </div>
            <div class="items-center justify-end">
            <a href="#/page/${store.currentPage}" class="text-gray-500">
              <i class="fa fa-times"></i>
            </a>
            </div>
          </div>
        </div>
      </div>
      <div class="h-full border rounded-xl bg-white m-6 p-4">
        <h2>${newsContent.title}</h2>
        <div class="text-gray-400 h-20">
          ${newsContent.content}
        </div>

        {{__comments__}}
      </div>
    </div>
  `;

  for (let i = 0; i < store.feeds.length; i++) {
    if (store.feeds[i].id === Number(id)) {
      store.feeds[i].read = true;
      break;
    }
  }

  updateView(template.replace('{{__comments__}}', makeComment(newsContent.comments)));
}

function makeComment(comments: NewsComment[]): string {
  const commentString = [];

  for (let i = 0; i < comments.length; i++) {
    const comment: NewsComment = comments[i];
    commentString.push(`
      <div style="padding-left: ${comment.level * 40}px;" class="mt-4">
        <div class="text-gray-400">
          <i class="fa fa-sort-up mr-2"></i>
          <strong>${comment.user}</strong>${comment.time_ago}
        </div>
        <p class="text-gray-700">${comment.content}</p>
      </div>
    `);

    if (comment.comments.length > 0) {
      //재귀호출
      commentString.push(makeComment(comment.comments));
    }
  }

  return commentString.join('');
}

function router(): void {
  const routePath = location.hash;

  //location hash에 #만 들어있는 경우에는 빈값을 반환
  if (routePath === '') {
    newsFeed();
  } else if (routePath.indexOf('#/page/') >= 0) {
    store.currentPage = Number(routePath.substr(7));
    newsFeed();
  } else {
    newsDetail();
  }
}

window.addEventListener('hashchange', router);

router();

</script>

타입과 인터페이스

Type Aliases가 아닌 interface 기능을 활용해서 타이핑하기

// Type Aliases
Type Store = {
	currentPage: number;
    feeds: NewsFeed[];
}

// interface
interface Store {
	currentPage: number;
    feeds: NewsFeed[];
}

Type Aliases와 interface는 타입을 결합하는 방식에서 차이가 큼
interface는 유니언타입이나 인터섹션을 지원하지 않음

// Type Aliases
type News = {
  id: number;
  time_ago: string;
  title: string;
  url: string;
  user: string;
  content: string;
}
type NewsFeed = News & {
  comments_count: number;
  points: number;
  read?: boolean;
}

// interface
interface News {
  id: number;
  time_ago: string;
  title: string;
  url: string;
  user: string;
  content: string;
}

interface NewsFeed extends News {
  comments_count: number;
  points: number;
  read?: boolean;
}

지시어 readonly

  • 값의 속성을 읽기 전용으로 설정하는 기능
  • 함수의 매개변수로 받는 값을 변경하지 않고 그대로 사용해야할 때 활용 가능
  • 값이 수정되면서 발생할 수 있는 에러를 방지하는 효과
  • Type Aliases와 interface에서 모두 사용 가능
<script>
interface News {
  readonly id: number;
  readonly time_ago: string;
  readonly title: string;
  readonly url: string;
  readonly user: string;
  readonly content: string;
}
</script>

상속과 믹스인

상속을 다루는 메커니즘 두 가지: 클래스 사용, 믹스인 사용

class

  • class는 최초의 초기화 과정이 필요하고 초기화 과정을 처리하는 함수가 바로 생성자
  • class는 항상 class 인스턴스를 만들어줘야 함
  • protected: 클래스의 속성과 메소드를 외부로 노출시키지 않는 지시어

class 사용의 단점

  • class extend는 코드에 적시되어야 하는 상속 방법으로, 상속의 관계를 바꾸고 싶으면 코드 자체를 바꿔야하므로 유연성이 떨어짐
  • class extend는 다중 상속을 지원하지 않음
<script>
...
class Api {
  url: string;
  ajax: XMLHttpRequest;

  // 초기화 과정을 처리하는 생성자
  constructor(url: string) {
    this.url = url;
    this.ajax = new XMLHttpRequest();;
  }

  protected getRequest<AjaxResponse>(): AjaxResponse {
    this.ajax.open('GET', this.url, false);
    this.ajax.send();

    return JSON.parse(this.ajax.response)
  }
}

class NewsFeedApi extends Api {
  getData(): NewsFeed[] {
    return this.getRequest<NewsFeed[]>();
  }
}

class NewsDetailApi extends Api {
  getData(): NewsDetail[] {
    return this.getRequest<NewsDetail[]>();
  }
}


function getData<AjaxResponse>(url: string): AjaxResponse {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

function makeFeeds(feeds: NewsFeed[]): NewsFeed[] {
  for (let i = 0; i < feeds.length; i++) {
    feeds[i].read = false;
  }

  return feeds;
}

function updateView(html: string): void {
  if (container != null) {
    container.innerHTML = html;
  } else {
    console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
  }
}

function newsFeed(): void {
  const api = new NewsFeedApi(NEWS_URL);
  let newsFeed: NewsFeed[] = store.feeds;
  const newsList = [];
  let template = `
    <div class="bg-gray-600 min-h-screen">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
          <div class="flex justify-start">
            <h1 class="font-extrabold">Hacker News</h1>
          </div>
          <div class="itmes-center justify-end">
            <a href="#/page/{{__prev_page__}}">Prev</a>
            <a href="#/page/{{__next_page__}}">Next</a>
            </div>
          </div>
        </div>
      </div>
      <div class="p-4 text-2xl text-gray-700">
        {{__news_feed__}}
      </div>
    </div>
  `;

  if (newsFeed.length === 0) {
    newsFeed = store.feeds = makeFeeds(api.getData());
  }

  for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
    newsList.push(`
      <div class="p-6 ${
        newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
      } mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
        <div class="flex">
          <div class="flex-auto">
            <a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
          </div>
          <div class="text-center text-sm">
            <div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
          </div>
        </div>
        <div class="flex mt-3">
          <div class="grid grid-cols-3 text-sm text-gray-500">
            <div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
            <div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
            <div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
          </div>
        </div>
      </div>
    `);
  }

  template = template.replace('{{__news_feed__}}', newsList.join(''));
  template = template.replace('{{__prev_page__}}', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
  template = template.replace(
    '{{__next_page__}}',
    String(store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
  ));

  updateView(template);
}

const ul = document.createElement('ul');

function newsDetail(): void {
  const id = location.hash.substr(7);
  const api = new NewsDetailApi(CONTENT_URL.replace('@id', id));
  const newsContent = api.getData();

  let template = `
    <div class="bg-gray-600 min-h-screen pb-8">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
            <div class="flex justify-start">
              <h1 class="font-extrabold">Hacher News</h1>
            </div>
            <div class="items-center justify-end">
            <a href="#/page/${store.currentPage}" class="text-gray-500">
              <i class="fa fa-times"></i>
            </a>
            </div>
          </div>
        </div>
      </div>
      <div class="h-full border rounded-xl bg-white m-6 p-4">
        <h2>${newsContent.title}</h2>
        <div class="text-gray-400 h-20">
          ${newsContent.content}
        </div>

        {{__comments__}}
      </div>
    </div>
  `;

  for (let i = 0; i < store.feeds.length; i++) {
    if (store.feeds[i].id === Number(id)) {
      store.feeds[i].read = true;
      break;
    }
  }

  updateView(template.replace('{{__comments__}}', makeComment(newsContent.comments)));
}
...
</script>

mixin

class를 마치 함수처럼 혹은 단독의 객체처럼 바라보면서 필요할 때마다 class를 합성해서 새로운 기능으로 확장해나가는 기법

<script>
function applyApiMixins(targetClass: any, baseClasses: any[]): void{
  baseClasses.forEach(baseClass => {
    Object.getOwnPropertyNames(baseClass.prototype).forEach(name => {
      const descriptor = Object.getOwnPropertyDescriptor(baseClass.prototype, name);

      if (descriptor) {
        Object.defineProperty(targetClass.prototype, name, descriptor);
      }
    });
  });
}

class Api {
  getRequest<AjaxResponse>(url: string): AjaxResponse {
    ajax.open('GET', url, false);
    ajax.send();

    return JSON.parse(ajax.response)
  }
}

class NewsFeedApi {
  getData(): NewsFeed[] {
    return this.getRequest<NewsFeed[]>(NEWS_URL);
  }
}

class NewsDetailApi {
  getData(id: string): NewsDetail {
    return this.getRequest<NewsDetail>(CONTENT_URL.replace('@id', id));
  }
}


function getData<AjaxResponse>(url: string): AjaxResponse {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

interface NewsFeedApi extends Api { };
interface NewsDetailApi extends Api { };

applyApiMixins(NewsFeedApi, [Api]);
applyApiMixins(NewsDetailApi, [Api]);

function makeFeeds(feeds: NewsFeed[]): NewsFeed[] {
  for (let i = 0; i < feeds.length; i++) {
    feeds[i].read = false;
  }

  return feeds;
}

function updateView(html: string): void {
  if (container != null) {
    container.innerHTML = html;
  } else {
    console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
  }
}

function newsFeed(): void {
  const api = new NewsFeedApi();
  let newsFeed: NewsFeed[] = store.feeds;
  const newsList = [];
  let template = `
    <div class="bg-gray-600 min-h-screen">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
          <div class="flex justify-start">
            <h1 class="font-extrabold">Hacker News</h1>
          </div>
          <div class="itmes-center justify-end">
            <a href="#/page/{{__prev_page__}}">Prev</a>
            <a href="#/page/{{__next_page__}}">Next</a>
            </div>
          </div>
        </div>
      </div>
      <div class="p-4 text-2xl text-gray-700">
        {{__news_feed__}}
      </div>
    </div>
  `;

  if (newsFeed.length === 0) {
    newsFeed = store.feeds = makeFeeds(api.getData());
  }

  for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
    newsList.push(`
      <div class="p-6 ${
        newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
      } mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
        <div class="flex">
          <div class="flex-auto">
            <a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
          </div>
          <div class="text-center text-sm">
            <div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
          </div>
        </div>
        <div class="flex mt-3">
          <div class="grid grid-cols-3 text-sm text-gray-500">
            <div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
            <div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
            <div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
          </div>
        </div>
      </div>
    `);
  }

  template = template.replace('{{__news_feed__}}', newsList.join(''));
  template = template.replace('{{__prev_page__}}', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
  template = template.replace(
    '{{__next_page__}}',
    String(store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
  ));

  updateView(template);
}

const ul = document.createElement('ul');

function newsDetail(): void {
  const id = location.hash.substr(7);
  const api = new NewsDetailApi();
  const newsDetail: NewsDetail = api.getData(id);

  let template = `
    <div class="bg-gray-600 min-h-screen pb-8">
      <div class="bg-white text-xl">
        <div class="mx-auto px-4">
          <div class="flex justify-between items-center py-6">
            <div class="flex justify-start">
              <h1 class="font-extrabold">Hacher News</h1>
            </div>
            <div class="items-center justify-end">
            <a href="#/page/${store.currentPage}" class="text-gray-500">
              <i class="fa fa-times"></i>
            </a>
            </div>
          </div>
        </div>
      </div>
      <div class="h-full border rounded-xl bg-white m-6 p-4">
        <h2>${newsDetail.title}</h2>
        <div class="text-gray-400 h-20">
          ${newsDetail.content}
        </div>

        {{__comments__}}
      </div>
    </div>
  `;

  for (let i = 0; i < store.feeds.length; i++) {
    if (store.feeds[i].id === Number(id)) {
      store.feeds[i].read = true;
      break;
    }
  }

  updateView(template.replace('{{__comments__}}', makeComment(newsDetail.comments)));
}
</script>

뷰 클래스로 코드 구조 개선

class는 인스턴스를 만들고 시작하므로, 일반 함수 호출과 구분하기 위해 첫문자를 대문자로 작성

상위 클래스로부터 extends 받으면, 반드시 상위 클래스의 생성자를 명시적으로 호출해줘야 함
super 키워드: 부모 오브젝트의 함수를 호출할 때 사용

<script>
interface Store {
  currentPage: number;
  feeds: NewsFeed[]; //명확히 어떤 타입의 데이터가 들어갈지 명시
}

interface News {
  readonly id: number;
  readonly time_ago: string;
  readonly title: string;
  readonly url: string;
  readonly user: string;
  readonly content: string;
}

// Intersection type
interface NewsFeed extends News {
  readonly comments_count: number;
  readonly points: number;
  read?: boolean; //콜론(:)과 속성명 사이의 물음표(?)는 데이터가 있을 수도 있고 없을 수도 있는 '선택 속성'을 의미
}

interface NewsDetail extends News {
  readonly comments: NewsComment[];
}

interface NewsComment extends News {
  readonly comments: NewsComment[];
  readonly level: number;
}

interface RouteInfo {
  path: string;
  page: View;
}

const ajax: XMLHttpRequest = new XMLHttpRequest();
const content = document.createElement('div');
const NEWS_URL = 'https://api.hnpwa.com/v0/news/1.json';
const CONTENT_URL = 'https://api.hnpwa.com/v0/item/@id.json';
const store: Store = {
  currentPage: 1,
  feeds: [],
};

function applyApiMixins(targetClass: any, baseClasses: any[]): void{
  baseClasses.forEach(baseClass => {
    Object.getOwnPropertyNames(baseClass.prototype).forEach(name => {
      const descriptor = Object.getOwnPropertyDescriptor(baseClass.prototype, name);

      if (descriptor) {
        Object.defineProperty(targetClass.prototype, name, descriptor);
      }
    });
  });
}

class Api {
  getRequest<AjaxResponse>(url: string): AjaxResponse {
    ajax.open('GET', url, false);
    ajax.send();

    return JSON.parse(ajax.response)
  }
}

class NewsFeedApi {
  getData(): NewsFeed[] {
    return this.getRequest<NewsFeed[]>(NEWS_URL);
  }
}

class NewsDetailApi {
  getData(id: string): NewsDetail {
    return this.getRequest<NewsDetail>(CONTENT_URL.replace('@id', id));
  }
}


function getData<AjaxResponse>(url: string): AjaxResponse {
  ajax.open('GET', url, false);
  ajax.send();

  return JSON.parse(ajax.response);
}

interface NewsFeedApi extends Api { };
interface NewsDetailApi extends Api { };

applyApiMixins(NewsFeedApi, [Api]);
applyApiMixins(NewsDetailApi, [Api]);

// 공통 요소의 class
abstract class View {
  // private 속성 접근자 사용 : View 클래스 안에서만 접근할 수 있음
  private template: string;
  private renderTemplate: string;
  private container: HTMLElement;
  private htmlList: string[];

  constructor(containerId: string, template: string) {
    const containerElement = document.getElementById(containerId);

    // null 값에 대한 예외처리 - 종료(throw)
    if (!containerElement) {
      throw '최상위 컨테이너가 없어 UI를 진행하지 못합니다.'
    }

    // class에 속성이 들어가면 생성자에서 반드시 초기화 해줘야 함
    this.container = containerElement;
    this.template = template;
    this.renderTemplate = template;
    this.htmlList = [];
  }

  // protected 속성 접근자 사용 : 외부에서는 접근 불가, 자식 요소에서만 접근 가능  
  protected updateView(): void {
    this.container.innerHTML = this.renderTemplate;

    // renderTemplate 초기화
    this.renderTemplate = this.template;
  }

  protected addHtml(htmlString: string): void {
    this.htmlList.push(htmlString)
  }

  protected getHtml(): string {
    const snapshot = this.htmlList.join('');
    this.clearHtmlList();
    return snapshot;
  }

  protected setTemplateData(key: string, value: string): void {
    this.renderTemplate = this.renderTemplate.replace(`{{__${key}__}}`, value)
  }

  private clearHtmlList(): void {
    this.htmlList = [];
  }

  // 추상메소드
  // 자식들에게 render 메소드를 구현하라는 의미의 마킹
  abstract render(): void;
}

class Router {
  routeTable: RouteInfo[];
  defaultRoute: RouteInfo | null;

  constructor() {
    // this.route는 브라우저의 이벤트 시스템이 호출하므로 this context는 Router 인스턴스가 아니다.
    // 따라서 defaultRoute나 routeTable 같은 정보에 접근할 수 없다.
    // 그러므로 값을 넘겨줄 때, 현재 등록 시점의 this context로 this를 고정시켜줘야 함 (bind 함수 사용)
    window.addEventListener('hashchange', this.route.bind(this));

    this.routeTable = [];
    this.defaultRoute = null;
  }

  setDefaultPage(page: View): void {
    this.defaultRoute = { path: '', page };
  }

  addRoutePath(path: string, page: View): void {
    this.routeTable.push({
      // path: path,
      // page: page,
      // 이름과 값이 같은 경우는 아래와 같이 생략 가능
      path, page
    })
  }

  route() {
    const routePath = location.hash;

    if (routePath === '' && this.defaultRoute) {
      this.defaultRoute.page.render();
    }

    for (const routeInfo of this.routeTable) {
      if (routePath.indexOf(routeInfo.path) >= 0) {
        routeInfo.page.render();
        break;
      }
    }
  }
}

class NewsFeedView extends View {
  private api: NewsFeedApi;
  private feeds: NewsFeed[];

  constructor(containerId: string) {    
    let template: string = `
      <div class="bg-gray-600 min-h-screen">
        <div class="bg-white text-xl">
          <div class="mx-auto px-4">
            <div class="flex justify-between items-center py-6">
            <div class="flex justify-start">
              <h1 class="font-extrabold">Hacker News</h1>
            </div>
            <div class="itmes-center justify-end">
              <a href="#/page/{{__prev_page__}}">Prev</a>
              <a href="#/page/{{__next_page__}}">Next</a>
              </div>
            </div>
          </div>
        </div>
        <div class="p-4 text-2xl text-gray-700">
          {{__news_feed__}}
        </div>
      </div>
    `;

    super(containerId, template);

    this.api = new NewsFeedApi();
    this.feeds = store.feeds;
  
    if (this.feeds.length === 0) {
      this.feeds = store.feeds = this.api.getData();
      this.makeFeeds();
    }
  }

  render(): void {
    store.currentPage = Number(location.hash.substr(7) || 1)

    for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
      // 구조분해할당
      const { id, title, comments_count, user, points, time_ago, read } = this.feeds[i];
      this.addHtml(`
        <div class="p-6 ${
          read ? 'bg-gray-500' : 'bg-white'
        } mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
          <div class="flex">
            <div class="flex-auto">
              <a href="#/show/${id}">${title}</a>
            </div>
            <div class="text-center text-sm">
              <div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${comments_count}</div>
            </div>
          </div>
          <div class="flex mt-3">
            <div class="grid grid-cols-3 text-sm text-gray-500">
              <div><i class="fas fa-user mr-1">${user}</i></div>
              <div><i class="fas fa-heart mr-1">${points}</i></div>
              <div><i class="fas fa-clock mr-1">${time_ago}</i></div>
            </div>
          </div>
        </div>
      `);
    }
  
    this.setTemplateData('news_feed', this.getHtml());
    this.setTemplateData('prev_page', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
    this.setTemplateData('next_page', String(store.currentPage * 10 < this.feeds.length ? store.currentPage + 1 : store.currentPage));
  
    this.updateView();
  }
    
  private makeFeeds(): void {
    for (let i = 0; i < this.feeds.length; i++) {
      this.feeds[i].read = false;
    }
  }
}

class NewsDetailView extends View {
  constructor(containerId: string) {  
    let template = `
      <div class="bg-gray-600 min-h-screen pb-8">
        <div class="bg-white text-xl">
          <div class="mx-auto px-4">
            <div class="flex justify-between items-center py-6">
              <div class="flex justify-start">
                <h1 class="font-extrabold">Hacher News</h1>
              </div>
              <div class="items-center justify-end">
              <a href="#/page/{{__currentPage__}}" class="text-gray-500">
                <i class="fa fa-times"></i>
              </a>
              </div>
            </div>
          </div>
        </div>
        <div class="h-full border rounded-xl bg-white m-6 p-4">
          <h2>{{__title__}}</h2>
          <div class="text-gray-400 h-20">
            {{__content__}}
          </div>
  
          {{__comments__}}
        </div>
      </div>
    `;

    super(containerId, template);
  }

  render() {
    const id = location.hash.substr(7);
    const api = new NewsDetailApi();
    const newsDetail: NewsDetail = api.getData(id);
  
    for (let i = 0; i < store.feeds.length; i++) {
      if (store.feeds[i].id === Number(id)) {
        store.feeds[i].read = true;
        break;
      }
    }
    this.setTemplateData('comments', this.makeComment(newsDetail.comments))
    this.setTemplateData('currentPage', String(store.currentPage))
    this.setTemplateData('title', newsDetail.title)
    this.setTemplateData('content', newsDetail.content)
    this.updateView();
  }
  
  makeComment(comments: NewsComment[]): string {
    for (let i = 0; i < comments.length; i++) {
      const comment: NewsComment = comments[i];

      this.addHtml(`
        <div style="padding-left: ${comment.level * 40}px;" class="mt-4">
          <div class="text-gray-400">
            <i class="fa fa-sort-up mr-2"></i>
            <strong>${comment.user}</strong>${comment.time_ago}
          </div>
          <p class="text-gray-700">${comment.content}</p>
        </div>
      `);

      if (comment.comments.length > 0) {
        //재귀호출
        this.addHtml(this.makeComment(comment.comments));
      }
    }

    return this.getHtml();
  }
}

// 라우터 인스턴스 생성
const router: Router = new Router();

// 만들어놓은 View class들을 작동시키기 위한 인스턴스 생성
const newsFeedView = new NewsFeedView('root');
const newsDetailView = new NewsDetailView('root');

router.setDefaultPage(newsFeedView);
router.addRoutePath('/page/', newsFeedView);
router.addRoutePath('/show/', newsDetailView);

router.route();
</script>

class에서 protected와 같은 별도의 속성 접근자를 사용하지 않으면 public이라는 속성 접근자가 디폴트로 붙음 (public은 생략 가능한 속성 접근자)
public은 외부에서 모두 접근 가능

profile
Frontend Developer

0개의 댓글