Фреймворконезависимый фронтенд

  • Tutorial
GoF, Чистая архитектура, Совершенный код — настольные книги «true программиста». Но в мире фронтенда многие идеи из этих книг недоступны. По крайней мере сходство с реальным миром найти очень сложно. Может быть современный фронтенд опережает время? Может быть «функциональное» программирование и Реакт уже доказали свое превосходство над ООП? В этой статье я хочу привести пример todo-list приложения, которое я постарался реализовать согласно принципам и подходам, описанным в классических книгах.

Зависимость от фреймворка


Фреймворк — краеугольный камень современного фронта. На hh.ru вакансии React vs Angular vs Vue разработчиков. Я работал с каждым их этих фреймворков, и очень долго не мог понять, зачем мне опыт работы с Vue от 3-х лет, чтобы перекрасить кнопку из красного в фиолетовый? Зачем мне знать, как наследоваться на прототипах, или принцип работы event loop, чтобы переместить эту же кнопку из левого угла в правый? Ответ прост — мы пишем приложения, привязанные к библиотекам.

Зачем компаниям разработчики с большим стажем работы с Реакт? Да потому что приложение сильно зависит от особенностей этого самого Реакта, и чтобы ничего не поломать при перекрашивании кнопки, вам стоит поломать голову над тем, как внутри Реакт работает change detection, рендеринг дерева компонентов, и как это завязано на задачу перекрашивания кнопки. (Согласен, это всё частные случаи… А в вашей компании готовы взять специалиста без опыта работы с фреймворком?)
Программируйте с использованием языка, а не на языке. (Макконелл)
Фреймворк — это инструмент, а не образ жизни. (Мартин)
Для мира фронта эти тезисы в лучшем случае пустой звук, в худшем — вызов доказать обратное. Давайте обратимся к официальной документации React, и посмотрим пример простейшего приложения todo-list.

Пример todo-list с официального сайта React
class TodoApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = { items: [], text: '' };
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  render() {
    return (
      <div>
        <h3>Список дел</h3>
        <TodoList items={this.state.items} />
        <form onSubmit={this.handleSubmit}>
          <label htmlFor="new-todo">
            Что нужно сделать?
          </label>
          <input
            id="new-todo"
            onChange={this.handleChange}
            value={this.state.text}
          />
          <button>
            Добавить #{this.state.items.length + 1}
          </button>
        </form>
      </div>
    );
  }

  handleChange(e) {
    this.setState({ text: e.target.value });
  }

  handleSubmit(e) {
    e.preventDefault();
    if (!this.state.text.length) {
      return;
    }
    const newItem = {
      text: this.state.text,
      id: Date.now()
    };
    this.setState(state => ({
      items: state.items.concat(newItem),
      text: ''
    }));
  }
}

class TodoList extends React.Component {
  render() {
    return (
      <ul>
        {this.props.items.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    );
  }
}


«Используя props и state, можно создать небольшое приложение списка дел. В этом примере используется state для отслеживания текущего списка элементов… „

Для начинающего программиста (т.е. меня пару лет назад) эта фраза автоматически генерирует вывод: “Вот идеальный пример todo-list приложения». Но кто хранит стейт в компоненте?! Для этого же есть state management библиотеки.

Пример todo-list из документации Redux

Да, так приложение стало куда понятнее и проще (нет). Может попробуем провести зависимости в правильном направлении?

Независимое решение


Давайте посмотрим на задачу о todo-list не как фронтендеры, то есть забудем, что нам нужно рисовать HTML («web — это деталь»). Проверить глазами результат мы не сможем, поэтому придется писать тесты (как говорит дядюшка Боб, «можно тогда и TDD применить»). А в чём задача? Что за todo-list? Пробуем писать.

Todo.spec.ts
import { Todo } from './Todo';

describe('Todo', () => {
  let todo: Todo;

  beforeEach(() => {
    todo = new Todo('description');
  });

  it('+getItems() should returns Todo[]', () => {
    expect(todo.getTitle()).toBe('description');
  });

  it('+isCompleted() should returns completion flag', () => {
    expect(todo.isCompleted()).toBe(false);
  });

  it('+toggleCompletion() should invert completion flag', () => {
    todo.toggleCompletion();
    expect(todo.isCompleted()).toBe(true);
  });
});


TodoList.spec.ts
import { TodoList } from './TodoList';

describe('TodoList', () => {
  let todoList: TodoList;

  beforeEach(() => {
    todoList = new TodoList();
  });

  it('+getItems() should returns Todo[]', () => {
    expect(todoList.getItems()).toEqual([]);
  });

  it('+add() should create item and add to collection', () => {
    todoList.add('Write tests');
    expect(todoList.getItems()).toHaveLength(1);
  });

  it('+add() should create item with the description', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    expect(item.getTitle()).toBe(description);
  });

  it('+getCompletedItems() should not returns uncompleted Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    expect(todoList.getCompletedItems()).toEqual([]);
  });

  it('+getCompletedItems() should returns completed Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    item.toggleCompletion();
    expect(todoList.getCompletedItems()).toEqual([item]);
  });

  it('+getUncompletedItems() should returns uncompleted Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    expect(todoList.getUncompletedItems()).toEqual([item]);
  });

  it('+getUncompletedItems() should not returns completed Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    item.toggleCompletion();
    expect(todoList.getUncompletedItems()).toEqual([]);
  });
});


export class Todo {
  private completed: boolean = false;

  constructor(private description: string) {}

  getTitle(): string {
    return this.description;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
  }
}

import { Todo } from './Todo';

export class TodoList {
  private items: Todo[] = [];

  getItems(): Todo[] {
    return this.items;
  }

  getCompletedItems(): Todo[] {
    return this.items.filter((todo) => todo.isCompleted());
  }

  getUncompletedItems(): Todo[] {
    return this.items.filter((todo) => !todo.isCompleted());
  }

  add(description: string): void {
    this.items.push(new Todo(description));
  }
}

Получаем два простых класса с информативными интерфейсами. Неужели всё? Тесты проходят. А теперь подцепим Реакт.

import React from 'react';

import { TodoList } from './core/TodoList';

export class App extends React.Component {
  todoList: TodoList = this.createTodoList();

  render(): any {
    return (
      <React.Fragment>
        <header>
          <h1>Todo List App</h1>
        </header>
        <main>
          <TodoListCmp todoList={this.todoList}></TodoListCmp>
          <AddTodoCmp todoList={this.todoList}></AddTodoCmp>
        </main>
      </React.Fragment>
    );
  }

  private createTodoList(): TodoList {
    const todoList = new TodoList();
    todoList.add('Initial created Todo');
    return todoList;
  }
}

export const TodoListCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return (
    <div>
      <h2>What to do?</h2>
      <ul>
        {todoList.getItems().map((todo) => (
          <li key={todo.getTitle()}>{todo.getTitle()}</li>
        ))}
      </ul>
    </div>
  );
};

export const AddTodoCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return <button onClick={() => todoList.add(`Todo ${todoList.getItems().length}`)}>Add</button>;
};

