Typescript и react

в 15:26, , рубрики: javascript, mobx, React, state management, TypeScript, Разработка веб-сайтов

Разработка на javascript иногда становится похожа на работу детектива. Как понять чужой код? Хорошо, если разработчик обладает тонким искусством называть переменные так, чтобы другие поняли суть. А как быть, если члены команды все таки не всегда способны понять замысел своего коллеги? Как понять, что приходит в аргумент какой-либо функции?

Предположим, что аргумент функции называется errors. Вероятно в errors находится массив. Скорее всего строк? Ну то, что массив это понятно. Ведь далее проверятся его длинна. Но свойство length есть и у строки. Похоже, чтобы точно разобраться, необходимо поставить breakpoint и запустить скрипт. Затем полностью пройти по сценарию на UI (например нам нужен финальный шаг формы). Теперь в devtools видно, что errors — это объект с набором определенных полей, среди которых и поле length.

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

Следует отметить некоторые достоинства ts. Его широко используют в различных фреймворках и он тесно связан с javascript. Развитие ts обусловливается потребностями frontend разработчиков.

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

Я использовал react, typescript и mobx. Mobx — гибкое средство для управления состоянием приложения. Mobx лаконичен. Он позволяет работать с состоянием компонентов react в синхронном стиле. Нет проблем типа:

this.setState({name: 'another string'});
alert(this.state.name);

В данном случае выведется старое значение state.name.

Кроме того, mobx удобен и не мешает работать с типами ts. Можно описывать state в виде отдельных классов или прямо внутри react компонента.

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

В папке TodoItem находится файл с react компонентом TodoItem.tsx, файл со стилями TodoItem.module.scss и файл состояния TodoItemState.ts.

В TodoItemState.ts описаны поля для хранения данных, способы доступа к ним и правила их изменения. Круг возможностей очень велик благодаря ООП и ts. Часть данных может быть приватной, часть открыта только для чтения и прочее. С помощью декоратора @o указаны observable поля. На их изменения реагируют react компоненты. Декораторы @a (action) используются в методах для изменения состояния.

// TodoItemState.ts
import { action as a, observable as o } from 'mobx';

export interface ITodoItem {
  id: string;
  name: string;
  completed: boolean;
}

export class TodoItemState {
  @o public readonly value: ITodoItem;
  @o public isEditMode: boolean = false;

  constructor(value: ITodoItem) {
    this.value = value;
  }

  @a public setIsEditMode = (value: boolean = true) => {
    this.isEditMode = value;
  };
  @a public editName = (name: string) => {
    this.value.name = name;
  };
  @a public editCompleted = (completed: boolean) => {
    this.value.completed = completed;
  };
}

В TodoItem.tsx в props передается всего два свойства. В mobx оптимально для общей производительности приложения передавать сложные структуры данных в props react компонента. Поскольку мы используем ts, то можно точно указать тип принимаемого компонентом объекта.

// TodoItem.tsx
import React, { ChangeEventHandler } from 'react';
import { observer } from 'mobx-react';
import { TodoItemState } from './TodoItemState';
import { EditModal } from 'components/EditModal';
import classNames from 'classnames';
import classes from './TodoItem.module.scss';

export interface ITodoItemProps {
  todo: TodoItemState;
  onDelete: (id: string) => void;
}

@observer
export class TodoItem extends React.Component<ITodoItemProps> {
  private handleCompletedChange: ChangeEventHandler<HTMLInputElement> = e => {
    const {
      todo: { editCompleted },
    } = this.props;
    editCompleted(e.target.checked);
  };

  private handleDelete = () => {
    const { onDelete, todo } = this.props;
    onDelete(todo.value.id);
  };

  private get editModal() {
    const { todo } = this.props;
    if (!todo.isEditMode) return null;
    return (
      <EditModal
        name={todo.value.name}
        onSubmit={this.handleSubmitEditName}
        onClose={this.closeEditModal}
      />
    );
  }

  private handleSubmitEditName = (name: string) => {
    const { todo } = this.props;
    todo.editName(name);
    this.closeEditModal();
  };

  private closeEditModal = () => {
    const { todo } = this.props;
    todo.setIsEditMode(false);
  };
  private openEditModal = () => {
    const { todo } = this.props;
    todo.setIsEditMode();
  };

  render() {
    const { todo } = this.props;
    const { name, completed } = todo.value;
    return (
      <div className={classes.root}>
        <input
          className={classes.chackbox}
          type="checkbox"
          checked={completed}
          onChange={this.handleCompletedChange}
        />
        <div
          onClick={this.openEditModal}
          className={classNames(
            classes.name,
            completed && classes.completedName
          )}>
          {name}
        </div>
        <button onClick={this.handleDelete}>del</button>
        {this.editModal}
      </div>
    );
  }
}

В интерфейсе ITodoItemProps описано todo свойство типа TodoItemState. Таким образом внутри react компонента мы обеспечены данными для отображения и методами их изменения. Причем, ограничения на изменение данных можно описать как в state классе, так и в методах react компонента, в зависимости от поставленных задач.

