Несколько советов по Angular

в 13:31, , рубрики: angular, best practices, rxjs

Прошло уже достаточно времени с выхода обновленного Angular. В настоящее время множество проектов завершено. От "getting started" множество разработчиков уже перешло к осмысленному использованию этого фреймворка, его возможностей, научились обходить подводные камни. Каждый разработчик и/или команда либо уже сформировали свои style guides и best practice либо используют чужие. Но в тоже время часто приходится сталкиваться с большим количеством кода на Angular, в котором не используются многие возможности этого фреймворка и/или написанного в стиле AngularJS.

В данной статье представлены некоторые возможности и особенности использования фреймворка Angular, которые, по скромному мнению автора, недостаточно освещены в руководствах или не используются разработчиками. В статье рассматривается использование "перехватчиков" (Interceptors) HTTP запросов, использование Route Guards для ограничения доступа пользователям. Даны некоторые рекомендации по использованию RxJS и управлению состоянием приложения. Также представлены некоторые рекомендации по оформлению кода проектов, которые возможно позволят сделать код проектов чище и понятнее. Автор надеется, что данная статья будет полезна не только разработчикам, которые только начинают знакомство с Angular, но и опытным разработчикам.

Работа с HTTP

Построение любого клиентского Web приложения производится вокруг HTTP запросов к серверу. В этой части рассматриваются некоторые возможности фреймворка Angular по работе с HTTP запросами.

Используем Interceptors

В некоторых случаях может потребоваться изменить запрос до того, как он попадет на сервер. Или необходимо изменить каждый ответ. Начиная с версии Angular 4.3 появился новый HttpClient. В нем добавлена возможность перехватывать запрос с помощью interceptors (Да, их наконец-то вернули только в версии 4.3!, это была одна из наиболее ожидаемых недостающих возможностей AngularJs, которые не перекочевали в Angular). Это своего рода промежуточное ПО между http-api и фактическим запросом.

Одним из распространенных вариантов использования может быть аутентификация. Чтобы получить ответ с сервера, часто нужно добавить какой-то механизм проверки подлинности в запрос. Эта задача с использованием interceptors решается достаточно просто:

import { Injectable } from "@angular/core";
import { Observable } from "rxjs/Observable";
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from @angular/common/http";

@Injectable()
export class JWTInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>
  {
    req = req.clone({
      setHeaders: {
        authorization: localStorage.getItem("token")
      }
    });

    return next.handle(req);
  }
}

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

import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";

import { AppComponent } from "./app.component";
import { HttpClientModule } from "@angular/common/http";
import { HTTP_INTERCEPTORS } from "@angular/common/http";

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: JWTInterceptor,
      multi: true
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Как видим подключение и реализация interceptors достаточно проста.

Отслеживание прогресса

Одной из особенностей HttpClient является возможность отслеживания хода выполнения запроса. Например, если необходимо загрузить большой файл, то, вероятно, возникает желание сообщать о ходе загрузки пользователю. Чтобы получить прогресс, необходимо установить для свойства reportProgress объекта HttpRequest значение true. Пример сервиса реализующего данный подход:

import { Observable } from "rxjs/Observable";
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { HttpRequest } from "@angular/common/http";
import { Subject } from "rxjs/Subject";
import { HttpEventType } from "@angular/common/http";
import { HttpResponse } from "@angular/common/http";

@Injectable()
export class FileUploadService {
  constructor(private http: HttpClient) {}

  public post(url: string, file: File): Observable<number> {
    var subject = new Subject<number>();
    const req = new HttpRequest("POST", url, file, {
      reportProgress: true
    });
    this.httpClient.request(req).subscribe(event => {
      if (event.type === HttpEventType.UploadProgress) {
        const percent = Math.round((100 * event.loaded) / event.total);
        subject.next(percent);
      } else if (event instanceof HttpResponse) {
        subject.complete();
      }
    });
    return subject.asObservable();
  }
}

Метод post возвращает объект наблюдателя (Observable), представляющий ход загрузки. Все что теперь нужно, это выводить ход выполнения загрузки в компоненте.

