Когда я только знакомился с JavaScript фреймворками, мой выбор пал на Angular. Банально за то, что его курирует Google. Я набросал небольшое приложение для голосования. Вышло нечто с навбаром от бустрапа в перемешку с компонентами из Material design, где я писал на TypeScript как на JavaScript, не понимая толком зачем это нужно. Всё это было запушено на гитхаб вместе с node_modules. Сейчас, оглядываясь назад, я понимаю что это лучший из худших моих пет проектов. В итоге Angular мне показался сложным и толком не оценив сей фрейморк, я ушел в React.
Узнав недавно по итогам StackOverflow 2020, что Angular признан самым неприятным( ужасающим ) веб фреймворком, я решил еще раз встретиться со старым другом.
Это статья о том, как вооружившись документацией и гуглом, я набросал простое приложение со списком популярных фильмов и поиском кино с использованием tmdb API https://developers.themoviedb.org/3/getting-started/introduction.
Результат - https://tenzeniga.github.io/angular-movies/
Код - https://github.com/TenzenIga/angular-movies/tree/master/src
Начать с Ангуляр довольно просто. Достаточно установить Angular Cli.
Для создания нового проекта вводим команду ng create
ng create movies
Будет предложено выбрать препроцессор для стилей. Я выбрал Sccs и выбрал включить роутинг.
Пока в проекте 1 главный компонент(помимо множества конфигурационных файлов) app.component.ts, в нем через декоратор @component подключен файл со стилями и разметкой app.component.html и app.component.scss.
Чтобы создать новый компонент нужно ввести команду ng generate component сокращенно ng g c На главной странице у нас будет крутой постер из Криминального чтива и поисковиком по центру. Создадим этот компонент:
Все стили, картинки и шрифты можно найти в репозитории
ng g c components/search
Компонент Search будет в директории components Если открыть app.modules.ts, там уже будет импортирован созданный компонент SearchComponent. Туда же нужно импортировать HttpClientModule и добавить массив imports.
Мы хотим отобразить инпут для поиска фильмов. При вводе данных должен появляться div с 5 подсказками. Чтобы получить список подсказок, нужно сделать API запрос search/movie. Это можно сделать в search.components.ts , но лучше вынести метод в отдельный сервис и внедрить его в компонент. Создадим сервис для api запросов
ng g s services/api
api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import {throwError as observableThrowError, Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ResponseMovies } from '../interfaces/interfaces';
@Injectable({
providedIn: 'root'
})
export class ApiService {
API_KEY = '****';
constructor(
private http: HttpClient
) { }
searchMovies(e:string):Observable<ResponseMovies>{
return this.http.get<ResponseMovies>(`https://api.themoviedb.org/3/search/movie?api_key=${this.API_KEY}&language=en-US&query=${e}&page=1&include_adult=false`)
.pipe(catchError(this.errorHandler));
}
errorHandler(error:HttpErrorResponse){
return observableThrowError(error.message || "Server error");
}
}
Чтобы сделать http запрос нужно импортировать HttpClient. В классе Apiservice создаем поле API_KEY с API ключом. Внутри метода конструктор инициализируем httpClient. Создаем метод searchMovies, который принимает строку и возвращает Observable. Для обработки ошибок используем методы библиотеки Rxjs
Так как мы используем TypeScript - нужно указать типы данных, которые мы получим с сервера. В документации tmdb API можно увидеть полную схему ответа.
Нам нужны только название фильма, id и ссылка на постер. В отдельном файле создадим и экспотируем интерфейсы ResponseMovie и Movie.
interface.ts
export interface ResponseMovies{
results:Movie[]
}
export interface Movie{
id: number
original_title: string
poster_path: string
}
Сервис готов, теперь его можно заинджектить в search.component.ts. Создадим разметку страницы. Нам нужен input и div который будет отображать подсказки
search.component.html
<div class="bg-image">
<div class="search-wrapper">
<h2>Your favorite movies.</h2>
<input type="text" #search placeholder="Search movies">
<div class="suggestions">
<p *ngFor="let suggestion of suggestions | slice:0:5">
{{suggestion.original_title}}
</p>
</div>
</div>
</div>
Тут используются некоторые фундаментальные концепии Angular. Структурная деректива ngFor позволяет перебрать массив suggestions. С помощью оператора pipe |, можно всячески форматировать отобржаемые данные, в данном случае, мы сокращаем массив до первых 5 элементов.
В input мы добавили шаблоную переменную #search, через неё мы будем отлавливать изменения в поле ввода.
search.component.ts
import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { ApiService } from 'src/app/services/api.service';
import { fromEvent } from 'rxjs';
import { map, debounceTime, distinctUntilChanged, tap, filter } from 'rxjs/operators';
import { Movie } from 'src/app/interfaces/interfaces';
@Component({
selector: 'app-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss']
})
export class SearchComponent implements AfterViewInit{
@ViewChild('search') searchInput: ElementRef;
suggestions:Movie[] = [];
constructor(
private apiService:ApiService
){}
ngAfterViewInit() {
// Api call search
fromEvent(this.searchInput.nativeElement,'keyup')
.pipe(
map((event:any)=>{
return event.target.value;
}),
filter(
res =>{
if(res.length > 2){
return true;
}else{
this.suggestions = [];
return false
}
}
),
debounceTime(500),
distinctUntilChanged(),
tap(() => {
this.handleSearch(this.searchInput.nativeElement.value)
})
).subscribe()
}
handleSearch(searchInput:string){
this.apiService.searchMovies(searchInput).subscribe(data =>{
this.suggestions = data.results;
});
}
}
В декоратор @ViewChild передаем имя переменной, поле searchInput теперь указывает на переменную search. Создаем пустой массив suggestion. В constructor иницируем ApiService.
ngAfterViewInit - метод жизненного цикла, который запускается после рендера компонента, так @ViewChild имеет доступ к значению в input.
Функция handleSearch запускает метод apiService и обновляет массив подсказок
Чтобы уменьшить количество запросов на сервер, хорошей практикой считается добавить искусственную задержку перед отправлением запроса, другими слвовами debounce. Для этого можно воспользоваться библиотекой rxjs:
filter - фукнция поиска не будет запускаться, пока инпут меньше 2 символов. debounceTime - время задержки. distinctUntilChanged - отличается ли новое значение от предыдущего.
Чтобы отобразить созданный компонент.
app.component.html
<app-search></app-search>
<router-outlet></router-outlet>
Создадим компонент для списка самых популярных фильмов и добавим метод getMovies в api.service.ts
ng g c components/movies-list
api.service.ts
...
getMovies():Observable<ResponseMovies>{
return this.http.get<ResponseMovies>(`https://api.themoviedb.org/3/movie/popular?api_key=${this.API_KEY}&language=en-US&page=1`)
.pipe(catchError(this.errorHandler));
}
}
movies-list.component.html
<section class="movies-section">
<div class="container">
<div class="subheading">Popular movies</div>
<div class='movie-list'>
<div *ngFor="let movie of movies | slice:0:10" class="movie-list__item">
<img [src]="'https://image.tmdb.org/t/p/w185' + movie.poster_path" />
</div>
</div>
</div>
</section>
Тут уже знакомая директива *ngFor и пайп slice. Для приведение значения переменной с ссылкой на постер фильма, src оборачивается в квадратные скобки [src].
movies-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Movie } from 'src/app/interfaces/interfaces';
import { ApiService } from 'src/app/services/api.service';
@Component({
selector: 'app-movies-list',
templateUrl: './movies-list.component.html',
styleUrls: ['./movies-list.component.scss']
})
export class MoviesListComponent implements OnInit {
movies:Movie[] = [];
constructor(
private apiService:ApiService,
) { }
ngOnInit(): void {
this.getMovies();
}
getMovies():void {
this.apiService.getMovies().subscribe(data =>{
this.movies = data.results;
});
}
}
ngOnit - метод жизненного цикла, который вызывается после инициализации компонента.
Аналогичным способом создается компонент со списком самых рейтинговых фильмов, разница лишь в методе API и количестве отображаемых элементов.
При клике на фильм из поиска или списка, нам нужно открыть отдельную страницу с описанием сюжета, трейлером и постером.Также не помешает страница для отображения ошибки 404 на случай, если страница не найдена. Для этого нужен роутинг.
Создадим компонеты страницы фильма и страницы 404
ng g c components/movie-page
ng g c components/page-not-found
И обертку для компонентов главной страницы Main и футер который будет отображаться на каждой странице.
ng g c components/main
ng g c components/footer
Стили и html футера в репозитории.
main.component.html
<app-search></app-search>
<app-movies-list></app-movies-list>
<app-top-movies></app-top-movies>
Чтобы добавить роут, в app.routing.module, нужно указать адрес и компонент. Адрес компонента PageNotFound указывается последним в списке.
app.routing.module
...
const routes: Routes = [
{path:'', component:MainComponent},
{path: 'movie/:id', component: MoviePageComponent},
{path:'**', component: PageNotFoundComponent}
];
...
В app.component.html
<router-outlet></router-outlet>
<app-footer></app-footer>
Т.к футер не входит в main.component.html, он будет отображаться на каждой странице.
Осталось сделать так чтобы при нажатии на фильм или название фильма открывалась страница фильма. Для этого в search.component.html нужно добавить click ивент.
search.component.html
...
<p (click)="onSelect(suggestion)" *ngFor="let suggestion of suggestions | slice:0:5">
{{suggestion.original_title}}
</p>
...
В search.component.ts нужно импортировать Router и заинджектить в constructor. Функция onSelect, через метод router.navigate перенаправляет на страницу /movie/{id} (То же самое проделывается в компоненте со списком фильмов)
search.component.ts
...
import { Router } from '@angular/router';
...
constructor(
private router:Router,
private apiService:ApiService
){}
onSelect(suggestion:Movie){
this.router.navigate(['/movie', suggestion.id]);
}
Чтобы получить информацию о фильме нужно воспользоваться методом tmdb API /movie/{movie_id}
Для того чтобы получить трейлер фильма - /movie/{movie_id}/videos
Оба метода возвращают объект с большим количеством полей и информации. Создадим интерфейс, где выделим нужные нам параметры.
interface.ts
...
export interface ResponseVideo{
results:Video[]
}
export interface MovieDetail{
backdrop_path:string | null
budget:number
genres:Genre[]
overview:string | null
popularity: number
release_date:string
poster_path:string | null
title:string
tagline:string | null
}
interface Genre{
id:number
name:string
}
export interface Video{
key:string
}
Добавим новые методы в сервис
api.service.ts
...
getMovieDetail(id:number):Observable<MovieDetail>{
return this.http.get<MovieDetail>(` https://api.themoviedb.org/3/movie/${id}?api_key=${this.API_KEY}&language=en-US`)
.pipe(catchError(this.errorHandler));
}
getMovieTrailer(id:number):Observable<ResponseVideo>{
return this.http.get<ResponseVideo>(`https://api.themoviedb.org/3/movie/${id}/videos?api_key=${this.API_KEY}&language=en-US`)
.pipe(catchError(this.errorHandler));
}
Осталось набросать html структуру компонента MoviePage
movie-page.component.html
<div class="movie" >
<div *ngIf="isLoading">
<h3>Loading...</h3>
</div>
<ng-container *ngIf="movie">
<div class="movie__detail movie-detail">
<button class="btn-back" (click)="goHome()">← Back</button>
<h2>{{movie.title}} ({{movie.release_date}})</h2>
<span class="movie__genre" *ngFor='let genre of movie.genres'>
{{genre.name}}
</span>
<h3>Overview</h3>
<p>{{movie.overview}}</p>
<iframe *ngIf='video' [src]="videoUrl" frameborder="0"></iframe>
</div>
<img class="movie__img" [src]="'https://image.tmdb.org/t/p/original' + movie.poster_path" />
</ng-container>
</div>
Деректива *ngIf, как можно догадаться из названия, позволяет отображать тот или иной элемент DOM в зависимости от условий. В данном случае мы показываем альтернативный шаблон #loading, пока данные не будут получены с сервера.
movie-page.component.ts
export class MoviePageComponent implements OnInit {
videoUrl;
video:Video[];
movie:MovieDetail;
isLoading: boolean;
constructor(
private route:ActivatedRoute,
private router:Router,
private apiService:ApiService,
private _sanitizer: DomSanitizer
) { }
//get movie info on page init
ngOnInit(): void {
let id = parseInt(this.route.snapshot.paramMap.get('id'));
this.isLoading = true;
this.apiService.getMovieDetail(id).subscribe(result => {
this.movie = result
});
this.apiService.getMovieTrailer(id).subscribe(data =>{
this.isLoading = false;
this.video = data.results;
this.videoUrl = this._sanitizer.bypassSecurityTrustResourceUrl( `https://www.youtube.com/embed/${this.video[0].key}`);
}, (err)=>{
this.isLoading = false;
})
}
goHome(){
this.router.navigate(['']);
}
}
С помощью ActivatedRoute мы можем достать id фильма this.route.snapshot.paramMap.get('id')
Так как ссылка на видео в iframe сохранена в качестве переменной, чтобы избежать ошибки Error: unsafe value used in a resource URL context, нужно использовать DomSanitazer.
Фукнция goHome() - позволяет вернутсья обратно на главную страницу.
Про компонент страницы page-not-found расписывать не буду, т.к там ничего не происходит. Можно убедиться, что она работает, если ввести ложный адрес.
Ангуляр очень мощный инструмент и моя поверхостная пробежка по базовым моментам не раскрывает и половины возможностей этого фреймворка. В сравнении с реактом, ощущается громоздким и перенасыщенным магией.