Frontend independente

GoF, Arquitetura Pura, Código Perfeito - Os verdadeiros manuais do programador. Mas no mundo do front-end, muitas idéias desses livros não estão disponíveis. Pelo menos a semelhança com o mundo real é muito difícil de encontrar. Talvez o frontend moderno esteja adiantado? Talvez a programação "funcional" e o React já tenham comprovado sua superioridade em relação ao POO? Neste artigo, quero dar um exemplo de um aplicativo de lista de tarefas que tentei implementar de acordo com os princípios e abordagens descritos nos livros clássicos.

Dependência de estrutura


A estrutura é a pedra angular da frente moderna. Nas vagas hh.ru estão os desenvolvedores React vs Angular vs Vue. Eu trabalhei com cada uma dessas estruturas e, durante muito tempo, não entendi por que tive que trabalhar com o Vue de três anos para recolorir um botão de vermelho para roxo? Por que preciso saber como herdar protótipos ou como o loop de eventos funciona para mover o mesmo botão do canto esquerdo para a direita? A resposta é simples - escrevemos aplicativos vinculados à biblioteca.

Por que empresas com longa experiência trabalhando com React? Sim, porque o aplicativo é altamente dependente dos recursos deste React e, para não quebrar nada ao repintar um botão, você deve saber como a detecção de alterações, a renderização da árvore de componentes funciona dentro do React e como isso se relaciona à tarefa de repintar o botão. (Eu concordo, todos esses são casos especiais ... E na sua empresa, você está pronto para contratar um especialista sem experiência em trabalhar com a estrutura?)
Programe usando um idioma, não um idioma. (McConnell) Um
framework é uma ferramenta, não um modo de vida. (Martin)
Para o mundo da frente, essas teses são, na melhor das hipóteses, frases vazias e, na pior das hipóteses, um desafio para provar o contrário. Vamos dar uma olhada na documentação oficial do React e ver um exemplo de um aplicativo simples da lista de tarefas.

Exemplo de lista de tarefas no site oficial do 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>
    );
  }
}


“Usando adereços e estado , você pode criar um pequeno aplicativo de lista de tarefas. Neste exemplo, state é usado para rastrear a lista atual de elementos ... “

Para um programador iniciante (ou seja, há alguns anos), essa frase gera automaticamente a saída:“ Aqui está um exemplo ideal de um aplicativo de lista de tarefas ”. Mas quem armazena o estado no componente ?! Para isso, existe uma biblioteca de gerenciamento de estado.

Um exemplo de uma lista de tarefas da documentação do Redux

Sim, é assim que o aplicativo se torna muito mais claro e fácil (não) . Podemos tentar desenhar as dependências na direção certa?

Decisão independente


Vejamos o problema da lista de tarefas não como front-end, ou seja, esqueça que precisamos desenhar HTML ("web is a detail"). Não poderemos verificar o resultado com nossos olhos, por isso teremos que fazer testes (como diz o tio Bob, "você pode usar o TDD então"). E qual é a tarefa? O que é lista de tarefas? Estamos tentando escrever.

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));
  }
}

Temos duas classes simples com interfaces informativas. Isso é tudo? Os testes são aprovados. Agora pegue o React.

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>;
};

E verifique se ... Adicionar um item não funciona. Hmm ... Agora está claro por que tudo precisa ser escrito em estado - para que o componente React seja redesenhado depois de aprender sobre as alterações. Mas esse é um motivo para violar todos os princípios possíveis e colocar a lógica no componente view? Um pouco de paciência e coragem. Para resolver o problema, chamar forceUpdate () em um loop infinito ou o padrão Observer é perfeito .

Gosto da biblioteca RxJs, mas não a conectarei, apenas copie sua API necessária para nossa tarefa.

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)));
  }
}


Na minha opinião, nada muito complicado. Adicione um teste (notificação de alterações - essa é a lógica).

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

Vamos pensar por um momento, mas uma alteração em um elemento Todo afeta o estado de um TodoList? Afeta - os métodos getCompletedItems / getUncompletedItems devem retornar um conjunto diferente de elementos. Talvez valha a pena mover o toggleCompletion para a classe TodoList? É uma péssima idéia: com essa abordagem, teremos que inflar o TodoList para todos os recursos relacionados a um novo elemento Todo (retornaremos a isso mais tarde). Mas como aprender sobre as mudanças, novamente o Observador? Para simplificar, permita que o próprio elemento Todo comunique alterações por meio de um retorno de chamada.

A versão completa do programa se parece com isso.

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>;
};

Parece que deve ser o aplicativo da lista de tarefas independente da estrutura. A única limitação é PL. Você pode implementar a exibição para o console ou usar Angular.

Talvez a versão atual do aplicativo não seja complexa o suficiente para garantir que uma abordagem independente esteja funcionando e para demonstrar seus pontos fortes. Portanto, conectaremos nossa imaginação para simular um cenário de desenvolvimento de lista de tarefas mais ou menos plausível.

Edições do cliente


