[Из песочницы] Typescript и react
Разработка на 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 {
private handleCompletedChange: ChangeEventHandler = 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 (
);
}
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 (
{name}
{this.editModal}
);
}
}
В интерфейсе 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 {
private appState = new AppState();
constructor(props: IAppProps) {
super(props);
this.appState.todoList.setTodos(initialTodos);
}
render() {
const { addTodo, todoList } = this.appState;
return (
);
}
}
В поле 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 компоненты реагировать на изменение данных.