И убедимся, что… Добавление элемента не работает. Хм… Теперь понятно, почему все надо писать в state — чтобы Реакт компонент перерисовывался, узнав об изменениях. Но разве это повод нарушить все возможные принципы и поместить логику во view компонент? Немножко терпения и отваги. Для решения проблемы прекрасно подойдет вызов forceUpdate() в бесконечном цикле или паттерн Наблюдатель.

Мне нравится библиотека RxJs, но я не буду её подключать, а только скопирую её API необходимое для нашей задачи.

Observable.spec.ts
import { Observable, Subject } from './Observable';

describe('Observable', () => {
  let subject: Subject<any>;
  let observable: Observable<any>;

  beforeEach(() => {
    subject = new Subject();
    observable = subject.asObservable();
  });

  it('should call callback on next value', async () => {
    const spy = jasmine.createSpy();
    observable.subscribe(spy);
    subject.next({});
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('should not call callback on next value if unsubscribed', async () => {
    const spy = jasmine.createSpy();
    const subscription = observable.subscribe(spy);
    subscription.unsubscribe();
    subject.next({});
    await delay();
    expect(spy).not.toHaveBeenCalled();
  });

  it('should send to callback subject.next value', async () => {
    const spy = jasmine.createSpy();
    observable.subscribe(spy);
    const sendingValue = {};
    subject.next(sendingValue);
    await delay();
    expect(spy.calls.first().args[0]).toBe(sendingValue);
  });
});

function delay(timeoutInMs?: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, timeoutInMs));
}


Observable.ts
export interface Observable<T = unknown> {
  subscribe(onNext: (value: T) => void): Subscription;
}

export interface Subscription {
  unsubscribe(): void;
}

export class Subject<T = unknown> implements Observable<T> {
  protected callbackSet: Set<(value: T) => void> = new Set();

  asObservable(): Observable<T> {
    return this;
  }

  subscribe(onNext: (value: T) => void): Subscription {
    this.callbackSet.add(onNext);
    return { unsubscribe: () => this.callbackSet.delete(onNext) };
  }

  next(value: T): void {
    Promise.resolve().then(() => this.callbackSet.forEach((onNext) => onNext(value)));
  }
}


По-моему, тоже ничего сложного. Добавим тест (оповещение об изменениях — та ещё логика).

  it('+TodoList.prototype.add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add('description');
    await delay();
    expect(spy).toHaveBeenCalled();
  });

На минутку задумаемся, а влияет ли изменение Todo-элемента на состояние TodoList? Влияет — методы getCompletedItems/getUncompletedItems должны вернуть другой набор элементов. Может быть стоит перенести toggleCompletion в класс TodoList? Плохая идея — с таким подходом нам придётся раздувать TodoList для каждой фичи, касающейся нового Todo-элемента (чуть позже к этому вернемся). Но как узнавать об изменениях, опять Наблюдатель? Поступим проще, пусть Todo-элемент сам сообщает об изменениях через callback.

Полный вариант программы выглядит так.

import React from 'react';
import { Observable, Subject } from 'src/utils/Observable';
import { generateId } from 'src/utils/generateId';

export class Todo {
  private completed: boolean = false;

  id: string = generateId();

  constructor(private description: string, private onCompletionToggle?: (todo: Todo) => void) {}

  getTitle(): string {
    return this.description;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
    this.onCompletionToggle?.(this);
  }
}

export class TodoList {
  private items: Todo[] = [];
  private changesSubject = new Subject();

  readonly changes: Observable = this.changesSubject.asObservable();

  getItems(): Todo[] {
    return this.items;
  }

  getCompletedItems(): Todo[] {
    return this.items.filter((todo) => todo.isCompleted());
  }

  getUncompletedItems(): Todo[] {
    return this.items.filter((todo) => !todo.isCompleted());
  }

  add(description: string): void {
    this.items.push(new Todo(description, () => this.changesSubject.next({})));
    this.changesSubject.next({});
  }
}

export class App extends React.Component {
  todoList: TodoList = this.createTodoList();

  render(): any {
    return (
      <React.Fragment>
        <header>
          <h1>Todo List App</h1>
        </header>
        <main>
          <TodoListCmp todoList={this.todoList}></TodoListCmp>
          <AddTodoCmp todoList={this.todoList}></AddTodoCmp>
        </main>
      </React.Fragment>
    );
  }

  componentDidMount(): void {
    this.todoList.changes.subscribe(() => this.forceUpdate());
  }

  private createTodoList(): TodoList {
    const todoList = new TodoList();
    todoList.add('Initial created Todo');
    return todoList;
  }
}

export const TodoListCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return (
    <div>
      <h2>What to do?</h2>
      <ul>
        {todoList.getUncompletedItems().map((todo) => (
          <TodoCmp key={todo.id} todo={todo}></TodoCmp>
        ))}
        {todoList.getCompletedItems().map((todo) => (
          <TodoCmp key={todo.id} todo={todo}></TodoCmp>
        ))}
      </ul>
    </div>
  );
};

export const TodoCmp: React.FC<{ todo: Todo }> = ({ todo }) => (
  <li
    style={{ textDecoration: todo.isCompleted() ? 'line-through' : '' }}
    onClick={() => todo.toggleCompletion()}
  >
    {todo.getTitle()}
  </li>
);

export const AddTodoCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return <button onClick={() => todoList.add(`Todo ${todoList.getItems().length}`)}>Add</button>;
};

Похоже, так и должно выглядеть независимое от фреймворка приложение todo-list. Единственное ограничение — ЯП. Можно реализовать отображение для консоли, или использовать Angular.

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

Правки от заказчика


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

Особые Todo-элементы


Одной из ключевых фишек ООП является возможность решать задачу через введение новых типов. Пожалуй, эта самый сильный приём ООП, способный в одиночку вытащить сложную и громоздкую программу. Например, я не знаю, что потребуется от Todo-элемента. Может быть нужно будет уметь изменять его название, может придётся добавлять дополнительные атрибуты, может быть возможность изменения этого элемента будет решаться через прямое обращение к серверу SpaceX… Но я уверен, что требования изменятся, и мне понадобятся разные типы Todo.

export class EditableTodo extends Todo {
  changeTitle(title: string): void {
    this.title = title;
    this.onChange?.(this);
  }
}

Похоже, для отображения специального типа, нам потребуется менять и view-компоненты. На практике я встречал (и писал) компоненты, в которых миллион разных условий превращают div-блок из жирафа в пулемёт. Чтобы избежать этой проблемы можно создать hoc-компонент с огромным switch-case списком. Или применить паттерн Посетитель и двойную диспетчеризацию, и позволить Todo-элементу самому решать, какого типа компонент нужно рисовать.

export class Todo {
  id: string = '';

  constructor(
    protected title: string,
    private completed: boolean = false,
    protected onChange?: (todo: Todo) => void,
  ) {}

  getTitle(): string {
    return this.title;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
    this.onChange?.(this);
  }

  render(renderer: TodoRenderer): any {
    return renderer.renderSimpleTodo(this);
  }
}

export class EditableTodo extends Todo {
  changeTitle(title: string): void {
    this.title = title;
    this.onChange?.(this);
  }

