Angular является очень консервативным фреймворком в плане стандартизации разработки, этим он зарекомендовал себя для крупной коммерческой разработки SPA приложений. Действительно, если вы используете Angular, то вам достаточно найти специалиста необходимой квалификации, и он не будет испытывать трудностей с технической частью проекта. Но сегодня я хотел бы немного разрушить представление о разработке Angular, и расширить ваш взгляд на разработку Angular приложений.

Статья рассчитана на уверенных разработчиков Angular, и подразумевает, что у вас есть опыт разработки средних и крупных приложений.

Представим, что мы хотим разработать классическое приложение списка дел. У нас есть анемичная модель элемента списка:

interface Todo {
  id: string;
  name: string; // Название
  date: string; // Дата создания
}

И сервис, который хранит список дел:

@Injectable({ providedIn: 'root' })
class TodoService {
  private readonly _todos$ = new BehaviorSubject<Todo[]>([]);

  readonly todos$ = this._todo$.asObservable();

  addTodo(todo: Todo) {
    this._todos$.next([...this._todos$.value, todo]);
  }
}

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

@Component({
  selector: 'todo-list',
  template: `
    <button type="button" class="add-todo" (click)="addTodo()">Добавить элемент</button>
    <div *ngFor="let todo of todos$ | async; trackBy: trackById" class="todo">
      <span class="todo-name">{{todo.name}}</span>
      <span class="todo-date">{{todo.date | date}}</span>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TodoListComponent {
  private readonly _todoService = inject(TodoService);

  readonly todos$ = this._todoService.todos$;

  addTodo() {
    this._todoService.addTodo({
      id: generateGuid(), // Генерация GUID опущена для простоты
      name: 'Новое дело',
      date: new Date()
    } as Todo);
  }

  trackById(index: number, todo: Todo) {
    return todo.id;
  }
}

Обратите внимание, что мы не стали хранить список дел непосредственно в компоненте. Такой подход часто используется при разработке Angular приложений. Данный подход позволяет отделить данные и бизнес логику от представления. Вся логика приложения хранится в сервисе, а компонент является отображением. Можно воспринимать это как некоторую вариацию MVVM.

При создании нового дела, мы создаем объект по умолчанию (смотри метод addTodo() в компоненте), и хотим дать пользователю возможность его редактировать. Добавим новый метод в наш сервис:

@Injectable({ providedIn: 'root' })
class TodoService {
  private readonly _todos$ = new BehaviorSubject<Todo[]>([]);

  readonly todos$ = this._todo$.asObservable();

  addTodo(todo: Todo) {
    this._todos$.next([...this._todos$.value, todo]);
  }

  // Добавили метод редактирования
  editTodo(editTodoId: string, name: string) {
    let todos = this._todos$.value;
    
    // Находим в списке дело, которое пользователь хочет изменить
    const editTodo = todos.find(todo => todo.id === editTodoId);

    // Если пользователь пытается отредактировать несуществующее дело
    // то ничего не делаем
    if (!editTodo) {
      return;
    }
    
    // Обновим список дел
    todos = todos.map(
      todo => todo.id !== editTodoId ?
        todo : { ...todo, name }
    )
    
    this._todos$.next(todos);
  }
}

А также обновим компонент:

@Component({
  selector: 'todo-list',
  template: `
    <button type="button" class="add-todo" (click)="addTodo()">Добавить элемент</button>
    <div *ngFor="let todo of todos$ | async; trackBy: trackById" class="todo">
      <span class="todo-name">{{todo.name}}</span>
      <span class="todo-date">{{todo.date | date}}</span>
      <button type="button" (click)="editTodo(todo.id)">Редактировать</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TodoListComponent {
  private readonly _todoService = inject(TodoService);

  readonly todos$ = this._todoService.todos$;

  addTodo() {
    this._todoService.addTodo({
      id: generateGuid(),
      name: 'Новое дело',
      date: new Date()
    } as Todo);
  }

  editTodo(editTodoId: string) {
    // Получаем новое значение от пользователя
    const name: string = prompt("Введите значение");

    // Обновляем список дел
    this._todoService.editTodo(editTodoId, name);
  }
  
  trackById(index: number, todo: Todo) {
    return todo.id;
  }
}

Оказалось, сделать это не так просто. Код метода в сервисе получился не самым простым и лаконичным. Это обусловлено некоторыми особенностями обнаружения изменений в недрах Angular.

Как вы могли заметить, мы используем механизм OnPush в качестве стратегии обнаружения изменений внутри компонента. В таком случае, для непрерывного отображения изменяющегося списка дел мы используем фильтр async, который подписывается на todos$ и обновляет список каждый раз, когда ссылка на значение внутри todos$ изменяется. Следовательно, при любом изменении объекта массива, нам нужно генерировать новую ссылку на массив (что мы успешно делаем с помощью метода map), иначе наше приложение не сможет распознать, что в массиве произошли изменения и произвести перерисовку.

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

class Todo extends BaseModel<Todo> {
  id: string;
  date: Date;

  constructor(public name: string) {
    this.id = generateGuid();
    this.date = new Date();
  }

  setName(name: string) {
    this.update({ name });
  }
}
@Injectable({ providedIn: 'root' })
class TodoService extends DataService<Todo> {
  // Сервис пустой
}
@Component({
  selector: 'todo-list',
  template: `
    <button type="button" class="add-todo" (click)="addTodo()">Добавить элемент</button>
    <div *ngFor="let todo of todos$ | async; trackBy: trackById" class="todo">
      <span class="todo-name">{{todo.name}}</span>
      <span class="todo-date">{{todo.date | date}}</span>
      <button type="button" (click)="editTodo(todo)">Редактировать</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TodoListComponent {
  private readonly _todoService = inject(TodoService);

  readonly todos$ = this._todoService.data$;

  addTodo() {
    // Метод add() был унаследован от DataService
    this._todoService.add(new Todo('Новое дело'))
  }

  editTodo(todo: Todo) {
    // Получаем новое значение от пользователя
    const name: string = prompt("Введите значение");

    // Обновляем список дел
    todo.setName(name);
  }
  
  trackById(index: number, todo: Todo) {
    return todo.id;
  }
}

Резюмируем изменения:

  • Вместо анемичной модели Todo, которая представляла собой объект, мы использовали класс, который наследуется от BaseModel.

  • Сервис TodoService теперь наследуется от класса DataService, который умеет обрабатывать список моделей.

  • Вместо того чтобы вызвать метод класса для изменения модели, мы вызываем метод самой модели внутри компонента. Таким образом, нам не нужно искать модель в списке и делать точечные изменения.

Код стал значительно проще с точки зрения восприятия. В действительности, данный подход дает еще больше преимуществ:

Мы можем расширять модели

Представим, что в нашем приложении есть не список дел, но и отдельный список напоминаний:

class Notification extends Todo {
   dueDate: Date; // Дата напоминания

    constructor(name: string, dueDate: Date) {
      super(name);

      this.dueDate = dueDate;
    }
}

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

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

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

@Injectable({ providedIn: 'root' })
class NotificationsService extends DataService<Notification> {}

Чем больше моделей, тем меньше кода нам придется в итоге написать. Прекрасно!

Мы можем определять тип модели используя instanceof

Представим что нам попался массив дел и уведомлений, и мы не знаем что из них что. В случае анемичных моделей (без использования классов), единственный способ это выяснить, это проверить полеdueDate модели. Если поле присутствует, то это Notification, иначе Todo.

const isNotification = Boolean(todo?.dueDate);

Но что если dueDate был установлен в null или undefined в результате ошибки или намеренно? В таком случае мы теряем возможность каким либо образом детерминировать вид данных. В нашем проекте могут быть десятки сложных моделей, для которых определение вида может оказаться сложной задачей. Например, если для определения вида данных нам придется проверять несколько полей на различные условия.

При использовании классов мы можем определить вид нашей модели простым использованием intanceof:

const isNotification = todo instanceof Notification;

При этом нам уже не важно, установлен ли dueDate или нет, проверка будет работать всегда.

Логика модели находится внутри модели

В терминах ООП это называется инкапсуляцией. В терминах DDD это называется богатой моделью (Rich model). Когда мы используем объекты для хранения данных, мы можем только получать и записывать данные в объект:

const foo: Person = {
   name: 'Максим',
   age: 29,
   salary: 70_000,
   company: 'ООО Рога и Копыта',
   isActive: true,
};

console.log(foo.name) // Считали данные
foo.name = 'Дмитрий'; // Записали данные

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

Богатые модели могут выполнять бизнес логику, управлять своими данными самостоятельно:

const user = new Person('Максим', 29, 70_000, 'ООО Рога и Копыта');

user.print();
user.deactivate();

Нам не обязательно обращаться к сервису, чтобы произвести манипуляцию с моделью, она самодостаточна.

Данный факт также упрощает работу с кодом. Удобно, когда модель и ее методы описаны в одном файле.

Как это работает?

Итак, мы выяснили, что использование богатой модели (классов) для описания моделей может быть выгодно при разработке приложений Angular. Давайте поймем, как это воплотить в реальном проекте.

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

Для реализации подобного механизма, я решил обратиться к уже существующей практики реализации форм в Angular. Если мы хотим создать свой собственный элемент формы, мы должны реализовать интерфейс ControlValueAccessor:

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}

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

Точно так же сервис хочет знать когда в модели произошли изменения. Поэтому попробуем перенести этот механизм registerOnChange для реализации задуманного:

export abstract class BaseModel<T> {
  // Функция _onChange будет установлена сервисом при добавлении модели в список
  private _onChange: Function;

  // Модель может вызвать onChange() чтобы сообщить о своих изменениях
  protected onChange() {
    this._onChange?.();
  }

  // Всопомгательный метод для обновлении модели
  update(obj: Partial<T>) {
    Object.assign(this, obj);
    this._onChange?.();
  }

  // Данный метод вызовет сервис, чтобы установить _onChange
  registerOnChange(onChange: Function) {
    this._onChange = onChange;
  }
}

Теперь опишем базовый вариант класса DataService:

abstract class DataService<T extends BaseModel<T>> {
  // Хранит список моделей (сущностей)
  protected readonly _data$ = new BehaviorSubject<T[]>([]);

  // Используется компонентом для отображения списка моделей
  readonly data$ = this._data$.asObservable();

  constructor() {
    this._onChange = this._onChange.bind(this);
  }

  // Добавление модели в список
  add(...models: T[]) {
    // Устанавливаем _onChange для модели
    models.forEach(model => {
      model.registerOnChange(this._onChange);
    });

    this._data$.next([...this._data$.value, ...models]);
  }

  // Удаление модели из списка
  delete(deleteModel: T) {
    if (!deleteModel) {
      return;
    }

    this._data$.next(this._data$.value.filter(model => model !== deleteModel));
  }

  clear() {
    this._data$.next([]);
    this._onChange();
  }

  protected _onChange() {
    // При изменении модели, генерируем новую ссылку на массив
    this._data$.next([...this._data$.value]);
  }
}

Таким образом достигается желаемый результат. При каждом изменении модели, вызывается метод _onChange(), который заставляет сервис сгенерировать новую ссылку на массив и перерисовать компонент.

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

  • Оптимизации перерисовки. Если сразу несколько моделей обновили свое состояние, то DataService должен генерировать событие перерисовки единожды.

  • Множественного обновления моделей. Иногда полезно обновить одно и то же поле согласно определенному критерию. Например, прибавить зарплату всем сотрудником женского пола. Обновлять каждую модель по отдельности не всегда эффективно.

  • Поиска по моделям. Если вы хотите получить выборку моделей по определенному критерию.

  • Поддержка Dependency Injection. Спорная, но иногда полезная вещь. Модель в нашем случае не может использовать внешние зависимости, совершать запросы или получать конфигурацию приложения. Расширив класс DataService, мы можем обеспечить некоторые возможности модели внедряться инжектор приложения.

Послесловие

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

К тому же, как мне кажется, данный подход малоизвестен и не описан в достаточной степени, что сподвигло меня написать данный пост. Надеюсь кто-то сможет почерпнуть для себя еще один способ реализации проектов на своем любимом фреймворке. Спасибо за внимание.

P.S. Возможно терминология, которая была использована в статье не точна, но я хотел бы оставить это на совести автора.

Комментарии (8)


  1. kubk
    16.09.2023 02:28

    Непонятно зачем делать свой pub-sub велосипед когда есть популярные протестированные библиотеки вроде Mobx. Пример как похожий функционал реализовывать с помощью mobx-angular. При этом Mobx корректно работает с вложенными данными, умеет точечно перерисовывать только нужные компоненты, имеет механизм транзакций (когда несколько синхронных изменений приводят лишь к одной перерисовке компонента).


    1. daemonyeen Автор
      16.09.2023 02:28
      -1

      При всем уважении, MobX это стейт менеджер, и к теме богатых моделей никак не относится, а совсем наоборот. Мне кажется вы не поняли суть поста. Суть поста была как реализовать Rich модели в Angular, MobX в этом не поможет.


      1. kubk
        16.09.2023 02:28
        +1

        Mobx - это система реактивности. Вы можете использовать как анемичную модель (сделать TodoService), так и rich модель (логика модели находится внутри модели). В моём примере по ссылке такая же rich модель как у вас. Просто разработчики библиотеки за много лет прошли гораздо дальше и продумали то, чего в статье нет.


        1. daemonyeen Автор
          16.09.2023 02:28

          Да, согласен, вариант рабочий. А как у MobX с поддержкой наследования?


  1. lebedec
    16.09.2023 02:28
    +1

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

    Данный пример демонстрирует проблему реализации реактивной обработки данных в Aungular фреймворке. Это не является проблемой анемичных моделей.

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

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

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

    Ваша реализация наивна и работает только на тривиальном примере. Просто представьте:

    • У вас 1000 записей и вы решили пройтись по всем для изменения одного поля. Сколько колбэков и аллокаций массива произойдет в вашей реализации?

    • Как управлять асинхронной бизнес логикой из подобной модели?

    • Как будут обрабатываться исключения если нужно атомарное изменение нескольких записей?


  1. dopusteam
    16.09.2023 02:28

    А зачем todos$ в сервисе сделан геттером? Обычное readonly поле, имхо, было бы лучше.


    1. daemonyeen Автор
      16.09.2023 02:28

      Да, опечатка, исправлю, спасибо


  1. dopusteam
    16.09.2023 02:28

    При каждом изменении модели, вызывается метод _onChange(), который заставляет сервис сгенерировать новую ссылку на массив и перерисовать компонент

    А что будет если я элементы массива прокидываю во вложенный компонент? У меня ж вложенный компонент не перерисуется, т.к. ссылка на элемент не изменилась.

    Код метода в сервисе получился не самым простым и лаконичным.

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

    Могу ещё предложить потенциальную проблему, если в будущем понадобится при добавлении\редактировании новой Todo проверять её, например, уникальность. Т.е. фактически выполнять действие не над todo, а над всем списком (т.к. сама сущность не может проверить своих соседей). В вашем случае, вам придётся скорее всего вносить правки и в компонент, и в сервис, и в модель, в то время как в случае с сервисом вся логика будет меняться только в сервисе.

    Но вообще, мы в своё время пришли к подобному решению, правда ещё на angularjs, вот там было суперудобно делать такие модели и работать с ними, там всё равно на каждый чих всё перерисовывается)

    Ещё у вас в коде достаточно много опечаток из за чего он даже не скомпилится, что наводит на мысль, что либо вы его не запускали, либо очень сильно меняли для статьи