Маршрутизация. Используем Route Guard

Маршрутизация позволяет сопоставлять запросы к приложению с определенными ресурсами внутри приложения. Довольно часто приходится решать задачу ограничения видимости пути, по которому располагаются определенные компоненты, в зависимости от некоторых условий. В этих случаях в Angular есть механизм ограничения перехода. В качестве примера, приведен сервис, который будет реализовывать route guard. Допустим, в приложении аутентификация пользователя реализована с использованием JWT. Упрощенный вариант сервиса, который выполняет проверку авторизован ли пользователь, можно представить в виде:

@Injectable()
export class AuthService {
  constructor(public jwtHelper: JwtHelperService) {}

  public isAuthenticated(): boolean {
    const token = localStorage.getItem("token");
    // проверяем не истек ли срок действия токена
    return !this.jwtHelper.isTokenExpired(token);
  }
}

Для реализации route guard необходимо реализовать интерфейс CanActivate, который состоит из единственной функции canActivate.

@Injectable()
export class AuthGuardService implements CanActivate {
  constructor(public auth: AuthService, public router: Router) {}
  canActivate(): boolean {
    if (!this.auth.isAuthenticated()) {
      this.router.navigate(["login"]);
      return false;
    }
    return true;
  }
}

Реализация AuthGuardService использует описанный выше AuthService для проверки авторизации пользователя. Метод canActivate возвращает логическое значение, которое может быть использовано в условии активации маршрута.

Теперь мы можем применить созданный Route Guard к любому маршруту или пути. Для этого при объявлении Routes мы указываем наш сервис, наследующий CanActivate интерфейс, в секции canActivate:

export const ROUTES: Routes = [
  { path: "", component: HomeComponent },
  {
    path: "profile",
    component: UserComponent,
    canActivate: [AuthGuardService]
  },
  { path: "**", redirectTo: "" }
];

В этом случае маршрут /profile имеет дополнительное конфигурационное значение canActivate. AuthGuard, описанный ранее передается аргументом в данное свойство canActivate. Далее метод canActivate будет вызываться каждый раз, когда кто-нибудь попытается получить доступ к пути /profile. Если пользователь авторизован он получит доступ к пути /profile, в противном случае он будет перенаправлен на путь /login.

Следует знать, что canActivate по прежнему позволяет активировать компонент по данному пути, но не позволяет перейти на него. Если нужно защитить активацию и загрузку компонента, то для такого случая можем использовать canLoad. Реализация CanLoad может быть сделана по аналогии.

Готовим RxJS

Angular построен на основе RxJS. RxJS — это библиотека для работы с асинхронными и основанными на событиях потоками данных, с использованием наблюдаемых последовательностей. RxJS — это реализация ReactiveX API на языке JavaScript. В основной своей массе ошибки, возникающие при работе с данной библиотекой, связаны с поверхностными знаниями основ её реализации.

Используем async вместо подписывания на события

Большое число разработчиков, которые только недавно пришли к использованию фреймворка Angular, используют функцию subscribe у Observable, чтобы получать и сохранять данные в компоненте:

@Component({
  selector: "my-component",
  template: `
    <span>{{localData.name}} : {{localData.value}}</span>`
})
export class MyComponent {
  localData;
  constructor(http: HttpClient) {
    http.get("api/data").subscribe(data => {
      this.localData = data;
    });
  }
}

Вместо этого мы можем подписываться через шаблон, используя async pipe:

@Component({
  selector: "my-component",
  template: `
    <p>{{data.name | async}} : {{data.value | async}}</p>`
})
export class MyComponent {
  data;
  constructor(http: HttpClient) {
    this.data = http.get("api/data");
  }
}

Подписываясь через шаблон, мы избегаем утечек памяти, потому что Angular автоматически отменяет подписку на Observable, когда компонент разрушается. В данном случае для HTTP запросов использование async pipe практически не предоставляет никаких преимуществ, кроме одного — async отменит запрос, если данные больше не нужны, а не завершит обработку запроса.

Многие возможности Observables не используются при подписке вручную. Поведение Observables может быть расширено повтором (например, retry в http запросе), обновлением на основе таймера или предварительным кешированием.

Используем $ для обозначения observables

Следующий пункт связан с оформлением исходных кодов приложения и вытекает из предыдущего пункта. Для того чтобы различать Observable от простых переменных довольно часто можно услышать совет использовать знак “$” в имени переменной или поля. Данный простой трюк позволит исключить путаницу в переменных при использовании async.

import { Component } from "@angular/core";
import { Observable } from "rxjs/Rx";

import { UserClient } from "../services/user.client";
import { User } from "../services/user";

@Component({
  selector: "user-list",
  template: `
  <ul class="user_list" *ngIf="(users$ | async).length">
    <li class="user" *ngFor="let user of users$ | async">
      {{ user.name }} - {{ user.birth_date }}
    </li>
  </ul>`
})
export class UserList {
  public users$: Observable<User[]>;

  constructor(public userClient: UserClient) {}

  public ngOnInit() {
    this.users$ = this.client.getUsers();
  }
}

Когда нужно отписываться (unsubscribe)

Наиболее частый вопрос, который возникает у разработчика при недолгом знакомстве с Angular — когда все таки нужно отписываться, а когда нет. Для ответа на этот вопрос сначала нужно определиться какой вид Observable в данный момент используется. В Angular существуют 2 вида Observable — финитные и инфинитные, одни производят конечное, другие, соответственно, бесконечное число значений.

Http Observable — финитный, а слушатели/наблюдатели (listeners) DOM событий — это инфинитные Observable.

Если подписка на значения инфинитного Observable производится вручную (без использования async pipe), то в обязательном порядке должна производится отписка. Если подписываемся в ручном режиме на финитный Observable, то отписываться не обязательно, об этом позаботится RxJS. В случае финитных Observables можем производить отписку, если Observable имеет более длительный срок исполнения, чем необходимо, например, кратно повторяющийся HTTP запрос.

Пример финитных Observables:

export class SomeComponent {
  constructor(private http: HttpClient) { }  
  ngOnInit() {
    Observable.timer(1000).subscribe(...);
    this.http.get("http://api.com").subscribe(...);
  }
}

Пример инфинитных Observables

export class SomeComponent {
  constructor(private element : ElementRef) { }

  interval: Subscription;
  click: Subscription;

  ngOnInit() {
    this.interval = Observable.interval(1000).subscribe(...);
    this.click = Observable.fromEvent(this.element.nativeElement, "click").subscribe(...);
  }

  ngOnDestroy() {
    this.interval.unsubscribe();
    this.click.unsubscribe();
  }
}

Ниже, более детально приведены случаи, в которых нужно отписываться

  1. Необходимо отписываться от формы и от отдельных контролов, на которые подписались:

export class SomeComponent {

  ngOnInit() {
    this.form = new FormGroup({...});
    this.valueChangesSubs  = this.form.valueChanges.subscribe(...);
    this.statusChangesSubs = this.form.statusChanges.subscribe(...);
  }

  ngOnDestroy() {
    this.valueChangesSubs.unsubscribe();
    this.statusChangesSubs.unsubscribe();
  }
}

  1. Router. Согласно документации Angular должен сам отписываться, однако этого не происходит. Поэтому во избежание дальнейших проблем производим отписывание самостоятельно:

export class SomeComponent {
  constructor(private route: ActivatedRoute, private router: Router) { }

  ngOnInit() {
    this.route.params.subscribe(..);
    this.route.queryParams.subscribe(...);
    this.route.fragment.subscribe(...);
    this.route.data.subscribe(...);
    this.route.url.subscribe(..);

    this.router.events.subscribe(...);
  }

  ngOnDestroy() {
    // Здесь мы должны отписаться от всех подписанных observables
  }
}

  1. Бесконечные последовательности. Примерами могут служить последовательности созданные с помощью interva() или слушатели события (fromEvent()):

