티스토리 뷰


HNPWA(Hacker News readers as Progressive Web Apps)은 Google I/O 2017 에서 소개된적이 있으며 TodoMVC와 같은 다양한 자바스크립트 프레임워크를 이용하여 한가지 앱을 구현한 프로젝트입니다.


이름에도 나와있든 이 프로젝트는 Progressive Web App (PWA) 기술을 이용하여 Hacker News 앱을 구현한 프로젝트이며, 이중에서 angular2(이하 angular)를 이용하여 구현한 angular2-hn에는 rxjs, firebase, workboxjs 등의 기술들이 포함되어있어, angular로 pwa를 구현하기전에 참고하기 좋은 프로젝트입니다.


이 글의 원문은 rangle.io 개발자인 Houssein Djirdeh의 블로그(https://houssein.me)에 있는 building hacker news with angular 2 cli, rxjs and webpack 입니다. 저자에게 허락을 받고 번역합니다.

(This article's origin is here[https://houssein.me/angular2-hacker-news] Thank you Houssein Djirdeh for allowing me to translate)


추가 내용이 필요하거나 일부(http -> httpclient) 내용은 수정하였습니다.

번역 글 작성시 사용된 angular-cli 버전은 1.2.7이며, angular-core 버전은 4.3.3 입니다.



Angular를 이용하여 프로젝트를 설정하고 구축해본적이 있으신분들은 이러한 작업들이 상당한 시간이 소요됨을 알고 계십니다. 해당 문제를 개선하고자 Angular 팀에서는 Angular CLI를 출시하였습니다. Angular CLI는 명령 줄 인터페이스로, Angular 프로젝트를 쉽게 구축할 수 있도록 도와줍니다.


이 글에서는 Angular CLI, RxJS Observables 및 Webpack을 모듈 로더로 사용하여 해커 뉴스 클라이언트를 구축 할 것이며, 다음 글에서는 추가적으로 PWA를 만들어 볼 예정입니다.


소스보기 / 결과물 보기


이 글에서는 해커 뉴스 페이지를 만들기 위한 단계별로 설명할 예정이며, 작업 단계에서 발생한 문제와 해결방법에 대해 설명이 포함되어있습니다.


아래의 단계로 작업을 진행할 예정입니다.


  1. 먼저 Hacker News의 첫 페이지를 작업합니다.

  2. 그 후 해당 페이지에 Observable Data Service를 래핑하여 데이터를 비동기적으로 불러옵니다.

  3. 사용자가 서로 다른 페이지와 스토리 유형을 탐색 할 수 있도록 Angular Component Router를 사용하여 라우팅을 추가합니다.

  4. 마지막으로 사용자 항목 설명 및 사용자 프로필로 이동할 수있는 경로를 추가합니다.

이 튜토리얼을 통해 angular의 작은 모듈부터 처음부터 끝까지 작업을 해보며, 아래의 중요한 내용들을 간단히 살펴보며 실제 프로젝트에 적용되는지 이해가 가능합니다.


  1. The NgModule decorator
  2. View Encapsulation
  3. RxJS

Getting Started



먼저 node.js를 이용하여 angular-cli를 전역 설치합니다.

npm install -g @angular/cli

angular-cli 설치가 끝난 이후에는 angular 프로젝트를 아래의 명령어로 생성 및 실행이 가능합니다.

ng new angular2-hn --style=scss
cd angular2-hn
ng serve

ng new 명령어는 angular-cli를 이용하여 프로젝트를 생성하는 명령이며 --style 옵션은 프로젝트의 style 코드를 선택하는 옵션으로 이 튜토리얼에서는 scss를 사용합니다.


ng serve를 이용하여 프로젝트를 실행하면 https://localhost:4200/에 접속하여 확인이 가능합니다.

멋지지 않습니까? Angular-cli는 원래 SystemJs를 이용하여 모듈을 번들 및 로드하고 있었으나 SystemJs는 번들 시간이 오래걸리고 3rd party library를 추가할때 불편하는등의 단점이 있어 Angular Cli팀은 SystemJs에서 Webpack으로 변경하였습니다. 

NgModule


Cli를 이용하여 Angular 프로젝트 생성 및 실행 시 Angular의 최신 릴리스 버전을 사용합니다. 우리는 먼저 @NgModule 데코레이터 살펴 보겠습니다. NgModule 데코레이터는 app.module.ts 파일에서 사용되는 것을 볼 수 있습니다.


// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

데코레이터(Decorator) 는 ES7에 제안된 내용이지만 최근 발표된 ES7에는 포함되지 않았으며 현재 stage-2에 포함된 내용으로 @로 시작하며 클래스나 프로퍼티에 역활을 표기해주는 언어적 장치입니다.


@NgModule 데코레이터는 그 밑에 AppModule 클래스가 NgModule용 클래스라는걸 꾸며주는 역활을 합니다. Angular에는 여러가지 데코레이터가 존재하는데 (Component, Directive, Input 등) 각각 Angular의 구성요소를 표기해주는 역활을 합니다.


실제로 작동하는 방식은 아래의 예시를 보시면 이해하기 쉬울듯 합니다.


TypeScript로 작성한 모듈 소스

@NgModule({
  imports: [BrowserModule],
  declarations: [HelloWorldComponent],
  bootstrap: [HelloWorldComponent]
})
export class AppModule {}


Es5로 작성된 모듈 소스

(function(app) {
  app.AppModule =
    ng.core.NgModule({
      imports: [ng.platformBrowser.BrowserModule],
      declarations: [app.HelloWorldComponent],
      bootstrap: [app.HelloWorldComponent]
    }).Class({
      constructor: function() {}
    });
})(window.app || (window.app = {}));


다시 app.module.ts로 돌아와서 보면 cli로 생성된 module 소스에는 기본적으로 AppComponent 및 BrowserModule등이 포함되어있습니다. 해당 소스를 FormsModule과 angular 4.3.0에 추가된 HttpClientModule를 아래처럼 추가하도록 하겠습니다.


// app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Let’s get ready to rumble


이 글에서는 위에 언급한거와 같이 scss를 사용할 예정입니다. 만약 angular-cli로 프로젝트 생성시 --style 옵션으로 scss 설정을 안하셨다면 아래의 명령어를 이용하여 변경해주시기 바랍니다. (scss는 sass 3 부터 sass 주문법이 되었습니다.)


ng set defaults.styleExt scss


해당 명령어를 입력 후에는 .angular-cli.json의 defaults 속성이 아래처럼 변경되어있습니다. ( angular-cli.json의 schma에 대해 궁금하시면 해당 링크에서 확인)


"defaults": {
    "styleExt": "scss",
    "component": {}
}


여기까지 설정이 완료되었다면 첫번째 컴퍼넌트인 HeaderComponent를 추가하겠습니다. 아래의 명령어를 터미널에 입력해주세요.


ng generate component Header

app/header 폴더안에 아래의 파일들이 생성된것을 확인하실 수 있습니다.

  • header.component.scss
  • header.component.html
  • header.component.ts
  • header.component.spec.ts


위의 이미지는 농담입니다. 실제 서비스되는 어플리케이션에서는 UNIT TEST는 매우 중요합니다. 이 튜토리얼에서는 단위테스트는 다루지 않으므로 spec 파일은 주석하거나 삭제하셔도 됩니다.


app.module.ts를 보면 추가한 HeaderComponent가 포함되어있는걸 확인하실 수 있습니다.


// app.module.ts

// ...
import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';

@NgModule({
  declarations: [
    AppComponent,
    HeaderComponent
  ],
//...


header.component.ts를 살펴보면 component의 선택자가 아래와 같이 app-header임을 알 수 있습니다.


@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss']
})


추가한 HeaderComponent를 root component인 app.component.ts에 추가해 보겠습니다.


<!-- app.component.html -->

<app-header></app-header>


이제 ng serve 명령어를 이용하여 어플리케이션 실행 시 header component가 정상적으로 추가되었음을 확인 가능합니다.



좋습니다. 이제 html 마크업과 스타일 코드를 추가해 보도록 하겠습니다.


<!-- app.component.html -->

<div id="wrapper">
  <app-header></app-header>
</div>


app.component.scss도 수정합니다. 원문에서는 github 링크로 제공되고 있는 스타일 코드입니다.


@import url(https://fonts.googleapis.com/css?family=Open%20Sans);

$mobile-only: "only screen and (max-width : 768px)";

body {
  margin-bottom: 0;

  @media #{$mobile-only} {
    margin: 0;
  }
}

#wrapper {
  background-color: #F6F6EF;
  position: relative;
  width: 85%;
  min-height: 80px;
  margin: 0 auto;
  font-family: 'Open Sans', sans-serif;
  font-size: 15px;
  height: 100%;

   @media #{$mobile-only} {
    width: 100%;
    background-color: #fff;
  }
}


