Рассказал о создании приложения для проверки скорости и точности печати (Typing Test App) на React + TypeScript и Redux Toolkit.
2К открытий5К показов
Всем доброго времени суток! В данной статье я хочу рассказать о создании приложения для проверки скорости и точности печати (Typing Test App). Приложение будем создавать на React + TypeScript, для работы с состоянием приложения используем Redux Toolkit.
В статье я не буду описывать создание стилей, а с полным кодом приложения вы можете ознакомиться в репозитории проекта.
Задачи
У пользователей должна быть возможность выбирать количество предложений
Текст необходимо получать из внешнего API;
Применение соответствующих стилей для правильного и неправильного символа;
Подсветка текущего символа;
Приложение должно вычислять и отображать скорость и точность печати текста пользователем;
Пользователи должны иметь возможность перезапустить текущий тест.
Настройка проекта
Создадим React проект с TypeScript шаблоном, выполнив в терминале следующую команду: npx create-react-app typing-test-app --template typescript.
После завершения установки, перейдем в директорию проекта, установим axios (npm install axios) и Redux Toolkit (npm install @reduxjs/toolkit react-redux).
Удалим все файлы, которые нам не пригодятся (оставим index.tsx, App.tsx, index.css, react-app-env.d.ts). Создадим папки api, assets, components, helpers, redux, types и style. Файл index.css переместим в папку style.
Приложение мы пишем на TypeScript, поэтому для функциональных компонентов желательно указывать тип FunctionComponent, который мы импортируем из React.
Header и Footer
В папке components создаем папку ui, в которой создадим два компонента Header и Footer. В Header, импортируем логотип и стили. Возвращать будем с логотипом и названием приложения.
В папке ui создадим компонент Button. Импортируем встроенный тип ComponentPropsWithoutRef, с помощью него мы сможем получить все атрибуты, которые есть у элемента
Перед тем как использовать созданные компоненты в проекте, мы создадим состояние для теста. Для этого воспользуемся Redux Toolkit.
В папке redux, создадим папку store, в которой создадим файл testSlice. Опишем тип для состояния теста. Для isTestStarted и isTestFinished, мы зададим тип boolean, а sentences будет string. Начальным состоянием isTestStarted и isTestFinished установим false, а sentences будет строкой с цифрой 4.
Далее, с помощью метода createSlice создадим слайс для нашего приложения. У нас будет четыре редюсера, по одному для работы с каждым отдельным состоянием и один общий, для сброса состояния теста к исходным значениям. Для типизации action.payload воспользуемся встроенным типом PayloadAction, которому в качестве дженерика будем передавать необходимый тип.
Далее, в папке store создаем одноименный файл и в нем опишем store нашего приложения. Редюсер у нас пока один, импортируем его из файла testSlice.
Экспортируем созданный store, и так как мы используем TypeScript, необходимо создать и экспортировать дополнительные типы. Они понадобятся для работы с Redux хуками. Для RootState воспользуемся встроенной TypeScript утилитой – ReturnType, которая будет принимать определение типа метода getState, а возвращать тип возвращаемого getState значения. Для определения типа AppDispatch воспользуемся оператором typeof.
import { configureStore } from '@reduxjs/toolkit';
import testReducer from './testSlice';
const store = configureStore({
reducer: {
testSlice: testReducer,
},
});
export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
В папке redux создадим файл hooks, импортируем в него созданные типы. И создаем два кастомных хука: useAppDispatch и useAppSelector. Создание этих хуков подробно описано в документации Redux Toolkit, это просто типизированные версии встроенных Redux хуков useDispatch и useSelector.
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store/store';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
В файле index.tsx. Обернем компонент App в Provider. В Provider передадим созданный store.
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './redux/store/store';
import './style/index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<Provider store={store}>
<App />
</Provider>
);
Откроем файл App, импортируем в него созданные хуки, функции setIsTestStarted и setSentences, и компоненты Button и ModalWindow.
Создадим функцию testStateToggler, внутри которой будем изменять состояние isTestStarted на true, тем самым запуская тест.
В блок добавим рендер по условию, если isTestStarted – true, то отрисовываем Test, если false, то отрисовываем ModalWindow. В ModalWindow передаем Button с функцией testStateToggler для события onClick.
import { FunctionComponent } from 'react';
import './style/typography.css';
import { useAppSelector, useAppDispatch } from './redux/hooks';
import { setIsTestStarted, setSentences } from './redux/store/testSlice';
import Header from './components/ui/Header';
import Footer from './components/ui/Footer';
import Test from './components/Test';
import ModalWindow from './components/ui/ModalWindow';
import Button from './components/ui/Button';
const App:FunctionComponent = () => {
const dispatch = useAppDispatch();
const isTestStarted = useAppSelector(state => state.testSlice.isTestStarted);
const testStateToggler = () => dispatch(setIsTestStarted(true));
return (
<>
<Header />
<main className='container main'>
{
isTestStarted
? <Test />
: <ModalWindow title='Take a typing test'>
<Button btnText='start' onClick={testStateToggler} />
</ModalWindow>
}
</main>
<Footer />
</>
);
};
export default App;
Теперь изначально будет отображаться компонент ModalWindow, а при нажатии на кнопку Start, появится компонент Test.
Работа с текстом
Для получения текста используем https://baconipsum.com/json-api/. В папке api создадим файл getText, который будет содержать асинхронную функцию. Внутри этой функции мы с помощью axios отправляем GET запрос на сервер и возвращаем ответ, в нашем случае в ответ на запрос мы ожидаем получить строку с текстом. В качестве аргумента функция getText будет принимать количество запрашиваемых предложений. Остальные параметры запроса берем из документации baconipsum
Теперь опишем тип для массива символов. В папке types создадим одноименный файл. Массив будет состоять из объектов с двумя свойствами: сам символ и css – класс.
export type TextType = {
char: string;
class: string;
};
Для работы с текстом создадим отдельный слайс, перейдем в папку redux/store и создадим там файл textSlice. Импортируем в него TextType и функцию getText. Для выполнения асинхронного запроса нам понадобится метод createAsyncThunk, импортируем его из redux toolkit.
Начнем с описания типа, здесь нам понадобится сам массив символов – text, состояние загрузки текста – isLoading, возможные ошибки загрузки – error, также нам нужно следить за индексом текущего символа – currentCharIndex, считать количество ошибок – mistakes и общее количество нажатий – pressingCount.
Далее, используя метод createAsyncThunk, создаем функцию fetchText. Для типизации createAsyncThunk используем дженерик, в который мы передадим три параметра. Первым параметром будет string, т.к. мы ожидаем, что функция вернет нам строку, вторым параметром также будет string, т.к. именно строку мы будем передавать функции колбэку и третьим параметром укажем rejectValue, он нам понадобится для обработки ошибок.
В сам метод createAsyncThunk мы передаем имя для action и асинхронную функцию для получения текста. Внутри этой асинхронной функции воспользуемся ранее созданной функцией getText, в которую передадим количество запрашиваемых предложений. В случае успеха будем возвращать response.data, в случае ошибки вернем встроенную функцию rejectWithValue с сообщением об ошибке.
В объекте reducers опишем редюсеры для работы с состоянием текста. setText для изменения массива с текстом, setCurrentCharIndex и setMistakes для изменения индекса текущего символа и количества ошибок соответственно, increasePressingCount для подсчета количества нажатий и resetTextState для сброса состояния к начальным значениям.
В extraReducers будем обрабатывать action для функции fetchText. Во время загрузки изменяем состояние isLoading на true и обнулять error. При успешном получении данных будем разбивать полученную строку на символы и формировать массив объектов, который сохраним в text. Для символа с индексом 0 сразу будем устанавливать класс ‘current-char’. В случае ошибки будем присваивать в error сообщение об ошибке.
import { configureStore } from '@reduxjs/toolkit';
import testReducer from './testSlice';
import textReducer from './textSlice';
const store = configureStore({
reducer: {
testSlice: testReducer,
textSlice: textReducer,
},
});
export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Для стилизации символов нам необходимо создать две функции. Первая будет стилизовать текущий символ, а вторая будет сверять нажатую клавишу с текущим символом и применять необходимый стиль, правильного или неправильного символа.
В папке helpers создадим файл charTransform. Начнем с функции getCurrentChar, на вход она будет принимать массив типа TextType и индекс текущего элемента, а возвращать новый массив типа TextType. Внутри самой функции будем перебирать входной массив и сравнивать индекс элемента массива с индексом текущего элемента, если индексы равны, то возвращаем элемент с классом current-char.
Теперь создадим функцию compareChars, на вход она будет принимать массив типа TextType, индекс текущего элемента, количество ошибок и нажатую клавишу, возвращать будет новый массив типа TextType, новый индекс текущего элемента и новое количество ошибок. Внутри функции делаем примерно тоже самое, проходим по массиву, сравниваем индексы и проверяем, правильно ли нажата клавиша.
Далее, в папке components создадим компонент Text. Импортируем в него функции getCurrentChar и compareChars. Из textSlice импортируем функции fetchText, setText, setCurrentCharIndex, increasePressingCount и setMistakes.
Для получения текста, внутри хука useEffect будем вызывать функцию fetchText. В которую будем передавать переменную sentences.
Добавим еще один useEffect, в котором при изменении индекса текущего символа, будем вызывать функцию getCurrentChar и изменять текст.
Для обработки нажатия клавиш также используем useEffect. Внутри будем сравнивать currentCharIndex с длиной текста и если currentCharIndex меньше, то используя конструкцию Function Expression, создаем функцию обработчик. В этой функции будем вызывать функцию compareChars, и обновлять текст, индекс текущего символа, количество ошибок и количество нажатий.
В этом компоненте мы используем условный рендеринг. Если есть ошибка, будем выводить на экран значение переменной error, если идет загрузка, будем отображать параграф с текстом «Loading text…», а когда текст загружен, будем выводить его на экран.
Импортируем созданный компонент Text в компонент Test.
import { FunctionComponent } from 'react';
import '../style/test.css';
import Text from './Text';
const Test:FunctionComponent = () => {
return (
<section className='test-container'>
<Text />
</section>
);
};
export default Test;
Подсчет скорости и точности печати
Для подсчета скорости печати нам потребуется таймер. В папке redux/store создадим файл timerSlice. Начальным состоянием будет выключенный таймер и количество секунд равное 0. Также будет три редюсера, для изменения состояния таймера, увеличения секунд на 1 и обнуления секунд.
import { configureStore } from '@reduxjs/toolkit';
import testReducer from './testSlice';
import textReducer from './textSlice';
import timerReducer from './timerSlice';
const store = configureStore({
reducer: {
testSlice: testReducer,
textSlice: textReducer,
timerSlice: timerReducer
},
});
export default store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Теперь нам необходимо добавить включение и выключение таймера в компонент Text. Перед созданием обработчика нажатия клавиш, будем проверять количество нажатий и при первом нажатии запускать таймер. Внутри функции keyPressHandler будем проверять новое значение индекса текущего символа, и если оно равно длине текста, то будем выключать таймер и устанавливать состояние isTestFinished в значение true.
Далее создадим функции подсчета скорости и точности печати. Для этого в папке helpers создадим файл statsCounting, и в нем создадим две эти функции.
Функция accuracyCounting будет принимать количество ошибок и общее количество нажатий, а возвращать процент правильно нажатых клавиш. Так как общее количество нажатий может быть равно нулю, необходимо это проверить.
Функция speedCounting принимает количество правильных символов и количество секунд. Возвращает скорость печати (количество слов в минуту). Для вычисления скорости необходимо перевести секунды в минуты, а количество правильных символов в количество слов (обычно подобные приложения берут среднюю длину слов равную пяти символам). Секунды могут быть равны нулю, поэтому их также необходимо проверить.
Теперь в папке components создадим компонент Stats, который будем использовать для отображения статистики. Импортируем в него функции speedCounting, accuracyCounting и increaseSeconds. В качестве props этот компонент будет принимать только необязательных children.
Создадим состояние для скорости и точности, а ошибки, нажатия, секунды и состояние таймера будем брать из глобального состояния. Воспользуемся хуком useEffect и при изменении количества ошибок, нажатий или секунд, будем вызывать функции speedCounting и accuracyCounting и устанавливать результаты вызова этих функции в соответствующее состояние.
Добавим еще один useEffect, в котором будем проверять, включен ли таймер, и если включен, то будем увеличивать количество секунд.
import { FunctionComponent } from 'react';
import '../style/test.css';
import Text from './Text';
import Stats from './Stats';
const Test:FunctionComponent = () => {
return (
<section className='test-container'>
<Text />
<Stats />
</section>
);
};
export default Test;
Завершение теста
В файле charTransform создадим еще одну функцию, она будет приводить массив с текстом в изначальное состояние. Принимать и возвращать она будет массив типа TextType. Внутри функции будем перебирать массив и устанавливать пустую строку в значение поля class вложенных объектов. Для объекта под индексом 0 class установим ‘current-char’.
В компоненте Test создадим две функции, для перезапуска и для начала нового теста. Добавим условный рендеринг, если isTestFinished – true, то будем отображать компонент ModalWindow со статистикой и двумя кнопками, Restart и New Test. Также в первый компонент Stats добавим кнопку перезапуска теста. Так как после нажатия кнопки, фокус остается на ней, нужно его принудительно снять, если этого не сделать, то при нажатии пробела во время печати кнопка будет срабатывать.
import { FunctionComponent } from 'react';
import '../style/test.css';
import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { resetSeconds } from '../redux/store/timerSlice';
import { setIsTestFinished, resetTestState } from '../redux/store/testSlice';
import { resetTextState, setText } from '../redux/store/textSlice';
import { restoreText } from '../helpers/charTransform';
import Text from './Text';
import Stats from './Stats';
import ModalWindow from './ui/ModalWindow';
import Button from './ui/Button';
const Test:FunctionComponent = () => {
const dispatch = useAppDispatch();
const isTestFinished = useAppSelector(state => state.testSlice.isTestFinished);
const text = useAppSelector(state => state.textSlice.text);
function restart() {
dispatch(resetSeconds());
dispatch(resetTextState());
dispatch(setText(restoreText(text)));
if (isTestFinished) {
dispatch(setIsTestFinished(false));
}
}
function newTest() {
dispatch(resetTestState());
dispatch(resetTextState());
dispatch(resetSeconds());
}
return (
<section className='test-container'>
<Text />
<Stats>
<Button
btnText='restart'
onClick={restart}
onFocus={(event) => event.target.blur()}
/>
</Stats>
{
isTestFinished &&
<ModalWindow title='Test completed!'>
<Stats />
<Button btnText='restart' onClick={restart}/>
<Button btnText='new test' onClick={newTest}/>
</ModalWindow>
}
</section>
);
};
export default Test;
Выбор количества предложений
Последнее, что осталось сделать – это добавить возможность выбирать количество предложений. Для этого в папке components/ui создадим компонент Select.
Тут нам также, как и в компоненте Button потребуется тип ComponentPropsWithoutRef, чтобы мы могли получить все атрибуты элемента select. В качестве props компонент будет принимать значение по умолчанию и массив значений. Возвращать будет один элемент select.
Теперь перейдем в App и добавим созданный компонент Select в отрисовку ModalWindow. В качестве defaultValue будем передавать состояние sentences, для options создадим небольшой массив объектов с двумя полями value и name, а при событии onChange будем изменять значение sentences.
Запускаем новую рубрику на Tproger. В первом выпуске — Сергей Сова, разработчик, фронтендер и подкастер, делится своими мыслями о Serverless SSR, новостях CSS и мастхев книге.