export class SomeComponent {

  constructor(private element : ElementRef) { }

  interval: Subscription;
  click: Subscription;

  ngOnInit() {
    this.intervalSubs = Observable.interval(1000).subscribe(...);
    this.clickSubs = Observable.fromEvent(this.element.nativeElement, "click").subscribe(...);
  }

  ngOnDestroy() {
    this.intervalSubs.unsubscribe();
    this.clickSubs.unsubscribe();
  }
}

takeUntil и takeWhile

Для упрощения работы с инфинитными Observables в RxJS существует две удобные функции — это takeUntil и takeWhile. Они производят одно и тоже действие — отписку от Observable по окончании какого-нибудь условия, разница лишь в принимаемых значениях. takeWhile принимает boolean, а takeUntilSubject.
Пример takeWhile:

export class SomeComponent implements OnDestroy, OnInit {
  public user: User;
  private alive: boolean = true;

  public ngOnInit() {
    this.userService
      .authenticate(email, password)
      .takeWhile(() => this.alive)
      .subscribe(user => {
        this.user = user;
      });
  }

  public ngOnDestroy() {
    this.alive = false;
  }
}

В этом случае при изменении флага alive произойдет отписка от Observable. В данном примере отписываемся при уничтожении компонента.
Пример takeUntil:

export class SomeComponent implements OnDestroy, OnInit {
  public user: User;
  private unsubscribe: Subject<void> = new Subject(void);

  public ngOnInit() {
    this.userService.authenticate(email, password)
      .takeUntil(this.unsubscribe)
      .subscribe(user => {
        this.user = user;
      });
  }

  public ngOnDestroy() {
    this.unsubscribe.next();
    this.unsubscribe.complete();
  }
}

В данном случае для отписки от Observable мы сообщаем, что subject принимает следующее значение и завершаем его.

Использование этих функций позволит избежать утечек и упростит работу с отписками от данных. Какую из функций использовать? В ответе на данный вопрос нужно руководствоваться личными предпочтениями и текущими требованиями.

Управление состоянием в Angular приложениях, @ngrx/store

Довольно часто при разработке сложных приложений мы сталкиваемся с необходимостью хранить состояние и реагировать на его изменения. Для приложений, разрабатываемых на фреймворке ReactJs существует множество библиотек, позволяющих управлять состоянием приложения и реагировать на его изменения — Flux, Redux, Redux-saga и т.д. Для Angular приложений существует контейнер состояний на основе RxJS вдохновленный Redux — @ngrx/store. Правильное управление состоянием приложения избавит разработчика от множества проблем при дальнейшем расширении приложения.

Почему Redux?
Redux позиционирует себя как предсказуемый контейнер состояния (state) для JavaScript приложений. Redux вдохновлен Flux и Elm.

Redux предлагает думать о приложении, как о начальном состоянии модифицируемом последовательностью действий (actions), что может являться хорошим подходом при построении сложных веб-приложений.

Redux не связан с каким-то определенным фреймворком, и хотя разрабатывался для React, может использоваться с Angular или jQuery.

Основные постулаты Redux:

  • одно хранилище для всего состояния приложения
  • состояние доступно только для чтения
  • изменения делаются «чистыми» функциями, к которым предъявляются следующие требования:
  • не должны делать внешних вызовов по сети или базе данных;
  • возвращают значение, зависящее только от переданных параметров;
  • аргументы являются неизменяемыми, т.е. функции не должны их изменять;
  • вызов чистой функции с теми же аргументами всегда возвращает одинаковый результат;

Пример функции управления состоянием:

// counter.ts
import { ActionReducer, Action } from "@ngrx/store";

export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";
export const RESET = "RESET";

export function counterReducer(state: number = 0, action: Action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;

    case DECREMENT:
      return state - 1;

    case RESET:
      return 0;

    default:
      return state;
  }
}

В основном модуле приложения импортируется Reducer и с использованием функции StoreModule.provideStore(reducers) делаем его доступным для Angular инжектора:

