Русский
Русский
English
Статистика
Реклама

Из песочницы 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, тип возвращаемого редьюсером значения - Stateexport 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 доступна передача только объекта типа Tasktype ActionObjectPayload = {    type: ActionType.TOGGLE | ActionType.REMOVE,    payload: Task}// Объединяем предыдущие две группы для использования в редьюсереexport type Action = ActionStringPayload | ActionObjectPayload;// Наш контекст состоит из стейта и функции-редьюсера, в которую будут передаваться Action. Тип Dispatch импортируется из библиотеки reactexport 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
Источник: habr.com
К списку статей
Опубликовано: 13.07.2020 00:19:13
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Javascript

Reactjs

Typescript

React js

React hooks

Usecontext

Usereducer

React + typescript

Testing-library/react

Категории

Последние комментарии

© 2006-2020, personeltest.ru