Angular — это фреймворк позволяющий быстро и удобно разрабатывать одностраничные веб приложения. В своей основе он использует язык программирования TypeScript. Давайте рассмотрим процесс создания процесс создания SPA-приложения (Single Page Application) с помощью данного фреймворка от идеи до публикации.
Данная статья достаточно объемна, поэтому будет разбита на несколько частей. Перед прочтением я рекомендую ознакомится с кратким описанием особенностей языка TypeScript в статье Немного о TypeScript. Для разработки будет использоваться Angular 5, Visual Studio 2017.
Итак, нашей целью является разработка простого приложения представляющее собой доску объявлений, куда можно публиковать короткие объявления. Приступ. Перед началом работы не забываем, что предварительно необходимо установить node.js.
Создаем новый проект
Выбираем тип приложения Angular
Необходимо подождать, пока будут загружены все библиотеки из nuget. После этого собираем проект, чтобы проверить корректность всех полученных библиотек и наличия всех необходимых компонентов системы. В результате мы видим стандартный шаблон приложения.
Не забудьте проверить файлы конфигурации приложения. Они должны выглядеть примерно следующим образом. Удалим лишнее и добавим недостающее, чтобы получить следующий вид:
Package.json
{ "name": "Adsmini", "private": true, "version": "1.0.0", "author": "Vadim Shvanov <shwanoff.ru>", "dependencies": { "@angular/common": "~5.0.0", "@angular/compiler": "~5.0.0", "@angular/core": "~5.0.0", "@angular/forms": "~5.0.0", "@angular/platform-browser": "~5.0.0", "@angular/platform-browser-dynamic": "~5.0.0", "@angular/platform-server": "~5.0.0", "@angular/router": "~5.0.0", "@angular/animations": "~5.0.0", "@angular/compiler-cli": "~5.0.0", "@angular/http": "~5.0.0", "core-js": "^2.4.1", "rxjs": "^5.5.2", "zone.js": "^0.8.14" }, "devDependencies": { "@ngtools/webpack": "1.5.0", "@types/chai": "4.0.1", "@types/jasmine": "2.5.53", "@types/webpack-env": "1.13.0", "angular2-router-loader": "0.3.5", "angular2-template-loader": "0.6.2", "aspnet-prerendering": "^3.0.1", "aspnet-webpack": "^2.0.1", "awesome-typescript-loader": "3.2.1", "bootstrap": "3.3.7", "chai": "4.0.2", "css": "2.2.1", "css-loader": "0.28.4", "es6-shim": "0.35.3", "event-source-polyfill": "0.0.9", "expose-loader": "0.7.3", "extract-text-webpack-plugin": "2.1.2", "file-loader": "0.11.2", "html-loader": "0.4.5", "isomorphic-fetch": "2.2.1", "jasmine-core": "2.6.4", "jquery": "3.2.1", "json-loader": "0.5.4", "karma": "1.7.0", "karma-chai": "0.1.0", "karma-chrome-launcher": "2.2.0", "karma-cli": "1.0.1", "karma-jasmine": "1.1.0", "karma-webpack": "2.0.3", "preboot": "4.5.2", "raw-loader": "0.5.1", "reflect-metadata": "0.1.10", "rxjs": "5.4.2", "style-loader": "0.18.2", "to-string-loader": "1.1.5", "typescript": "2.4.1", "url-loader": "0.5.9", "webpack": "2.5.1", "webpack-hot-middleware": "2.18.2", "webpack-merge": "4.1.0", "zone.js": "0.8.12" } }
Tsconfig.json
{ "compilerOptions": { "module": "es2015", "moduleResolution": "node", "target": "es5", "sourceMap": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "suppressImplicitAnyIndexErrors": true, "noStrictGenericChecks": true, "strict": true, "lib": [ "es6", "dom" ], "noImplicitAny": true, "skipLibCheck": true, "types": [ "webpack-env" ], "typeRoots": [ "node_modules/@types/", "webpack-env" ] }, "exclude": [ "bin", "node_modules", "wwwroot" ], "atom": { "rewriteTsconfig": false } }
Теперь нам нужно подготовить html шаблон страницы, на основе которой будет строиться приложение. Будем использовать css фреймворк bootstrap 4 версии. Внешний вид макета страницы будет следующий:
Исходный код страницы будет подробнее рассмотрен позже, при выделении отдельных компонентов.
Теперь приступим к переносу данного шаблона на Angular. Зайдем в файл _Layout.cshtml и изменим его структуру.
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] | Ads mini</title> <meta name="description" content="Доска мини объявлений Ads mini"> <meta name="keywords" content="реклама, объявления, ads"> <meta name="author" content="shwanoff.ru"> <base href="~/" /> <link rel="icon" type="image/png" href="~/favicon.png"> <link rel="stylesheet" href="~/dist/bootstrap.css" asp-append-version="true" /> <link rel="stylesheet" href="~/dist/style.css" asp-append-version="true" /> <script src="~/dist/bootstrap.js" asp-append-version="true"></script> </head> <body> @RenderBody() @RenderSection("scripts", required: false) </body> </html>
Также очистим проект от лишних данных, созданных для демонстрации работы приложения при создании. Подробнее об этом можно прочитать в статье Очистка стандартного решения Angular 2 на базе ASP.NET Core.
Теперь для проверки перенесем все содержимое страницы в home компонент, чтобы проверить работу всех библиотек. Просто копируем все содержимое между тегами <body> в файл Adsmini\ClientApp\app\components\home\home.component.html. Файлы таблиц стилей, скриптов и изображения помещаем в папку Adsmini\wwwroot\dist\ и меняем ссылки в соответствии с этим расположением файлов. Запускаем приложение чтобы проверить работу.
Обратите внимание, если у вас возникает ошибка с кодом TS2339 TypeScript (TS) Property does not exist on type, то вам необходимо зайти в файл Adsmini\ClientApp\boot.server.ts и изменить строчку с ошибкой следующим образом:
const zone: NgZone = moduleRef.injector.get(NgZone);
Теперь нам нужно приступить к декомпозиции компонентов на более мелкие части. Подробнее про создание компонентов можно прочитать в статье Создание нового компонента Angular и Создание базового макета Angular на основе статичной html страницы.
Для начала изменим файл app.component.html
<div class="container"> <header-component></header-component> <div class="row"> <div class="col-md-3"> <description-component></description-component> <type-component title="Категории"></type-component> </div> <div class="col-md-6 bd-content"> <main role="main"> <router-outlet></router-outlet> </main> </div> <div class="col-md-3 position-sticky"> <search-component></search-component> <footer-component></footer-component> </div> </div> </div>
Здесь мы задаем статическое содержимое страницы, которое не будет меняться при переходе с одной страницы на другую. Изменяемое содержимое будет размещаться в компоненте <router-outlet></router-outlet> в зависимости от того, на какую страницу мы перешли. Давайте теперь создадим все недостающие компоненты страницы.
Заголовок страницы (header)
Выделим заголовок в отдельный компонент header. Для этого создадим следующую структуру папок:
Header.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'header-component', templateUrl: './header.component.html' }) export class HeaderComponent { title = "header"; logoPath = "./dist/logo.png"; }
Header.component.html
<header class="header"> <nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top" role="navigation"> <div class="container justify-content-md-center"> <nav class="navbar navbar-light bg-light"> <a class="navbar-brand" href="#"> <img [src]="logoPath" width="30" height="30" class="d-inline-block align-top" alt="adsmini"> Ads mini </a> </nav> </div> </nav> </header>
Обратите внимание, что мы объявляем переменную logoPath, на которую потом ссылаемся в разметке <img [src]=»logoPath»>. Удалим соответствующий html код из home.component.
Ну и наконец зарегистрируем наш компонент в app.shared.module.ts
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpModule } from '@angular/http'; import { RouterModule } from '@angular/router'; import { AppComponent } from './components/app/app.component'; import { HomeComponent } from './components/home/home.component'; import { HeaderComponent } from './components/shared/header/header.component'; import { DescriptionComponent } from './components/shared/description/description.component'; import { TypeComponent } from './components/shared/type/type.component'; import { SearchComponent } from './components/shared/search/search.component'; import { FooterComponent } from './components/shared/footer/footer.component'; import { CardSummaryComponent } from './components/card/card-summary/card-summary.component'; import { CardDetailComponent } from './components/card/card-detail/card-detail.component'; @NgModule({ declarations: [ AppComponent, HomeComponent, HeaderComponent, DescriptionComponent, TypeComponent, SearchComponent, FooterComponent, CardSummaryComponent, CardDetailComponent ], imports: [ CommonModule, HttpModule, FormsModule, RouterModule.forRoot([ { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', component: HomeComponent }, { path: '**', redirectTo: 'home' } ]) ] }) export class AppModuleShared { }
Создание компонента Описание (description)
description.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'description-component', templateUrl: './description.component.html' }) export class DescriptionComponent { title = "description"; content = "Описание приложения."; }
description.component.html
<div id="description"> <div class="card"> <div class="card-body"> <span>{{content}}</span> </div> </div> </div>
Обратите внимание, что здесь мы выводим значение переменной content обращаясь к ней {{content}}.
Создание компонента Категория (type)
type.component.ts
import { Component, Input } from '@angular/core'; @Component({ selector: 'type-component', templateUrl: './type.component.html' }) export class TypeComponent { @Input() title: string; items: Array<string>; constructor() { this.items = ["Элемент 1", "Элемент 2", "Элемент 3"]; } }
type.component.html
<div id="types"> <div class="card"> <div class="card-header"> <a href="#">{{title}}</a> </div> <ul class="list-group list-group-flush"> <li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" *ngFor="let item of items"> {{item}} <!--<span class="badge badge-primary badge-pill">140</span>--> </li> </ul> </div> </div>
Обратите внимание, что здесь мы создаем компонент, который принимает значение в переменную, а кроме того содержит массив элементов, который выводится циклически в виде списка. В дальнейшем мы изменим данный компонент с использованием отдельного класса, чтобы выводить не только название категории, но и количество записей в ней.
Создание компонента Поиск (search)
search.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'search-component', templateUrl: './search.component.html' }) export class SearchComponent { title = "search"; input: string; search(input: string) { this.input = input; console.log(this.input); } }
search.component.html
<div id="search"> <div class="card"> <div class="card-body"> <form class="form-inline"> <div class="input-group"> <input type="text" #searchInput class="form-control" placeholder="Поиск" aria-label="Поиск" aria-describedby="Поиск" > <div class="input-group-append"> <button (click)="search(searchInput.value)" class="btn btn-outline-secondary" type="button">Найти</button> </div> </div> </form> </div> </div> </div>
В данном компоненте при нажатии на кнопку мы получаем значение из поля ввода #searchInput и передаем его в компонент для дальнейшей обработки.
Создание компонента Подвал (footer)
footer.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'footer-component', templateUrl: './footer.component.html' }) export class FooterComponent { title = "footer"; author = "Шванов Вадим"; year = 2018; link = "https://shwanoff.ru/about/"; content = "Все права защищены."; }
footer.component.html
<div id="footer"> <div class="card"> <div class="card-body"> <footer role="contentinfo" class="footer"> <span class="text-muted">© <a href="{{link}}">{{author}}</a>, {{year}}. {{content}}</span> </footer> </div> </div> </div>
Создание компонента Карточка объявления (Card-summary)
Обратите внимание, что так как с карточками объявлений у нас будут связаны несколько представлений, мы создаем отдельную папку, в которую добавим все связанные компоненты.
card-summary.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'card-summary-component', templateUrl: './card-summary.component.html' }) export class CardSummaryComponent { title = "Заголовок"; date = new Date().toLocaleString(); text = "Текст объявления"; }
card-summary.component.html
<div class="card"> <div class="card-body"> <div class="card-body"> <h4>{{title}}</h4> <h6 class="card-subtitle text-muted">{{date}}</h6> <p class="card-text">{{text}}</p> </div> </div> </div>
Теперь нам необходимо добавить компонент подробного объявления, чтобы при нажатии на объявление отображалась вся информация доступная в объявлении.
Компонент детальной информации объявления (card-detail)
card-detail.component.ts
import { Component } from '@angular/core'; import { CurrencyPipe } from '@angular/common'; @Component({ selector: 'card-detail-component', templateUrl: './card-detail.component.html' }) export class CardDetailComponent { title = "Заголовок"; phone = "+7 (920) 737-1024"; city = "Курск"; type = "Категория 1"; price = 1000; name = "Вадим"; tags = ["объявление", "пример", "карточка"]; site = "https://shwanoff.ru"; date = new Date().toLocaleString(); text = "Текст объявления"; longText = "Подробный текст объявления"; }
card-detail.component.html
<div class="card"> <div class="card-header"> <h3 class="card-title">{{title}}</h3> <p class="card-subtitle card-title">{{date}}</p> <p class="card-title float-left">{{city}}</p> <p class="card-title float-right">{{type}}</p> </div> <div class="card-body"> <div class="card-text"> <h6>{{text}}</h6> <p>{{longText}}</p> <p class="text-right">{{price | currency:'RUB':'symbol-narrow'}}</p> <div class="d-block"> <p> <strong>{{name}}</strong> </p> <p class="float-right"> {{phone}} </p> </div> <p> <a href="{{site}}">{{site}}</a> </p> <a href="#" class="badge badge-secondary" *ngFor="let tag of tags">{{tag}}</a> </div> </div> </div>
После всех манипуляций и переноса кода в отдельные компоненты в home.component.html останется только один компонент, и тот в дальнейшем будет изменен.
<card-summary-component></card-summary-component>
Теперь нам необходимо настроить навигацию, чтобы при нажатии на объявление отображалась карточка с подробной информацией. Для этого добавим строку в RouterModule файла app.shared.module.ts
{ path: 'card/:id', component: CardDetailComponent },
Теперь если в браузере вбить адрес http://localhost:64080/card/1234, то отобразится окно с подробным представлением объявления
Добавим переход на страницу подробностей объявления при нажатии на объявление на главной странице. Для этого изменим компонент card-summary-component следующим образом
import { Component } from '@angular/core'; import { Router } from '@angular/router'; @Component({ selector: 'card-summary-component', templateUrl: './card-summary.component.html' }) export class CardSummaryComponent { title = "Заголовок"; date = new Date().toLocaleString(); text = "Текст объявления"; constructor(private router: Router) { } goToDetail(id: number) { this.router.navigate(["card", id]); } }
А также добавим событие нажатия на карту в html файле этого же компонента
<div class="card" (click)="goToDetail(1234)">
Компонент формы добавления объявления (card-add)
Ну и наконец создадим компонент для добавления новых объявлений. Для начала изменим компонент с описанием приложения, добавив туда кнопку для перехода на форму добавления объявления.
description.component.ts
import { Component } from '@angular/core'; import { Router } from '@angular/router'; @Component({ selector: 'description-component', templateUrl: './description.component.html' }) export class DescriptionComponent { title = "description"; content = "Описание приложения."; constructor(private router: Router) { } goToAdd() { this.router.navigate(["add"]); } }
description.component.html
<div id="description"> <div class="card"> <div class="card-body"> <span>{{content}}</span> <button class="btn btn-success btn-block" (click)="goToAdd()">Добавить</button> </div> </div> </div>
app.shared.module
Добавим в RouterModule еще одно правило навигации
{ path: 'add', component: CardAddComponent },
Теперь перейдем непосредственно к созданию компонента формы добавления нового объявления.
card-add.component.ts
import { Component } from '@angular/core'; import { NgForm } from '@angular/forms'; import { NgModel } from '@angular/forms'; @Component({ selector: 'card-add-component', templateUrl: './card-add.component.html' }) export class CardAddComponent { date: Date; public types = ["Категория 1", "Категория 2", "Категория 3"]; public title: string; public phone: string; public city: string; public type: string; public price: number; public name: string; public tags: string; public site: string; public text: string; public longText: string; public email: string; constructor() { } add() { this.date = new Date(); console.log(JSON.stringify(this)); } }
card-add.component.html
<div class="alert alert-info" role="alert" [hidden]="addForm.form.valid"> Заполните все обязательные поля. </div> <div class="card"> <div class="card-header"> <h3 class="card-title">Добавить объявление</h3> </div> <div class="card-body"> <div class="card-text"> <form (ngSubmit)="add()" #addForm="ngForm"> <div class="form-group"> <label for="title"><strong>Заголовок*</strong></label> <input type="text" class="form-control" id="title" name="title" required [(ngModel)]="title" placeholder="Введите короткий заголовок объявления (50 символов)..."> </div> <div class="form-group"> <label for="text"><strong>Краткое описание*</strong></label> <input type="text" class="form-control" id="text" name="text" required [(ngModel)]="text" placeholder="Введите короткое описание объявления (140 символов)..."> </div> <div class="form-group"> <label for="type"><strong>Категория*</strong></label> <select class="form-control" id="type" name="type" required [(ngModel)]="type"> <option selected>Введите категорию объявления...</option> <option *ngFor="let type of types" [value]="type">{{type}}</option> </select> </div> <div class="form-group"> <label for="longText">Подробное описание</label> <input type="text" class="form-control" id="longText" name="longText" [(ngModel)]="longText" placeholder="Введите подробное описание..."> </div> <div class="form-group"> <label for="price">Цена</label> <input type="number" class="form-control" id="price" name="price" [(ngModel)]="price" placeholder="0.00"> </div> <div class="form-group"> <label for="name">Имя</label> <input type="text" class="form-control" id="name" name="name" [(ngModel)]="name" placeholder="Введите Ваше имя..."> </div> <div class="form-group"> <label for="phone">Контактный телефон</label> <input type="tel" class="form-control" id="phone" name="phone" [(ngModel)]="phone" placeholder="+7 (999) 999-9999"> </div> <div class="form-group"> <label for="email">Электронная почта</label> <input type="email" class="form-control" id="email" name="email" [(ngModel)]="email" placeholder="mail@domen.com"> </div> <div class="form-group"> <label for="site">Сайт</label> <input type="url" class="form-control" id="site" name="site" [(ngModel)]="site" placeholder="https://shwanoff.ru/"> </div> <div class="form-group"> <label for="city">Город</label> <input type="text" class="form-control" id="city" name="city" [(ngModel)]="city" placeholder="Введите название вашего города..."> </div> <div class="form-group"> <label for="tags">Теги</label> <input type="text" class="form-control" id="tags" name="tags" [(ngModel)]="tags" placeholder="Введите теги чезез запятую..."> </div> <button type="submit" class="btn btn-primary float-right" [disabled]="!addForm.form.valid"> Добавить </button> </form> </div> </div> </div>
При нажатии кнопки Добавить данные из полей передаются в соответствующие переменные компонента для дальнейшей обработки.
Заключение
На первом этапе разработки нами были созданы все необходимые для работы системы компоненты. Исходный код доступен в репозитории github. В следующей статье мы рассмотрим взаимодействие с хранилищем данных, использование классов и расширим функционал наших компонентов.
Продолжение в статье Создание Angular 5 приложения. Часть 2