  render(renderer: TodoRenderer): any {
    return renderer.renderEditableTodo(this);
  }
}

export class TodoRenderer {
  renderSimpleTodo(todo: Todo): any {
    return <SimpleTodoCmp todo={todo}></SimpleTodoCmp>;
  }

  renderFixedTodo(todo: Todo): any {
    return <FixedTodoCmp todo={todo}></FixedTodoCmp>;
  }

  renderEditableTodo(todo: EditableTodo): any {
    return <EditableTodoCmp todo={todo}></EditableTodoCmp>;
  }
}

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

Теперь мы готовы. Страх перед новыми требованиями об «особых» Todo-элементах исчез. Думаю, разработчики сами могли бы проявить инициативу, и предложить пару фич, требующих введения новых типов, которые сейчас добавляются за счёт написания нового кода и минимальное изменение существующего.

Сохранение данных на сервер


Что за приложение без взаимодействия с сервером? Конечно, нужно уметь сохранять наш список через HTTP — ещё одно новое требование. Пробуем решить проблему.

AppTodoList.spec.ts
import { delay } from 'src/utils/delay';
import { TodoType } from '../core/TodoFactory';
import { AppTodoList } from './AppTodoList';

describe('AppTodoList', () => {
  let todoList: AppTodoList;

  beforeEach(() => {
    todoList = new AppTodoList({
      getItems: async () => [{ type: TodoType.Simple, title: 'Loaded todo', completed: false }],
      save: () => delay(),
    });
  });

  it('+resolve() should load saved todo items', async () => {
    await todoList.resolve();
    expect(todoList.getItems().length).toBeGreaterThan(0);
  });

  it('+resolve() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes after resolve()', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay(1);
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('+todo.onChange() should emit changes', async () => {
    await todoList.resolve();
    await delay(1);
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [], save: async () => spy() });
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+todo.onChange() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [{ title: '' }], save: async () => spy() });
    await todoList.resolve();
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should save todoList state on success and rollback to it on error', async () => {
    const api = { getItems: async () => [], save: () => Promise.resolve() };
    todoList = new AppTodoList(api);
    todoList.add({ title: '1' });
    await delay(1);
    const savedData = JSON.stringify(todoList.getItems());
    api.save = () => Promise.reject('Mock saving failed');
    todoList.add({ title: '2' });
    expect(todoList.getItems()).toHaveLength(2);
    await delay(1);
    expect(JSON.stringify(todoList.getItems())).toBe(savedData);
  });
});


export interface TodoListApi {
  getItems(): Promise<TodoParams[]>;
  save(todoParamsList: TodoParams[]): Promise<void>;
}

export class AppTodoList implements TodoList {
  private todoFactory = new TodoFactory();
  private changesSubject = new Subject();

  changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private subscription: Subscription = this.state.changes.subscribe(() => this.onStateChanges());
  private synchronizedTodoParamsList: TodoParams[] = [];

  constructor(private api: TodoListApi) {}

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.updateState(todoParamsList);
  }

  private updateState(todoParamsList: TodoParams[]): void {
    const todoList = new TodoListImp(todoParamsList);
    this.state = todoList;
    this.subscription.unsubscribe();
    this.subscription = todoList.changes.subscribe(() => this.onStateChanges());
    this.synchronizedTodoParamsList = todoParamsList;
    this.changesSubject.next({});
  }

  private async onStateChanges(): Promise<void> {
    this.changesSubject.next({});
    try {
      const params = this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
      await this.api.save(params);
      this.synchronizedTodoParamsList = params;
    } catch {
      this.updateState(this.synchronizedTodoParamsList);
    }
  }

  destroy(): void {
    this.subscription.unsubscribe();
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }
}

Мы не знаем, как должно вести себя приложение. Ждать, что сохранение прошло успешно, и отображать изменения? Позволить изменениям отобразиться, и в случае ошибки откатиться на синхронизированное состояние? Или вовсе игнорировать ошибки сохранения? Скорее всего, заказчик тоже этого не знает. Поэтому изменения требований неизбежны, но они должны повлиять только на один класс, ответственный за сохранение. А на подходе следующая правка.

Перемещение по истории изменений


«Нужна возможность отмены/повтора действий»…

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

TodoListHistory.spec.ts
import { TodoParams } from 'src/core/TodoFactory';
import { delay } from 'src/utils/delay';
import { TodoListHistory } from './TodoListHistory';

describe('TodoListHistory', () => {
  let history: TodoListHistory;

  beforeEach(() => {
    history = new TodoListHistory();
  });

  it('+getState() should returns TodoParams[]', () => {
    expect(history.getState()).toEqual([]);
  });

  it('+setState() should rewrite current state', () => {
    const newState = [{ title: '' }] as TodoParams[];
    history.setState(newState);
    expect(history.getState()).toBe(newState);
  });

  it('+hasPrev() should returns false on init', () => {
    expect(history.hasPrev()).toBe(false);
  });

  it('+hasPrev() should returns true after setState()', () => {
    history.setState([]);
    expect(history.hasPrev()).toBe(true);
  });

  it('+switchToPrev() should switch on prev state', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState(prevState);
    history.setState([]);
    history.switchToPrev();
    expect(history.getState()).toBe(prevState);
  });

  it('+hasPrev() should returns false after switch to first', () => {
    history.setState([]);
    history.switchToPrev();
    expect(history.hasPrev()).toBe(false);
  });

  it('+hasNext() should returns false on init', () => {
    expect(history.hasNext()).toBe(false);
  });

  it('+hasNext() should returns true after switchToPrev()', () => {
    history.setState([]);
    history.switchToPrev();
    expect(history.hasNext()).toBe(true);
  });

  it('+switchToNext() should switch on next state', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState([]);
    history.setState(prevState);
    history.switchToPrev();
    history.switchToNext();
    expect(history.getState()).toBe(prevState);
  });

  it('+hasNext() should returns false after switchToNext()', () => {
    history.setState([]);
    history.switchToPrev();
    history.switchToNext();
    expect(history.hasNext()).toBe(false);
  });

  it('+hasNext() should returns false after setState()', () => {
    history.setState([]);
    history.switchToPrev();
    history.setState([]);
    expect(history.hasNext()).toBe(false);
  });

  it('+switchToPrev() should switch on prev state after setState()', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState(prevState);
    history.setState([]);
    history.switchToPrev();
    history.setState([]);
    history.switchToPrev();
    expect(history.getState()).toBe(prevState);
  });

  it('+setState() should not emit changes', async () => {
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.setState([]);
    await delay();
    expect(spy).not.toHaveBeenCalled();
  });

  it('+switchToPrev() should emit changes', async () => {
    history.setState([]);
    await delay();
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.switchToPrev();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+switchToPrev() should emit changes', async () => {
    history.setState([]);
    history.switchToPrev();
    await delay();
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.switchToNext();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+reset() should reset history and apply initial state', async () => {
    history.setState([]);
    expect(history.hasPrev()).toBe(true);
    const initState = [{ title: '' }] as TodoParams[];
    history.reset(initState);
    expect(history.hasPrev()).toBe(false);
    expect(history.getState()).toBe(initState);
  });
});


