Написание простого приложения React-Redux с помощью Typescript - Часть 1

Если разработка интерфейса javascript по-прежнему является «Диким Западом» по сравнению с такими фреймворками, как Django или Spring, то React и Redux - это город сумасшедших, застрявший в глуши без шерифа.

Я время от времени использовал React и Redux для небольших проектов, каждый раз просматривая руководство, загружая базовое простое приложение и затем приостанавливая его, чтобы я мог выполнить некоторую «настоящую работу». Через 6 месяцев я вернулся в Redux и изучил его заново. В последний раз, когда я делал это, я хотел попробовать Typescript, поскольку все говорят о том, насколько он упрощает разработку. Я наткнулся только на пару статей, в которых приводились примеры его использования, и «легкий» - не то слово, которое пришло мне в голову. Кода типа «Тип» было больше, чем реального кода. Я не видел никакой пользы в добавлении тонны раздутого ПО в проект, которое в конечном итоге усложнило бы его поддержку, а уж точно не облегчило бы.

На прошлой неделе мне нужно было снова изучить React и Redux, и меня беспокоило то же самое, мне было больно держать модель в своей голове, и было бы намного лучше, если бы я мог писать контейнеры / компоненты и знать эти поля и свойства, которые я мог бы использовать вместо того, чтобы предполагать или надеяться, что редактор каким-то образом это выяснит. Я тоже скучаю по старым временам Java, но никому не говорите, что я это сказал, ладно?

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

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

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

Часть 1 проведет вас через настройку проекта React Typescript и начало работы с типизированным кодом Redux.

Итак, для начала нам понадобится самое простое приложение. Если вы еще не установили (а я знаю, что это так) установите Node. Я использую текущую версию LTS 8.11.3. О, кстати, если вы используете Windows (я один из немногих, кто использует), есть пара инструментов NVM, я использую nvm-windows.

После того, как вы установили узел, продолжайте и установите «Yarn» и «Create React App».

npm install -g yarn create-react-app

Create React App - это безболезненный способ запустить и запустить приложение React за секунды без борьбы с Webpack. Вы также можете использовать альтернативные шаблоны, один из которых - create-response-app-typescript.

Итак, чтобы создать базовое приложение для реагирования, которое использует Typescript, просто

create-react-app contacts --scripts-version=react-scripts-ts
cd contacts/
yarn start

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

Typescript заимствует идею из других языков, таких как Java, в том, что он поддерживает классы и интерфейсы. Класс определяет свойства «вещи» и содержит функции / методы, которые вы можете вызывать, чтобы что-то делать с экземплярами этой «вещи», то есть класс Dog будет иметь свойство «цвет» и метод «кора». TypeScript поддерживает классы ES6 и расширяет их с помощью таких вещей, как общедоступные / частные средства доступа. В дополнение к классам ES6 Typescript поддерживает интерфейсы. Интерфейс описывает, как будет выглядеть вещь, в случае с классом Dog интерфейс скажет, что у него есть цвет и метод под названием «bark», но на самом деле он не будет хранить цвет или содержать логику для метода bark. Если что-то проходит мимо объекта, который, по его словам, реализует интерфейс «Собака», то вы знаете, что он будет иметь цвет, который вы можете назвать «лаем». Это важно при работе с наследованием или может использоваться для принятия формы объекта без необходимости определять, как это работает. Они похожи на контракты, обещающие, как вещь будет выглядеть. В нашем случае мы будем передавать объекты Contact, поэтому мы описываем, как выглядит Contact.

# src/models/IContact.ts
export default interface IContact {
  id?: string
  name: string
  email?: string
}

Интерфейс имеет букву «I» спереди, потому что машинописный линтер считает это хорошей практикой. Позже мы, возможно, захотим представить класс Contact, и мы можем сказать, что он соответствует интерфейсу IContact, чтобы убедиться, что он ведет себя так, как ожидалось. «Id» - необязательная строка, так как у новых контактов ее не будет. Электронная почта также не является обязательной, а имя - обязательным.

Redux имеет идею хранилища, которое представляет состояние. Некоторые простые приложения могут иметь одно хранилище с одним редуктором и несколькими действиями, но в этом случае мы собираемся создать корневое хранилище и построить его, объединив другие. Первое хранилище, которое мы создадим, будет сосредоточено на контактах и ​​будет содержать список контактов и текущий выбранный контакт, когда мы смотрим на детали или экран редактирования.

Прежде чем писать код, давайте добавим в проект Redux.

