Функціональні та складані сигнали в Angular

Що, якщо я скажу, що ви можете складати свій стан, як LEGO блоки? Саме це дозволяють нам робити функціональні сигнали.

pic

Спершу давайте подивимось на маленьку утиліту, яка змінить наше уявлення про інжекцію залежностей:

import { InjectionToken, Provider } from "@angular/core";  

export function createInjectionToken(  
 createFn: () => T,  
 description?: string  
): [InjectionToken, () => Provider] {  
 const injectionToken = new InjectionToken(description ?? "");  

 return [  
 injectionToken,  
 () => ({  
 provide: injectionToken,  
 useFactory: createFn,  
 }),  
 ];  
}

Ця маленька функція — наша секретна зброя. Ми всі звикли до традиційного підходу Angular — визначення сервісів з декоратором @Injectable() і їх надання в модулях чи компонентах. Але ця функція використовує інший підхід. Вона створює динамічний токен інжекції, пов'язаний з його провайдером, що дозволяє нам складати функції керування станом у провайдери значень, які можна інжектувати, без необхідності використовувати визначення класів.

Почнемо з основних елементів.
Спочатку, наше керування списком:

import { computed, signal } from "@angular/core";  
import { PropertyOfType } from "./utils";  

export const withList = <T, P extends keyof T>(  
 idProperty: P,  
) => {  
 type ID = T[P] extends string | number ? T[P] : never;  
 const values = signal<Record<ID, T>>({} as Record<ID, T>);  

 return {  
 /** Обчислений масив всіх елементів */  
 values: computed(() => Object.values(values()) as T[]),  

 /** Обчислений масив усіх ID елементів */  
 keys: computed(() => Object.keys(values()) as ID[]),  

 /**  
 * Заміна всіх елементів на новий масив  
 * @param value - Нові елементи для зберігання  
 */  
 set: (value: T[]) => {  
 const data = value.reduce(  
 (accumulator, item) => ({  
 ...accumulator,  
 [item[idProperty] as ID]: item,  
 }),  
 {} as Record<ID, T>,  
 );  
 values.set(data);  
 },  

 /**  
 * Отримує елемент за ID  
 * @param id - Ідентифікатор елемента  
 * @returns Елемент, якщо знайдено, або null  
 */  
 get: (id: ID): T | null => values()[id],  

 /**  
 * Додає один елемент до списку  
 * @param value - Елемент для додавання  
 */  
 add: (value: T) => {  
 const id = value[idProperty] as ID;  
 values.set({ ...values(), [id]: value });  
 },  

 /**  
 * Оновлює існуючий елемент  
 * @param id - Ідентифікатор елемента  
 * @param value - Нове значення елемента  
 */  
 update: (id: ID, value: T) => {  
 values.set({ ...values(), [id]: value });  
 },  

 /**  
 * Видаляє елемент за ID  
 * @param id - Ідентифікатор елемента  
 */  
 remove: (id: ID) => {  
 const copy = { ...values() };  
 delete copy[id];  
 values.set(copy);  
 },  

 /** Обчислена кількість елементів у списку */  
 size: computed(() => Object.keys(values()).length),  
 };  
};

Далі, наше керування вибором:

import { computed, signal } from "@angular/core";  

export const withSelection = <T>() => {  
 const values = signal<Record<T, boolean>>({} as Record<T, boolean>);  

 return {  
 /**  
 * Вибір одного або кількох значень  
 * @param rest - Значення для вибору  
 */  
 select: (...rest: T[]) =>  
 values.set({  
 ...values(),  
 ...rest.reduce(  
 (accumulator, value) => ({ ...accumulator, [value]: true }),  
 {},  
 ),  
 }),  

 /**  
 * Скасування вибору значення  
 * @param value - Значення для скасування вибору  
 */  
 deselect: (value: T) => {  
 const copy = { ...values() };  
 delete copy[value];  
 values.set(copy);  
 },  

 /**  
 * Перевіряє, чи вибрано значення  
 * @param value - Значення для перевірки  
 * @returns true, якщо значення вибрано  
 */  
 has: (value: T) => values()[value] ?? false,  

 /**  
 * Перемикає стан вибору значення  
 * @param value - Значення для перемикання  
 */  
 toggle(value: T) {  
 this.has(value) ? this.deselect(value) : this.select(value);  
 },  

 /** Очищає всі вибори */  
 clear: () => values.set({} as Record<T, boolean>),  

 /** Обчислена кількість вибраних значень */  
 size: computed(() => Object.keys(values()).length),  
 };  
};