AppTodoList.spec.ts
import { delay } from 'src/utils/delay';
import { TodoType } from '../core/TodoFactory';
import { AppTodoList } from './AppTodoList';

describe('AppTodoList', () => {
  let todoList: AppTodoList;

  beforeEach(() => {
    todoList = new AppTodoList({
      getItems: async () => [{ type: TodoType.Simple, title: 'Loaded todo', completed: false }],
      save: () => delay(),
    });
  });

  it('+resolve() should load saved todo items', async () => {
    await todoList.resolve();
    expect(todoList.getItems().length).toBeGreaterThan(0);
  });

  it('+resolve() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes after resolve()', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('+todo.onChange() should emit changes', async () => {
    await todoList.resolve();
    await delay();
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [], save: async () => spy() });
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+todo.onChange() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [{ title: '' }], save: async () => spy() });
    await todoList.resolve();
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should ignore error on save', async () => {
    const api = { getItems: async () => [], save: () => Promise.resolve() };
    todoList = new AppTodoList(api);
    todoList.add({ title: '1' });
    await delay();
    api.save = jasmine.createSpy().and.returnValue(Promise.reject('Mock saving failed'));
    todoList.add({ title: '2' });
    expect(todoList.getItems()).toHaveLength(2);
    await delay();
    expect(api.save).toHaveBeenCalled();
    expect(todoList.getItems()).toHaveLength(2);
  });

  it('+resolve() should provide current todoList state to history', async () => {
    expect(todoList.getItems()).toHaveLength(0);
    expect(todoList.getHistory().getState()).toHaveLength(0);
    await todoList.resolve();
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
  });

  it('+add() should provide current todoList state to history', async () => {
    expect(todoList.getItems()).toHaveLength(0);
    expect(todoList.getHistory().getState()).toHaveLength(0);
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
  });

  it('+history.switchToPrev() should change todoList state on prev', async () => {
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
    todoList.getHistory().switchToPrev();
    await delay();
    expect(todoList.getHistory().getState()).toHaveLength(0);
    expect(todoList.getItems()).toHaveLength(0);
  });

  it('+history.switchToPrev() should change todoList state on prev after resolve()', async () => {
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(2);
    expect(todoList.getHistory().getState()).toHaveLength(2);
    todoList.getHistory().switchToPrev();
    await delay();
    expect(todoList.getHistory().getState()).toHaveLength(1);
    expect(todoList.getItems()).toHaveLength(1);
  });

  it('+add() should emit changes after history.switchToPrev()', async () => {
    todoList.add({ title: '' });
    todoList.getHistory().switchToPrev();
    await delay();
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });
});


import { TodoParams } from 'src/core/TodoFactory';
import { Observable, Subject } from 'src/utils/Observable';

export class TodoListHistory {
  private changesSubject = new Subject();
  private history: TodoParams[][] = [this.state];

  changes: Observable = this.changesSubject.asObservable();

  constructor(private state: TodoParams[] = []) {}

  reset(state: TodoParams[]): void {
    this.state = state;
    this.history = [this.state];
  }

  getState(): TodoParams[] {
    return this.state;
  }

  setState(state: TodoParams[]): void {
    this.deleteHistoryAfterCurrentState();
    this.state = state;
    this.history.push(state);
  }

  private nextState(state: TodoParams[]): void {
    this.state = state;
    this.changesSubject.next({});
  }

  private deleteHistoryAfterCurrentState(): void {
    this.history = this.history.slice(0, this.getCurrentStateIndex() + 1);
  }

  hasPrev(): boolean {
    return this.getCurrentStateIndex() > 0;
  }

  hasNext(): boolean {
    return this.getCurrentStateIndex() < this.history.length - 1;
  }

  switchToPrev(): void {
    const prevStateIndex = Math.max(this.getCurrentStateIndex() - 1, 0);
    this.nextState(this.history[prevStateIndex]);
  }

  switchToNext(): void {
    const nextStateIndex = Math.min(this.getCurrentStateIndex() + 1, this.history.length - 1);
    this.nextState(this.history[nextStateIndex]);
  }

  private getCurrentStateIndex(): number {
    return this.history.indexOf(this.state);
  }
}

import { Observable, Subject, Subscription } from 'src/utils/Observable';
import { Todo } from '../core/Todo';
import { TodoFactory, TodoParams } from '../core/TodoFactory';
import { TodoList, TodoListImp } from '../core/TodoList';
import { TodoListApi } from './TodoListApi';
import { HistoryControl, TodoListHistory } from './TodoListHistory';

export class AppTodoList implements TodoList {
  private readonly todoFactory = new TodoFactory();
  private readonly history: TodoListHistory = new TodoListHistory();

  private changesSubject = new Subject();
  readonly changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private stateSubscription: Subscription = this.state.changes.subscribe(() =>
    this.onStateChanges(),
  );
  private historySubscription = this.history.changes.subscribe(() => this.onHistoryChanges());

  constructor(private api: TodoListApi) {}

  private onStateChanges(): void {
    const params = this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
    this.history.setState(params);
    this.api.save(params).catch(() => {});
    this.changesSubject.next({});
  }

  private onHistoryChanges(): void {
    const params = this.history.getState();
    this.updateStateTodoList(params);
    this.api.save(params).catch(() => {});
  }

  private updateStateTodoList(todoParamsList: TodoParams[]): void {
    const todoList = new TodoListImp(todoParamsList);
    this.state = todoList;
    this.stateSubscription.unsubscribe();
    this.stateSubscription = this.state.changes.subscribe(() => this.onStateChanges());
    this.changesSubject.next({});
  }

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.history.reset(todoParamsList);
    this.updateStateTodoList(todoParamsList);
  }

  destroy(): void {
    this.stateSubscription.unsubscribe();
    this.historySubscription.unsubscribe();
  }

  getHistory(): HistoryControl<TodoParams[]> {
    return this.history;
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }
}

Так как с историей изменений всё примерно однозначно, выделим управление историей todo-list в базовый класс.

import { Todo } from 'src/core/Todo';
import { Observable, Subject, Subscription } from 'src/utils/Observable';
import { TodoFactory, TodoParams } from '../core/TodoFactory';
import { TodoList, TodoListImp } from '../core/TodoList';
import { HistoryControl, HistoryState } from './HistoryState';

export class HistoricalTodoList implements TodoList, HistoryControl {
  protected readonly todoFactory = new TodoFactory();
  protected readonly history = new HistoryState<TodoParams[]>([]);

  private changesSubject: Subject = new Subject();
  readonly changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private stateSubscription: Subscription = this.state.changes.subscribe(() =>
    this.onStateChanged(this.getSerializedState()),
  );

  constructor() {}

  protected onStateChanged(params: TodoParams[]): void {
    this.history.addState(params);
    this.changesSubject.next({});
  }

  protected onHistorySwitched(): void {
    this.updateState(this.history.getState());
  }

  protected updateState(todoParamsList: TodoParams[]): void {
    this.state = new TodoListImp(todoParamsList);
    this.updateStateSubscription();
    this.changesSubject.next({});
  }

  private updateStateSubscription(): void {
    this.stateSubscription.unsubscribe();
    this.stateSubscription = this.state.changes.subscribe(() =>
      this.onStateChanged(this.getSerializedState()),
    );
  }

  private getSerializedState(): TodoParams[] {
    return this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
  }

  destroy(): void {
    this.stateSubscription.unsubscribe();
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }

  canUndo(): boolean {
    return this.history.hasPrev();
  }

  canRedo(): boolean {
    return this.history.hasNext();
  }

  undo(): void {
    this.history.switchToPrev();
    this.onHistorySwitched();
  }

  redo(): void {
    this.history.switchToNext();
    this.onHistorySwitched();
  }
}

import { TodoParams } from 'src/core/TodoFactory';
import { HistoricalTodoList } from './HistoricalTodoList';
import { TodoListApi } from './TodoListApi';

export class ResolvableTodoList extends HistoricalTodoList {
  constructor(private api: TodoListApi) {
    super();
  }

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.history.reset(todoParamsList);
    this.updateState(todoParamsList);
  }

  protected onStateChanged(params: TodoParams[]): void {
    super.onStateChanged(params);
    this.api.save(params).catch(() => this.undo());
  }

  protected onHistorySwitched(): void {
    super.onHistorySwitched();
    this.api.save(this.history.getState()).catch(() => {});
  }
}

Решение проблемы с сохранением пришло само-собой. Теперь мы можем не заботиться о том, какую стратегию сохранения выберет заказчик в конечном счёте. Можете предоставить ему все 3 варианта на выбор, расширив базовый класс.

Итоги


Кажется, из нашего todo-list получился небольшой прототип Google Keep. Ссылка на репозиторий, чтобы запустить приложение или пробежаться по истории коммитов.

Чем принципиально отличается приведенный пример от большинства фронтенд-приложений? Мы не зависели от библиотек, следовательно, разобраться в этом приложении может человек, никогда не работавший с Реакт. Наши решения были нацелены только на получение результата, без отвлечений на детали работы фреймворка, поэтому код более-менее отражает решаемую задачу. Нам удалось обеспечить легкое добавление новых типов Todo-элементов, и мы готовы к изменению стратегии сохранения.

С какими трудностями мы столкнулись? Мы решили проблему обновления представления с помощью паттерна Наблюдатель без привязки к фреймворку. Как выяснилось, применение этого паттерна всё равно требовалось для решения основной задачи (даже если бы нам не нужно было рисовать HTML). Поэтому мы не понесли издержек, отказавшись от «услуг» встроенной в фреймворк системы обнаружения изменений.

Хочется особо подчеркнуть, что написание тестов не представляло собой никакой сложности. Тестировать простые независимые объекты с информативным интерфейсом одно удовольствие. Сложность кода зависела только от самой задачи и моих навыков (или криворукости).

Как насчет уровня разработчика, который бы справился с этой задачей? Смог бы «джуниор Реакт-девелопер» написать подобное решение? «Программирование больше похоже на ремесло», поэтому без практики использования ООП и паттернов, думаю, это было бы сложно. Но вы и ваша компания сами решаете, во что вкладываете свои силы. Практикуетесь в ООП или разбираетесь в тонкостях очередного фреймворка? Я лишь заново убедился в актуальности литературных трудов опытных программистов, и показал, как можно начать на фронте использовать советы классиков на полную мощность.

Спасибо за прочтение!

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

А вы рискнёте писать свой следующий проект без завязки на фреймворк?

  • 26,8%Да, можно попробовать33
  • 47,2%Нет, игра не стоит свеч58
  • 26,0%Я и так весь независимый32

Средняя зарплата в IT