O principal pesadelo da maioria dos projetos é a mudança de requisitos. Você sabe que as edições não podem ser evitadas e você sabe que isso é normal. Mas como você se prepara para mudanças futuras?

Elementos especiais do Todo


Um dos principais recursos do OOP é a capacidade de resolver o problema através da introdução de novos tipos. Talvez esta seja a técnica de POO mais poderosa que pode, sozinha, executar um programa complexo e complicado. Por exemplo, não sei o que é necessário para o elemento Todo. Pode ser necessário alterar seu nome, pode ser necessário adicionar atributos adicionais, pode ser possível alterar esse elemento através do acesso direto ao servidor SpaceX ... Mas tenho certeza de que os requisitos serão alterados, e precisarei de diferentes tipos de Todo.

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

Parece que para exibir um tipo especial, precisaremos alterar também os componentes da visualização. Na prática, conheci (e escrevi) componentes nos quais um milhão de condições diferentes transformam um bloco div de uma girafa em uma metralhadora. Para evitar esse problema, você pode criar um componente hoc com uma enorme lista de casos de switch. Ou aplique o padrão Visitor e o despacho duplo e deixe o elemento Todo decidir por si mesmo que tipo de componente desenhar.

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>;
  }
}

A opção de despacho duplo é especialmente útil quando um tipo de item tem visões diferentes. Você pode alterá-los, substituindo TodoRenderers diferentes no método de renderização.

Agora estamos prontos. O medo de novas demandas por elementos Todo especiais "desapareceu". Eu acho que os próprios desenvolvedores poderiam tomar a iniciativa e oferecer alguns recursos que exigem a introdução de novos tipos, que agora são adicionados ao escrever um novo código e alterar o mínimo existente.

Salvando dados no servidor


Que tipo de aplicativo é sem interagir com o servidor? Obviamente, você precisa salvar nossa lista via HTTP - outro novo requisito. Estamos tentando resolver o problema.

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);
  }
}

Não sabemos como o aplicativo deve se comportar. Aguarde o salvamento ter sucesso e exiba as alterações? Permitir que as alterações sejam exibidas e, em caso de erro, reverter para o estado sincronizado? Ou ignorar completamente os erros de salvamento? Muito provavelmente, o cliente também não sabe disso. Portanto, mudanças nos requisitos são inevitáveis, mas devem afetar apenas uma classe responsável pela conservação. E a caminho da próxima edição.

Histórico de alterações


“Precisamos da capacidade de cancelar / refazer ações” ...

Parece que uma onda de novas edições nos pega de surpresa. Mas, em nenhum caso, você não pode sacrificar testes. Agora, não está totalmente claro qual hierarquia de herança é mais adequada e se a herança é geralmente adequada. Portanto, nada de ruim acontecerá se simplesmente suplementarmos nossa classe suja (consideraremos isso como detalhes de implementação), sacrificando o princípio de responsabilidade exclusiva.

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);
  }
}

Como o histórico de alterações é praticamente o mesmo, vamos separar o gerenciamento do histórico da lista de tarefas na classe base.

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(() => {});
  }
}

A solução para o problema de conservação veio por si só. Agora não podemos nos preocupar com a estratégia de conservação que o cliente escolherá a longo prazo. Você pode fornecer a ele todas as 3 opções para escolher, expandindo a classe base.

Sumário


Parece que em nossa lista de tarefas temos um pequeno protótipo do Google Keep. Link para o repositório para iniciar o aplicativo ou revisar o histórico de confirmações.

O que difere fundamentalmente este exemplo da maioria dos aplicativos front-end? Como não dependíamos de bibliotecas, uma pessoa que nunca trabalhou com o React pode entender esse aplicativo. Nossas decisões foram direcionadas apenas à obtenção do resultado, sem distrações nos detalhes da estrutura, de modo que o código reflete mais ou menos o problema que está sendo resolvido. Conseguimos facilitar a adição de novos tipos de elementos Todo e estamos prontos para alterar a estratégia de conservação.

Que dificuldades encontramos? Resolvemos o problema de atualizar a visualização usando o padrão Observer sem referência à estrutura. Como se viu, a aplicação desse padrão ainda era necessária para resolver o problema principal (mesmo que não precisássemos desenhar HTML). Portanto, não incorremos em custos abandonando os “serviços” do sistema de detecção de alterações incorporado à estrutura.

Eu gostaria de enfatizar que escrever testes não foi uma dificuldade. Testar objetos independentes simples com uma interface informativa é um prazer. A complexidade do código dependia apenas da tarefa em si e de minhas habilidades (ou curvatura).

E o nível de desenvolvedor que lidaria com isso? O Junior React Developer poderia escrever essa solução? “A programação é mais como uma arte”, portanto, sem a prática de usar OOP e padrões, acho que seria difícil. Mas você e sua empresa decidem em que investir. Você pratica OOP ou entende os meandros da próxima estrutura? Só me convenci novamente da relevância das obras literárias de programadores experientes e mostrei como começar a usar os conselhos dos clássicos em plena capacidade na frente.

Obrigado pela leitura!

All Articles