Создание hacker news с angular 2 cli, rxjs и webpack
March 2017
Если вы когда-либо создавали приложения с Angular 2, то знаете, что первоначальная настройка проекта отнимает немало времени. К счастью, команда Angular создала Angular CLI — утилиту командной строки, облегчающую эту задачу.
В этой статье мы построим клиента Hacker News, используя Angular CLI, RxJS Observables и Webpack как загрузчик модулей.
Готовое приложение.
Исходный код.
Мы постепенно, шаг за шагом, пройдём весь процесс построения приложения и я постараюсь подробно объяснять важные моменты по ходу дела, а также сделанные мной ошибки и способы их решения.
Вот краткий список того, что нам предстоит сделать:
- Мы начнём с построения каркаса приложения, главной страницы Hacker News
- Затем подключим Observable Data Service для асинхронной загрузки данных
- Добавим роутинг с Angular Component Router для построения навигации между страницами и видами топиков
- И, наконец, добавим роуты чтобы пользователь мог перейти к комментариям к топику и в профили пользователей.
Приступим
Убедитесь, что у вас установлен Node и npm, затем установите CLI в терминале:
npm install -g @angular/cli
Создадим и запустим приложение:
ng new angular2-hn
cd angular2-hn
ng serve
Откройте браузер по адресу localhost:4200/
Круто, да?
Настроим Sass как препроцессор CSS:
ng set defaults.styleExt scss
Создадим первый компонент HeaderComponent
ng generate component Header
Будет создана папка header, содержащая такие файлы:
- header.component.scss
- header.component.html
- header.component.ts
- header.component.spec.ts
Посмотрим на файл app.module.ts и увидим, что наш компонент уже задекларирован:
// app.module.ts
// ...
import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';
@NgModule({
declarations: [
AppComponent,
HeaderComponent
],
//...
А взглянув в файл header.component.ts, вы увидите, что селектор компонентов — app-header. Добавим его в наш корневой компонент, app.component.ts.
<!-- app.component.html -->
<app-header></app-header>
Запустим приложение. Компонент header загрузился нормально:
Супер. Теперь добавим кое-какую разметку и стили.
<!-- app.component.html -->
<div id="wrapper">
<app-header></app-header>
</div>
Стили из файла app.component.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>
Стили этого компонента можно скачать здесь. Запустим приложение:
Наше приложение должно быть отзывчивым. Посмотрим, как оно выглядит сейчас на уменьшенном экране:
Но у нас с края появился непредвиденный отступ. Это из-за того, что элемент body имеет отступ по-умолчанию (через margin):
Но если открыть app.component.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 {
}
Перезапустим приложение и посмотрим на него. Теперь стили применились как и должно.
Несколько компонентов
Добавим ещё пару компонентов, 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>
Вот так теперь выглядит приложение:
Так как каждый топик, или элемент, будет иметь собственные атрибуты, имеет смысл создать отдельный компонент для этого.
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-ам, но при этом позволяют следить за потоком данных и реагировать на различные события с ним.
Источник: Вступление в Реактивное Программирование, которое вы пропустили
На диаграмме изображены события, которые происходят при клике на кнопку. Обратите внимание, как этот поток испускает значения (представляющие события клика по кнопке), ошибку, а также событие завершения.
Концепция использования 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:
Итак, мы получаем идентификатор каждого 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 будут показаны его детали. Здесь можно скачать файлы стилей компонента.
Код приложения на текущем этапе можно скачать здесь. Перезапустите приложение и увидим такую картину:
Скорость работы
А теперь взглянем на страничку запросов, которые приходят при загрузке главной страницы приложения.
Ух ты, 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>
И посмотрим что у нас получилось:
Всё работает намного быстрее! Исходный код этого этапа можно скачать здесь.
Роутинг
Мы уже много сделали, но сделаем паузу и нарисуем структуру компонентов нашего приложения. Простите за моё неумение работать в Powerpoint.
Начнём с того, что мы уже сделали:
Также обрисуем компоненты, показывающие переход к странице комментариев:
Чтобы пользователь мог переходить между этими страницами, нам нужен небольшой роутинг. Создадим компонент:
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);
Вот что мы сделали:
- Мы создали массив роутов, с указанием относительного пути и привязкой к конкретному компоненту
- Ссылки в шапке страницы будут указывать на разные пути: news, newest, show, ask и jobs. Все эти пути привязаны к StoriesComponent
- С корневого пути мы сделаем редирект на news, возвращающий топ историй
- При привязке StoriesComponent мы передаём storiesType как параметр свойства data.
- :page используем как токен, поэтому StoriesComponent может получать список топиков определённой страницы
- :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>
Запустите приложение и кликните на комментарии топика:
Красота. Роутинг к 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>
Запустим приложение и сможем увидеть комментарии где и положено:
Исходный код этого этапа можно взять здесь.
Профили пользователей
Остались только профили пользователей. Их мы сделаем абсолютно так же, как и предыдущие элементы, поэтому описание этого этапа мы пропустим. Вот что нужно сделать:
- Добавить ещё один запрос в службу данных, работающий конечную точку по пользователям.
- Создать компонент для этого.
- Добавить поле в файл с роутами.
- Обновить во всех компонентах ссылки, указывающие на пользователя.
Вот готовый код этой части.
Заключение
Мы закончили. Для сборки приложения можно запустить
ng build --prod
или
ng serve --prod
Источник: «building hacker news with angular 2 cli, rxjs and webpack»