DevGang
Авторизоваться

Обработка разбивки на страницы с помощью хранилищ компонентов NgRx

NgRx - это популярная библиотека управления состоянием для Angular, которая широко используется для реализации шаблона Redux в ваших приложениях.

Однако, в зависимости от вариантов использования, вы не всегда захотите использовать всю артиллерию, из которой состоит состояние. 

Для таких сценариев NgRx представила более локальный способ управления хранилищем по компонентам: хранилища компонентов.

В этой статье мы увидим краткий пример того, как настроить простое хранилище компонентов с использованием новейшего API NgRx и как использовать его для разбивки списка элементов из страницы.

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

Начальная настройка

Наше приложение будет просто отображать список задач с разбивкой на страницы. 

Здесь мы не будем реализовывать логику для переключения, редактирования, добавления и выполнения каких-либо других действий с ними, поскольку это не является основной темой этой статьи.

Создание приложения Angular

Для нашего приложения Angular мы будем использовать Angular версии 15,2, которая является последней на момент написания этой статьи.

В новом каталоге введите следующую команду:

ng new --directory ./  --minimal --inline-template --skip-tests

Затем вы можете оставить все остальные параметры со значениями по умолчанию и использовать их в своем любимом редакторе кода, избавиться от исходного содержимого app.component.ts и заменить его следующим:

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h1>Simple pagination with NgRx component stores</h1>
  `,
  styles: []
})
export class AppComponent {
}

Поскольку мы будем использовать автономные компоненты, вы также можете удалить app.module.ts и заменить запуск приложения в main.ts следующим:

// main.ts
import { provideHttpClient } from "@angular/common/http";
import { bootstrapApplication } from "@angular/platform-browser";

import { AppComponent } from "./app/app.component";

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient()],
}).catch((err) => console.error(err));

Теперь мы можем нажать ng serve и продолжить нашу демонстрацию.

Имитация backend

Чтобы извлечь элементы списка дел, мы будем использовать отличный JSON placeholder API для имитация бэкэнда.

Для этого сначала создадим контракт во вновь созданном файле todo-item.ts:

// todo-item.ts
export interface TodoItem {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

Теперь мы можем приступить к реализации нашего TodoItemService для их извлечения:

// todo-item.service.ts
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";

import { Observable } from "rxjs";

import { TodoItem } from "./todo-item";

@Injectable({ providedIn: "root" })
export class TodoItemService {
  private readonly _http = inject(HttpClient);

  getTodoItems(offset?: number, pageSize?: number): Observable<TodoItem[]> {
    const params = new HttpParams({
      fromObject: {
        _start: offset ?? 0,
        _limit: pageSize ?? 10,
      },
    });

    return this._http.get<TodoItem[]>(
      "https://jsonplaceholder.typicode.com/todos",
      { params }
    );
  }
}

Создание нашего компонента

Создав наш сервис, мы можем использовать контракт из нового TodoItemComponent:

// todo-item.component.ts
import { NgIf } from "@angular/common";
import { Component, Input } from "@angular/core";

import { TodoItem } from "./todo-item";

@Component({
  selector: "app-todo-item",
  standalone: true,
  imports: [NgIf],
  template: `
    <div *ngIf="todoItem">
      <input type="checkbox" [checked]="todoItem.completed" />
      <span>#{{ todoItem.id }} - {{ todoItem.title }}</span>
    </div>`,
})
export class TodoItemComponent {
  @Input() todoItem?: TodoItem;
}
Возможно, это не самый красивый дисплей с элементами todo, но мы вам его предоставляем.

Наконец, используйте наши кирпичики для отображения нашей первой страницы:

// app.component.ts
import { AsyncPipe, NgFor } from "@angular/common";
import { Component, inject } from "@angular/core";

import { TodoItemComponent } from "./todo-item.component";
import { TodoItemService } from "./todo-item.service";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [NgFor, AsyncPipe, TodoItemComponent],
  template: `
    <h1>Simple pagination with NgRx component stores</h1>

    <app-todo-item
      *ngFor="let todoItem of todoItems$ | async"
      [todoItem]="todoItem"
    />
  `,
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);
  readonly todoItems$ = this._todoItemService.getTodoItems();
}

Вы должны снова увидеть нашу домашнюю страницу с некоторыми элементами todo:

Обработка разбивки на страницы

Теперь, когда наше приложение запущено и отображаются некоторые элементы задач, мы можем сосредоточится на нашей проблеме разбивки на страницы.

Мы просто немного обновим наш AppComponent, чтобы предоставить нашему пользователю доступ к предыдущей и следующей странице:

// app.component.ts
// ...

@Component({
  // ...
  template: `
    <h1>Simple pagination with NgRx component stores</h1>

    <app-todo-item
      *ngFor="let todoItem of todoItems$ | async"
      [todoItem]="todoItem"
    />

    <!-- 👇 Pagination buttons -->
    <button type="button" aria-label="Previous Page" (click)="onPreviousPage()">
      ←
    </button>

    <button type="button" aria-label="Next Page" (click)="onNextPage()">
      →
    </button>

  `,
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);
  readonly todoItems$ = this._todoItemService.getTodoItems();

  // 👇 Associated handlers
  onPreviousPage(): void {}
  onNextPage(): void {}
}

Самый простой способ

Самый простой способ - ввести переменную, содержащую детали разбиения на страницы, и при щелчке обновить ее значение переназначив наблюдаемую функцию todoItems$:

// app.component.ts
// ...

@Component({
  // ...
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);
  todoItems$ = this._todoItemService.getTodoItems();

  private _pagination = {
    offset: 0,
    pageSize: 10,
  };

  onPreviousPage(): void {
    this._pagination.offset -= this._pagination.pageSize;

    const { offset, pageSize } = this._pagination;
    this.todoItems$ = this._todoItemService.getTodoItems(offset, pageSize);
  }

  onNextPage(): void {
    this._pagination.offset += this._pagination.pageSize;

    const { offset, pageSize } = this._pagination;
    this.todoItems$ = this._todoItemService.getTodoItems(offset, pageSize);
  }
}

Однако данный подход не самый реактивный, и мы должны императивно внедрять логику повсюду (в нашем случае это всего лишь два метода).

Попробуем сделать лучше!

С использованием Subject

Используя RxJS BehaviorSubject, мы можем сохранять единое состояние наших сведений о разбивке на страницы и динамически реагировать на любое изменение значения для обновления наших запрошенных элементов todo:

// app.component.ts
// ...

@Component({
  // ...
})
export class AppComponent {
  private readonly _todoItemService = inject(TodoItemService);

  // 👇 Introducing a subject holding the pagination details
  private readonly _pagination$ = new BehaviorSubject({
    offset: 0,
    pageSize: 10,
  });

  // 👇 Reactively update the todo items on pagination change
  readonly todoItems$ = this._pagination$.pipe(
    switchMap(({ offset, pageSize }) =>
      this._todoItemService.getTodoItems(offset, pageSize)
    )
  );

  onPreviousPage(): void {
    const { offset, pageSize } = this._pagination$.getValue();

    this._pagination$.next({
      offset: offset - pageSize,
      pageSize,
    })
  }

  onNextPage(): void {
    const { offset, pageSize } = this._pagination$.getValue();

    this._pagination$.next({
      offset: offset + pageSize,
      pageSize,
    })
  }
}

Это может быть лучше, но у нас все еще есть наша логика разбивки на страницы внутри нашего компонента, а также его состояние.

Похоже, это отличный вариант использования для хранилища компонентов!

Добавление управления состоянием

Прежде чем использовать хранилище компонентов, мы сначала должны установить соответствующий пакет:

npm install @ngrx/component-store --save

Как только установка будет завершена, создайте новый файл с именем app.component-store.ts, внутри которого мы сначала определим наше состояние.

Наше состояние должно содержать:

  1. TodoItems для отображения
  2. Детали пагинации (смещение и размер страницы)
export interface AppState {
  todoItems: TodoItem[];
  offset: number;
  pageSize: number;
}

Для использования нашего хранилища нам также необходимо начальное состояние, используемое для инициализации хранилища:

const initialState: AppState = {
  todoItems: [],
  offset: 0,
  pageSize: 10,
};

Теперь мы можем создать наше хранилище и инициализировать его нашим начальным состоянием:

@Injectable()
export class AppComponentStore extends ComponentStore<AppState> {
  constructor() {
    super(initialState);
  }
}

Наконец, если мы хотим его использовать, мы должны предоставить его нашему AppComponent с помощью метода provideComponentStore:

// app.component.ts
@Component({
  // ...
  providers: [
    // 👇 Provide our component store to our component
    provideComponentStore(AppComponentStore),
  ]
})
export class AppComponent {
  // ...
}

Распространенным способом использования хранилищ компонентов является представление модели представления как vm$ для использования компонентом.

Давайте воспользуемся этим синтаксисом, чтобы удалить прежнюю логику из нашего шаблона:

// app.component-store.ts
@Injectable()
export class AppComponentStore extends ComponentStore<AppState> {
  readonly vm$ = this.select(({ todoItems }) => ({ todoItems }));

  constructor() {
    super(initialState);
  }
}
// app.component.ts
@Component({
  selector: "app-root",
  standalone: true,
  imports: [NgIf, NgFor, AsyncPipe, TodoItemComponent],
  template: `
    <!-- 👇 Wrap the view model in a container with an async pipe to rerender on new values -->
    <ng-container *ngIf="vm$ | async as vm">
      <h1>Simple pagination with NgRx component stores</h1>

      <!-- 👇 Use the todo items exposed by the view model -->
      <app-todo-item
        *ngFor="let todoItem of vm.todoItems"
        [todoItem]="todoItem"
      />

      <button type="button" aria-label="Previous Page" (click)="onPreviousPage()">
        ←
      </button>

      <button type="button" aria-label="Next Page" (click)="onNextPage()">
        →
      </button>
    </ng-container>
  `,
  providers: [provideComponentStore(AppComponentStore)],
})
export class AppComponent {
  // 👇 Consume the component store and its API instead of
  // handling the logic here
  private readonly _componentStore = inject(AppComponentStore);
  readonly vm$ = this._componentStore.vm$;

  onPreviousPage(): void {}

  onNextPage(): void {}
}

И мы продолжаем.

Теперь, когда наше хранилище компонентов инициализировано, мы можем перенести в него нашу логику разбивки на страницы.

В такого рода хранилищах, как и в более "классических", мы можем разделить наш код на три основных раздела:

  • Селекторы
  • Последствия (эффект)
  • Редуктор

В хранилище компонентов селекторы часто оставляются в стороне в пользу модели представления.

Однако мы можем активировать некоторые эффекты, чтобы они повлияли на хранилище.

Используя этот подход, давайте вызовем некоторые события, касающиеся смещения страницы, и соответствующим образом обновим наше состояние

@Injectable()
export class AppComponentStore extends ComponentStore<AppState> {
  private readonly _todoItemService = inject(TodoItemService);

  readonly vm$ = this.select(({ todoItems }) => ({ todoItems }));

  constructor() {
    super(initialState);
  }

  // 👇 Effect loading the todo items
  readonly loadNextPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.select((state) => state)),
      map(([, state]) => state),
      tap(({ offset }) => this.updateOffset(offset + 1)),
      switchMap(({ offset, pageSize }) =>
        this._todoItemService.getTodoItems(offset * pageSize, pageSize).pipe(
          tapResponse(
            (todoItems: TodoItem[]) => this.updateTodoItems(todoItems),
            () => console.error("Something went wrong")
          )
        )
      )
    );
  });

  // 👇 Updaters for our state
  private readonly updateOffset = this.updater(
    (state: AppState, offset: number) => ({
      ...state,
      offset,
    })
  );

  private readonly updateTodoItems = this.updater(
    (state: AppState, todoItems: TodoItem[]) => ({
      ...state,
      todoItems,
    })
  );
}
Эффект для предыдущей страницы почти такой же, попробуйте реализовать его сами!

Теперь мы можем назвать наши эффекты:

// app.component.ts
@Component({ 
  // ...
})
export class AppComponent {
  private readonly _componentStore = inject(AppComponentStore);
  readonly vm$ = this._componentStore.vm$;

  onPreviousPage(): void {}

  onNextPage(): void {
    this._componentStore.loadNextPage();
  }
}

К сожалению, когда мы посещаем нашу страницу еще раз, ничего не отображается до тех пор, пока не отобразится onNextPage.

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

Чтобы решить ее, мы будем действовать в два этапа:

Первый — разделить загрузку страницы и загрузку другого смещения на два отдельных эффекта:

// app.component-store.ts
  readonly loadPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.select((state) => state)),
      map(([, state]) => state),
      switchMap(({ offset, pageSize }) =>
        this._todoItemService.getTodoItems(offset * pageSize, pageSize).pipe(
          tapResponse(
            (todoItems: TodoItem[]) => this.updateTodoItems(todoItems),
            () => console.error("Something went wrong")
          )
        )
      )
    );
  });

  readonly loadNextPage = this.effect((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.select((state) => state.offset)),
      map(([, state]) => state),
      tap((offset) => this.updateOffset(offset + 1)),
      tap(() => this.loadPage())
    );
  });

Во-вторых, используя преимущества хуков жизненного цикла хранилища компонентов NgRx. Мы можем выполнить действие после того, как состояние было инициировано или после того, как было инициировано хранилище.

В нашем случае загрузка первой страницы после настройки хранилища звучит как отличный вариант, давайте сделаем это!

@Injectable()
export class AppComponentStore
  extends ComponentStore<AppState>
  // 👇 Implement the hook
  implements OnStoreInit
{
  private readonly _todoItemService = inject(TodoItemService);

  readonly vm$ = this.select(({ todoItems }) => ({ todoItems }));

  constructor() {
    super(initialState);
  }

  // 👇 Load the page once the store is initialized
  ngrxOnStoreInit() {
    this.loadPage();
  }

  // ...
}

Если мы обновим нашу страницу, мы сможем увидеть, что наше исправление работает и что мы успешно внедрили разбивку на страницы с использованием хранилища компонентов.

Если вы хотели бы пойти немного дальше, вы можете попробовать:

  • Создайте эффект загрузки предыдущей страницы
  • Обрабатывать границы смещения (например, не опускаться ниже 0)
  • Добавьте состояние загрузки и ошибки
  • Измените размер страницы
#JavaScript #Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу