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

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

Это продолжение. Первая часть. Вторая часть.

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

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

Разработка: Создание 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»
0 комментариев
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.