Обработка разбивки на страницы с помощью хранилищ компонентов 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
, внутри которого мы сначала определим наше состояние.
Наше состояние должно содержать:
TodoItems
для отображения- Детали пагинации (смещение и размер страницы)
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)
- Добавьте состояние загрузки и ошибки
- Измените размер страницы