그 다음 header를 수정하도록 하겠습니다.


<!-- header.component.html -->

<header id="header">
  <a class="home-link" href="/">
    <img class="logo" src="https://i.imgur.com/J303pQ4.png">
  </a>
  <div class="header-text">
    <div class="left">
      <h1 class="name">
        <a href="/">Angular 2 HN</a>
      </h1>
      <span class="header-nav">
        <a href="">new</a>
        <span class="divider">
          |
        </span>
        <a href="">show</a>
        <span class="divider">
          |
        </span>
        <a href="">ask</a>
        <span class="divider">
          |
        </span>
        <a href="">jobs</a>
      </span>
    </div>
    <div class="info">
      Built with <a href="https://cli.angular.io/" target="_blank">Angular CLI</a>
    </div>
  </div>
</header>

header.component.scss도 아래와 같이 수정해줍니다.

$mobile-only: "only screen and (max-width : 768px)";

#header {
  background-color: #b92b27;
  color: #fff;
  padding: 6px 0;
  line-height: 18px;
  vertical-align: middle;
  position: relative;
  z-index: 1;
  width: 100%;

  @media #{$mobile-only} {
    height: 50px;
    position: fixed;
    top: 0;
  }

  a {
    display: inline;
  }
}

.home-link {
  width: 50px;
  height: 66px;
}

.logo {
  width: 50px;
  padding: 3px 8px 0;

  @media #{$mobile-only} {
    width: 45px;
    padding: 0 0 0 10px;
  }
}

h1 {
  font-weight: bold;
  display: inline-block;
  vertical-align:middle;
  margin: 0;
  font-size: 16px;

  a {
    color: #fff;
    text-decoration: none;
  }
}

.name {
  margin-right: 30px;
  margin-bottom: 2px;

  @media #{$mobile-only} {
    display: none;
  }
}

.header-text {
  position: absolute;
  width: inherit;
  height: 20px;
  left: 10px;
  top: 27px;

  @media #{$mobile-only} {
    top: 22px;
  }
}

.left {
  position: absolute;
  left: 60px;
  font-size: 16px;

  @media #{$mobile-only} {
    width: 100%;
    left: 0;
  }
}

.header-nav {
  display: inline-block;

  @media #{$mobile-only} {
    float: right;
    margin-right: 20px;
  }

  a {
    color: #fff;
    text-decoration: none;

    &:hover {
      font-weight: bold;
    };
  }
}

.info {
  position: absolute;
  right: 20px;
  font-size: 16px;

  @media #{$mobile-only} {
    display: none;
  }

  a {
    color: #fff;
    font-weight: bold;
    text-decoration: none;
  }
}


여기까지 작업하신 후 어플리케이션 화면을 보면 아래와 같습니다.


View Encapsulation


이 어플리케이션은 반응형 페이지로 만들고 있기 때문에 가능하다면 다른 크기의 디바이스에서 보여지는 모습을 체크하는 것도 중요합니다. 뷰포트를 조정하여 모바일 장치에서 어떻게 보이는지 확인해겠습니다.




대부분의 최신 브라우저는 body 태그에 margin 값을 가지고 있기때문에 가장 자리들이 여백이 발생되고 있습니다.



하지만 app.component.scss를 보면 화면 크기가 768px보다 작은 경우 margin : 0로 설정하고 있습니다.


$mobile-only: "only screen and (max-width : 768px)";

body {
  margin-bottom: 0;

  @media #{$mobile-only} {
    margin: 0;
  }
}

왜 이렇게 rendering이 되는지 이유를 살펴보면 Angular Conponent의 스타일 캡슐화 방식 때문입니다. 이 튜토리얼에서 Component의 캡슐화에 대해 자세히 다루지 않겠지만, 간략하게 각 방식에 대해 설명을 하자면 아래와 같습니다.


  • None: 캡슐화를 하지 않습니다. Shadow DOM이 없으며, 스타일을 추가하거나 변경하면 전체 HTML 문서에 적용됩니다.
  • Emulated: Angular에서 지원하는 Shadow DOM을 사용합니다. (기본값)
  • Native:브라우저의 Shadow DOM을 사용합니다 .(이 기능을 지원하는 브라우저 확인)


app.component.scss는 app.component.ts의 selector에 적용되도록 캡슐화가 되어있습니다. 그러다보니 해당 scss에서 body를 수정할려고해도 적용이 안되는 문제가 발생된것이며 이에 대한 해결책은 두가지정도 방법이 있습니다.


  1. 캡슐화 방식을 변경
  2. app.component.scss가 아닌 styles.scss에 코드 적용

이 튜토리얼은 먼저 캡슐화 방식을 변경해보도록 하겠습니다.

// app.component.ts

import { Component, ViewEncapsulation } from '@angular/core';

@Component({
  selector: 'app-root',
  encapsulation: ViewEncapsulation.None,
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})

export class AppComponent {
}


app.component.ts에  encapsulation: ViewEncapsulation.None 항목을 추가하고 다시 어플리케이션을 실행하면 스타일코드가 적용된걸 확인 하실 수 있습니다. 위에 설명드린거와 같이 ViewEncapsulation.None으로 설정시 캡슐화를 하지않고 문서 전체 대상으로 적용되기 때문입니다.



사실 이러한 경우는 첫번째 방법보다 두번째 방법이 더 단순하고 올바른 방법입니다. src 디렉토리 안에보면 styles.scss 파일이 있습니다. 이 파일에는 전역 스타일 코드를 작성하는 파일이며 우리가 작성했던 body 코드는 이 부분에 넣어주기만 하면 끝납니다. 아래 코드 처럼 말이죠


@import url(https://fonts.googleapis.com/css?family=Open%20Sans);

$mobile-only: "only screen and (max-width : 768px)";

body {
  margin-bottom: 0;

  @media #{$mobile-only} {
    margin: 0;
  }
}

이러한 삽질 덕분에 우리는 캡슐화에 대해 알 수 있었습니다 :)


Multiple Components


그 다음으로 Stories와 Footer라는 두개의 Component를 추가로 만들어 보겠습니다. Stories는 Hacker News의 게시물을 지정된 순서대로 노출을 위한 뼈대에 해당합니다.

ng g component Stories
// stories.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-stories',
  templateUrl: './stories.component.html',
  styleUrls: ['./stories.component.scss']
})

export class StoriesComponent implements OnInit {
  items: number[];

  constructor() {
    this.items = Array(30);
  }

  ngOnInit() {
  }
}
<!-- stories.component.html -->

<div class="main-content">
  <ol>
    <li *ngFor="let item of items; let i = index" class="post">
      Story #{{i}}
    </li>
  </ol>
  <div class="nav">
    <a href="" class="prev">
      ‹ Prev
    </a>
    <a href="" class="more">
      More ›
    </a>
  </div>
</div>
/* stories.component.scss */
$mobile-only: "only screen and (max-width : 768px)";

a {
  color: #b92b27;
  text-decoration: none;
  font-weight: bold;

  &:hover {
    text-decoration: underline;
  };
}

ol {
  padding: 0 40px;
  margin: 0;

  @media #{$mobile-only} {
    box-sizing: border-box;
    list-style: none;
    padding: 0 10px;
  }

  li {
    position: relative;
    -webkit-transition: background-color .2s ease;
    transition: background-color .2s ease;
  }
}

.list-margin {
  @media #{$mobile-only} {
    margin-top: 55px;
  }
}

.main-content {
  position: relative;
  width: 100%;
  min-height: 100vh;
  -webkit-transition: opacity .2s ease;
  transition: opacity .2s ease;
  box-sizing: border-box;
  padding: 8px 0;
  z-index: 0;
}

.post {
  padding: 10px 0 10px 5px;
  transition: background-color 0.2s ease;
  border-bottom: 1px solid #CECECB;

  .itemNum {
    color: #696969;
    position: absolute;
    width: 30px;
    text-align: right;
    left: 0;
    top: 4px;
  }
}

.item-block {
  display: block;
}


.nav {
  padding: 10px 40px;
  margin-top: 10px;
  font-size: 17px;

  a {
    @media #{$mobile-only} {
      color: #B92B27;
      text-decoration: none;
    }
  }

  @media #{$mobile-only} {
    margin: 20px 0;
    text-align: center;
    padding: 10px 80px;
    height: 20px;
  }

  .prev {
    padding-right: 20px;

    @media #{$mobile-only} {
      float: left;
      padding-right: 0;
    }
  }

  .more {
    @media #{$mobile-only} {
      float: right;
    }
  }
}

.job-header {
  font-size: 15px;
  padding: 0 40px 10px;

  @media #{$mobile-only} {
    padding: 60px 15px 25px 15px;
    border-bottom: 2px dotted #b92b27;
  }
}

.loader {
  background: #B92B27;
  -webkit-animation: load1 1s infinite ease-in-out;
  animation: load1 1s infinite ease-in-out;
  width: 1em;
  height: 4em;
  &:before, &:after {
    background: #B92B27;
    -webkit-animation: load1 1s infinite ease-in-out;
    animation: load1 1s infinite ease-in-out;
    width: 1em;
    height: 4em;
  }
  &:before, &:after {
    position: absolute;
    top: 0;
    content: '';
  }
  &:before {
    left: -1.5em;
    -webkit-animation-delay: -0.32s;
    animation-delay: -0.32s;
  }
}

.loading-section {
    height: 70px;
    margin: 40px 0 40px 40px;

  @media #{$mobile-only} {
    display: block;
    position: relative;
    margin: 45vh 0;
  }
}

.loader {
  color: #B92B27;
  text-indent: -9999em;
  margin: 20px 20px;
  position: relative;
  font-size: 11px;
  -webkit-transform: translateZ(0);
  -ms-transform: translateZ(0);
  transform: translateZ(0);
  -webkit-animation-delay: -0.16s;
  animation-delay: -0.16s;
  &:after {
    left: 1.5em;
  }

  @media #{$mobile-only} {
    margin: 20px auto;
  }
}

@-webkit-keyframes load1 {
  0%,
    80%,
    100% {
    box-shadow: 0 0;
    height: 2em;
  }
  40% {
    box-shadow: 0 -2em;
    height: 3em;
  }
}

@keyframes load1 {
  0%,
    80%,
    100% {
    box-shadow: 0 0;
    height: 2em;
  }
  40% {
    box-shadow: 0 -2em;
    height: 3em;
  }
}

@media #{$mobile-only} {
  @-webkit-keyframes load1 {
    0%,
      80%,
      100% {
      box-shadow: 0 0;
      height: 4em;
    }
    40% {
      box-shadow: 0 -2em;
      height: 5em;
    }
  }

  @keyframes load1 {
    0%,
      80%,
      100% {
      box-shadow: 0 0;
      height: 3em;
    }
    40% {
      box-shadow: 0 -2em;
      height: 4em;
    }
  }
}


그 다음 Footer Component를 추가하도록 하겠습니다.

ng g component Footer
<!-- footer.component.html -->

<div id="footer">
    <p>Show this project some ❤ on
      <a href="https://github.com/housseindjirdeh/angular2-hn" target="_blank">
        GitHub
      </a>
    </p>
</div>
/* footer.component.scss */

$mobile-only: "only screen and (max-width : 768px)";

#footer {
  position: relative;
  padding: 10px;
  height: 60px;
  border-top: 2px solid #b92b27;
  letter-spacing: 0.7px;
  text-align: center;

  a {
    color: #b92b27;
    font-weight: bold;
    text-decoration: none;

    &:hover {
      text-decoration: underline;
    }
  }

  @media #{$mobile-only} {
    display: none;
  }
}


추가한 Component들을 app.component에 추가하도록 하겠습니다.


<!-- app.component.html -->

<div id="wrapper">
  <app-header></app-header>
  <app-stories></app-stories>
  <app-footer></app-footer>
</div>


여기까지 작업한 내용으로 어플리케이션을 실행하면 아래와 같이 됩니다.




글이나 항목에는 각각의 속성들이 있으므로, 이 부분도 Component로 작성하면 좋습니다. 이 튜토리얼에서는 글에 대한 컴퍼넌트를 Item Component로 지정하겠습니다.


ng g component Item


실제 데이터가 들어오면 story component 자식 component로 item 이 사용되며 해당 식별 index는 itemID로 지정됩니다.

먼저 작업했던 stories.component에 item component를 추가하도록 하겠습니다.

<!-- stories.component.html -->

<div class="main-content">
  <ol>
    <li *ngFor="let item of items; let i = index" class="post">
      <item class="item-block" itemID="{{ i + 1 }}"></item>
    </li>
  </ol>
  <div class="nav">
    <a href="" class="prev">
      ‹ Prev
    </a>
    <a href="" class="more">
      More ›
    </a>
  </div>
</div>

추가했던 Item Component들을 수정하겠습니다. 아래의 component에서 selector와 @Input 데코레이터를 눈여겨 봐주시기 바랍니다.

// item.component.ts

import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'item',
  templateUrl: './item.component.html',
  styleUrls: ['./item.component.scss']
})
export class ItemComponent implements OnInit {
  @Input() itemID: number;

  constructor() { }

  ngOnInit() {
  }

}
<!-- item.component.html -->

<p>Story #{{itemID}}<p>


어플리케이션 재실행 시 위에서 추가한 @Input 데코레이터를 통해 ItemComponent에 itemID가 전달되며, 전달받은 itemID를 표기하고 있습니다.

지금까지 어플리케이션 기본 구조를 작업했으며, 작업한 소스에 대한 내용은 해당 링크에 있습니다.

RxJS and Observables


실제 데이터를 가져오기전에 RxJs 및 observables 에 대해 알아보도록 하겠습니다.


Angular의 Http client를 이용하면 원하는 위치에서 서버와 통신이 가능하며, 서버에서 데이터를 가져 오려면 가장 먼저 할 일은 http.get 에 리소스 URL을 넘겨 호출해야합니다. 이때의 리턴값이 무엇일까요?


Angular에서는 RxJs 라이브러리를 사용하여 Observable 또는 비동기 데이터 스트림을 반환합니다. 여러분들은 이미 Promise의 개념과 이를 이용하여 비동기를 처리하는 방식에 대해서는 잘 알고 있을 것입니다. Observables는 Promise처럼 데이터를 얻지 만, 데이터 스트림을 subscribe하고 특정 데이터 변경 내용에 응답 할 수 있습니다.



위의 diagram은 사용자가 버튼을 클릭 할때 발생하는 이벤트에 대해 보여지고 있습니다. 스트림이 값을 전달하는 방법을 보면(클릭 이벤트를 뜻함) 완료된 이벤트뿐만 아니라 오류도 전달하고 있습니다.

이러한 방식의 어플리케이션에서 Observables를 사용하는 개념을 Reactive Programming라고 합니다.

Observable Data Service


이제 실제 데이터를 처리하기 위해 Observable Data Service를 만들어서 의존성 주입을 해보도록 하겠습니다.


ng g service hackernews-api

생성된 서비스파일을 설정하기 전에 Hacker News API가 어떻게 작동하는지 확인해보도록 하겠습니다. 공식 문서를 살펴보면 모든 항목 (투표, 댓글, 기사, 작업)은 id 값을 통해 구별 하고있습니다.


// https://hacker-news.firebaseio.com/v0/item/2.json?print=pretty

{
  "by" : "phyllis",
  "descendants" : 0,
  "id" : 2,
  "kids" : [ 454411 ],
  "score" : 16,
  "time" : 1160418628,
  "title" : "A Student's Guide to Startups",
  "type" : "story",
  "url" : "https://www.paulgraham.com/mit.html"
}

스토리 유형에 따라 다른 url(엔드포인트)에 접근해야합니다. 예를들어 상위 랭킹 뉴스는 아래와 같이 접근해서 조회할 수 있습니다.


// https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty

[ 12426766, 12426315, 12424656, 12425725, 12426064, 12427341, 12425692, 12425776, 12425324, 12425750, 12425135, 12427073, 12425632, 12423733, 12425720, 12427135, 12425683, 12423794, 12424987, 12423809, 12424738, 12425119, 12426759, 12425711, 12422891, 12424731, 12423742, 12424131, 12424184, 12422833, 12424421, 12426729, 12423373, 12421687, 12427437 ...]


따라서 첫 페이지에 상위랭킹 뉴스를 노출할려면 이 url를 페이지별로 순차적으로 접근해야합니다. 어플리케이션 전체적으로 이 서비스가 필요하기 때문에 app.module NgModule 메타데이터 속의 provider에 포함시키도록 하겠습니다.


// app.module.ts

//...
import { HackerNewsAPIService } from './hackernews-api.service';

@NgModule({
  declarations: [
    ...
   ],
  imports: [
    ...
  ],
  providers: [HackerNewsAPIService],
  bootstrap: [AppComponent]
})
export class AppModule { }


이제 HackerNewsAPIService에 요청 메서드를 구현하겠습니다.


// hackernews-api.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';

@Injectable()
export class HackerNewsAPIService {
  baseUrl: string;

  constructor(private http: HttpClient) {
    this.baseUrl = 'https://hacker-news.firebaseio.com/v0';
  }

  fetchStories(): Observable {
    return this.http.get(`${this.baseUrl}/topstories.json`);
  }
}


이전에서 언급한거와 같이 http.get은 Observable이 리턴됩니다. fetchStories 메서드를 보시면 Observable를 넘겨주고있습니다. component에서 이 Observable를 어떻게 처리하는지 보시겠습니다.


// stories.component.ts

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';

import { HackerNewsAPIService } from '../hackernews-api.service';

@Component({
  selector: 'app-stories',
  templateUrl: './stories.component.html',
  styleUrls: ['./stories.component.scss']
})

export class StoriesComponent implements OnInit {
  items;

  constructor(private _hackerNewsAPIService: HackerNewsAPIService) {}

  ngOnInit() {
    this._hackerNewsAPIService.fetchStories()
                    .subscribe(
                      items => this.items = items,
                      error => console.log('Error fetching stories'));
  }
}

Component가 초기화때 발생되는 이벤트인 ngOnInit에 데이터 스트림을 subscribe하고 해당 리턴값을 item 속성에 설정합니다. 뷰에서 설정해야하는 것은 SlicePipe에 보여질 값이 500개가 아닌 30개씩만 노출되도록 수정하는 것 뿐입니다.


<!-- stories.component.html -->

<div class="main-content">
  <ol>
    <li *ngFor="let item of items | slice:0:30" class="post">
      <item class="item-block" itemID="{{ item }}"></item>
    </li>
  </ol>
  <!-- ... -->
</div>

이제 어플리케이션을 다시 실행하면 리스트 항목에 인기순으로 노출되는 ID목록이 보입니다.



Item의 ID 항목을 Component에 전달하였으니 이번에는 Item의 상세 내용을 가져오는 Observable subscription를 서비스에 작성하도록 하겠습니다.


// hackernews-api.service.ts

//...
  
fetchItem(id: number): Observable {
  return this.http.get(`${this.baseUrl}/item/${id}.json`);
}


그 다음 Item Component view에 사용할 pipe들을 먼저 처리하고 수정하도록 하겠습니다.

우리가 사용할 pipe는 크게 2가지입니다.


먼저 해커 뉴스의 시간 데이터는 Unix format으로 넘어오기 때문에 사람이 이해 할 수 있는 형식으로 변환이 필요합니다. 이 튜토리얼에서는 moment.js의 구현체인 angular2-moment 라이브러리를 사용합니다.


npm install --save angular2-moment

angular2-moment를 npm으로 설치한 이후 app.module에 포함시켜줍니다.

import { MomentModule } from 'angular2-moment';

@NgModule({
  imports: [
    MomentModule
  ]
})

모듈을 불러오고 나면 우리는 amFromUnix와 amTimeAgo pipe를 사용 할 수 있습니다.


두번째로 처리할 pipe는 링크가 있는 항목을 도메인만 표시해주기 위해 domain이라는 pipe를 사용할 예정입니다.


ng generate pipe domain
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'domain'
})
export class DomainPipe implements PipeTransform {

  transform(url: any, args?: any): any {
    if (url) {
      const domain = '(' + url.split('/')[2] + ')';
      return domain ? domain.replace('www.', '') : '';
    }
  }
}

pipe 처리가 추가가 완료되면 item component를 약간 수정하도록 하겠습니다.

// item.component.ts

import { Component, Input, OnInit } from '@angular/core';

import { HackerNewsAPIService } from '../hackernews-api.service';

@Component({
  selector: 'item',
  templateUrl: './item.component.html',
  styleUrls: ['./item.component.scss']
})
export class ItemComponent implements OnInit {
  @Input() itemID: number;
  item;

  constructor(private _hackerNewsAPIService: HackerNewsAPIService) {}

  ngOnInit() {
    this._hackerNewsAPIService.fetchItem(this.itemID).subscribe(data => {
      this.item = data;
    }, error => console.log('Could not load item' + this.itemID));
  }
}
<!-- item.component.html -->

<div *ngIf="!item" class="loading-section">
  <!-- You can add a loading indicator here if you want to :) </i> -->
</div>
<div *ngIf="item">
  <div class="item-laptop">
    <p>
      <a class="title" href="{{item.url}}">
        {{item.title}}
      </a>
      <span class="domain">{{item.url | domain}}</span>
    </p>
    <div class="subtext-laptop">
      {{item.score}} points by
      <a href="">{{item.by}}</a>
      {{ (item.time | amFromUnix) | amTimeAgo }}
      <a href="">
        <span *ngIf="item.descendants !== 0">
          {{item.descendants}}
          <span *ngIf="item.descendants === 1">comment</span>
          <span *ngIf="item.descendants > 1">comments</span>
        </span>
        <span *ngIf="item.descendants === 0">discuss</span>
      </a>
    </div>
  </div>
  <div class="item-mobile">
    <!-- Markup that shows only on mobile (to give the app a
    responsive mobile feel). Same attributes as above
    nothing really new here (but refer to the source
    file if you're interested) -->
  </div>
</div>
/* item.component.scss */

$mobile-only: "only screen and (max-width : 768px)";
$laptop-only: "only screen and (min-width : 769px)";

p {
  margin: 2px 0;

  @media #{$mobile-only} {
    margin-bottom: 5px;
    margin-top: 0;
  }
}

a {
  color: #000;
  cursor: pointer;
  text-decoration: none;
}

.title {
  font-size: 16px;
}

.subtext-laptop {
  font-size: 12px;
  color: #696969;
  font-weight: bold;
  letter-spacing: 0.5px;

  a {
    color: #b92b27;

    &:hover {
      text-decoration: underline;
    };
  }
}

.subtext-palm {
  font-size: 13px;
  color: #696969;
  font-weight: bold;
  letter-spacing: 0.5px;

  a {
    color: #b92b27;

    &:hover {
      text-decoration: underline;
    };
  }

  .details {
    margin-top: 5px;

    .right {
      float: right;
    }
  }
}

.domain {
  color: #696969;
  font-weight: bold;
  letter-spacing: 0.5px;
}

.item-laptop {
  @media #{$mobile-only} {
    display: none;
  }
}

.item-mobile {
  @media #{$laptop-only} {
    display: none;
  }
}

.item-details {
  padding: 10px;
}

이제 어플리케이션을 실행하면 해커 뉴스 첫 페이지가 나타납니다. 지금까지 작업된 내용을 보실려면 해당 링크에서 확인해 주시기 바랍니다.


Things are kinda slow though


어플리케이션 로드시 요청 데이터들을 한번 살펴보겠습니다.



31 개 요청 및 20.8KB 전송 546ms 요청 횟수가 너무 많으며, 첫 페이지 불러오는 시간의 5배가 소요됩니다. 단일 게시물을 확인할때 댓글이 많다면 심각한 문제가 될수 있습니다.


2000개의 코멘트가 있는 글을 불러올때 어떤일이 발생하는지 해당 링크를 누르면 gif파일로 확인이 가능합니다.(약 1.5MB의 741건 요청이 90초 정도 걸립니다.) 저러면 실제 사용 서비스는 불가능해보입니다.


참고용으로 github에 이 버전의 어플리케이션을 가지고 있습니다. 해당 링크에서 코멘트가 불러와지는 시간을 직접 확인 가능합니다.

Let’s switch things up


우리는 이제 여러번의 요청을 통해 데이터를 가져오는게 좋지 않다는걸 경험하였습니다. 조금 찾아본 결과 단일 요청으로 세부정보까지 제공해주는 비공식 API가 있다는걸 알아냈습니다.


예를들어 상위랭킹 뉴스 목록의 응답은 아래와 같습니다.

// https://node-hnapi.herokuapp.com/news?page=1

[
  {
    "id": 12469856,
    "title": "Owl Lisp – A purely functional Scheme that compiles to C",
    "points": 57,
    "user": "rcarmo",
    "time": 1473524669,
    "time_ago": "2 hours ago",
    "comments_count": 9,
    "type": "link",
    "url": "https://github.com/aoh/owl-lisp",
    "domain": "github.com"
  },
  {
    "id": 12469823,
    "title": "How to Write Articles and Essays Quickly and Expertly",
    "points": 52,
    "user": "bemmu",
    "time": 1473524142,
    "time_ago": "2 hours ago",
    "comments_count": 6,
    "type": "link",
    "url": "https://www.downes.ca/post/38526",
    "domain": "downes.ca"
  },
  ...
]


응답데이터를 보면 domain 항목이 따로 있을뿐만 아니라 time_ago 같은 속성도 있습니다. 멋지네요 앞에서 만든 domain.pipe.tsangular2-moment 라이브러리를 제거해도 문제 없을 것 같습니다. HackerNewService에서 변경해야할 사항을 살펴보겠습니다.


// hackernews-api.service.ts

@Injectable()
export class HackerNewsAPIService {
  baseUrl: string;

  constructor(private http: HttpClient) {
    this.baseUrl = 'https://node-hnapi.herokuapp.com';
  }

  fetchStories(storyType: string, page: number): Observable {
    return this.http.get(`${this.baseUrl}/${storyType}?page=${page}`);
  }
}


데이터 응답시 500개의 최상위 뉴스들이 조회가 되지 않기때문에 페이지 번호가 추가적으로 필요합니다. storyType를 end-point(url)에 넘기고 있어 사용자가 이동하는 주제에 맞춰 해당 주제의 뉴스를 노출 할 수 있습니다.


이번에는 stories component에 변경사항을 살펴보겠습니다. 최상위 뉴스를 얻기 위해 'news' 값과 페이지 값에 1를 넘겨 조회하도록 하겠습니다.


// stories.component.ts

export class StoriesComponent implements OnInit {
  items;

  constructor(private _hackerNewsAPIService: HackerNewsAPIService) {}

  ngOnInit() {
    this._hackerNewsAPIService.fetchStories('news', 1)
                              .subscribe(
                                items => this.items = items,
                                error => console.log('Error fetching stories'));
  }
}

template 파일은 아래처럼 변경합니다.

<!-- stories.component.html -->

<div class="loading-section" *ngIf="!items">
  <!-- You can add a loading indicator here if you want to :) -->
</div>
<div *ngIf="items">
  <ol>
    <li *ngFor="let item of items" class="post">
      <item class="item-block" [item]="item"></item>
    </li>
  </ol>
  <div class="nav">
    <a class="prev">
      ‹ Prev
    </a>
    <a class="more">
      More ›
    </a>
  </div>
</div>

더 이상 item component에서 비동기로 개별적으로 데이터를 불러오지 않고 stories component에 로딩 표시용 section을 추가합니다. 그리고 item의 데이터를 자식 component에 전달만 하면 됩니다.


즉 item.component.ts에 더 이상 HackerNewsService가 필요 없어지고 부모로부터 Item 객체를 가져오기 때문에 ItemComponent는 깨끗해집니다.

// item.component.ts

export class ItemComponent implements OnInit {
  @Input() item;

  constructor() {}

  ngOnInit() {

  }
}


item.component.html은 크게 변경사항이 없지만 item 객체가 있는지 확인하는 *ngif구문을 없애도 되며(부모에서 해당 처리를 수행함), 또한 각 매개변수들은 새로운 API 데이터의 속성을 참고합니다.


<!-- item.component.html -->

<div class="item-laptop">
  <p>
    <a class="title" href="">
      {{item.title}}
    </a>
    <span *ngIf="item.domain" class="domain">({{item.domain}})</span>
  </p>
  <div class="subtext-laptop">
    <span>
      {{item.points}} points by
      <a href="">{{item.user}}</a>
    </span>
    <span>
      {{item.time_ago}}
      <span> |
        <a href="">
          <span *ngIf="item.comments_count !== 0">
            {{item.comments_count}}
            <span *ngIf="item.comments_count === 1">comment</span>
            <span *ngIf="item.comments_count > 1">comments</span>
          </span>
          <span *ngIf="item.comments_count === 0">discuss</span>
        </a>
      </span>
    </span>
  </div>
</div>
<div class="item-mobile">
  <!-- Markup that shows only on mobile (to give the app a
    responsive mobile feel). Same attributes as above
    nothing really new here (but refer to the source
    file if you're interested) -->
</div>

이제 변경된 소스를 어플리케이션 실행하여 확인해 보자



이제 훨씬 빠르게 로드되는걸 확인 할 수 있습니다. 지금까지의 단계의 소스는 해당 링크에서 확인 가능합니다.

Routing


우리는 꽤 먼 길을 걸어왔지만, 계속하기전에 어플리케이션의 전체 component 구조를 그려보도록 하겠습니다. 제 부족한 파워포인트 실력은 양해부탁드립니다.




또한 comment가 표시되는 페이지 component의 구조도 살펴보겠습니다.



사용자가 이러한 페이지를 접근 할 수 있도록 하려면 애플리케이션에 routing을 포함시켜야 합니다. routi ng 생성하기 이전에 ItemComments component를 생성하겠습니다.


ng g component ItemComments


그 다음 app.routes.ts 파일을 src/app 폴더안에 생성합니다.


// app.routes.ts

import { Routes, RouterModule } from '@angular/router';

import { StoriesComponent } from './stories/stories.component';
import { ItemCommentsComponent } from './item-comments/item-comments.component';

const routes: Routes = [
  {path: '', redirectTo: 'news/1', pathMatch : 'full'},
  {path: 'news/:page', component: StoriesComponent, data: {storiesType: 'news'}},
  {path: 'newest/:page', component: StoriesComponent, data: {storiesType: 'newest'}},
  {path: 'show/:page', component: StoriesComponent, data: {storiesType: 'show'}},
  {path: 'ask/:page', component: StoriesComponent, data: {storiesType: 'ask'}},
  {path: 'jobs/:page', component: StoriesComponent, data: {storiesType: 'jobs'}},
  {path: 'item/:id', component: ItemCommentsComponent}
];

export const routing = RouterModule.forRoot(routes);

앞으로 우리가 진행할 작업에 대한 개요입니다.


  1. 우리는 상대경로를 가진 특정 Component에 맵핑된 route의 배열을 생성했습니다.
  2. news, newest, show, ask 그리고 jobs등의 다양한 메뉴들을 상단 링크에 라우팅 할 예정이며 이러한 경로들은 StoriesComponent에 맵핑됩니다.
  3. 기본 root 경로를 최상위 news 목록으로 리다이렉트되도록 처리하였습니다.
  4. StorietComponent에 연결할때 data 속성을 통해 storiesType를 매개 변수로 전달합니다. 이를 통해 각 경로에 관련된 뉴스 유형을 가질 수 있습니다(데이터 서비스를 사용하여 뉴스 목록을 가져올 때 필요함).
  5. : page가 토큰으로 사용되어 StoriesComponent가 특정 페이지의 뉴스 목록을 가져올 수 있습니다.
  6. : id는 ItemCommentsComponent가 특정 item에 대한 모든 comment를 얻을 수 있도록 사용됩니다.
라우팅으로 할 수 있는 일은 훨씬 많지만, 우리에게 필요한건 이러한 기본설정뿐입니다. 이제 app.module.ts를 열어 라우팅을 등록해보겠습니다.

// app.module.ts

// ...
import { routing } from './app.routes';

@NgModule({
  declarations: [
    //...
  ],
  imports: [
    //...
    routing
  ],
  providers: [HackerNewsAPIService],
  bootstrap: [AppComponent]
})
export class AppModule { }

Angular에서 route에서 Component를 불러오게 알려주기 위해선 RouterOutlet를 사용해야 합니다.

<!-- app.component.html -->

<div id="wrapper">
  <app-header></app-header>
  <router-outlet></router-outlet>
  <app-footer></app-footer>
</div>

Story Navigation


HeaderComponent에 링크에 해당하는 경로로 바인딩 하도록 하겠습니다.


<!-- header.component.html -->

<header>
  <div id="header">
    <a class="home-link" routerLink="/news/1">
      <img class="logo" src="https://i.imgur.com/J303pQ4.png">
    </a>
    <div class="header-text">
      <div class="left">
        <h1 class="name">
          <a routerLink="/news/1" class="app-title">Angular 2 HN</a>
        </h1>
        <span class="header-nav">
          <a routerLink="/newest/1">new</a>
          <span class="divider">
            |
          </span>
          <a routerLink="/show/1">show</a>
          <span class="divider">
            |
          </span>
          <a routerLink="/ask/1">ask</a>
          <span class="divider">
            |
          </span>
          <a routerLink="/jobs/1">jobs</a>
        </span>
      </div>
      <div class="info">
        Built with <a href="https://cli.angular.io/" target="_blank">Angular CLI</a>
      </div>
    </div>
  </div>
</header>

RouterLink directive는 특정 element에 경로를 바인딩합니다. 이제 StoriesComponent를 업데이트합시다.


// stories.component.ts

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ActivatedRoute } from '@angular/router';

import { HackerNewsAPIService } from '../hackernews-api.service';

@Component({
  selector: 'app-stories',
  templateUrl: './stories.component.html',
  styleUrls: ['./stories.component.scss']
})

export class StoriesComponent implements OnInit {
  typeSub: any;
  pageSub: any;
  items;
  storiesType;
  pageNum: number;
  listStart: number;

  constructor(
    private _hackerNewsAPIService: HackerNewsAPIService,
    private route: ActivatedRoute
  ) {}

  ngOnInit() {
    this.typeSub = this.route
      .data
      .subscribe(data => this.storiesType = (data as any).storiesType);

    this.pageSub = this.route.params.subscribe(params => {
      this.pageNum = +params['page'] ? +params['page'] : 1;
      this._hackerNewsAPIService.fetchStories(this.storiesType, this.pageNum)
                              .subscribe(
                                items => this.items = items,
                                error => console.log('Error fetching' + this.storiesType + 'stories'),
                                () => this.listStart = ((this.pageNum - 1) * 30) + 1);
    });
  }
}


우리가 추가한 내용을 살펴보겠습니다. 먼저 현재 route 정보를 접근할 수 있는 ActivatedRoute 서비스를 의존성에 추가하였습니다.


import { ActivatedRoute } from '@angular/router';

@Component({
  //...
})

export class StoriesComponent implements OnInit {
//..

constructor(
  private route: ActivatedRoute
) {}
//...
}

ngOnInit에서  route의 data 속성에서 storiesType을 component 속성에 저장합니다. Component의 storiesType 속성으로 설정시 any type으로 방법을 주목하시기 바랍니다. 이렇게하면 타입검사에 벗어나는 간단한 방법이며, 그렇지 않으면 data에 storiesType 속성이 없다고 에러가 발생될 수도 있습니다.


ngOnInit() {
  this.typeSub = this.route
    .data
    .subscribe(data => this.storiesType = (data as any).storiesType);

// ...
}


마지막으로 route 매개변수를 subscribe하여 페이지 번호를 가져옵니다. 그런 다음 데이터 서비스를 사용하여 뉴스 목록을 가져옵니다.


ngOnInit() {
  // ...

  this.pageSub = this.route.params.subscribe(params => {
    this.pageNum = +params['page'] ? +params['page'] : 1;
      this._hackerNewsAPIService.fetchStories(this.storiesType, this.pageNum)
        .subscribe(
          items => this.items = items,
          error => console.log('Error fetching' + this.storiesType + 'stories'),
          () => {
            this.listStart = ((this.pageNum - 1) * 30) + 1;
            window.scrollTo(0, 0);
          });
    });
}

완료 처리를 위해 우리는 onCompleted(subscribe 3번째 파라미터)를 사용하여 목록의 첫번째 값을 나타내는 listStart 변수를 업데이트하고(아래의 stories.component.html 에서 확인 가능) 사용자가 페이지를 전환하려고 할 때 페이지 맨 아래에 멈추지 않도록 창 위쪽으로 스크롤합니다.


<!-- stories.component.html -->

<div class="main-content">
  <div class="loading-section" *ngIf="!items">
    <!-- You can add a loading indicator here if you want to :) -->
  </div>
  <div *ngIf="items">
    <ol start="{{ listStart }}">
      <li *ngFor="let item of items" class="post">
        <item class="item-block" [item]="item"></item>
      </li>
    </ol>
    <div class="nav">
      <a *ngIf="listStart !== 1" [routerLink]="['/' + storiesType, pageNum - 1]" class="prev">
        ‹ Prev
      </a>
      <a *ngIf="items.length === 30" [routerLink]="['/' + storiesType, pageNum + 1]" class="more">
        More ›
      </a>
    </div>
  </div>
</div>

여기까지 목록의 상단 네비게이션 및 페이징 처리까지 완료하였습니다. 어플리케이션을 실행하여 변경된 페이지를 확인해보시기 바랍니다.

Item Comments



거의 다 끝났습니다. comment 페이지의 Component들을 추가하기 전에 ItemComponent의 링크를 업데이트하여 라우팅에 포함시켜 보겠습니다.


<!-- item.component.html -->

<div class="item-laptop">
  <p>
    <a class="title" href="{{item.url}}">
      {{item.title}}
    </a>
    <span *ngIf="item.domain" class="domain">({{item.domain}})</span>
  </p>
  <div class="subtext-laptop">
    <span>
      {{item.points}} points by
      <a href="">{{item.user}}</a>
    </span>
    <span>
      {{item.time_ago}}
      <span> |
         <a [routerLink]="['/item', item.id]">
          <span *ngIf="item.comments_count !== 0">
            {{item.comments_count}}
            <span *ngIf="item.comments_count === 1">comment</span>
            <span *ngIf="item.comments_count > 1">comments</span>
          </span>
          <span *ngIf="item.comments_count === 0">discuss</span>
        </a>
      </span>
    </span>
  </div>
</div>
<div class="item-mobile">
  <!-- Markup that shows only on mobile (to give the app a
    responsive mobile feel). Same attributes as above,  
    nothing really new here (but refer to the source
    file if you're interested) -->
</div>
어플리케이션을 실행하고 뉴스 항목의 comment 부분을 클릭하여 주시기 바랍니다.


멋집니다. 우리는 ItemCommentsComponent로 라우팅이 되었음을 확인 할 수 있습니다. 이제 나머지 component들도 추가해 보겠습니다.


ng g component CommentTree
ng g component Comment


우리는 hackernews.api 서비스에 새로운 GET 요청을 추가하여 comment를 가져와야 합니다. component를 수정하기전에 먼저 살펴보겠습니다.


// hackernews.api.service.ts

//...

fetchComments(id: number): Observable {
  return this.http.get(`${this.baseUrl}/item/${id}`);
}


item-comments.component도 수정하도록 하겠습니다.


// item-comments.component.ts

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

import { HackerNewsAPIService } from '../hackernews-api.service';

@Component({
  selector: 'app-item-comments',
  templateUrl: './item-comments.component.html',
  styleUrls: ['./item-comments.component.scss']
})
export class ItemCommentsComponent implements OnInit {
  sub: any;
  item;

  constructor(
    private _hackerNewsAPIService: HackerNewsAPIService,
    private route: ActivatedRoute
  ) {}

  ngOnInit() {    
    this.sub = this.route.params.subscribe(params => {
      const itemID = +params['id'];
      this._hackerNewsAPIService.fetchComments(itemID).subscribe(data => {
        this.item = data;
      }, error => console.log('Could not load item' + itemID));
    });
  }
}

StoriesComponent에서 처리했던거와 비슷하게 라우트 매개변수를 subscribe하여 itemID를 확인 후 해당 뉴스의 comment들을 조회합니다.


<!-- item-comments.component.html -->

<div class="main-content">
  <div class="loading-section" *ngIf="!item">
    <!-- You can add a loading indicator here if you want to :) -->
  </div>
  <div *ngIf="item" class="item">
    <div class="mobile item-header">
     <!-- Markup that shows only on mobile (to give the app a
    responsive mobile feel). Same attributes as below,
    nothing really new here (but refer to the source
    file if you're interested) -->
    </div>
    <div class="laptop" [class.item-header]="item.comments_count > 0 || item.type === 'job'" [class.head-margin]="item.text">
      <p>
        <a class="title" href="{{item.url}}">
        {{item.title}}
        </a>
        <span *ngIf="item.domain" class="domain">({{item.domain}})</span>
      </p>
      <div class="subtext">
        <span>
        {{item.points}} points by
          <a href="">{{item.user}}</a>
        </span>
        <span>
          {{item.time_ago}}
          <span> |
            <a [routerLink]="['/item', item.id]">
              <span *ngIf="item.comments_count !== 0">
                {{item.comments_count}}
                <span *ngIf="item.comments_count === 1">comment</span>
                <span *ngIf="item.comments_count > 1">comments</span>
              </span>
              <span *ngIf="item.comments_count === 0">discuss</span>
            </a>
          </span>
        </span>
      </div>
    </div>
    <p class="subject" [innerHTML]="item.content"></p>
    <app-comment-tree [commentTree]="item.comments"></app-comment-tree>
  </div>
</div>

component 맨위에는 뉴스 정보가 노출되고 그뒤에는 뉴스 내용(item.content)이 표시됩니다. 그런 다음 CommentTreeComponent의 선택자인 app-comment-tree에 해당 뉴스 comment(item.comments)들을 입력합니다.


/* item-comments.component.scss */

$mobile-only: "only screen and (max-width : 768px)";
$laptop-only: "only screen and (min-width : 769px)";
$tablet-only: "only screen and (max-width: 1024px)";

.main-content {
  position: relative;
  width: 100%;
  min-height: 100vh;
  -webkit-transition: opacity .2s ease;
  transition: opacity .2s ease;
  box-sizing: border-box;
  padding: 8px 0;
  z-index: 0;
}

.item {
  box-sizing: border-box;
  padding: 10px 40px 0 40px;
  z-index: 0;
}

@media #{$tablet-only} {
  .item {
    padding: 10px 20px 0 40px;
  }
}

@media #{$mobile-only} {
  .item {
    box-sizing: border-box;
    padding: 110px 15px 0 15px;
  }
}

.head-margin {
  margin-bottom: 15px;
}

p {
  margin: 2px 0;
}

.subject {
  word-wrap: break-word;
  margin-top: 20px;
}

a {
  color: #000;
  cursor: pointer;
  text-decoration: none;
}

@media #{$mobile-only} {
  .laptop {
    display: none;
  }
}

@media #{$laptop-only} {
  .mobile {
    display: none;
  }
}

.title {
  font-size: 16px;
}

.title-block {
  text-align: center;
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
  margin: 0 75px;
}

@media #{$mobile-only} {
  .title {
    font-size: 15px;
  }
  .back-button {
    position: absolute;
    top: 52%;
    width: 0.6rem;
    height: 0.6rem;
    background: transparent;
    border-top: .3rem solid #B92B27;
    border-right: .3rem solid #B92B27;
    box-shadow: 0 0 0 lightgray;
    transition: all 200ms ease;
    left: 4%;
    transform: translate3d(0, -50%, 0) rotate(-135deg);
  }
}

.subtext {
  font-size: 12px;
}

.domain, .subtext {
  color: #696969;
  font-weight: bold;
  letter-spacing: 0.5px;
}

.domain a {
  color: #b92b27;
}

.subtext a {
  color: #b92b27;
  &:hover {
    text-decoration: underline;
  }
}

.item-details {
  padding: 10px;
}

.item-header {
  border-bottom: 2px solid #b92b27;
  padding-bottom: 10px;
}

@media #{$mobile-only} {
  .item-header {
    background-color: #fff;
    padding: 10px 0 10px 0;
    position: fixed;
    width: 100%;
    left: 0;
    top: 62px;
  }
}


