Привет Хабр, сегодня мы позанимаемся с TypeScript и React-hooks. Данный туториал поможет вам разобраться с основами "тайпскрипта" и поможет в работе над тестовым заданием для фронтендера.
Тестовые задания на проекте "Без Воды" — это возможность получить code-review. Дедлайн для текущего задания — 11 апреля 2019 года.
Видео-версия
Если вам лень читать, приходите на вебинар 20 марта в 21:00 по Москве. Регистрация (без e-mail'ов и sms). Вебинар состоялся, запись вебинара.
Подготовка
Для старта можно взять Create-react-app TypeScript версию, либо воспользоваться моим стартером (в который уже включен reach-router)
Я буду использовать свой стартер (об этом в разделе "Практика").
Теория TypeScript
TS решает проблему "динамической типизации" в JavaScript, когда ваша переменная может принимать разные значения. То строка, то число, а то и вовсе объект. Так было удобно писать в "19м веке", однако, теперь все сходятся на том, что если у вас заранее определены типы (считай — правила), то кодовая база получается легче в поддержке. Да и багов на этапе разработки становится меньше.
Например, у вас есть компонент, который отображает одну новость, мы можем для новости указать следующий тип:
// наша новость - это объект { }
// c полями
export interface INewsItem {
id: number; // id новости - это число
title: string; // title (заголовок) - строка
text: string; // text (текст новости) - строка
link: string; // link (ссылка) - строка
timestamp: Date; // timestamp (дата) - тип Дата в js
}
Таким образом, мы указали строгие "статические" типы для свойств нашего объекта "новость". Если мы будем пытаться достать не существующее свойство — TypeScript покажет ошибку.
import * as React from 'react'
import { INewsItem } from '../models/news' // импортировали "типизацию"
interface INewsItemProps {
data: INewsItem; // указали здесь, наш объект (подробный код в блоке выше)
}
const NewsItem: React.FC<INewsItemProps> = ({
data: { id, text, abracadabra }, // вытащили переменные из свойств, id и text - ок, abracadabra - ошибка
}) => {
return (
<article>
<div>{id}</div>
<div>{text}</div>
</article>
)
}
export { NewsItem }
Так же, Visual studio Code и другие продвинутые редакторы покажут вам ошибку:
Наглядно, удобно. В моем случае, VS Code показывает сразу две ошибки: не задан тип переменной (то есть, ее не существует в нашем "интерфейсе" новости) и "переменная не используется". Причем, не используемые переменные при использовании TypeScript выделяются бледным цветом в VS Code по умолчанию.
Здесь стоит одной строкой указать причину столь тесной интеграции TypeScript и VS Code: оба продукта являются разработкой Microsoft.
Что еще нам может сразу же сказать TypeScript? Если говорить в контексте переменных — всё. TS очень мощный, он понимает "что к чему".
const NewsItem: React.FC<INewsItemProps> = ({ data: { id, title, text } }) => {
return (
<article>
<div>{id.toUpperCase()}</div> {/* Эй, парень, у 'number' нет никаких toUpperCase() */}
<div>{title.toUpperCase()}</div> {/* а у строки есть! */}
<div>{text}</div>
</article>
)
}
Здесь, TypeScript сразу же ругается на несуществующее свойство — toUpperCase
у типа number
. А как мы знаем, действительно, метод toUpperCase() есть только у строковых типов.
А теперь представьте, вы начинаете писать название какой-нибудь функции, открываете скобку и вам редактор сразу же показывает всплывающее окно-подсказку, в котором указано какие аргументы и какого типа могут быть переданы в функцию.
Или представьте — вы строго следовали рекомендациям и типизация на вашем проекте пуленепробиваемая. Помимо автоподстановок вы избавляетесь на проекте от проблемы с неявными (undefined
) значениями.
Практика
Перепишем первое тестовое задание на react-hooks + TypeScript. Пока что, опустим Redux, иначе вместо работы над "перезапущенным тз#1", вы просто скопируете все отсюда.
Инструментарий
(для тех кто использует VS Code)
Для удобства рекомендую вам поставить расширение TSLint.
Чтобы включить автофикс ошибок TSLint в момент сохранения, добавьте в настройках редактора:
// в settings.json visual studio
"editor.codeActionsOnSave": {
"source.fixAll.tslint": true
}
Вы можете попасть в настройки через меню, или посмотреть, где они живут физически в вашей операционной системе.
Настройки TSLint — стандартные, плюс я отключил одно правило.
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"linterOptions": {
"exclude": [
"node_modules/**/*.ts",
"src/serviceWorker.js"
]
},
"rules": {
"object-literal-sort-keys": false // правило сортировки свойств объекта по алфавиту отключено
}
}
На этом интеграция закончена!
Пишем приложение
Будем знакомиться с новыми для нас вещами прямо по ходу пьесы. Для начала, склонируйте себе ветку 1-start или синхронизируйтесь в своем коде с моим кодом.
Все react type script файлы у нас имеют расширение .tsx.
Что интересного есть в стартовом шаблоне?
- pre-commit hook (мы это уже обсуждали: текст, видео)
- TSLint (замена ESLint)
- файлы для старта и зависимости для этого и следующих шагов
Начнем работу с src/App.tsx:
import * as React from 'react'
import './App.css'
const App = () => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
</div>
)
}
const RoutedApp = () => {
return <App />
}
export { RoutedApp }
Ок, стандартное начало. Попробуем добавить какое-нибудь свойство в <App />
src/App.tsx
const App = props => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
{/* добавили отрисовку name из props */}
<p>Привет, {props.name}</p>
</div>
)
}
// добавили name
const RoutedApp = () => {
return <App name="Max Frontend" />
}
Получаем ошибку:
(если вы не получили ошибку, то проверьте строгость настроек вашего tsconfig.json, там должно быть правило noImplicitAny)
Вы уже догадываетесь по переводу текста ошибки, что наши свойства не должны иметь тип any. Этот тип можно перевести как "что угодно". На нашем проекте есть правило, которое запрещает неявный вывод такого типа.
— Неявный вывод типа?
— Именно! TypeScript по умолчанию умеет выводить тип переменной и он с этим хорошо справляется. Это называется Type Inference
Пример:
let x = 3
// TS знает, что x имеет тип number, и этот number выведен "не явно" (implicit)
let x: number = 3
// мы явно (explicit) указали, что x имеет тип number
// в данном случае нет разницы как записать,
// так как это примитивная задача и TS может сделать вывод типа переменной
В случае с props
— TS не может определить тип переменной на 100% и поэтому говорит — пусть будет что угодно
(то есть — тип any
). Это делается неявно и это запрещено правилом noImplicitAny в настройках проекта (tsconfig.json)
Мы можем явно указать тип any и ошибка исчезнет. Тип переменной указывается через двоеточие.
// добавили через двоеточие :any
// и так как "props: any" не может быть записано без круглых скобок
// добавили и их
const App = (props: any) => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
<p>Привет, {props.name}</p>
</div>
)
}
Готово, ошибок нет, проект работает, но какой толк от такой типизации, когда props
могут быть чем угодно? Мы же точно знаем, что у нас name — строка
. Отсюда вытекает правило:
Старайтесь избегать тип any
Бывают случаи, когда any
необходим и это нормально, но это сразу же удар ниже пояса по строгой типизации.
Чтобы описать типы props
, мы будем использовать ключевое слово interface
:
// добавили интерфейс, с названием IAppProps
// поставили впереди букву I, так как у нас настроено TSLint правило
// требующее I в начале имени интерфейса
interface IAppProps {
name: string; // переменная name имеет тип string
}
// передали интерфейс в качестве типов для наших props
const App = (props: IAppProps) => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
<p>Привет, {props.name}</p>
</div>
)
}
Попробуйте изменить тип name
на number
и сразу же возникнет ошибка.
Причем, ошибка так же будет подчеркнута и в VS Code (и многих других редакторах). Ошибка говорит о том, что у нас не соответствие: передаем строку, а ожидаем число.
Исправим, и добавим в <App />
еще один props
— site
src/App.tsx
interface IAppProps {
name: string;
}
const App = (props: IAppProps) => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
<p>Привет, {props.name}</p>
{/* отрисовка site */}
<p>Сайт: {props.site}</p>
</div>
)
}
// добавили site
const RoutedApp = () => {
return <App name="Max Frontend" site="maxpfrontend.ru" />
}
Получили ошибку:
Type error: Property 'site' does not exist on type 'IAppProps'. TS2339
Свойство site
не существует в типе IAppProps
. Здесь сразу же хочется сказать о том, что по названию типа нам сразу понятно, где искать. Поэтому именуйте типы корректно.
Прежде чем исправить, давайте сделаем так: удалим параграф, отрисовывающий props.site
.
Получим другой текст ошибки:
Здесь хочу отметить лишь то, что TS вывел: site
является типом string
(на скриншоте это подчеркнуто).
Исправим:
interface IAppProps {
name: string;
site: string; // добавили описание типа
}
const App = (props: IAppProps) => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
<p>Привет, {props.name}</p>
<p>Сайт: {props.site}</p>
</div>
)
}
const RoutedApp = () => {
return <App name="Max Frontend" site="maxpfrontend.ru" />
}
Ни ошибок, ни проблем.
Для работы с роутингом, нам потребуется отрисовка children. Давайте забежим вперед, и попробуем отрисовать "дочерний компонент".
const App = (props: IAppProps) => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
... // вырезано
<p>Сайт: {props.site}</p>
{props.children}
</div>
)
}
const Baby = () => {
return <p>дочерний компонент</p>
}
const RoutedApp = () => {
return (
<App name="Max Frontend" site="maxpfrontend.ru">
<Baby />
</App>
)
}
TS ругнется, мол так и эдак — children
не описаны в IAppProps
.
Конечно, нам бы не хотелось "типизировать" какие-то стандартные вещи и здесь нам на помощь приходит коммьюнити, которое уже типизировало многое до нас. Например — @types/react пакет, содержит всю типизацию для react.
Установив этот пакет (в моем примере он уже установлен), мы можем использовать следующую запись:
React.FunctionComponent<P>
или сокращенный вариант
React.FC<P>
где <P>
— это типы для наших props
, то есть запись примет вид.
React.FC<IAppProps>
Для тех, кто увлекается чтением больших объемов текста, прежде чем попрактиковаться, могу предложить статью про "дженерики" (те самые < и >). Для остальных, пока достаточно того, что мы переведем эту фразу так: функциональный компонент принимающий <такие-то свойства>.
Запись для компонента App немного изменится. Полная версия.
src/App.tsx
// на всякий случай напоминаю, что данная запись говорит
// возьми все экспорты из пакета React и положи их в React.XXX,
// где XXX - имя экспортированной переменной
import * as React from 'react'
// Так же, тайп скрипт сам поймет, что нужно вытащить типы
// из @types/react
// интерфейс остался без изменений
interface IAppProps {
name: string;
site: string;
}
// новая запись
const App: React.FC<IAppProps> = props => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<p>Навигация</p>
</nav>
<p>Отрисовка роутов</p>
<p>Привет, {props.name}</p>
<p>Сайт: {props.site}</p>
{props.children}
</div>
)
}
const Baby = () => {
return <p>дочерний компонент</p>
}
const RoutedApp = () => {
return (
<App name="Max Frontend" site="maxpfrontend.ru">
<Baby />
</App>
)
}
Давайте разберем по символам следующую строку:
const App: React.FC<IAppProps> = props => {
— Почему после props
исчез тип?
— Потому что, после App
— добавился.
Мы записали, что переменная App будет типа: React.FC<IAppProps>
.
React.FC
— это тип "функция", и мы внутри < > указали, какого типа аргумент она принимает, то есть указали, что наши props
будут типа IAppProps
.
(здесь есть риск, что я вам несколько приврал в терминах, но для упрощения примера, я думаю это ок)
Итого: мы научились указывать тип свойств для переданных props
, при этом не потеряв "свои" свойства React-компонентов.
Исходный код на данный момент.
Добавляем роутинг
Будем использовать reach-router, чтобы расширить кругозор. Этот пакет очень похож на react-router.
Добавим страницу — Новости (News), подчистим <App />
.
src/pages/News.tsx
import * as React from 'react'
const News = () => {
return (
<div className="news">
<p>Новости</p>
</div>
)
}
export { News }
src/App.tsx
import * as React from 'react'
// импортивали необходимое из reach-router
import { Link, Router } from '@reach/router'
import { News } from './pages/News'
import './App.css'
interface IAppProps {
name: string;
site: string;
}
const App: React.FC<IAppProps> = props => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<Link to="/">Home</Link> <Link to="news">News</Link>{' '}
</nav>
<hr />
<p>
{' '}
Автор: {props.name} | Сайт: {props.site}
</p>
<hr />
{props.children}
</div>
)
}
// удалили Baby, добавили News. Для app - указали path
const RoutedApp = () => {
return (
<Router>
<App path="/" name="Max Frontend" site="maxpfrontend.ru">
<News path="/news" />
</App>
</Router>
)
}
export { RoutedApp }
Приложение сломалось, ошибка (одна из ошибок, так как терминал показывает первую):
К такой записи мы уже немного привыкли, и по ней понимаем, что path
не существует в описании типов для <App />
.
Опять же, все описано до нас. Мы воспользуемся пакетом @types/reach__router и типом RouteComponentProps
. Чтобы не потерять наши свойства, будем использовать ключевое слово extends
.
import * as React from 'react'
// вытащили тип RouteComponentProps из рич-роутера
// ts опять же, сам поймет откуда брать
import { Link, RouteComponentProps, Router } from '@reach/router'
import { News } from './pages/News'
import './App.css'
// extends позволяет нам наследовать все типы из RouteComponentProps
// и затем добавить свои
interface IAppProps extends RouteComponentProps {
name: string;
site: string;
}
// ... далее без изменений
Для любознательных, что за типы описаны в RouteComponentProps.
Ошибка в <App />
исчезла, но осталась в <News />
, так как мы не указали типизацию для этого компонента.
Мини задачка: укажите типизацию для <News />
. На данный момент, туда передаются только свойства из роутера.
Ответ:
src/Pages/News.tsx
import * as React from 'react'
import { RouteComponentProps } from '@reach/router'
// так как нам нужны только RouteComponentProps
// мы их сразу передаем в качестве P ( React.FC<P> )
const News: React.FC<RouteComponentProps> = () => {
return (
<div className="news">
<p>Новости</p>
</div>
)
}
export { News }
Двигаемся далее и добавим роут с параметром. Параметры в reach-router живут напрямую в props. В react-router, как вы помните, они живут в props.match
.
src/App.tsx
import * as React from 'react'
import { Link, RouteComponentProps, Router } from '@reach/router'
import { About } from './pages/About'
import { News } from './pages/News'
// ... (вырезано)
const RoutedApp = () => {
return (
<Router>
<App path="/" name="Max Frontend" site="maxpfrontend.ru">
<News path="/news" />
{/* добавили роут с параметром source */}
<About path="/about/:source" />
</App>
</Router>
)
}
export { RoutedApp }
src/pages/About.tsx
import * as React from 'react'
import { RouteComponentProps } from '@reach/router'
const About: React.FC<RouteComponentProps> = props => {
return (
<div className="about">
<p>Страница about</p>
{/* пытаемся отрисовать source параметр */}
<p>{props.source}</p>
</div>
)
}
export { About }
Ошибка, которой мы не ждали:
Свойство source не существует… С одной стороны недоумение: мы же передаем его в path, которое string, с другой стороны радость: Ах, ты ж, как постарались авторы библиотеки и типизации, добавив нам это предупреждение.
Для исправления, воспользуемся одним из вариантов: наследуемся (extend) от RouteComponentProps
и укажем опциональное свойство source
. Опциональное, потому что его может и не быть в нашем URL-адресе.
В TypeScript для указания опционального свойства используется знак вопроса.
src/pages/About.tsx
import * as React from 'react'
import { RouteComponentProps } from '@reach/router'
interface IAboutProps extends RouteComponentProps {
source?: string; // параметр source - опциональный, его может и не быть (в таком случае в props.source будет undefined)
}
const About: React.FC<IAboutProps> = props => {
return (
<div className="about">
<p>Страница about</p>
<p>{props.source}</p>
</div>
)
}
export { About }
src/App.tsx (заодно русифицируем навигацию)
import * as React from 'react'
import { Link, RouteComponentProps, Router } from '@reach/router'
import { About } from './pages/About'
import { News } from './pages/News'
import './App.css'
interface IAppProps extends RouteComponentProps {
name: string;
site: string;
}
const App: React.FC<IAppProps> = props => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<Link to="/">Домой</Link> <Link to="news">Новости</Link>{' '}
<Link to="/about/habr">Про habr</Link>{' '}
</nav>
<hr />
<p>
{' '}
Автор: {props.name} | Сайт: {props.site}
</p>
<hr />
{props.children}
</div>
)
}
const RoutedApp = () => {
return (
<Router>
<App path="/" name="Max Frontend" site="maxpfrontend.ru">
<News path="/news" />
<About path="/about/:source" />
</App>
</Router>
)
}
export { RoutedApp }
Итого: научились типизировать компоненты, участвующие в ротуинге.
Исходный код на данный момент.
Поработаем с хуками и продолжим типизировать
Напоминаю, что наша задача, реализовать тестовое задание, но без Redux.
Я подготовил ветку для старта данного шага с роутингом, нерабочей формой логина и необходимыми страницами.
Загружаем новости
Новости это массив объектов.
Представим нашу новость:
{
id: 1,
title: 'Делаем CRUD приложение с помощью React-hooks',
text: 'В данном конспекте создается простое CRUD-приложение без бэкэнда',
link:
'https://maxpfrontend.ru/perevody/delaem-crud-prilozhenie-s-pomoschyu-react-hooks/',
timestamp: new Date('01-15-2019'),
},
Давайте сразу же напишем модель (типы для новости):
src/models/news.ts (расширение .ts)
export interface INewsItem {
id: number;
title: string;
text: string;
link: string;
timestamp: Date;
}
Из новенького — timestamp указали тип Date
.
Представим, наш вызов за данными:
const fakeData = [
{
id: 1,
title: 'Делаем CRUD приложение с помощью React-hooks',
text: 'В данном конспекте создается простое CRUD-приложение без бэкэнда',
link:
'https://maxpfrontend.ru/perevody/delaem-crud-prilozhenie-s-pomoschyu-react-hooks/',
timestamp: new Date('01-15-2019'),
},
{
id: 2,
title: 'Знакомство с React hooks',
text: 'Из статьи можно узнать как использовать useState и useEffect хуки',
link: 'https://maxpfrontend.ru/perevody/znakomstvo-s-react-hooks/',
timestamp: new Date('01-06-2019'),
},
{
id: 3,
title: 'Авторизация с помощью Google Sign In',
text: 'Как авторизоваться через Google Sign In по документации',
link:
'https://maxpfrontend.ru/vebinary/avtorizatsiya-s-pomoschyu-google-sign-in/',
timestamp: new Date('11-02-2018'),
},
]
export const getNews = () => {
const promise = new Promise(resolve => {
resolve({
status: 200,
data: fakeData, // выдаем наши новости
})
})
return promise // возвращаем promise
}
Наш вызов из api getNews
возвращает Promise, причем этот "промис" имеет определенный тип, который мы тоже можем описать:
interface INewsResponse {
status: number; // статус - число
data: INewsItem[]; // data - массив объектов, причем объекты типа INewsItem [1]
errorText?: string; // возможно, будет поле errorText типа строка, а может не будет
}
// [1] напоминаю, что в models мы описали нашу модель новости
export interface INewsItem {
id: number;
title: string;
text: string;
link: string;
timestamp: Date;
}
// запись вида какой_то_тип[] - говорит нам о массиве переменных какого_то_типа
// [{какой_то_тип}, {какой_то_тип}, {какой_то_тип}]
Жарко? Сейчас будет еще жарче, так как тип Promise — это дженерик, нам снова придется иметь дело с <
и >
. Это самое трудное место туториала, поэтому вчитываемся в итоговый код:
src/api/News.ts
import { INewsItem } from '../models/news' // вытащили модельку новости
interface INewsResponse { // описали тип для ОТВЕТА_ОТ_СЕРВЕРА
status: number;
data: INewsItem[];
errorText?: string;
}
const fakeData = [
//... вырезано
]
// далее типизируем функцию
// функции типизируется по формуле:
// const myFunc = ():тип_возвращаемоего_значение { return возвращаемое_значение }
// getNews - это функция, принимает ноль аргументов () (нечего типизировать)
// и возвращает тип Promise
// так как Promise - это generic, значит можно сказать:
// возвращает Promise<T>, где T - это тип, который возвращается в промисе [1]
// в нашем случае, этот T описан, он является - INewsResponse
export const getNews = (): Promise<INewsResponse> => {
// строка ниже, аналогично [1]
const promise = new Promise<INewsResponse>(resolve => { // [2]
resolve({
status: 200,
data: fakeData,
})
})
return promise // мы возвращаем переменную promise [2] типа Promise<INewsResponse>
}
Перекур.
Показываем новости
src/pages/News.tsx
import * as React from 'react'
import { RouteComponentProps } from '@reach/router'
import { getNews } from '../api/news'
import { NewsItem } from '../components/NewsItem' // еще не готов
import { INewsItem } from '../models/news'
const News: React.FC<RouteComponentProps> = () => {
// useState - это тоже дженерик, он может принимать тип T
// в нашем случае, тип T - это массив из INewsItem
// так же, в качестве начального значения, мы указали пустой массив []
const [news, setNews] = React.useState<INewsItem[]>([]) // <- здесь в скобках указано начальное значение
React.useEffect(() => {
getNews()
.then(res => {
setNews(res.data)
})
.catch(err => {
// наши правила TSLint запрещают оставлять в коде console.log
// поэтому, "выключим" линтер для следующей строки
// tslint:disable-next-line: no-console
console.warn('Getting news problem', err)
})
}, [])
return (
<div className="news">
{news.map(item => (
<NewsItem data={item} key={item.id} />
))}
</div>
)
}
export { News }
В коде даны комментарии, которые касаются TypeScript. Если вам нужна помощь с react-hooks, то можете прочитать здесь: документация (EN), туториал (RU).
Задача: напишите компонент <NewsItem />
, который будет показывать новости. Не забудьте указать правильный тип. Используйте модель INewsItem
.
Результат может выглядеть следующим образом:
Решение ниже.
src/components/NewsItem.tsx
import * as React from 'react'
import { INewsItem } from '../models/news'
interface INewsItemProps {
data: INewsItem; // [1]
}
// [2]
const NewsItem: React.FC<INewsItemProps> = ({
data: { title, text, timestamp, link },
}) => {
return (
<article>
<br />
<div>
{
<a href={link} target="_blank">
{title}
</a>
}{' '}
| {timestamp.toLocaleDateString()}
</div>
<div>{text}</div>
</article>
)
}
export { NewsItem }
Вопрос, почему мы описали интерфейс таким образом (комментарии в коде [1] и [2]). Могли ли мы просто написать:
React.FC<INewsItem>
Овет ниже.
.
.
.
Не могли бы, так как мы передаем новость в свойстве data
, то есть мы должны написать:
React.FC<{ data: INewsItem }>
Что мы и сделали, лишь с тем отличием, что указали interface
, на тот случай, если в компонент будут добавлены другие свойства. Да и читаемость лучше.
Итого: попрактиковались в типизации для Promise и useEffect. Описали тип для еще одного компонента.
Если вы до сих пор не хлопаете в ладоши от автоподстановки и строгости TS, то вы либо не практикуте, либо строгая типизация не для вас. Во втором случае — ничем помочь не могу, это дело вкуса. Обращаю внимание, что рынок диктует свои условия и все больше проектов живут с типизацией, если не с TypeScript, то с flow.
Исходный код на данный момент.
Auth api
Напиши api
для логина. Принцип схожий — возвращаем promise
с ответом авторизован/ошибка. Информацию о статусе авторизации будем хранить в localStorage
(многие считают это вопиющим нарушением безопасности, я немного отстал от споров и не знаю чем закончилось).
В нашем приложении, логин это связка имени и пароля (и то и другое — string), опишем модель:
src/models/user.ts
export interface IUserIdentity {
username: string;
password: string;
}
src/api/auth.ts
import { navigate } from '@reach/router'
import { IUserIdentity } from '../models/user'
// вновь определяем интерфейс для ответа от бэкэнда
// где в качестве data - указываем any (то есть, что угодно)
// здесь, это в качестве примера, так как бэкэнда у нас нет,
// и мы не знаем, что от него может прийти (будто бы)
interface IAuthResponse {
status: number;
data?: any; [1]
errorText?: string;
}
// функция-заглушка, которая будто-бы проверяет админ это или нет
// в качестве аргумента принимает тип IUserIdentity
// в качестве результата - возвращает boolean значение (true или false)
const checkCredentials = (data: IUserIdentity): boolean => {
if (data.username === 'Admin' && data.password === '12345') {
return true
} else {
return false
}
}
// запрос к "псевдо-бэкэнду", принцип точной такой же, как и в случае с новостями
// разница лишь в том, что теперь наша функция принимает 1 аргумент - data
// вовзращает так же Promise<T>, где T - типа IAuthResponse
export const authenticate = (data: IUserIdentity): Promise<IAuthResponse> => {
const promise = new Promise<IAuthResponse>((resolve, reject) => {
if (!checkCredentials(data)) {
reject({
status: 500,
errorText: 'incorrect_login_or_password',
})
}
window.localStorage.setItem('tstz.authenticated', 'true')
resolve({
status: 200,
data: 'ok', // так как наш псевдо-бэкэнд отвечает string, мы можем исправить в IAuthResponse [1] any на string
})
})
return promise
}
// функция для проверки, авторизован ли пользователь
// принимает 0 аргументов ()
// вовзвращает true или false (тип boolean)
export const checkAuthStatus = (): boolean => {
if (localStorage.getItem('tstz.authenticated')) {
return true
} else {
return false
}
}
// функция для логаута, принимает 0 аргументов
// ничего не возвращает (для этого используется тип void)
export const logout = (): void => {
window.localStorage.removeItem('tstz.authenticated')
navigate('/') // используем для переброса на новый url-адрес (reach-router)
}
Листинг кода приличный, но в нем многое нам уже знакомо.
Из нового:
- если функция ничего не возвращает, указываем тип возвращаемого значения
void
- для "программного переброса" на новый URL, используем navigate
Форма логина
Задание: напишите функционал для работы с формой логина. Используйте useState хук. Для типизации параметра event
в onChange
/onSubmit
обработчиках — укажите any
. Корректные типы посмотрите в решении.
React.useState
— так же является дженериком, значит вы можете передавать произвольный тип Т (React.useState<T>
)
Напоминаю, что по условию задачи, если введены не верные данные, то нужно показать уведомление. Если введены верные, то переадресовать на /profile
(используйте navigate
)
Решение ниже.
.
.
.
.
src/pages/Login.tsx
import * as React from 'react'
import { navigate, RouteComponentProps } from '@reach/router'
import { authenticate } from '../api/auth'
import { IUserIdentity } from '../models/user'
// компонент не принимает ничего, кроме свойств из роутера
// поэтому указываем их сразу в < >
const Login: React.FC<RouteComponentProps> = () => {
// useStaet так же, как и useEffect - дженерик,
// поэтому указываем тип переменной, которая будет участвовать в state
const [user, setField] = React.useState<IUserIdentity>({
username: '',
password: '',
})
// аналогично, указываем тип переменной (простая строка) для "уведомления"
const [notification, setNotification] = React.useState<string>('')
// для e (event) указываем, что это <input />
// запись имеет вид: React.SyntheticEvent<HTMLInputElement>
const onInputChange = (fieldName: string) => (
e: React.SyntheticEvent<HTMLInputElement>
): void => {
setField({
...user,
[fieldName]: e.currentTarget.value,
})
setNotification('')
}
// для e (event) указываем, что это тэг form
// запись имеет вид: React.SyntheticEvent<HTMLFormElement>
const onSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault()
authenticate(user)
.then(() => {
navigate(`/profile`) // переадресация на profile
})
.catch(err => {
if (err.errorText) {
setNotification(err.errorText)
} else {
// tslint:disable-next-line: no-console
console.warn('request problem', err)
}
})
}
return (
<>
<h2>Login</h2>
<form onSubmit={onSubmit}>
{notification ? <p>{notification}</p> : null}
<input
type="text"
value={user.username}
onChange={onInputChange('username')}
/>
<input
type="text"
value={user.password}
onChange={onInputChange('password')}
/>
<button>Login</button>
</form>
</>
)
}
export { Login }
То, что касается TS — можете прочесть в комментариях. В остальном, код можно понять, если вы знакомы с основами JavaScript.
Итого: попрактиковались с useState и узнали как можно типизировать event
→ Исходный код на данный момент
Защищенный роут
Данный момент к TypeScript'у отношения не имеет, но мы должны закончить начатое задание.
Итак, в reach-router мы поступим примерно таким же образом, как и в случае с react-router. Сделаем родительский компонент, который будет проверять авторизован ли пользователь, а затем пропускать его дальше, либо редиректить.
src/components/common/Authenticated.tsx
import * as React from 'react'
import { Redirect, RouteComponentProps } from '@reach/router'
import { checkAuthStatus } from '../../api/auth'
// про noThrow можете прочитать здесь - https://reach.tech/router/api/Redirect
const Authenticated: React.FC<RouteComponentProps> = ({ children }) => {
return checkAuthStatus() ? (
<React.Fragment>{children}</React.Fragment>
) : (
<Redirect to="/login" noThrow={true} />
)
}
export { Authenticated }
src/App.tsx
import * as React from 'react'
import { Link, RouteComponentProps, Router } from '@reach/router'
import { Authenticated } from './components/сommon/Authenticated'
import { Home } from './pages/Home'
import { Login } from './pages/Login'
import { News } from './pages/News'
import { Profile } from './pages/Profile'
import { checkAuthStatus, logout } from './api/auth'
import './App.css'
const App: React.FC<RouteComponentProps> = props => {
return (
<div className="container">
<h1>TZ #1 with hooks & TypeScript</h1>
<nav>
<Link to="/">Домой</Link> <Link to="news">Новости</Link>{' '}
<Link to="profile">Профиль</Link>{' '}
{checkAuthStatus() ? <button onClick={logout}>Выйти</button> : null}
</nav>
{props.children}
</div>
)
}
const RoutedApp = () => {
return (
<Router>
<App path="/">
<Home path="/" />
<Login path="/login" />
<News path="/news" />
<Authenticated path="/profile">
<Profile path="/" />
</Authenticated>
</App>
</Router>
)
}
export { RoutedApp }
Комментировать здесь нечего.
Давайте исправим небольшую оплошность в форме логина. Укажите type="password"
для поля с паролем.
Прежде чем мы закончим туториал, давайте разрешим вопрос с красивым текстом ошибки. Я буду использовать "псевдо-локализацию", а для тех, кто интересуется темой, рекомендую посмотреть пакет react-intl, либо react-i18next.
У нас здесь есть интересный момент, поэтому еще толика вашего внимания. Создадим файл с переводами:
src/localization/formErrors.ts
const formErrors = {
ru: {
incorrect_login_or_password: 'Имя пользователя или пароль введены не верно',
},
en: {
incorrect_login_or_password: 'Incorrect login or password',
},
}
export { formErrors }
И воспользуемся им в <Login />
src/pages/Login.tsx
import * as React from 'react'
import { navigate, RouteComponentProps } from '@reach/router'
import { authenticate } from '../api/auth'
// импортировали переводы
import { formErrors } from '../localization/formErrors'
import { IUserIdentity } from '../models/user'
// захардкодили значение языка
const lang = 'ru'
const Login: React.FC<RouteComponentProps> = () => {
// ... вырезано
const onSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault()
authenticate(user)
.then(() => {
navigate(`/profile`)
})
.catch(err => {
if (err.errorText) {
// показываем уведомление на языке пользователя (в нашем случае, всегда ru)
setNotification(formErrors[lang][err.errorText])
} else {
// tslint:disable-next-line: no-console
console.warn('request problem', err)
}
})
}
// ... вырезано
}
export { Login }
Ошибочка, сэр.
TypeScript волнуется, так как он не может наверняка вывести, какой тип у названий свойств. Давайте поможем ему в этом, установив index signature (не нашел ссылок в документации, поэтому прикладываю ответ на StackOverflow).
interface IFormErrors {
[key: string]: {
[key: string]: string,
};
}
const formErrors: IFormErrors = {
ru: {
incorrect_login_or_password: 'Имя пользователя или пароль введены не верно',
},
en: {
incorrect_login_or_password: 'Incorrect login or password',
},
}
export { formErrors }
Мы указали, что язык может быть указан строкой. Внутри языка может быть различное количество свойств, причем имя свойства будет типа "строка".
Заключение
Мы познакомились на практике с некоторыми особенностями TypeScript. На деле, TS позволяет гораздо больше. Например, вы можете указать, что ваша переменная может принимать значение только строка "one" или "two" (тип — union).
Как всегда, все самое актуальное — в документации.
Присоединяйтесь к проекту "Без Воды" в telegram или youtube и присылайте решения тестового задания (дедлайн 11 апреля 2019).
Спасибо за внимание! Напоминаю, про вебинар:
Список используемых ресурсов
Быстрый старт CRA + TypeScript
Вместо документации — пост Understanding TypeScript's type notation (автор Dr.Axel Rauschmayer)
Так же шпаргалка по типам от Microsoft, с их недавнего буткемпа
Все о конфигурации TSLint
Конфиги tslint
Выключение tslint проверки для блоков кода или строк
В чем разница между tsconfig.json и tslint.json
В чем разница между d.ts и .ts файлами
Похоже, что в данный момент нет возможности проверять только staging файлы.
Туториалы
react-typescript-samples от LemonCode
Любопытно:
Все типы для es5 (все!)
Все типы для React v16
Список всех опций для typescript-компилятора.
Можно подсмотреть как свои компоненты типизирует Microsoft. Примеры из UI-библиотеки Fabric. Так же, это можно сделать на github проекта.