Компонент TodoList похож на TodoItem. В TodoListState.ts можно заметить геттеры с декоратором @c (@computed). Это обычные геттеры классов, только их значения мемоизируются и пересчитываются при изменении их зависимостей. Computed по назначению похож на селекторы в redux. Удобно, что не нужно, подобно React.memo или reselect, явно передавать список зависимостей. React компоненты реагируют на изменение computed также как и на изменение observable. Интересной особенностью является то, что перерасчет значения не происходит, если в данный момент computed не участвует в рендере (что экономит ресурсы). Поэтому, несмотря на сохранение постоянных значений зависимостей, computed может пересчитаться (cсуществует способ явно указать mobx, что необходимо сохранять значение computed).

// TodoListState.ts
import { action as a, observable as o, computed as c } from 'mobx';
import { ITodoItem, TodoItemState } from 'components/TodoItem';

export enum TCurrentView {
  completed,
  active,
  all,
}

export class TodoListState {
  @o public currentView: TCurrentView = TCurrentView.all;
  @o private _todos: TodoItemState[] = [];

  @c
  public get todos(): TodoItemState[] {
    switch (this.currentView) {
      case TCurrentView.active:
        return this.activeTodos;
      case TCurrentView.completed:
        return this.completedTodos;
      default:
        return this._todos;
    }
  }

  @c
  public get completedTodos() {
    return this._todos.filter(t => t.value.completed);
  }
  @c
  public get activeTodos() {
    return this._todos.filter(t => !t.value.completed);
  }

  @a public setTodos(todos: ITodoItem[]) {
    this._todos = todos.map(t => new TodoItemState(t));
  }

  @a
  public addTodo = (todo: ITodoItem) => {
    this._todos.push(new TodoItemState(todo));
  };
  @a
  public removeTodo = (id: string): boolean => {
    const index = this._todos.findIndex(todo => todo.value.id === id);
    if (index === -1) return false;
    this._todos.splice(index, 1);
    return true;
  };
}

Доступ к списку todo открыт только через computed поле, где, в зависимости от режима просмотра, возвращается необходимый отфильтрованный набор данных (завершенные, активные или все todo). В зависимостях todo указаны computed поля completedTodos, activeTodos и приватное observable поле _todos.

Рассмотрим главный компонент App. В нем рендерятся форма для добавления новых todo и список todo. Тут же создается экземпляр главного стейта AppSate.

// App.tsx
import React from 'react';
import { observer } from 'mobx-react';
import { TodoList, initialTodos } from 'components/TodoList';
import { AddTodo } from 'components/AddTodo';
import { AppState } from './AppState';
import classes from './App.module.scss';

export interface IAppProps {}

@observer
export class App extends React.Component<IAppProps> {
  private appState = new AppState();

  constructor(props: IAppProps) {
    super(props);
    this.appState.todoList.setTodos(initialTodos);
  }

  render() {
    const { addTodo, todoList } = this.appState;
    return (
      <div className={classes.root}>
        <div className={classes.container}>
          <AddTodo onAdd={addTodo} />
          <TodoList todoListState={todoList} />
        </div>
      </div>
    );
  }
}

В поле appState находится экземпляр класса TodoListState для отображения компонента TodoList и метод добавления новых todo, который передается в компонент AddTodo.

// AppState.ts
import { action as a } from 'mobx';
import { TodoListState } from 'components/TodoList';
import { ITodoItem } from 'components/TodoItem';

export class AppState {
  public todoList = new TodoListState();

  @a public addTodo = (value: string) => {
    const newTodo: ITodoItem = {
      id: Date.now().toString(),
      name: value,
      completed: false,
    };
    this.todoList.addTodo(newTodo);
  };
}

Компонент AddTodo имеет изолированный стейт. К нему нет доступа из общего стейта. Единственная связь с appState осуществляется через метод appState.addTodo при submit формы.
Для стейта компонента AddTodo используется библиотека formstate, которая отлично дружит с ts и mobx. Formstate позволяет удобно работать с формами, осуществлять валидацию форм и прочее. Форма имеет только одно обязательное поле name.

// AddTodoState.ts
import { FormState, FieldState } from 'formstate';

export class AddTodoState {
  // Create a field
  public name = new FieldState('').validators(
    val => !val && 'name is required'
  );

  // Compose fields into a form
  public form = new FormState({
    name: this.name,
  });

  public onSubmit = async () => {
    //  Validate all fields
    const res = await this.form.validate();
    // If any errors you would know
    if (res.hasError) {
      console.error(this.form.error);
      return;
    }
    const name = this.name.$;
    this.form.reset();
    return name;
  };
}

В целом, нет смысла описывать полностью поведение всех компонентов. Полный код приведен тут.

В данной статье приведена попытка автора писать простой, гибкий и структурированный код, который легко поддерживать. React делит UI на компоненты. В компонентах описаны классы стейтов (можно отдельно тестировать каждый класс). Экземпляры стейтов создаются либо в самом компоненте, либо уровнем выше, в зависимости от задач. Достаточно удобно, что можно указывать типы полей класса и типы свойств компонентов благодаря typescript. Благодаря mobx мы можем, практически незаметно для разработчика, заставить react компоненты реагировать на изменение данных.

Автор: Leopardius

Источник


* - обязательные к заполнению поля