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

Файл package.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-сборки в нужном месте.

Файл tsconfig.json:

{   "compilerOptions": {     "sourceMap": true,     "noImplicitAny": false,     "module": "commonjs",     "target": "es6",     "lib": [       "es2015",       "es2017",       "dom"     ],     "removeComments": true,     "allowSyntheticDefaultImports": false,     "jsx": "react",     "allowJs": true,     "baseUrl": "./",     "paths": {       "components/*": [         "src/components/*"       ]     }   } }

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

Файл webpack.config.json:

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. Сборка готова.

Разработка

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

<div id="root"></div>

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

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

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

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

Выглядеть это будет так: текстовое поле для ввода + кнопка Добавить задачу, и ниже — список добавленных задач. Задачи можно удалять и менять им статус.

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

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

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

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

Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов.

TypeScript при передаче пропсов

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

const NewTask: React.FC<MyProps> = ({taskName}) => {...

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

interface MyProps {     taskName: String; }

useContext

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

Пример использования useContext

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

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

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

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

useReducer

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

Подробнее о useReducer

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

import * as React from 'react'; import {useReducer} 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<React.Reducer<PersonState, PersonAction>>(personReducer, initialState);      return (         <div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>             Hello, {person.name} {person.surname}!         </div>     ) }  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 {useReducer} from "react"; import {Action, State, ContextState} from "../types/stateType"; import NewTask from "./NewTask"; import TasksList from "./TasksList";  // Начальные значения стейта export const initialState: State = {     newTask: '',     tasks: [] }  // <Partial> позволяет создать контекст без дефолтных значений export const ContextApp = React.createContext<Partial<ContextState>>({});  // Создаём редьюсер, принимающий на вход текущий стейт и объект Action с полями type и payload, тип возвращаемого редьюсером значения - State export const todoReducer = (state: State, action: Action):State => {     switch (action.type) {         case ActionType.ADD: {             return {...state, tasks: [...state.tasks, {                     name: action.payload,                     isDone: false                 }]}         }         case ActionType.CHANGE: {             return {...state, newTask: action.payload}         }         case ActionType.REMOVE: {             return {...state, tasks:  [...state.tasks.filter(task => task !== action.payload)]}         }         case ActionType.TOGGLE: {             return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone}))]}         }         default: throw new Error('Unexpected action');     } };  const App:  React.FC = () => { // Используем созданный todoReducer, передав его аргументом в useReduser. Изначально в стейт попадёт initialState, и далее при диспатче (changeState) будет обновляться.     const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);      const ContextState: ContextState = {         state,         changeState     };  // Передаём в контекст результаты работы useReducer - стейт и метод его изменения     return (         <>             <ContextApp.Provider value={ContextState}>                 <NewTask />                 <TasksList />             </ContextApp.Provider>         </>     ) }  export default App;

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

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

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

import {Dispatch} 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<Action> }

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

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

import * as React from 'react';  import {useContext} from "react"; import {ContextApp} from "./App";  import {TaskName} from "../types/taskType"; import {ActionType} from "../types/stateType";  const NewTask: React.FC = () => { // получаем state и dispatch-метод     const {state, changeState} = useContext(ContextApp);  // отправляем два действия редьюсеру todoReducer - добавление задачи и изменение инпута. После их успешной обработки переменная state обновится. Для уточнения интерфейса передаваемого события можно воспользоваться расширенными React-интерфейсами     const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {         event.preventDefault();         changeState({type: ActionType.ADD, payload: task})         changeState({type: ActionType.CHANGE, payload: ''})     }  // аналогично - отправим изменение значения в инпуте     const changeTask = (event: React.ChangeEvent<HTMLInputElement>) => {         changeState({type: ActionType.CHANGE, payload: event.target.value})     }      return (         <>             <form onSubmit={(event)=>addTask(event, state.newTask)}>                 <input type='text' onChange={(event)=>changeTask(event)} value={state.newTask}/>                 <button type="submit">Add a task</button>             </form>         </>     ) };  export default NewTask; 

TasksList.tsx:

import * as React from 'react';  import {Task} from "../types/taskType"; import {ActionType} from "../types/stateType"; import {useContext} from "react"; import {ContextApp} from "./App";  const TasksList: React.FC = () => { // Получаем состояние и диспатч (названный changeState)     const {state, changeState} = useContext(ContextApp);      const removeTask = (taskForRemoving: Task) => {         changeState({type: ActionType.REMOVE, payload: taskForRemoving})     }     const toggleReadiness = (taskForChange: Task) => {         changeState({type: ActionType.TOGGLE, payload: taskForChange})     }      return (         <>             <ul>                 {state.tasks.map((task,i)=>(                     <li key={i} className={task.isDone ? 'ready' : null}>                         <label>                             <input type="checkbox" onChange={()=>toggleReadiness(task)} checked={task.isDone}/>                         </label>                         <div className="task-name">                             {task.name}                         </div>                         <button className='remove-button' onClick={()=>removeTask(task)}>                             X                         </button>                     </li>                 ))}             </ul>         </>     ) };  export default TasksList;

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

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

Для тестирования будут использоваться 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:

 "jest": {     "preset": "ts-jest",     "setupFiles": [       "./src/__tests__/setup.ts"     ],     "snapshotSerializers": [       "enzyme-to-json/serializer"     ],     "testRegex": "/__tests__/.*\\.test.(ts|tsx)$",     "moduleFileExtensions": [       "ts",       "tsx",       "js",       "jsx",       "json",       "node"     ]   },

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

"test": "jest"

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

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

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

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

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

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

import * as React from 'react'; import {shallow} from 'enzyme'; import {fireEvent, render, cleanup} from "@testing-library/react"; import App from "../components/App";  describe('<App />', () => { // jest-функция afterEach с переданным коллбеком cleanup вызывается после каждого теста и очищает среду тестирования     afterEach(cleanup);      it('hasn`t got changes', () => { //  метод shallow библиотеки enzyme позволяет производить юнит-тестирование, без отрисовки дочерних компонентов.          const component = shallow(<App />); // При первом запуске теста будет создан снимок компонента. При последующих тестированиях будет проверяться идентичность снимка с текущим содержимым компонента. Для обновления 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(<App/>);         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 {ContextApp, initialState} from "../components/App";  import {shallow} from "enzyme"; import {cleanup, render} from "@testing-library/react";  import TasksList from "../components/TasksList"; import {State} from "../types/stateType";  describe('<TasksList />',() => {      afterEach(cleanup);  // Создаём тестовый стейт     const testState: State = {         newTask: '',         tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]     }  // Передаём в ContextApp созданный тестовый стейт     const Wrapper = () => {         return (             <ContextApp.Provider value={{state: testState}}>                 <TasksList/>             </ContextApp.Provider>             )     }      it('should render right tasks length', async () => {         const {container} = render(<Wrapper/>);  // Проверяем длину отображаемого списка         expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);     });  })

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

Проект можно скачать с GitGub-репозитория.

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

Ресурсы

React JS. Хуки
Working with React Hooks and TypeScript

ссылка на оригинал статьи https://habr.com/ru/post/510642/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *