Todo list react typescript

Todolist на React Hooks + TypeScript: от сборки до тестирования

Начиная с версии 16.9, в библиотеке React JS доступен новый функционал — хуки. Они дают возможность использовать состояние и другие функции React, освобождая от необходимости писать класс. Использование функциональных компонентов совместно с хуками позволяет разработать полноценное клиентское приложение.

Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript.

Сборка

Структура проекта следующая:

├── src
| ├── components
| ├── index.html
| ├── index.tsx
├── package.json
├── tsconfig.json
├── webpack.config.json

< "name": "todo-react-typescript", "version": "1.0.0", "description": "", "main": "index.tsx", "scripts": < "start": "webpack-dev-server --port 3000 --mode development --open --hot", "build": "webpack --mode production" >, "author": "", "license": "ISC", "devDependencies": < "ts-loader": "^5.2.1", "html-webpack-plugin": "^3.2.0", "typescript": "^3.8.2", "webpack": "^4.41.6", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.10.3" >, "dependencies": < "@types/react": "^16.9.23", "@types/react-dom": "^16.9.5", "react": "^16.12.0", "react-dom": "^16.12.0" >>

Для поддержки TypeScript, помимо пакета typescript, необходим ts-loader, скомпилирующий исходные tsx-файлы в js-код, а также пакеты со специальными типами данных для React — @types/react и @types/react-dom. Дополнительно ставим html-webpack-plugin, он обеспечит корректную работу dev-сервера при отсутствии index.html — файла в корне проекта, и создаст этот файл автоматически для production-сборки в нужном месте.

Поле «jsx» задаёт режим компиляции исходного кода. Всего есть 3 режима: «preserve», «react» и «react-native».

const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = < entry: './src/index.tsx', resolve: < extensions: ['.ts', '.tsx', '.js'] >, output: < path: path.join(__dirname, '/dist'), filename: 'bundle.min.js' >, module: < rules: [ < test: /\.ts(x?)$/, exclude: /node_modules/, use: [ < loader: "ts-loader" >] > ] >, plugins: [ new HtmlWebpackPlugin(< template: './src/index.html' >) ] >;

Точка входа приложения — ./src/index.tsx. С помощью resolve.extensions разрешаем обрабатывать ts/tsx/js файлы. Добавляем ts-loader и html-webpack-plugin. Сборка готова.

Читайте также:  Форма обратной связи на PHP с отправкой на почту

Разработка

В файле index.html прописываем контейнер, куда будет рендериться приложение:

В директории components создаем наш первый пока что пустой компонент — App.tsx.
Файл index.tsx:

import * as React from 'react'; import * as ReactDOM from 'react-dom'; import App from "./components/App"; ReactDOM.render ( , document.getElementById("root") );

Todolist-приложение будет иметь следующую функциональность:

  • добавить задачу
  • удалить задачу
  • изменить статус задачи (выполнена / не выполнена)

Для этих целей можно разделить приложение всего на два компонента — создание новой задачи и список всех задач. Поэтому App.tsx на начальном этапе будет иметь следующий вид:

import * as React from 'react'; import NewTask from "./NewTask"; import TasksList from "./TasksList"; const App = () => < return ( <>   ) > export default App;

В текущей директории создадим и экспортируем пустые компоненты NewTask и TasksList. Так как нам необходимо обеспечить взаимосвязь между ними, нужно определить, как это будет происходить. В React существуют два подхода к взаимодействию между компонентами:

  1. Хранение текущего состояния приложения и всех его методов в родительском компоненте (в нашем случае — в App.tsx) и передача дочерним компонентам через пропсы (классический способ);
  2. Хранение состояния и методов управления состоянием отдельно. В этом случае приложение нужно обернуть специальным компонентом — провайдером, и передать в него необходимые для дочерних компонентов методы и свойства (использование хука useContext).

* Если в компонент всё же передаются пропсы, TypeScript потребует явного указания типа для компонента:

Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров:

useContext

Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером.

import * as React from 'react'; import from "react"; interface Person < name: String, surname: String >export const PersonContext = React.createContext>(<>); const PersonWrapper = () => < const person: Person = < name: 'Spider', surname: 'Man' >return ( <> >  ) > const PersonComponent = () => < const person = useContext(PersonContext); return ( 
Hello, !
) > export default PersonWrapper;

В примере создаём интерфейс для контекста — будем передавать поля name и surname, оба типа String.

Создаём контекст методом createContext и передаём в него пока что пустой объект. Для того, чтобы TypeScript «не ругался» на отсутствие обязательных полей интерфейса, есть специальный тип Partial — он допускает отсутствие передаваемых полей.

Далее в созданный контекст передаём данные — объект person, и внутрь провайдера помещаем компонент. Теперь контекст будет доступен в любом компоненте, добавленном внутрь провайдера. Вызвать его можно как раз с помощью хука useContext.

