Создание hacker news с angular 2 cli, rxjs и webpack

March 2017

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack
Ес­ли вы ко­гда-ли­бо со­зда­ва­ли при­ло­же­ния с Angular 2, то зна­е­те, что пер­во­на­чаль­ная на­строй­ка про­ек­та от­ни­ма­ет нема­ло вре­ме­ни. К сча­стью, ко­ман­да Angular со­зда­ла Angular CLI — ути­ли­ту ко­манд­ной стро­ки, об­лег­ча­ю­щую эту за­да­чу.

В этой ста­тье мы по­стро­им кли­ен­та Hacker News, ис­поль­зуя Angular CLI, RxJS Observables и Webpack как за­груз­чик мо­ду­лей.

Го­то­вое при­ло­же­ние.
Ис­ход­ный код.

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack
Мы по­сте­пен­но, шаг за ша­гом, прой­дём весь про­цесс по­стро­е­ния при­ло­же­ния и я по­ста­ра­юсь по­дроб­но объ­яс­нять важ­ные мо­мен­ты по хо­ду де­ла, а та­к­же сде­лан­ные мной ошиб­ки и спо­со­бы их ре­ше­ния.

Вот крат­кий спи­сок то­го, что нам пред­сто­ит сде­лать:

  1. Мы нач­нём с по­стро­е­ния кар­ка­са при­ло­же­ния, глав­ной стра­ни­цы Hacker News
  2. За­тем под­клю­чим Observable Data Service для асин­хрон­ной за­груз­ки дан­ных
  3. До­ба­вим ро­утинг с Angular Component Router для по­стро­е­ния на­ви­га­ции меж­ду стра­ни­ца­ми и ви­да­ми то­пи­ков
  4. И, на­ко­нец, до­ба­вим ро­уты что­бы поль­зо­ва­тель мог пе­рей­ти к ком­мен­та­ри­ям к то­пи­ку и в про­фи­ли поль­зо­ва­те­лей.
При­сту­пим

Убе­ди­тесь, что у вас уста­нов­лен Node и npm, за­тем уста­но­ви­те CLI в тер­ми­на­ле:

npm install -g @angular/cli

Со­зда­дим и за­пу­стим при­ло­же­ние:

ng new angular2-hn
cd angular2-hn
ng serve

От­крой­те бра­у­зер по ад­ре­су localhost:4200/

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1
Кру­то, да?

На­стро­им Sass как пре­про­цес­сор CSS:

ng set defaults.styleExt scss

Со­зда­дим пер­вый ком­по­нент HeaderComponent

ng generate component Header

Бу­дет со­зда­на пап­ка header, со­дер­жа­щая та­кие фай­лы:

  • header.​com​pone​nt.​scss
  • header.​com​pone​nt.​html
  • header.​com​pone​nt.​ts
  • header.​com​pone​nt.​spec.​ts

По­смот­рим на файл app.​module.​ts и уви­дим, что наш ком­по­нент уже за­де­кла­ри­ро­ван:

// app.module.ts

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

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

А взгля­нув в файл header.​com​pone​nt.​ts, вы уви­ди­те, что се­лек­тор ком­по­нен­тов — app-header. До­ба­вим его в наш кор­не­вой ком­по­нент, app.​com​pone​nt.​ts.

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

<app-header></app-header>

За­пу­стим при­ло­же­ние. Ком­по­нент header за­гру­зил­ся нор­маль­но:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1
Су­пер. Те­перь до­ба­вим кое-ка­кую раз­мет­ку и сти­ли.

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

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

Сти­ли из фай­ла app.​com​pone​nt.​scss мо­же­те ска­чать здесь. Пе­рей­дём к header.

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

<header id="header">
  <a class="home-link" href="/">
    <img class="logo" src="https://i.imgur.com/J303pQ4.png" alt="Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1" />
  </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>

Сти­ли это­го ком­по­нен­та мож­но ска­чать здесь. За­пу­стим при­ло­же­ние:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1

На­ше при­ло­же­ние долж­но быть от­зыв­чи­вым. По­смот­рим, как оно вы­гля­дит сей­час на умень­шен­ном экране:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1
Но у нас с края по­явил­ся непред­ви­ден­ный от­ступ. Это из-за то­го, что эле­мент body име­ет от­ступ по-умол­ча­нию (че­рез margin):

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1
Но ес­ли от­крыть app.​com​pone​nt.​scss, там ука­за­но пра­ви­ло margin: 0 для экра­нов мень­ше 768px:

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

body {
  margin-bottom: 0;

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

Так по­че­му же оно так ра­бо­та­ет? Это из-за спо­со­ба, ко­то­рым Angular ин­кап­су­ли­ру­ет CSS-сти­ли ком­по­нен­та. Не бу­дем вда­вать­ся в де­та­ли, но есть три спо­со­ба, ко­то­рые мо­жет ис­поль­зо­вать Angular для это­го:

  • None: Angular ни­че­го не пред­при­ни­ма­ет — ни ин­кап­су­ля­ции, ни Shadow DOM, про­сто обыч­ная за­груз­ка сти­лей.
  • Emulated: Angular эму­ли­ру­ет по­ве­де­ние Shadow DOM. Это спо­соб по-умол­ча­нию.
  • Native: Angular ис­поль­зу­ет на­тив­ный Shadow DOM бра­у­зе­ра (толь­ко в бра­у­зе­рах, име­ю­щих со­от­вет­ству­ю­щую под­держ­ку).
    В кор­не­вом ком­по­нен­те мы до­бав­ля­ем сти­ли эле­мен­ту body, но оно не ра­бо­та­ет, по­то­му что мы не ука­за­ли Angular не при­ме­нять ни­ка­ких дей­ствий к пред­став­ле­нию ком­по­нен­та:
// 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 {
}

Пе­ре­за­пу­стим при­ло­же­ние и по­смот­рим на него. Те­перь сти­ли при­ме­ни­лись как и долж­но.

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1

Несколь­ко ком­по­нен­тов

До­ба­вим ещё па­ру ком­по­нен­тов, Stories и Footer. 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 мож­но взять здесь. Под­вал стра­ни­цы очень про­стой (сти­ли для него ска­чай­те тут).

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>

Об­но­вим кор­не­вой ком­по­нент, что­бы уви­деть до­бав­лен­ные ком­по­нен­ты:

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

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

Вот так те­перь вы­гля­дит при­ло­же­ние:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 1
Так как каж­дый то­пик, или эле­мент, бу­дет иметь соб­ствен­ные ат­ри­бу­ты, име­ет смысл со­здать от­дель­ный ком­по­нент для это­го.

ng g component Item

Ко­гда у нас бу­дут ре­аль­ные дан­ные, нуж­но бу­дет пе­ре­да­вать иден­ти­фи­ка­тор эле­мен­та из ком­по­нен­та то­пи­ков его до­чер­не­му эле­мен­ту. Тем вре­ме­нем, сде­ла­ем пе­ре­да­чу по­зи­ции спис­ка как itemID:

<!-- 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.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.

Итак, у нас по­лу­чил­ся от­лич­ный кар­кас глав­ной стра­ни­цы. Здесь ис­ход­ный код при­ме­ра на те­ку­щем эта­пе.

RxJS и Observables

В Angular 2 для общения с сервером мы используем библиотеку RxJS, которая возвращает Observable с данными, или асинхронный поток данных. Вероятно, вы уже знакомы с концепцией Promise-ов и как с их помощью можно асинхронно получать данные. Observable получают данные подобно promise-ам, но при этом позволяют следить за потоком данных и реагировать на различные события с ним.

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 2
Источник: Вступление в Реактивное Программирование, которое вы пропустили

На диаграмме изображены события, которые происходят при клике на кнопку. Обратите внимание, как этот поток испускает значения (представляющие события клика по кнопке), ошибку, а также событие завершения.

Концепция использования Observable в приложениях известна как Реактивное Программирование.

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"
}

К примеру, если нам нужно получить такие данные, как рейтинги на главной страницы, необходимо использовать специальную конечную точку, близкую к топикам. И лучшие топики можно получить так:

// 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 ...]

Таким образом, мы получаем список топиков в топе, а затем необходимо пройтись по каждому из них. Приступим.

Включим службу в метаданные provider нашего NgModule:

// app.module.ts

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

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

Теперь добавим метод для запроса в неё:

// hackernews-api.service.ts

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

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

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

  fetchStories(): Observable<any> {
    return this.http.get(`${this.baseUrl}/topstories.json`)
                    .map(response => response.json());
  }
}

Как мы говорили ранее, вызов http.get возвращает Observable с данными. В fetchStories мы принимаем Observable, а затем map-им его в формат JSON.

// 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'));
  }
}

В хуке ngOnInit, который срабатывает при инициализации компонента, мы подписываемся (subscribe) на поток данных и присваиваем атрибуту items то, что нам будет возвращено. А в наше представление мы добавим SlicePipe для вывода только 30 элементов списка из 500, которые нам возвращает запрос.

<!-- 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:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 2
Итак, мы получаем идентификатор каждого item, теперь добавим подписку на детали каждого элемента и для этого напишем новый метод:

// hackernews-api.service.ts

//...

fetchItem(id: number): Observable<any> {
  return this.http.get(`${this.baseUrl}/item/${id}.json`)
                  .map(response => response.json());
}

Немного доработаем компонент item:

// 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">
  <!-- Сюда можно добавить индикатор загрузки, если очень надо :) </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">
    <!-- Разметка только для мобильных устройств -->
  </div>
</div>

Здесь всё просто: мы подписываемся на соответствующий поток для каждого элемента. В разметке у нас есть индикатор загрузки, который виден до получения данных с сервера. При загрузке элемента из Observable будут показаны его детали. Здесь можно скачать файлы стилей компонента.

Код приложения на текущем этапе можно скачать здесь. Перезапустите приложение и увидим такую картину:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 2

Скорость работы

А теперь взглянем на страничку запросов, которые приходят при загрузке главной страницы приложения.

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3

Ух ты, 31 запрос и 20.8КБ передано за 546 мс. Это почти в пять раз медленне загрузки главной страницы Hacker News и вдвое больший объём данных при загрузке топиков. Это очень медленно. Даже приняв во внимание, что мы грузим главную страницу один раз и можем смириться с полусекундной задержкой, то загрузка комментариев к популярной новости займёт очень много времени!
Вы можете увидеть как я загружаю новость с 2000 комментариев тут. Если вам лень смотреть гифку, то вот статистика: там 741 запрос, 1,5 МБ и 90 сек для загрузки примерно 700 комментариев (я не стал ждать пока все комментарии загрузятся).

Вносим коррективы

Теперь мы видим, что выбрали не лучшее решение, используя множественные сетевые запросы для загрузки элементов и их содержимого. После недолгого поиска, я нашёл неофициальный 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.ts, созданный ранее, а также удалить библиотеку angular2-moment. И доработаем нашу службу:

// hackernews-api.service.ts

export class HackerNewsAPIService {
  baseUrl: string;

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

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

Так как API не загружает все 500 топиков, нам необходимо будет добавить номер страницы как аргумент. Обратите внимание также на то, как мы передаём storyType — это позволит нам показывать разные типы топиков, в зависимости от запроса пользователя.

Доработаем компонент stories. Можно начать лишь передав ‘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'));
  }
}

Соответствующая разметка:

<!-- 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>

И нам нужно ещё добавить индикатор загрузки.

Доработаем ItemComponent — в файле item.component.ts уже не нужен HackerNewsService:

// item.component.ts

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

  constructor() {}

  ngOnInit() {

  }
}

Разметка:

<!-- 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>

И посмотрим что у нас получилось:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3
Всё работает намного быстрее! Исходный код этого этапа можно скачать здесь.

Роутинг

Мы уже много сделали, но сделаем паузу и нарисуем структуру компонентов нашего приложения. Простите за моё неумение работать в Powerpoint.

Начнём с того, что мы уже сделали:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3
Также обрисуем компоненты, показывающие переход к странице комментариев:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3
Чтобы пользователь мог переходить между этими страницами, нам нужен небольшой роутинг. Создадим компонент:

ng g component ItemComments