111 111 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 6 788 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 74

    +1

    Делаю всё то же самое, только без ООП на композиции функций, было интересно посмотреть на другой подход.
    По теме: разговоров об отделении бизнес-логики от представления в последнее время куча, но по прежнему задумываются об этом с самого начала в основном те, кто вынужден поддерживать несколько платформ одновременно.

      0
      А есть возможность поделиться ссылкой?
      +1

      Использовал подход на ооп в рамках mobx. Было удобно. В рамках реакт компонент удобства не особо много, как по мне.

        +6
        Нда, до чего же докатился мир, если уж реализацию MVVM нужно фронтэндерам продвигать отдельной статьёй.

        А если серьезно, то я виню в этом главным образом туториалы, начиная с angularJS и его HeroApp. Манера писать туториалы вместо документации, и при этом вообще класть на любое подобие нормальной архитектуры в этих туториалах — пошла откуда-то оттуда.
          +2

          Автор, а вы видели MobX? Всё то же самое, только уже написано до вас

            0

            Это зависимость от стороннего фреймвёрка!

              –1
              class Todo {
                  id = Math.random()
                  @observable title = ""
                  @observable finished = false
              }


              А вы уверены, что без знания особенностей работы MobX я напишу todo-list (смогу реализовать паттерн Состояние, например)?
              Согласен, что MobX очень похож на мой вариант. Я привёл пример реализации паттерна Observable (который в MobX и реализован), и хочу спросить… Зачем мне очередная библиотека ради одного класса на 20 строк?
                +2

                Затем, что 20 строк – это уже зачаток библиотеки? Потом понадобится расширить функциональность, добавить фичей, так и будете свой класс-велосипед дорабатывать?

                  +3
                  В Mobx описание базового API помещается в несколько абзацев — observable, computed и (опционально) action. Mobx даёт возможность использовать вычисляемые значения, автоматически определяет зависимости и отписывается от значений, которые уже не используются в рендере. У вас отписки ручные да ещё и от каждой подписки отдельно. В RxJS-подобных решениях для вычисляемых значений нужны будут дополнительные операторы вроде combineLatest/distinctUntilChanged/share и т.д, в Mobx это обычный JavaScript. Тут сравнение. Вообще стейт-менеджеры вроде Redux/Mobx как раз таки делают код независимым от UI, позволяя переиспользовать одну и ту же логику с любой популярной фронтенд библиотекой или фреймворком.
                    0
                    Ребята, не хочу отговаривать вас от использования mobx/redux/rxjs или любых других библиотек. Но хочу показать, что можно без особых расходов обойтись и без них.
                    В моём примере, мне понадобился паттерн Наблюдатель не только для рендеринга, но и для сохранения, и для управления историей. То есть этот паттерн необходим для решения самой задачи todo-list, а не для проблемы отображения изменений. Не думаю, что mobx подойдёт для написания консольного приложения.
                      0
                      Не думаю, что mobx подойдёт для написания консольного приложения.

                      А что не так с использованием mobx в консольном приложении?

                      0
                      Справедливости ради, статья — не про стейт менеджмент. И да, можно было взять mobx, и не писать самому часть кода. А можно было и не брать.

                      В любом случае, «просто mobx» вам архитектуру фронта не улучшит, mobx не навязывает архитектуру, и даже взяв mobx можно спокойно продолжать пихать всю логику внутрь компонентов. Более того, много учебных примеров mobx так и написаны — всё та же проблема в выборе между лаконичностью примера и масштабируемостью кода.
                      +2

                      Всё-таки может различать framework agnostic и NIH? Это разные подходы, хотя временами могут приводить к схожим итоговым результатам.

                        –1
                        Я не против использования сторонних библиотек, но не хочу завязывать на них логику, т.е. нарушать принцип инверсии зависимостей.
                          0

                          Инжектите библиотеки в свои классы. Можете даже стандартную либу оборачивать в абстракции и их инжектить.

                      –1
                      Повторюсь.
                      Паттерн Наблюдатель (Observable) реализован и в mobx, и в redux. Но в этих библиотеках он используется (заточен) для ререндеринга view-компонентов. В моём примере паттерн Наблюдатель играет важную роль в ключевой работе самого приложения (даже если бы не нужно было рисовать view). Поясню: в примере нужно событие изменения TodoList для сохранения через API. Так как это верхнеуровневая логика, я не хочу нарушать принцип инверсии зависимостей.
                        0
                        В MobX он не заточен под рендеринг компонента, MobX'у самому по себе пофигу вообще где он используется, на фронте или на бэке. Это mobx-react уже дает обвязку над компонентом для авто подписок/отписок и перерендера в случае изменений.
                          0
                          В MobX он не заточен под рендеринг компонента, MobX'у самому по себе пофигу вообще где он используется, на фронте или на бэке.

                          Как MobX помогает писать бизнес-логику?
                          Автор, а вы видели MobX? Всё то же самое, только уже написано до вас

                          Что тогда написано до меня? Google Keep?
                          Какую задачу решает MobX и как это относится к моему примеру?
                            0
                            Как MobX помогает писать бизнес-логику?

                            Как душе угодно, ограничений нет. У вас есть реактивное состояние, на изменение которого реагирует view и может реагировать любой код.
                            codesandbox.io/s/ecstatic-cloud-l956n
                              0
                              Какую задачу решает MobX и как это относится к моему примеру?

                              Управление состоянием и реакции на изменения состояния.
                                0
                                То есть для реализации паттерна Наблюдатель (реакции на изменения) вам обязательно нужен MobX? Я не против.
                                  0
                                  В нем есть абсолютно всё, для того чтобы создавать качественный, быстрый и бесконечно масштабируемый фронтенд. Разумеется прямые руки и техническую грамотность никто не отменял. Не вижу ни одной причины не использовать MobX.
                                    0

                                    В точности как и в VanillaJs)

                                0
                                Как MobX помогает писать бизнес-логику?

                                Избавляет от необходимости писать в мутаторах this.onCompletionToggle?.(this) и т. п. Избавляет от необходимости подписываться на onCompletionToggle явно, избавляет от необходимости на каждое изменение проверять canUndo и т. п., а только отреагировать когда именно результат canUndo изменится.


                                В общем позволяет реализовывать бизнес-логику в чистом виде, не "пачкая" её классы средствами взаимодействия с внешним видом. Чистый JS по сути с вкраплениями декораторов типа @observable или @computed, хотя можно и без них обойтись — они сахар.

                                  –1
                                  Избавляет от необходимости писать в мутаторах this.onCompletionToggle?.(this)

                                  Не избавляет. У вас два объекта, одному надо реагировать на изменения другого. Вы можете маскировать это за 10-ю декораторами. Но если логика требует этого, вы просто будете плясать вокруг АПИ своей любимой библиотеки, но точно так же подпишитесь на эти изменения. Только сделаете это неявно. А хорошо это или плохо?
                                    +1

                                    Если речь именно про бизнес-логику, то скорее эти два объекта будут либо в связи "has-a", либо будет сервис, который который их будет синхронно менять, что само по себе вызывает вопросы насколько качественно проведена декомпозиция. Pub/sub и ко для меня прежде всего инфраструктурный паттерн снаружи доменной модели. Чем меньше модель знает об инфраструктуре, тем лучше. А если без явных доменных событий никак, то скорее выберу какой-то евент-эмиттер, чем колбэки на каждый чих.

                                      –2
                                      Доменная модель, доменные события… Я в статье DDD не упоминаю. Если вы просто хотите поумничать, то пишите свои статьи (или в личку). Например, вопрос о том, что на фронте называть «бизнес-логикой» (и есть ли она там вообще) — это отдельная тема.
                                        0
                                        Цель примера — показать как руками (а не библиотеками) решается задача (любая), и посмотреть, что даст такое решение. Хотелось показать, что таким образом, ответственность за фичи распределяется по отдельным классам. (По крайней мере, без завязок на фреймворки проще разбить эту ответственность.) Конечно, у меня это получилось не в полной мере. Но по-моему, такой подход стоит рассмотреть. Особенно тем, у кого изменение одной фичи (стратегии сохранения, например) требует переписывания всего проекта.
                                          0
                                          Но по-моему, такой подход стоит рассмотреть

                                          Ни в коем случае, это нельзя применять в проектах
                                          0
                                          Мой посыл такой — всё намного проще чем кажется. Да, без великов тут не обойтись. Но написав свой велик, ты хоть поймёшь, чем тот велик с гитхаба лучше, и для чего он создан.

                                          Если вы привыкли сперва выбирать библиотеки, а потом что-то реализовывать, подумайте как сделать наоборот. Возможность откладывать принятие необратимых решений — признак хорошей архитектуры.
                                            0
                                            И мне абсолютно не понятна ваша позиция по поводу callback. Почему я не могу это использовать, если это решает мою проблему в данный момент?
                                              0
                                              Прочитайте пожалуйста что такое ES6 Proxy или getters/setters и все сразу встанет на места.
                                          0
                                          В общем позволяет реализовывать бизнес-логику в чистом виде, не «пачкая» её классы средствами взаимодействия с внешним видом.

                                          Ну не может библиотека описывать бизнес-логику в чистом виде! О чём вы?
                                          Возьмите моё решение и перепишите на Java, C#, Kotlin… Это будет один и тот же код, но с разным синтаксисом.
                                          Если завяжетесь на mobx, то это тоже будет один и тот же код. Но есть ли mobx для других языков? Спор ни о чём.
                                          Не нравится моё решение — хорошо… Оно достаточно криворукое. Но предметное обсуждение было бы полезней. Умеете через mobx писать программы так, что фичи разделены по разным классам и не пересекаются. Значит, у вас нет проблем. И наверно, статья не для вас.
                                            +1

                                            Библиотека позволяет не мешать бизнес-логику с инфраструктурой уведомления кого-то о событиях внутри объекта и использовать один унифицированный способ для этого (чем прозрачнее — тем лучше). Не говоря уже о включении логики сохранения/загрузки в бизнес-сущность.

                                              0
                                              Здесь не нужна инфраструктура оповещения о событии. Вы тащите это из DDD, которое тут не уместно.
                                              Логика сохранения/загрузки… ну да, можно было бы это вытащить отдельно. Создать для этого сервис. Ну так покажите, как это будет выглядеть, мне интересно! Я рассматривал этот вариант, но пошёл другим путём.
                                                0

                                                Ну как не нужна, если вы её создаёте с нуля? Все эти on* чем по вашему являются?

                                      +1

                                      Кажется, к вам в руки попал молоток, и теперь все кажется гвоздями.


                                      Паттерны — дело полезное, но не в ситуации когда мы вкидываем хорошую библиотеку, чтобы самому реализовать паттерн

                                        0

                                        А мне кажется, что в этой ветке собрались поклонники mobx)


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


                                        Mobx подойдёт для анемичной модели, когда у вас все свойства наружу торчат и объектом называют контейнеры с данными.


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

                                          +1
                                          Mobx подойдёт для анемичной модели, когда у вас все свойства наружу торчат и объектом называют контейнеры с данными.

                                          Ну нет. Это утверждение сродни «на JS можно делать только анемичные модели, потому что всё свойствами наружу» (ничего не мешает вам как-то выразить публичный интерфейс даже на JS).
                                          Mobx подходит для любых моделей, так как ортогонален им.
                                            –1

                                            Да, для всего подходит) я уже обожаю mobx и везде его использую!


                                            Вот только как я должен был написать статью про независимость от фреймворков, не обидев никого? Видимо, затащив все возможные библиотеки в пример.


                                            Но скорее всего, этот пример похож на mobx из-за кочевого слова class… Поэтому столько внимания и сравнения)

                                              0
                                              Вот только как я должен был написать статью про независимость от фреймворков, не обидев никого? Видимо, затащив все возможные библиотеки в пример.

                                              Ну лично я нисколько не обиделся — я прекрасно вижу, что статья не о том.
                                            0
                                            Вся фишка в удобстве использования и в том, как выглядит код. А не в том, что вы сделали свою имплементацию супер упрощенную, которая делается ещё проще если уж на то пошло)
                                              0

                                              Я не делал имплементации mobx. Я делал Todo list…

                                                0
                                                Todo list это не Real World задача.
                                                Вы сделали Hello World и показали людям, круто, только толку от этого, если этим нельзя пользоваться в реальной жизни. Есть уже давно существующий крутой инструмент.
                                                  0

                                                  Подскажите, как я мог бы дополнить свой пример до Real world задачи?

                                                    0
                                                    Работа с АПИ, получение списков чего угодно, пагинация, страница деталей элемента списка. Это самый упрощенный вариант задач из реального мира, но уже он может отразить преимущества/недостатки, кол-во кода, читаемость кода, удобство для дальнейшего развития и поддержания кода. В идеале всё это залить на codesandbox чтобы можно было видеть сразу как это работает и играться с кодом.
                                                      0

                                                      По-моему, очень простое дополнение. Работа с АПИ уже есть. Пагинация или ленивая загрузка элементов списка? Да, задача...


                                                      А может вы просто сами сделаете с использованием чего угодно ту же работу?


                                                      Перечислю: сперва напишите Todo list, потом подключите сохранение на сервер, потом добавите перемещение по истории, а потом измените стратегию сохранения на сервер.


                                                      А то, мне кажется, вам и ленивой загрузки элементов не хватит для уровня real world.

                                                        0
                                                        Просто сравните ваш код и код с использованием MobX. Небо и земля. И сами надеюсь понимаете в чью пользу. Ссылку я уже кидал где можно все посмотреть в работе.

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

                                                        1) Они уже используются многие годы.
                                                        2) Не увидел ни грамму мощности в вашей имплементации.
                                                        Реактивность с ES6 Proxy или с getters/setters вот это мощь, да, а ещё гигантское удобство.
                                              +1
                                              Mobx подойдёт для анемичной модели, когда у вас все свойства наружу торчат и объектом называют контейнеры с данными.

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

                                            0

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

                                          +1

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

                                            0
                                            Хотите сказать, у меня получился микрофреймворк?

                                            К сожалению, я слишком молод для backbone) Может посмотрю доку на досуге.
                                            0
                                            Выглядит так, будто вы пытаетесь переизобрести Angular. C React в качестве шаблонизатора.
                                            Кстати зачем делать запрос к api промисом, если есть fromFetch
                                              0
                                              Выглядит так, будто вы пытаетесь переизобрести Angular

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

                                              Кстати зачем делать запрос к api промисом, если есть fromFetch

                                              В примере RxJs не используется. Просто API похожее реализовал.
                                              0
                                              Одной из ключевых фишек ООП является возможность решать задачу через введение новых типов.

                                              Мне казалось, это фишка всех типизированных языков программирования, не связанная с ООП.

                                                0

                                                Как по мне выглядит здраво в контексте приложения, меняющего своё view в рантайме. В более-менее "приземлённых" приложениях это скорее overengeneering чем что-то полезное. DDD и Clean Architecture более удачно ложатся на бекенд как по мне.

                                                  0

                                                  Ну все SPA приложения меняют view в рантайме. Или что вы имели в виду?

                                                    0

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

                                                  0

                                                  Больная тема)
                                                  Всегда стараюсь донести суть общепринятых в ООП-сообществе подходов до коллег из фронтенда, и всегда сталкиваюсь со сложностью понимания. Основные тезисы, вызывающие трудности на контрасте с бэкенд (сам специализируюсь в бэке):


                                                  • как писать тесты без рендеринга html, как писать код не дожидаясь бэкенд, хотя уже есть спека апи. Бэкенд как правило удаётся тестировать на разных уровнях, не смотря на обилие интеграций со сторонними сервисами.
                                                  • зачастую все решается наследованием и жёсткой завязкой на компоненты фреймворка, понимания контракта и отделения бизнес логики от него нет. S.O.L.I.D, интерфейсы в ходу, мидлы синьоры обычно в курсе.
                                                  • много разговоров о крутости разных фреймворков, в то время как бэкенд чаще обсуждает чистый код, CI, CD, архитектуру, взаимодействие микросервисов.
                                                    –1
                                                    Автор, посмотри на это пожалуйста и ты прозреешь
                                                    codesandbox.io/s/ecstatic-cloud-l956n
                                                      –1
                                                      Так статья-ж не об этом. Статья о том, что было бы здорово понимать то, что находится под капотом и как оно там работает, а не просто брать и использовать.
                                                      Брать и использовать умеют любые ребятишки, которые прошли курсы «Стань фронтэнд-разработчиком за 3 недели и получай 300кк/нсек». И эти же самые ребятишки будут с пеной у рта доказывать, что их любимая библиотека огонь не обращая внимания на то, что речь вообще не о их любимой библиотеке.
                                                        +1
                                                        Ну так в Mobx всё понятно, в 5ой версии реактивность строится на ES6 Proxy, в 4ой версии на getters/setters. Там нет никакой магии и точно так же в качестве факультатива можно реализовать свою версию реактивности как на ES6 Proxy так и на getters/setters. Вся суть именно в том, что ты знаешь какие есть возможности у языка и делаешь максимально удобный инструмент, MobX яркий тому пример. Никто не мешает ровно тоже самое реализовать самому и пользоваться этим в удовольствие. А в этой статье крутые возможности языка вообще не использованы, так вот зачем преднамеренно лишать себя благ которые можно выжать из языка?
                                                          0
                                                          Вы когда учились разработке — первым делом прочитали GoF, Мартина, изучили спеку языка и сразу начали делать пушку-гонку?
                                                          Я думаю все в процессе изучают что-то простое, потом сложнее, сложнее, экспериментируют, получают опыт и далее. Мне нравится в статье то, что она о концепциях, которые не обязательно использовать вот прям для стейт-менеджмента. Она о концепциях, которые вообще полезно в голову загрузить и использовать по надобности, про прозрачные интерфейсы, про способы коммуникации частей системы. Какая разница насколько точно знаешь язык и инструменты, если лабаешь на них как обезьяна? И обратное — если знаешь что и как хочешь сделать — сделаешь на любом инструменте.

                                                          А вы все mobx, mobx… Вестстрейт наверно когда mobx задумывал — сталкивался с такими же комментаторами, которые говорили «да ты чо, редакс же есть!» и не видели концепции
                                                            0

                                                            То что автор статьи задумывается о паттернах и пытается их где-то применить – это здорово. Для образования и развития кругозора пригодится. А в продакшен-проекте я бы предпочел увидеть MobX, чем горе-велосипед.

                                                              –1
                                                              Понимаю, что вы не привыкли к использованию паттернов в продакшн-проектах. Хорошо это или плохо? Почему вы к этому не привыкли? Боитесь или не разрешают? Да, это хороший комментарий, наводящий на интересные размышления.

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

                                                              Почему-то, разработчикам проще работать с чем-то знакомым. Со знакомой структурой папок, например… Хотя проекты дико разные, но нам хочется найти серебряную пулю. Но вместе с универсальными решениями в проект тянутся одни и те же универсальные проблемы. А иногда вообще теряется понимание, «что» мы решаем, и зачем нам три библиотеки для стейт-менеджмент.

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

                                                                Почему вы считаете что я не привык? Просто мне для использования паттерна необязательно писать его с нуля.


                                                                Например, вы задумывались о том, что метод Array.prototype.filter – это реализация паттерна "Стратегия"? И для его использования необязательно писать свои кастомные классы.


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

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

                                                                  А можно поподробней, что в моей реализации не работоспособно?
                                                                    +1
                                                                    1) Нужно писать лишний код.
                                                                    2) Нужно явно подписываться на изменения, нормальные решения давным давно сами подписываются и сами отписываются.
                                                                    3) Нужно явно говорить что были сделаны изменения чтобы вызвать реакции, нормальные решения давным давно сами определяют были ли изменения и в зависимости от того, что именно изменилось вызывают реакции там, где измененные данные используются.
                                                                    4) Иммутабильность и все вытекающие из этого.
                                                              0
                                                              Автор написал:
                                                              Я лишь заново убедился в актуальности литературных трудов опытных программистов, и показал, как можно начать на фронте использовать советы классиков на полную мощность.

                                                              На полную мощность карл, понимаете о чем я?) Люди говорят про mobx, потому, что там как раз JavaScript используется на полную мощность.
                                                                0
                                                                Уважаемый, MaZaAa. Вам поставят плюсик на собеседовании за знание ES6 и версий mobx. Но я больше не стану реагировать на ваши комментарии к этой статье, потому что вы абсолютно не понимаете о чем речь.

                                                                Вы из предложения в три строчки не можете понять, что я посоветовал использовать «советы классиков», а не возможности js. Но для этого эти советы нужно прочитать в книжках.
                                                                  +1
                                                                  Мда, вы предложили создать гужевую повозку, но только вы похоже не знаете, что уже давным давно люди на машинах передвигаются на порядки эффективнее.
                                                                  Вот и я вам говорю, ваш паттерн наблюдателя уже давным давно реализован много раз, и одна из наиболее удачных реализаций для JS это mobx.
                                                                  Гужевая повозка вас везет и машина вас везет, но чем пользоваться гораздо удобнее как думаете?
                                                                  Вот тоже самое mobx и ваша реализация которую вы пытаетесь защитить и сказать людям чтобы ей пользовались, если бы были времена EcmaScript 3, то такая реализация была бы актуальна, но увы в начиная с ES2015 с появлением getters/setters реализация этого паттерна стала на порядок удобнее, я уже молчу по возможности ES6 Proxy которые прикончили небольшие ограничения getters/setters. И всем этим люди уже наслаждаются годами. А вы говорите, а давайте вернемся в далекое прошлое, круто же.
                                                                  Вы опоздали на 5 лет, либо опоздали с выбором языка на котором реализовали этот паттерн. т.к. этот язык даем вам гораздо большие возможности и абсурдно ими не пользоваться.
                                                                    0
                                                                    Это не вашу «библиотеку» раскритиковали за незнание ES6 3 года назад?
                                                                    Подучили, теперь отыгрываетесь на других, рассказывая, какие они немодные-немолодёжные и надо срочно использовать ваш любимый самый крутой блаблабла фреймворк (в статье «Фреймворконезависимый фронтенд», ага)?

                                                                    Статья отличная, даже если это слишком примитивно для продакшна — рассказывает, как это устроено у подобных библиотек под капотом. Не надо унизительных сравнений с «гужевыми повозками».
                                                                      –1
                                                                      Вы видимо не читали статью и тред комментариев, автор написал:
                                                                      Я лишь заново убедился в актуальности литературных трудов опытных программистов, и показал, как можно начать на фронте использовать советы классиков на полную мощность.

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

                                                                      Не было бы слов, цитату которых я выше прикрепил, не было бы никаких сравнений.
                                                                      надо срочно использовать ваш любимый самый крутой блаблабла фреймворк (в статье «Фреймворконезависимый фронтенд», ага)?

                                                                      1) Где хоть слово о фреймворке?
                                                                      2) Вы знаете чем отличается фреймворк от библиотеки? Если да, то к чему эта писанина?
                                                        +1
                                                        Огромное спасибо Автору jesterst, статья крутая!
                                                        Всегда хотел увидеть как будет выглядеть классическое ООП во фронтенде.

                                                        Так сложилось, что я пишу только на React, а React больше об ФП, чем об ОПП. Потому двигаюсь больше в сторону ФП. Например я не читал ЧК и ЧА, но читал Composing Software by Eric Elliott. Очень крутая книга, рекомендую если хотите познакомиться с ФП. Мне нравилось ФП и раньше, но после прочтения этой книги я его особенно полюбил.

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

                                                        Но есть и третий путь. Использовать ФП и ООП вместе, мы ведь пишем на JavaScript/TypeScript. Можно использовать ФП для логики которую можно выразить как чистые функции (такой на самом деле много) и ООП для side effect. Достаточно разобраться с чистыми функциями и каррированием из ФП, инкапсуляцией из ООП, понять когда использовать какой инструмент и вы сможете писать действительно очень выразительный и чистый код, который поймет каждый, даже тот кто не знает ни ФП, ни ООП.

                                                        Этот подход на самом деле и использует React, это идея которая делает его простым для всех. Ведь в ФП сложно с side effect, а ООП хуже справляется чем ФП с простыми вещами.
                                                        Вы можете использовать эту идею за пределами React, чтобы писать фреймворконезависимый фронтенд например. В этом вам поможет книга, которую я упоминал выше, Composing Software by Eric Elliott.

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

                                                        Самое читаемое