// app.module.ts
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { counterReducer } from "./counter";

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.provideStore({ counter: counterReducer })
  ]
})
export class AppModule { }

Далее производится внедрение Store сервиса в необходимые компоненты и сервисы. Для выбора "среза" состояния используется функция store.select():

// app.component.ts
...
interface AppState {
  counter: number;
}
@Component({
  selector: "my-app",
  template: `
  <button (click)="increment()">Increment</button>
  <div>Current Count: {{ counter | async }}</div>
  <button (click)="decrement()">Decrement</button>
  <button (click)="reset()">Reset Counter</button>`
})
class AppComponent {
  counter: Observable<number>;
  constructor(private store: Store<AppState>) {
    this.counter = store.select("counter");
  }
  increment() {
    this.store.dispatch({ type: INCREMENT });
  }
  decrement() {
    this.store.dispatch({ type: DECREMENT });
  }
  reset() {
    this.store.dispatch({ type: RESET });
  }
}

@ngrx/router-store

В некоторых случаях удобно связывать состояние приложения с текущим маршрутом приложения. Для этих случаев существует модуль @ngrx/router-store. Чтобы приложение использовало router-store для сохранения состояния, достаточно подключить routerReducer и добавить вызов RouterStoreModule.connectRoute в основном модуле приложения:

import { StoreModule } from "@ngrx/store";
import { routerReducer, RouterStoreModule } from "@ngrx/router-store";

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.provideStore({ router: routerReducer }),
    RouterStoreModule.connectRouter()
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

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

import { RouterState } from "@ngrx/router-store";

export interface AppState {
...
  router: RouterState;
};

Дополнительно можем указать начальное состояние приложения при объявлении store:

StoreModule.provideStore(
  { router: routerReducer },
  {
    router: {
      path: window.location.pathname + window.location.search
    }
  }
);

Поддерживаемые действия:

import { go, replace, search, show, back, forward } from "@ngrx/router-store";

//Навигация с новым состоянием в истории
store.dispatch(go(["/path", { routeParam: 1 }], { query: "string" }));

// Навигация с заменой текущего состояния в истории
store.dispatch(replace(["/path"], { query: "string" }));

// Навигация без добавления нового состояния в историю
store.dispatch(show(["/path"], { query: "string" }));

// Навигация только с изменением параметров запроса
store.dispatch(search({ query: "string" }));

// Навигация назад
store.dispatch(back());

// Навигация вперед
store.dispatch(forward());

Использование контейнера состояния избавит от многих проблем при разработке сложных приложений. Однако, важно делать управление состоянием как можно проще. Довольно часто приходится сталкиваться с приложениями, в которых присутствует излишняя вложенность состояний, что лишь усложняет понимание работы приложения.

Организация кода

Избавляемся от громоздких выражений в import

Многим разработчикам известна ситуация, когда выражения в import довольно громоздкие. Особенно это заметно в больших приложениях, где много повторно используемых библиотек.

import { SomeService } from "../../../core/subpackage1/subpackage2/some.service";

Что еще плохо в этом коде? В случае, когда понадобиться перенести наш компонент в другую директорию, выражения в import будут не действительны.

В данном случае использование псевдонимов позволит уйти от громоздких выражений в import и сделать наш код гораздо чище. Для того чтобы подготовить проект к использованию псевдонимов необходимо добавить baseUrl и path свойства вtsconfig.json:

/ tsconfig.json
{
"compilerOptions": {
    ...
    "baseUrl": "src",
    "paths": {
      "@app/*": ["app/*"],
      "@env/*": ["environments/*"]
    }
  }
}

С этими изменениями достаточно просто управлять подключаемыми модулями:

import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs/Observable";

/* глобально доступные компоненты */
import { SomeService } from "@app/core";
import { environment } from "@env/environment";

/* локально доступные компоненты используют относительный путь*/
import { LocalService } from "./local.service";

@Component({
  /* ... */
})
export class ExampleComponent implements OnInit {
  constructor(
    private someService: SomeService,
    private localService: LocalService
  ) { }
}

В данном примере импорт SomeService производится напрямую из @app/core вместо громоздкого выражения (например @app/core/some-package/some.service). Это возможно благодаря ре-экспорту публичных компонентов в основном файле index.ts. Желательно создать файл index.ts на каждый пакет в котором нужно произвести реэкспорт всех публичных модулей:

//  index.ts
export * from "./core.module";
export * from "./auth/auth.service";
export * from "./user/user.service";
export * from "./some-service/some.service";

Core, Shared и Feature модули

Для более гибкого управления составными частями приложения довольно часто в литературе и различных интернет ресурсах рекомендуют разносить видимость его компонентов. В этом случае управление составными частями приложения упрощается. Наиболее часто используется следующее разделение: Core, Shared и Feature модули.

CoreModule

Основное предназначение CoreModule — описание сервисов, которые будут иметь один экземпляр на все приложение (т.е. реализуют паттерн синглтон). К таким часто относятся сервис авторизации или сервис для получения информации о пользователе. Пример CoreModule:

import { NgModule, Optional, SkipSelf } from "@angular/core";
import { CommonModule } from "@angular/common";
import { HttpClientModule } from "@angular/common/http";
/* сервисы  */
import { SomeSingletonService } from "./some-singleton/some-singleton.service";

@NgModule({
  imports: [CommonModule, HttpClientModule],
  declarations: [],
  providers: [SomeSingletonService]
})
export class CoreModule {
  /* удостоверимся что CoreModule импортируется только одним NgModule the AppModule */
  constructor(
    @Optional()
    @SkipSelf()
    parentModule: CoreModule
  ) {
    if (parentModule) {
      throw new Error("CoreModule is already loaded. Import only in AppModule");
    }
  }
}

SharedModule

В данном модуле описываются простые компоненты. Эти компоненты не импортируют и не внедряют зависимости из других модулей в свои конструкторы. Они должны получать все данные через атрибуты в шаблоне компонента. SharedModule не имеет никакой зависимости от остальной части нашего приложения.Это также идеальное место для импорта и реэкспорта компонентов Angular Material или других UI библиотек.

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { MdButtonModule } from "@angular/material";
/*экспортируемые компоненты */
import { SomeCustomComponent } from "./some-custom/some-custom.component";

@NgModule({
  imports: [CommonModule, FormsModule, MdButtonModule],
  declarations: [SomeCustomComponent],
  exports: [
    /* компоненты Angular Material*/
    CommonModule,
    FormsModule,
    MdButtonModule,
    /* компоненты проекта */
    SomeCustomComponent
  ]
})
export class SharedModule { }

FeatureModule

Здесь можно повторить Angular style guide. Для каждой независимой функции приложения создается отдельный FeatureModule. FeatureModule должны импортировать сервисы только из CoreModule. Если некоторому модулю понадобилось импортировать сервис из другого модуля, возможно, этот сервис необходимо вынести в CoreModule.

В некоторых случаях возникает потребность в использовании сервиса только некоторыми модулями и нет необходимости выносить его в CoreModule. В этом случае можно создать особый SharedModule, который будет использоваться только в этих модулях.
Основное правило, используемое при создании модулей — попытаться создать модули, которые не зависят от каких-либо других модулей, а только от сервисов, предоставляемых CoreModule и компонентов, предоставляемых SharedModule.

Это позволит коду разрабатываемых приложений быть более чистым, простым в поддержке и расширении. Это также уменьшает усилия, необходимые для рефакторинга. Если следовать данному правилу, можно быть уверенным, что изменения одном модуле не смогут повлиять или разрушить остальную часть нашего приложения.

Список литературы

  1. https://github.com/ngrx/store
  2. http://stepansuvorov.com/blog/2017/06/angular-rxjs-unsubscribe-or-not-unsubscribe/
  3. https://medium.com/@tomastrajan/6-best-practices-pro-tips-for-angular-cli-better-developer-experience-7b328bc9db81
  4. https://habr.com/post/336280/
  5. https://angular.io/docs

Автор: avkazakov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js