yarn add redux react-redux 
yarn add --dev @types/react-redux

Обратите внимание на дополнительную установку в наших зависимостях для разработчиков. Если вы посмотрите на свой package.json, вы увидите список подобных зависимостей. Эта зависимость содержит объявления типов для response-redux. Это помогает Typescript знать, как выглядит react-redux, и помогает обрабатывать типы для кода, который его использует.

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

# src/api/contacts.ts
import IContact from '../models/IContact'
const contact: IContact = {
  id: '1',
  name: 'Fred',
}
export async function fetchContacts(
  page: number = 1,
  limit: number = 10
)
: Promise<IContact[]> {
  return [contact]
}
export async function fetchContact(id: string)
: Promise<IContact> {
  return contact
}

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

Поскольку это асинхронное действие, мы будем использовать redux-thunk, чтобы позволить нам выполнять асинхронные вызовы и отправлять события с результатом.

yarn add redux-thunk

# src/store/contacts/actions.ts
import { fetchContacts } from '../../api/contacts'
import IContact from '../../models/IContact'
export function getContacts() {
  return (dispatch) => {
    fetchContacts().then((contacts: IContact[]) => {
      dispatch(createLoadContactsAction(contacts))
    })
  }
}

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

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

# src/store/contacts/actions.ts
export enum actionTypes {
  LOAD_CONTACTS = 'LOAD_CONTACTS',
  LOAD_CONTACT = 'LOAD_CONTACT',
  CLEAR_CURRENT_CONTACT = 'CLEAR_CURRENT_CONTACT',
}

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

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

# src/store/contacts/actions.ts
function createLoadContactsAction(contacts: IContact[]) {
  return {
    payload: contacts,
    type: actionTypes.LOAD_CONTACTS,
  }
}

Мы уже начинаем получать выгоду от проверки типов, эта функция ожидает отправки массива объектов, соответствующих интерфейсу IContact.

Почти готово: получаем контакты, создаем действие с контактами и отправляем. А теперь глазурь на торте, еще немного информации о проверке типов, чтобы избавиться от красных линий и убедиться, что данные, которые мы передаем, верны.

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

# src/store/contacts/actions.ts
interface ILoadContactsAction {
  type: typeof actionTypes.LOAD_CONTACTS
  payload: IContact[]
}
function createLoadContactsAction(contacts: IContact[])
:ILoadContactsAction {
  return {
    payload: contacts,
    type: actionTypes.LOAD_CONTACTS,
  }
}

Теперь мы проверяем, соответствует ли возвращаемое действие интерфейсу ILoadContactsAction. Если вы используете такой редактор, как Visual Studio Code, ваша волнистая линия в этой функции должна исчезнуть. Для развлечения замените строку «payload: contacts» на «payload: 10», вы должны получить ошибку машинописного текста. Ура!

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

# src/store/contacts/actions.ts
type PromiseAction = Promise<ILoadContactsAction>
type ThunkAction = (dispatch: Dispatch) => any
type Dispatch = (action:
  ILoadContactsAction | 
  ThunkAction | 
  PromiseAction 
) => any

Redux-Thunk может предоставить определение типа для ThunkAction, которое также включает другие доступные параметры, не показанные здесь, такие как getState. На момент написания всех примеров, которые я обнаружил, использование этого не соответствует последней версии Redux-Thunk, поэтому на данный момент подойдет и это. Сейчас мы просто используем ILoadContactsAction, но в более поздней части мы добавим больше к этому фрагменту кода.

Обновите свой getContacts прямо сейчас

# src/store/contacts/actions.ts
export function getContacts(): ThunkAction {
  return (dispatch: Dispatch) => {
    fetchContacts()
      .then((contacts: IContact[]) => {    
        dispatch(createLoadContactsAction(contacts))
      })
  }
}

Итак, теперь действие «getContacts» указывает, что оно возвращает «ThunkAction», который является функцией, которая принимает параметр типа «Dispatch». Функция диспетчеризации, переданная в преобразователь, имеет параметр, который должен быть действием.

Этому приложению требуется еще 2 действия, поэтому давайте выполним их, прежде чем мы перейдем к редуктору и покажем еще один кусочек магии Typescript.

В дополнение к получению коллекции контактов нам необходимо иметь возможность загружать детали для одного контакта и сбрасывать выбранный контакт обратно на ноль. Мы уже создали типы действий, когда указали перечисление «actionTypes», но нам нужно действие преобразователя и пара создателей действий.

# src/stopre/contacts/actions.ts
interface ILoadContactAction {
  type: typeof actionTypes.LOAD_CONTACT
  payload: IContact
}
interface IClearContactAction {
  type: typeof actionTypes.CLEAR_CURRENT_CONTACT
}
function createLoadContactAction(contact: IContact)
:ILoadContactAction {
  return {
    payload: contact,
    type: actionTypes.LOAD_CONTACT,
  }
}
export function clearCurrentContact()
:IClearContactAction {
  return {
    type: actionTypes.CLEAR_CURRENT_CONTACT,
  }
}
export function getContact(id: string): ThunkAction {
  return (dispatch: Dispatch) => {
    fetchContact().then((contact: IContact) => {
      dispatch(createLoadContactAction(contact))
    })
  }
}

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

export type Action =
  | ILoadContactsAction
  | ILoadContactAction
  | IClearContactAction
function createLoadContactsAction(contact: IContact):Action {
function createLoadContactAction(contact: IContact):Action {
export function clearCurrentContact():Action {

Это немного упрощает импорт типов действий в редуктор. А теперь о магии. Идите вперед и измените значение полезной нагрузки для одного из этих создателей действий, он показывает ошибку. Компилятор Typescript достаточно умен, чтобы по-прежнему знать, какой из интерфейсов применяется к возвращаемому значению на основе значения свойства «type», он по-прежнему знает, что когда вы загружаете контакт, ему нужен один IContact. Тип «Action» экспортируется, поскольку он будет использоваться редуктором, а действие clearCurrentContact экспортируется, поскольку оно будет использоваться напрямую, во многом как thunkActions. Нет смысла экспортировать других создателей действий, поскольку у нас нет доступа к ним за пределами файла, и нам не нужно тестировать их сейчас, когда Typescript проверяет их правильность. Мы также приводим в порядок другие типы в файле, чтобы использовать «Действие», чтобы код оставался аккуратным.

Если вы следовали инструкциям, у вас должны быть:

# src/models/IContact.ts
export default interface IContact {
  id?: string
  name: string
  email?: string
}
# src/api/contacts.ts
import IContact from '../models/IContact'
const contact: IContact = { id: '1', name: 'Fred' }
export function getContacts(): Promise<IContact[]> {
  return new Promise(resolve => {
    resolve([contact])
  })
}
export function getContact(id: string): Promise<IContact> {
  return new Promise(resolve => {
    resolve(contact)
  })
}
# src/store/contacts/actions.ts
import { fetchContact, fetchContacts } from '../../api/contacts'
import IContact from '../../models/IContact'
// Type information to define strictly typed actions
export enum actionTypes {
  LOAD_CONTACTS = 'LOAD_CONTACTS',
  LOAD_CONTACT = 'LOAD_CONTACT',
  CLEAR_CURRENT_CONTACT = 'CLEAR_CURRENT_CONTACT',
}
interface ILoadContactsAction {
  type: typeof actionTypes.LOAD_CONTACTS
  payload: IContact[]
}
interface ILoadContactAction {
  type: typeof actionTypes.LOAD_CONTACT
  payload: IContact
}
interface IClearContactAction {
  type: typeof actionTypes.CLEAR_CURRENT_CONTACT
}
export type Action =
  | ILoadContactsAction
  | ILoadContactAction
  | IClearContactAction
type PromiseAction = Promise<Action>
type ThunkAction = (dispatch: Dispatch) => any
type Dispatch = (action: Action | ThunkAction | PromiseAction) => any
// Action creators
function createLoadContactsAction(contacts: IContact[]): Action {
  return {
    payload: contacts,
    type: actionTypes.LOAD_CONTACTS,
  }
}
function createLoadContactAction(contact: IContact): Action {
  return {
    payload: contact,
    type: actionTypes.LOAD_CONTACT,
  }
}
export function clearCurrentContact(): Action {
  return {
    type: actionTypes.CLEAR_CURRENT_CONTACT,
  }
}
// Thunk Actions
export function getContacts(): ThunkAction {
  return (dispatch: Dispatch) => {
    fetchContacts().then((contacts: IContact[]) => {
      dispatch(createLoadContactsAction(contacts))
    })
  }
}
export function getContact(id: string): ThunkAction {
  return (dispatch: Dispatch) => {
    fetchContact(id).then((contact: IContact) => {
      dispatch(createLoadContactAction(contact))
    })
  }
}

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

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

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

Код для части 1 можно найти в репозитории github, прилагаемом к этой статье.

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