А теперь создадим файл app.routes.ts в папке 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. Мы создали массив роутов, с указанием относительного пути и привязкой к конкретному компоненту
  2. Ссылки в шапке страницы будут указывать на разные пути: news, newest, show, ask и jobs. Все эти пути привязаны к StoriesComponent
  3. С корневого пути мы сделаем редирект на news, возвращающий топ историй
  4. При привязке StoriesComponent мы передаём storiesType как параметр свойства data.
  5. :page используем как токен, поэтому StoriesComponent может получать список топиков определённой страницы
  6. :id используется также, поэтому ItemCommentsComponent получает все комментарии к нужному топику

С роутингом можно сделать ещё много интересного, но этой основы нам пока достаточно. Откроем app.module.ts и зарегистрируем наш роутинг:

// app.module.ts

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

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

Чтобы Angular знал, куда загружать нужный компонент, нам нужен RouterOutlet.

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

<div id="wrapper">
  <app-header></app-header>
  <router-outlet></router-outlet>
  <app-footer></app-footer>
</div>
Навигация по топикам

Привяжем навигационные ссылки в 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" alt="Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3" />
    </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 ответственна за привязку определённого элемента к роуту. Теперь обновим 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);
    });
  }
}

Опишем вкратце, что мы сделали. Вначале мы импортировали ActivatedRoute — это служба, обеспечивающая доступ к информации в роуте.

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

@Component({
  //...
})

export class StoriesComponent implements OnInit {
//..

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

Затем мы подписываемся на свойства данных роута и сохраняем storiesType в переменной компонента в хуке ngOnInit.

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

// ...
}

И, наконец, мы подписываемся на параметры роута и получаем номер страницы. Затем получаем список топиков:

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() для обновления переменной listStart, которую используем как начальное значение для нашего упорядоченного списка (его вы увидите в разметке ниже). Также мы прокручиваем страницу вверх, чтобы пользователь не застрял внизу страницы при переходе на другую страницу.

<!-- 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>

Главная страница готова, у нас есть навигация и пагинация. А лучше сами проверьте как работает приложение.

Комментарии

Мы почти закончили! Перед тем, как начать добавлять компоненты комментариев, обновим ссылки в 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>

Запустите приложение и кликните на комментарии топика:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3
Красота. Роутинг к ItemCommentsComponent работает. Теперь создадим остальные компоненты.

ng g component CommentTree
ng g component Comment

Добавим новый GET-запрос в нашу службу для получения комментариев.

// hackernews.api.service.ts

//...

fetchComments(id: number): Observable<any> {
  return this.http.get(`${this.baseUrl}/item/${id}`)
                  .map(response => response.json());
}

И заполним наши компоненты

// 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 => {
      let itemID = +params['id'];
      this._hackerNewsAPIService.fetchComments(itemID).subscribe(data => {
        this.item = data;
      }, error => console.log('Could not load item' + itemID));
    });
  }
}

Также как в StoriesComponent, мы сделаем подписку к параметрам роута, получим id элемента и по нему получим нужные комментарии.

<!-- 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>

В начале компонента мы выводим детали элемента, идущие за его описанием (item.content). Затем вводим объект комментариев (item.comments) в app-comment-tree, селектор для CommentTreeComponent. Стили для этого компонента можно скачать здесь.

Теперь доработаем 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>

Мы выводим список комментариев директивой ngFor. Здесь можно скачать стили.

Доработаем CommentComponent, отвечающий за конкретный комментарий:

// 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>

Запустим приложение и сможем увидеть комментарии где и положено:

Разработка: Создание hacker news с angular 2 cli, rxjs и webpack, часть 3
Исходный код этого этапа можно взять здесь.

Профили пользователей

Остались только профили пользователей. Их мы сделаем абсолютно так же, как и предыдущие элементы, поэтому описание этого этапа мы пропустим. Вот что нужно сделать:

  1. Добавить ещё один запрос в службу данных, работающий конечную точку по пользователям.
  2. Создать компонент для этого.
  3. Добавить поле в файл с роутами.
  4. Обновить во всех компонентах ссылки, указывающие на пользователя.

Вот готовый код этой части.

Заключение

Мы закончили. Для сборки приложения можно запустить

ng build --prod

или

ng serve --prod

Источник: «building hacker news with angular 2 cli, rxjs and webpack»

comments powered by Disqus