[JavaScript, ReactJS, TypeScript] Todolist на React Hooks + TypeScript: от сборки до тестирования
                    
                           
                    
                        Автор 
                        Сообщение 
                    
                                        
                        
                            
                                
                                
                                                                                                            news_bot ®
                                                                        
                                                                                                                                                
                                                                            
                                                                                                                
                                            Стаж: 7 лет 8 месяцев                                        
                                                                                                                
                                            Сообщений: 27286                                        
                                                                                                                                                
                                                             
                            
                                
                             
                         
                        
                            
                                
                                    
                                        
                                        
 Начиная с версии 16.9, в библиотеке React JS доступен новый функционал — хуки. Они дают возможность использовать состояние и другие функции React, освобождая от необходимости писать класс. Использование функциональных компонентов совместно с хуками позволяет разработать полноценное клиентское приложение.
Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript.
Сборка
Структура проекта следующая:
├── src
| ├── components
| ├── index.html
| ├── index.tsx
├── package.json
├── tsconfig.json
├── webpack.config.json
Файл package.json:
SPL
{
  "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:
SPL
{
  "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:
SPL
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 существуют два подхода к взаимодействию между компонентами:
- Хранение текущего состояния приложения и всех его методов в родительском компоненте (в нашем случае — в App.tsx) и передача дочерним компонентам через пропсы (классический способ);
 
- Хранение состояния и методов управления состоянием отдельно. В этом случае приложение нужно обернуть специальным компонентом — провайдером, и передать в него необходимые для дочерних компонентов методы и свойства (использование хука useContext).
Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов.
TypeScript при передаче пропсов
SPL
* Если в компонент всё же передаются пропсы, TypeScript потребует явного указания типа для компонента:
const NewTask: React.FC<MyProps> = ({taskName}) => {...
Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров:
interface MyProps {
    taskName: String;
}
useContext
Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером.
Пример использования useContext
SPL
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
SPL
Хук 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
===========
 Источник:
habr.com
===========
Похожие новости:
- [CSS, JavaScript] Atomizer vs Minimalist Notation (MN)
 
- [Open source, PHP, JavaScript] readable — еще один линтер для PHP
 
- [JavaScript, Разработка веб-сайтов] Интеграция ЭЦП НУЦ РК в информационные системы на базе веб технологий
 
- [Разработка веб-сайтов, JavaScript, Node.JS] Управление зависимостями JavaScript
 
- [Firefox, JavaScript, Python, Реверс-инжиниринг, Системы обмена сообщениями] Магия WebPush в Mozilla Firefox. Взгляд изнутри
 
- [JavaScript, Программирование, Учебный процесс в IT] Двоичное кодирование вместо JSON (перевод)
 
- [Canvas, JavaScript, WebGL, Математика, Работа с 3D-графикой] Canvas и геометрия. Это почти просто
 
- [Разработка веб-сайтов, JavaScript] Самый sexy framework для веб-приложений
 
- [JavaScript, Игры и игровые приставки, Разработка веб-сайтов, Социальные сети и сообщества] Программист создал аналог Club Penguin для взрослых, где можно одновременно общаться как в Zoom и играть
 
- [JavaScript, ReactJS, VueJS, Разработка веб-сайтов] Устройство ленивой загрузки в популярных фронтенд-фреймворках (перевод)
Теги для поиска: #_javascript, #_reactjs, #_typescript, #_react_js, #_react_hooks, #_usecontext, #_usereducer, #_react_+_typescript, #_@testinglibrary/react, #_javascript, #_reactjs, #_typescript 
                         
                        
                            
                                                                    
                                                             
                         
                    
                    
                
                
            
        
    
    
    
    
    
            
    
            
    
        
    
    
        
                        Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
    
    
        
        Текущее время: 01-Ноя 02:50
Часовой пояс: UTC + 5 
            
    
                
| Автор | Сообщение | 
|---|---|
| news_bot ® 
                                                                            
                                                                                                                
                                            Стаж: 7 лет 8 месяцев                                         | |
| Начиная с версии 16.9, в библиотеке React JS доступен новый функционал — хуки. Они дают возможность использовать состояние и другие функции React, освобождая от необходимости писать класс. Использование функциональных компонентов совместно с хуками позволяет разработать полноценное клиентское приложение. Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript. Сборка Структура проекта следующая: ├── src | ├── components | ├── index.html | ├── index.tsx ├── package.json ├── tsconfig.json ├── webpack.config.json Файл package.json:SPL{ "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:SPL{ "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:SPLconst 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 существуют два подхода к взаимодействию между компонентами: 
 Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов. TypeScript при передаче пропсовSPL* Если в компонент всё же передаются пропсы, TypeScript потребует явного указания типа для компонента: const NewTask: React.FC<MyProps> = ({taskName}) => {... Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров: interface MyProps { taskName: String; } useContext Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером. Пример использования useContextSPLimport * 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 для более удобной работы с хранилищем состояния. Подробнее о useReducerSPLХук 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 =========== Источник: habr.com =========== Похожие новости: 
 | |
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
    Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 01-Ноя 02:50
Часовой пояс: UTC + 5 