.loader {
  background: #B92B27;
  -webkit-animation: load1 1s infinite ease-in-out;
  animation: load1 1s infinite ease-in-out;
  width: 1em;
  height: 4em;
  &:before, &:after {
    background: #B92B27;
    -webkit-animation: load1 1s infinite ease-in-out;
    animation: load1 1s infinite ease-in-out;
    width: 1em;
    height: 4em;
  }
  &:before, &:after {
    position: absolute;
    top: 0;
    content: '';
  }
  &:before {
    left: -1.5em;
    -webkit-animation-delay: -0.32s;
    animation-delay: -0.32s;
  }
}

.loading-section {
  height: 70px;
  margin: 40px 0 40px 40px;

  @media #{$mobile-only} {
    display: block;
    position: relative;
    margin: 45vh 0;
  }
}

.loader {
  color: #B92B27;
  text-indent: -9999em;
  margin: 20px 20px;
  position: relative;
  font-size: 11px;
  -webkit-transform: translateZ(0);
  -ms-transform: translateZ(0);
  transform: translateZ(0);
  -webkit-animation-delay: -0.16s;
  animation-delay: -0.16s;
  &:after {
    left: 1.5em;
  }

  @media #{$mobile-only} {
    margin: 20px auto;
  }
}

@-webkit-keyframes load1 {
  0%,
  80%,
  100% {
    box-shadow: 0 0;
    height: 2em;
  }
  40% {
    box-shadow: 0 -2em;
    height: 3em;
  }
}

@keyframes load1 {
  0%,
  80%,
  100% {
    box-shadow: 0 0;
    height: 2em;
  }
  40% {
    box-shadow: 0 -2em;
    height: 3em;
  }
}

@media #{$mobile-only} {
  @-webkit-keyframes load1 {
    0%,
    80%,
    100% {
      box-shadow: 0 0;
      height: 4em;
    }
    40% {
      box-shadow: 0 -2em;
      height: 5em;
    }
  }

  @keyframes load1 {
    0%,
    80%,
    100% {
      box-shadow: 0 0;
      height: 3em;
    }
    40% {
      box-shadow: 0 -2em;
      height: 4em;
    }
  }
}

그 다음으로 CommentTreeComponent를 설정하겠습니다.


// comment-tree.component.ts

import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'app-comment-tree',
  templateUrl: './comment-tree.component.html',
  styleUrls: ['./comment-tree.component.scss']
})
export class CommentTreeComponent implements OnInit {
  @Input() commentTree;

  constructor() {}

  ngOnInit() {

  }
}
<!-- comment-tree.component.html -->

<ul class="comment-list">
  <li *ngFor="let comment of commentTree" >
    <app-comment [comment]="comment"></app-comment>
  </li>
</ul>
/* comment-tree.component.scss */

ul {
  list-style-type: none;
  padding: 10px 0;
}

li {
  display: list-item;
}

ngFor directive를 이용하여 모든 comment들을 노출하도록 하였습니다.


이제 CommentComponent를 작성하도록 하겠습니다. 이 component는 하나의 comment에 해당하는 요소입니다.


// comment.component.ts

import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'app-comment',
  templateUrl: './comment.component.html',
  styleUrls: ['./comment.component.scss']
})
export class CommentComponent implements OnInit {
  @Input() comment;
  collapse: boolean;

  constructor() {}

  ngOnInit() {
    this.collapse = false;
  }
}
<!-- comment.component.html -->

<div *ngIf="!comment.deleted">
  <div class="meta" [class.meta-collapse]="collapse">
    <span class="collapse" (click)="collapse = !collapse">[{{collapse ? '+' : '-'}}]</span>
    <a [routerLink]="['/user', comment.user]" routerLinkActive="active">{{comment.user}}</a>
    <span class="time">{{comment.time_ago}}</span>
  </div>
  <div class="comment-tree">
    <div [hidden]="collapse">
      <p class="comment-text" [innerHTML]="comment.content"></p>
      <ul class="subtree">
        <li *ngFor="let subComment of comment.comments">
          <app-comment [comment]="subComment"></app-comment>
        </li>
      </ul>
    </div>
  </div>
</div>
<div *ngIf="comment.deleted">
  <div class="deleted-meta">
    <span class="collapse">[deleted]</span> | Comment Deleted
  </div>
</div>
/* comment.component.scss */

$mobile-only: "only screen and (max-width : 768px)";
$laptop-only: "only screen and (min-width : 769px)";
$tablet-only: "only screen and (max-width: 1024px)";

:host >>> {
  a {
    color: #b92b27;
    font-weight: bold;
    text-decoration: none;
    &:hover {
      text-decoration: underline;
    }
  }
}

.meta {
  font-size: 13px;
  color: #696969;
  font-weight: bold;
  letter-spacing: 0.5px;
  margin-bottom: 8px;
  a {
    color: #b92b27;
    text-decoration: none;

    &:hover {
      text-decoration: underline;
    }
  }
  .time {
    padding-left: 5px;
  }
}

@media #{$mobile-only} {
  .meta {
    font-size: 14px;
    margin-bottom: 10px;
    .time {
      padding: 0;
      float: right;
    }
  }
}

.meta-collapse {
  margin-bottom: 20px;
}

.deleted-meta {
  font-size: 12px;
  color: #696969;
  font-weight: bold;
  letter-spacing: 0.5px;
  margin: 30px 0;
  a {
    color: #b92b27;
    text-decoration: none;
  }
}

.collapse {
  font-size: 13px;
  letter-spacing: 2px;
  cursor: pointer;
}

.comment-tree {
  margin-left: 24px;
}

@media #{$tablet-only} {
  .comment-tree {
    margin-left: 8px;
  }
}

.comment-text {
  font-size: 14px;
  margin-top: 0;
  margin-bottom: 20px;
  word-wrap: break-word;
  line-height: 1.5em;
}

.subtree {
  margin-left: 0;
  padding: 0;
  list-style-type: none;
}

여기서 우리는 app-comment를 재귀로 호출했다는 것에 주목해야합니다. comments 안에 대댓글 개념으로 comments 배열 가지고 있어 대댓글까지 표기하기 위해 재귀를 사용했습니다.


이제 어플리케이션을 실행하면 각 뉴스의 모든 comment들을 보실수 있습니다.



지금까지의 작업 단계의 소스를 보실려면 여기에서 확인하실 수 있습니다.

User Profiles


우리에게 남은 것은 사용자 프로필뿐입니다. 개념은 거의 동일하므로 이 부분은 자세히 설명하지 않겠습니다. 프로필을 처리하기 위해서 해야할일은 아래와 같습니다.


  1. 데이터 서비스에 유저 정보를 조회하는 메서드를 설정합니다.
  2. user component를 생성합니다.
  3. app.route 에 /user 경로에 대해 추가합니다.
  4. 다른 component(item / item-comments)에 route 링크를 처리합니다.


사용자 프로필 처리까지 완료한 소스는 여기에서 확인 가능합니다.

Wrapping things up


모든 작업이 완료되었습니다. production으로 빌드 혹은 실행 하기 위해 ng build --prod 또는 ng serve --prod를 명령어를 입력 하시면 uglifying 및 tree-shaking을 사용할 수 있습니다.


이 튜토리얼이 유용했다면 트윗 또는 repo에 star (번역 repo)를 보내주세요.


이 글 이후 오프라인에서도 작동하고 모바일 홈 화면에 설치 할 수 있는  프로그레시브 웹 앱(PWA)을 만드는 방법에 대한 포스팅을 확인해 주시기 바랍니다.


번역후기


번역이 아직까지 익숙치 않아 오번역이 제법있을 수 있으나 최대한 튜토리얼을 직접 진행해보고 맥락에 맞게 번역하였습니다.

사실 PWA에 관련된 포스팅을 번역할려고 했으나 앞에 해당 포스팅이 선행으로 있어서 해당 글 부터 번역했습니다.


해당 글에 대한 피드백은 언제나 환영입니다.

고맙습니다.

댓글