useReducer

Также понадобится useReducer для более удобной работы с хранилищем состояния.

Хук useReducer позволяет управлять стейтом посредством вызова одной единственной функции, но с разными параметрами: по соглашению, название действия передаётся в поле type, а данные — в поле payload. Пример реализации:

import * as React from 'react'; import from "react"; interface PersonState < name: String, surname: String >interface PersonAction < type: 'CHANGE', payload: PersonState >const personReducer = (state: PersonState, action: PersonAction): PersonState => < switch (action.type) < case 'CHANGE': return action.payload; default: throw new Error('Unexpected action'); >> const PersonComponent = () => < const initialState = < name: 'Unknown', surname: 'Guest' >const [person, changePerson] = useReducer>(personReducer, initialState); return ( 
changePerson(>)>> Hello, !
) > export default PersonComponent;

В useReducer передаём функцию-редьюсер personReducer, которая будет отрабатывать при вызове changePerson.

В переменной person изначально будет записан initialState, который по ходу вызовов changePerson будет заменяться возвращаемым редьюсером значением.

В данном примере обновления будут происходить только на действие CHANGE, но плюс редьюсера состоит в том, что логику можно легко и быстро расширить:

case 'CHANGE': return action.payload; case 'CLEAR': return < name: 'Undefined', surname: 'Undefined' >;

useContext + useReducer

Интересной заменой библиотеки Redux может быть использование контекста в связке с useReducer. В этом случае в контекст будет передаваться результат выполнения хука useReducer — возвращаемый им стейт и функция для его обновления. Добавим эти хуки в приложение:

import * as React from 'react'; import from "react"; import from "../types/stateType"; import NewTask from "./NewTask"; import TasksList from "./TasksList"; // Начальные значения стейта export const initialState: State = < newTask: '', tasks: [] >// позволяет создать контекст без дефолтных значений export const ContextApp = React.createContext>(<>); // Создаём редьюсер, принимающий на вход текущий стейт и объект Action с полями type и payload, тип возвращаемого редьюсером значения - State export const todoReducer = (state: State, action: Action):State => < switch (action.type) < case ActionType.ADD: < return ]> > case ActionType.CHANGE: < return > case ActionType.REMOVE: < return task !== action.payload)]> > case ActionType.TOGGLE: < return (task !== action.payload ? task : ))]> > default: throw new Error('Unexpected action'); > >; const App: React.FC = () => < // Используем созданный todoReducer, передав его аргументом в useReduser. Изначально в стейт попадёт initialState, и далее при диспатче (changeState) будет обновляться. const [state, changeState] = useReducer>(todoReducer, initialState); const ContextState: ContextState = < state, changeState >; // Передаём в контекст результаты работы useReducer - стейт и метод его изменения return ( <> >   ) > export default App;

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

Typescript. Добавление типов в приложение

В файле stateType прописываем TypeScript-типы для приложения:

import from "react"; // Созданная задача имеет название и статус готовности export type Task = < name: string; isDone: boolean >export type Tasks = Task[]; // В состоянии хранится записываемая в инпут новая задача, а также массив уже созданных задач export type State = < newTask: string; tasks: Tasks >// Все возможные варианты действий со стейтом export enum ActionType < ADD = 'ADD', CHANGE = 'CHANGE', REMOVE = 'REMOVE', TOGGLE = 'TOGGLE' >// Для действий ADD и CHANGE доступна передача только строковых значений type ActionStringPayload = < type: ActionType.ADD | ActionType.CHANGE, payload: string >// Для действий TOGGLE и REMOVE доступна передача только объекта типа Task type ActionObjectPayload = < type: ActionType.TOGGLE | ActionType.REMOVE, payload: Task >// Объединяем предыдущие две группы для использования в редьюсере export type Action = ActionStringPayload | ActionObjectPayload; // Наш контекст состоит из стейта и функции-редьюсера, в которую будут передаваться Action. Тип Dispatch импортируется из библиотеки react export type ContextState = < state: State; changeState: Dispatch>

Использование контекста

Теперь state готов и может быть использован в компонентах. Начнём с NewTask.tsx:

import * as React from 'react'; import from "react"; import from "./App"; import from "../types/taskType"; import from "../types/stateType"; const NewTask: React.FC = () => < // получаем state и dispatch-метод const = useContext(ContextApp); // отправляем два действия редьюсеру todoReducer - добавление задачи и изменение инпута. После их успешной обработки переменная state обновится. Для уточнения интерфейса передаваемого события можно воспользоваться расширенными React-интерфейсами const addTask = (event: React.FormEvent, task: TaskName) => < event.preventDefault(); changeState() changeState() > // аналогично - отправим изменение значения в инпуте const changeTask = (event: React.ChangeEvent) => < changeState() > return ( <> 
addTask(event, state.newTask)>> changeTask(event)> value=/>
) >; export default NewTask;