І наша пагінація:

import { computed, signal } from "@angular/core";  

export const withPagination = () => {  
 const page = signal(1);  
 const pageSize = signal(10);  
 const pageIndex = computed(() => page() - 1);  

 return {  
 /** Поточний номер сторінки (починаючи з 1) */  
 page,  

 /** Кількість елементів на сторінку */  
 pageSize,  

 /** Індекс сторінки (починаючи з 0) */  
 pageIndex,  

 /** Обчислений зсув для поточної сторінки */  
 offset: computed(() => pageIndex() * pageSize()),  
 };  
};

Тепер ось де стає цікаво. Комбінуючи ці будівельні блоки разом, ми можемо створювати більш складну функціональність. Давайте додамо можливості пошуку та сортування, щоб продемонструвати цю силу. Зверніть увагу на два ключові аспекти: по-перше, ми переналаштували сигнал значень(), щоб обробляти фільтрацію та сортування.
По-друге, ми створили функцію selectPage(), яка використовує як керування вибором, так і pageData() — чудовий приклад того, як композиція дозволяє будувати нові функції з уже існуючих:

import { computed, signal } from "@angular/core";  
import { toObservable, toSignal } from "@angular/core/rxjs-interop";  
import { debounceTime, distinctUntilChanged } from "rxjs";  
import { withList } from "./list";  
import { withPagination } from "./pagination";  
import { withSelection } from "./selection";  
import { PropertyOfType } from "./utils";  

export const withPage = <  
 T extends Record<string, any>,  
 P extends PropertyOfType<T>,  
 K extends keyof T = keyof T,  
>({  
 idProperty,  
 filterProperty,  
}: {  
 idProperty: P;  
 filterProperty: K;  
}) => {  
 type ID = T[P] extends string | number ? T[P] : never;  
 const list = withList(idProperty);  
 const selection = withSelection();  
 const pagination = withPagination();  
 const sort = signal<{ active: string; direction: "asc" | "desc" | "" }>({  
 active: idProperty as string,  
 direction: "asc",  
 });  

 const searchText = signal("");  
 const query = toSignal(  
 toObservable(searchText).pipe(debounceTime(300), distinctUntilChanged()),  
 );  

 /** Обчислені значення з застосованими сортуванням і фільтрацією */  
 const values = computed(() => {  
 const searchQuery = query();  
 const sortField = sort().active;  
 const sortDirection = sort().direction;  
 const sortValues =  
 sortField === ""  
 ? [...list.values()]  
 : [  
 ...list  
 .values()  
 .sort((a, b) =>  
 compare(a[sortField], b[sortField], sortDirection === "asc"),  
 ),  
 ];  
 if (!searchQuery) {  
 return sortValues;  
 }  
 return sortValues.filter((item) =>  
 item[filterProperty].includes(searchQuery),  
 );  
 });  

 /** Обчислені дані для поточної сторінки */  
 const pageData = computed(() =>  
 values().slice(  
 pagination.offset(),  
 pagination.offset() + pagination.pageSize(),  
 ),  
 );  

 return {  
 selection: {  
 ...selection,  

 /** Вибирає всі елементи на поточній сторінці */  
 selectPage: () =>  
 selection.select(...pageData().map((item) => item[idProperty])),  
 },  
 ...{  
 ...list,  
 values,  
 },  
 searchText,  
 ...pagination,  
 pageData,  
 sort,  
 };  
};  

function compare(a: number | string, b: number | string, asc: boolean) {  
 return (a < b ? -1 : 1) * (asc ? 1 : -1);  
}

Але почекайте, є ще більше! Давайте подивимося, як ми можемо обробляти завантаження взаємопов'язаних даних.
Ось наше керування списком користувачів, яке показує деякі розширені патерни Signal:

import { inject, resource, Signal } from "@angular/core";  
import { toObservable, toSignal } from "@angular/core/rxjs-interop";  
import {  
 combineLatest,  
 distinctUntilChanged,  
 firstValueFrom,  
 map,  
 of,  
 timer,  
} from "rxjs";  
import { withList } from "../reactivity/list";  
import { Todo, User } from "./todo.model";  
import { UserService } from "./user.service";  

export const withUserList = (todos: Signal<Todo[]>) => {  
 const list = withList("id");  
 const service = inject(UserService);  

 const userIds$ = toObservable(todos).pipe(  
 map((todos) => new Set(todos.map((todo) => todo.userId))),  
 distinctUntilChanged((x, y) => {  
 if (x.size !== y.size) return false;  
 for (const userId of x) {  
 if (!y.has(userId)) return false;  
 }  
 return true;  
 }),  
 );  

 const userIds = toSignal(userIds$);  

 const loader = resource({ // Так, я знаю, що це не зовсім правильне використання resource(), але хто мене зупинить? :)  
 request: () => userIds(),  
 loader: async ({ request, abortSignal }) => {  
 if (!request) {  
 list.set([]);  
 return;  
 }  
 const users = await firstValueFrom(  
 combineLatest([  
 of(  
 Promise.all(  
 [...request].map((userId) => service.fetch(userId, abortSignal)),  
 ),  
 ),  
 timer(2000),  
 ]).pipe(map(([users]) => users)),  
 );  
 list.set(users);  
 },  
 });  

 return {  
 ...list,  
 loading: loader.isLoading,  
 };  
};

Тут є два цікавих патерни. По-перше, ми використовуємо observable userIds$ для фільтрації унікальних ID з наших todos — це запобігає непотрібним викликам API, коли ті самі користувачі з’являються кілька разів. По-друге, ми використовуємо combineLatest з таймером, щоб обробити умови гонки. Якщо API відповідає занадто швидко, ми все одно показуємо стан завантаження протягом мінімальної тривалості, запобігаючи мерехтінню інтерфейсу.

Ось тут наш підхід справді показує свою силу.
Ми можемо створити повну сторінку Todo з усією необхідною функціональністю:

import { computed, inject, resource } from "@angular/core";  
import { createInjectionToken } from "../reactivity/injector";  
import { withPage } from "../reactivity/page";  
import { Todo } from "./todo.model";  
import { TodoService } from "./todo.service";  
import { withUserList } from "./user.list";  

const withTodoPage = () => {  
 const page = withPage({  
 idProperty: "id",  
 filterProperty: "title",  
 });  
 const users = withUserList(page.pageData);  
 const service = inject(TodoService);  

 const loader = resource({ // Так, я знаю, що це не зовсім правильне використання resource(), але хто мене зупинить? :)   
 loader: async () => {  
 const data = await service.fetch();  
 page.set(data);  
 },  
 });  

 return {  
 ...page,  
 loading: loader.isLoading,  
 users,  
 isEmpty: computed(() => !loader.isLoading() && page.size() === 0),  
 toggle(id: Todo["id"]) {  
 const todo = page.get(id);  
 if (!todo) return;  
 page.update(id, {  
 ...todo,  
 completed: !todo.completed,  
 });  
 },  
 };  
};  

const [TodoPage, provideTodoPage] = createInjectionToken(  
 withTodoPage,  
 "TodoPage",  
);  

export { provideTodoPage, TodoPage };

І використати це у нашому компоненті:

import { Component, inject } from "@angular/core";  
import { FormsModule } from "@angular/forms";  
import { MatCheckboxModule } from "@angular/material/checkbox";  
import { MatFormFieldModule } from "@angular/material/form-field";  
import { MatInputModule } from "@angular/material/input";  
import { MatPaginatorModule } from "@angular/material/paginator";  
import { MatSlideToggleModule } from "@angular/material/slide-toggle";  
import { MatSortModule } from "@angular/material/sort";  
import { MatTableModule } from "@angular/material/table";  
import { provideTodoPage, TodoPage } from "./features/todo.page";  

@Component({  
 selector: "adk-root",  
 standalone: true,  
 imports: [  
 FormsModule,  
 MatTableModule,  
 MatSortModule,  
 MatCheckboxModule,  
 MatSlideToggleModule,  
 MatPaginatorModule,  
 MatInputModule,  
 MatFormFieldModule,  
 ],  
 template: `
@if (todos.users.loading()) {  

    } @else {    {{ todos.users.get(element.userId)?.name }}    }                       Done                                                  `,    providers: [provideTodoPage()],   })   export class AppComponent {    todos = inject(TodoPage);   } ```  Краса цього підходу полягає в його складаності та повторному використанні.
Кожен елемент управління станом ізольований, його можна тестувати, і він може бути поєднаний, як будівельні блоки, для створення складної функціональності.

![pic](https://drive.javascript.org.ua/0e86548a531_0bNw1IVoZrllM46_LbXfOQ_gif)



Перекладено з: [Functional & Composable Angular Signals](https://medium.com/@bmfyfz/functional-angular-signals-ee6408b1e15d)

Leave a Reply

Your email address will not be published. Required fields are marked *