Приложение готово! Осталось протестировать его.

Тестирование

Для тестирования будут использоваться Jest + Enzyme, а также @testing-library/react.
Необходимо установить dev-зависимости:

"@testing-library/react": "^10.4.3", "@testing-library/react-hooks": "^3.3.0", "@types/enzyme": "^3.10.5", "@types/jest": "^24.9.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "enzyme-to-json": "^3.3.4", "jest": "^26.1.0", "ts-jest": "^26.1.1",

В package.json добавляем настройки для jest:

и в блоке «scripts» добавляем скрипт запуска тестов:

Создаём в директории src новый каталог __tests__ и в нем — файл setup.ts с таким содержимым:

import from 'enzyme'; import * as ReactSixteenAdapter from 'enzyme-adapter-react-16'; const adapter = ReactSixteenAdapter as any; configure(< adapter: new adapter() >);

Создадим файл todoReducer.test.ts, в котором протестируем редьюсер:

import from "../reducers/todoReducer"; import from "../types/stateType"; import from "../types/taskType"; describe('todoReducer',()=> < it('returns new state for "ADD" type', () =>< // Создаём стейт с пустым массивом задач const initialState: State = ; // Создаём действие 'ADD' и передаём в него текст 'new task' const updateAction: Action = ; // Вызываем редьюсер с переданными стейтом и экшеном const updatedState = todoReducer(initialState, updateAction); // Ожидаем получить в стейте добавленную задачу expect(updatedState).toEqual(]>); >); it('returns new state for "REMOVE" type', () => < const task: Task = const initialState: State = ; const updateAction: Action = ; const updatedState = todoReducer(initialState, updateAction); expect(updatedState).toEqual(); >); it('returns new state for "TOGGLE" type', () => < const task: Task = const initialState: State = ; const updateAction: Action = ; const updatedState = todoReducer(initialState, updateAction); expect(updatedState).toEqual(]>); >); it('returns new state for "CHANGE" type', () => < const initialState: State = ; const updateAction: Action = ; const updatedState = todoReducer(initialState, updateAction); expect(updatedState).toEqual(); >); >) 

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

Тестирование компонента App.tsx, в отличие от редьюсера, требует использования дополнительных методов из разных библиотек. Тестовый файл App.test.tsx:

import * as React from 'react'; import from 'enzyme'; import from "@testing-library/react"; import App from "../components/App"; describe('', () => < // jest-функция afterEach с переданным коллбеком cleanup вызывается после каждого теста и очищает среду тестирования afterEach(cleanup); it('hasn`t got changes', () =>< // метод shallow библиотеки enzyme позволяет производить юнит-тестирование, без отрисовки дочерних компонентов. const component = shallow(); // При первом запуске теста будет создан снимок компонента. При последующих тестированиях будет проверяться идентичность снимка с текущим содержимым компонента. Для обновления snapshots необходимо запустить тест с флагом -u: jest -u expect(component).toMatchSnapshot(); >); // Так как в компоненте будут происходить асинхронные действия (вызываться события на DOM-элементах), оборачиваем тест в async it('should render right input value', async () => < // render() функция доступна в библиотеке @testing-library/react" и отличается от shallow() тем, что строит настоящее DOM-дерево для тестируемого компонента. Переменная container — это элемент div, в который будет выведен компонент. const < container >= render(); expect(container.querySelector('input').getAttribute('value')).toEqual(''); // вызываем событие изменения инпута и передаём туда значение 'test' fireEvent.change(container.querySelector('input'), < target: < value: 'test' >, >) // ожидаем получить в инпуте значение 'test' expect(container.querySelector('input').getAttribute('value')).toEqual('test'); // вызываем событие клика на кнопку. При этом событии поле инпута должно очищаться fireEvent.click(container.querySelector('button')) // ожидаем получить в инпуте пустое значение атрибута value expect(container.querySelector('input').getAttribute('value')).toEqual(''); >); >) 

В TasksList компоненте проверим, правильно ли отображается передаваемый стейт. Файл TasksList.test.tsx:

import * as React from 'react'; import from "../components/App"; import from "enzyme"; import from "@testing-library/react"; import TasksList from "../components/TasksList"; import from "../types/stateType"; describe('',() => < afterEach(cleanup); // Создаём тестовый стейт const testState: State = < newTask: '', tasks: [, ] > // Передаём в ContextApp созданный тестовый стейт const Wrapper = () => < return ( >>  ) > it('should render right tasks length', async () => < const = render(); // Проверяем длину отображаемого списка expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length); >); >)

Аналогичную проверку поля newTask можно сделать для компонента NewTask, проверяя value у элемента input.

На этом всё, спасибо за внимание.

Источник

Оцените статью