Хуки — это мощный способ абстрагировать логику из компонента, чтобы сделать его многократно используемым и упростить компонент. Сегодня у нас есть упражнение, которое поможет вам попрактиковаться в написании собственных хуков, создав секундомер с возможностью отслеживания пройденных кругов.
Этот пост был первоначально опубликован на сайте React Native School. Обязательно посетите его, чтобы получить эксклюзивные трюки, советы и руководства по React Native!
- Используемый хук
- Требования к крючку
- Запуск функциональности
- Функциональность остановки
- Форматированное время
- Сброс часов
- Отслеживание круга
- Отображение времени всех кругов
- Отображение текущего времени прохождения круга
- Определить время самого медленного круга
- Определение самого быстрого времени круга
- Следующие шаги
Используемый хук
Прежде чем мы начнем создавать пользовательский хук, давайте посмотрим на его использование. Этот пример взят из приложения React Native с открытым исходным кодом, разработанного React Native School.
Мы рассмотрим, что означает каждый элемент в отдельности, а пока посмотрим, как пользовательский хук используется в компоненте. Вот код для экрана на рисунке выше.
// screens/StopWatch.tsx
import { StyleSheet } from "react-native"
import { Text, View, StatusBar, SafeAreaView } from "components/themed"
import { CircleButton } from "components/buttons"
import { useStopWatch } from "hooks/useStopWatch"
import { LapList } from "components/lists"
const StopWatch = () => {
const {
// actions
start,
stop,
reset,
lap,
// data
isRunning,
time,
// lap data
laps,
currentLapTime,
hasStarted,
slowestLapTime,
fastestLapTime,
} = useStopWatch()
return (
<SafeAreaView style={{ flex: 1 }}>
<StatusBar />
<View style={styles.container}>
<Text style={styles.timeText}>{time}</Text>
<View style={styles.row}>
<CircleButton
onPress={() => {
isRunning ? lap() : reset()
}}
>
{isRunning ? "Lap" : "Reset"}
</CircleButton>
<CircleButton
onPress={() => {
isRunning ? stop() : start()
}}
color={isRunning ? "red" : "green"}
>
{isRunning ? "Stop" : "Start"}
</CircleButton>
</View>
<LapList
hasStarted={hasStarted}
currentLapTime={currentLapTime}
laps={laps}
fastestLapTime={fastestLapTime}
slowestLapTime={slowestLapTime}
/>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
/* ... */
})
export default StopWatch
Требования к крючку
Давайте составим краткий список требований к этому хуку.
- Возможность запуска и остановки секундомера
- Возвращать общее время, отформатированное, в процессе работы часов
- Возможность сбросить время на часах до 0
- Возможность отслеживать круг
- Возвращать время всех кругов
- Отображение обновленного текущего времени круга
- Обозначение самого медленного времени круга
- Определить самое быстрое время круга
Запуск функциональности
Давайте приступим к созданию нашего пользовательского хука. Два ресурса из документации React будут полезны для ознакомления:
- Правила использования хуков
- Создание собственных хуков
Наш хук будет называться useStopWatch
, и на данном этапе мы просто хотим добавить возможность запускать часы и обновлять прошедшее время.
Мы напишем это на TypeScript (узнайте больше о TypeScript в React Native).
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const start = () => {
setIsRunning(true)
setStartTime(Date.now())
}
return {
start,
isRunning,
time,
}
}
Первое, что мы сделали, это создали функцию start
, которая при вызове будет хранить миллисекунды с момента наступления эпохи. Мы вычтем это значение из текущего времени, чтобы узнать, сколько миллисекунд прошло с момента запуска таймера.
У нас также есть булево значение isRunning
, которое позволяет узнать, запущен таймер или нет.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
if (startTime > 0) {
interval.current = setInterval(() => {
setTime(() => Date.now() - startTime)
}, 1)
} else {
if (interval.current) {
clearInterval(interval.current)
interval.current = undefined
}
}
}, [startTime])
const start = () => {
setIsRunning(true)
setStartTime(Date.now())
}
return {
start,
isRunning,
time,
}
}
Теперь мы добавили блок кода внутри хука useEffect
. Он зависит от состояния startTime
, поэтому при каждом его изменении этот блок будет запускаться заново.
Если startTime
равно 0, то мы очищаем наш интервал, который мы отслеживаем через useRef
(подробнее о хуке useRef).
Если значение startTime
больше 0, то мы запускаем интервал, который вычитает текущее время из startTime
, давая нам прошедшие миллисекунды.
Фактическая задержка, которую мы задаем нашим setInterval
, не имеет значения (это может быть каждая миллисекунда, как указано выше, или каждая секунда). Точность времени не зависит от этого — все, что будет корректироваться, это частота обновления пользовательского интерфейса.
Функциональность остановки
Далее мы добавим возможность остановить/приостановить таймер. Когда мы остановим таймер, мы сбросим состояние startTime
, но нам нужно отслеживать, как долго таймер работал, когда был остановлен. Мы добавим новую часть состояния, timeWhenLastStopped
, которая будет отслеживать, сколько миллисекунд таймер работал, когда был остановлен.
Затем мы возьмем значение этого состояния и добавим его к истекшему времени текущего таймера, чтобы вычислить общее время.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
if (startTime > 0) {
interval.current = setInterval(() => {
setTime(() => Date.now() - startTime + timeWhenLastStopped)
}, 1)
} else {
if (interval.current) {
clearInterval(interval.current)
interval.current = undefined
}
}
}, [startTime])
const start = () => {
setIsRunning(true)
setStartTime(Date.now())
}
const stop = () => {
setIsRunning(false)
setStartTime(0)
setTimeWhenLastStopped(time)
}
return {
start,
stop,
isRunning,
time,
}
}
Форматированное время
Сейчас время хранится в миллисекундах. Человеку это не очень удобно читать, поэтому мы переформатируем его в часы (если применимо), минуты, секунды и миллисекунды. Кроме того, с помощью функции padStart
мы убедимся, что число состоит как минимум из двух цифр.
Затем мы используем функцию formatMs
для форматирования времени, которое мы возвращаем из хука.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
const padStart = (num: number) => {
return num.toString().padStart(2, "0")
}
const formatMs = (milliseconds: number) => {
let seconds = Math.floor(milliseconds / 1000)
let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60)
// using the modulus operator gets the remainder if the time roles over
// we don't do this for hours because we want them to rollover
// seconds = 81 -> minutes = 1, seconds = 21.
// 60 minutes in an hour, 60 seconds in a minute, 1000 milliseconds in a second.
minutes = minutes % 60
seconds = seconds % 60
// divide the milliseconds by 10 to get the tenths of a second. 543 -> 54
const ms = Math.floor((milliseconds % 1000) / 10)
let str = `${padStart(minutes)}:${padStart(seconds)}.${padStart(ms)}`
if (hours > 0) {
str = `${padStart(hours)}:${str}`
}
return str
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
return {
start,
stop,
isRunning,
time: formatMs(time),
}
}
Поскольку время будет быстро меняться, важно, чтобы текст имел фиксированную ширину, чтобы макет не смещался постоянно. О том, как настроить текст фиксированной ширины в React Native, мы рассказывали в другой статье.
Сброс часов
Теперь, когда функции запуска и остановки работают, мы также хотим иметь возможность сбросить таймер. Единственная разница между этим и остановкой таймера заключается в том, что мы сбросим time
на 0
, а также установим timeWhenLastStopped
на 0
.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
const reset = () => {
setIsRunning(false)
setStartTime(0)
setTimeWhenLastStopped(0)
setTime(0)
}
return {
start,
stop,
reset,
isRunning,
time: formatMs(time),
}
}
Отслеживание круга
Следующая функция — отслеживание кругов по мере их прохождения. Для этого мы добавим элемент состояния, представляющий собой массив чисел. Для отслеживания круга мы добавим в массив текущее время, когда вызывается функция lap
.
Обратите внимание на изменение в функции reset
. При вызове этой функции мы сбрасываем состояние кругов в пустой массив.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const [laps, setLaps] = useState<number[]>([])
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
const reset = () => {
setIsRunning(false)
setStartTime(0)
setTimeWhenLastStopped(0)
setTime(0)
setLaps([]) // NEW LINE
}
const lap = () => {
setLaps(laps => [time, ...laps])
}
return {
start,
stop,
reset,
lap,
isRunning,
time: formatMs(time),
}
}
Отображение времени всех кругов
Затем, чтобы отобразить время прохождения круга, нам потребуется немного математики и форматирования.
Сначала нам нужно определить время круга, взяв время остановки текущего круга и вычтя время остановки предыдущего круга. Это даст вам фактическое время прохождения круга.
Затем нужно определить, для какого круга это время. Поскольку самый новый круг добавляется в начало массива, мы вычитаем индекс из длины массива laps.
Например.
["a", "b", "c", "d", "e"] // length === 5
// Lap 5 ('a'). index = 0, 5 - 0.
// Lap 4 ('b'). index = 1, 5 - 1.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export type LapData = {
time: string
lap: number
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const [laps, setLaps] = useState<number[]>([])
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
const reset = () => {
/* ... */
}
const lap = () => {
/* ... */
}
const formattedLapData: LapData[] = laps.map((l, index) => {
const previousLap = laps[index + 1] || 0
const lapTime = l - previousLap
return {
time: formatMs(lapTime),
lap: laps.length - index,
}
})
return {
start,
stop,
reset,
lap,
isRunning,
time: formatMs(time),
laps: formattedLapData,
}
}
Отображение текущего времени прохождения круга
Если вы вернетесь к рисунку в начале, то заметите, что у нас есть не только общее время, которое обновляется, но и текущее время круга. Время круга — это время, прошедшее с момента предыдущего круга.
Чтобы рассчитать это время, нужно вычесть время последнего круга из текущего времени или, если это первый круг, time
и currentLapTime
будут одинаковыми.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export type LapData = {
/* ... */
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const [laps, setLaps] = useState<number[]>([])
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
const reset = () => {
/* ... */
}
const lap = () => {
/* ... */
}
const formattedLapData: LapData[] = laps.map((l, index) => {
/* ... */
})
return {
start,
stop,
reset,
lap,
isRunning,
time: formatMs(time),
laps: formattedLapData,
currentLapTime: laps[0] ? formatMs(time - laps[0]) : formatMs(time),
hasStarted: time > 0,
}
}
Мы также добавили булево значение hasStarted
. Он используется для определения того, должна ли отображаться информация о кругах.
Определить время самого медленного круга
Мы вышли на финишную прямую! Предпоследнее, что мы сделаем, это определим время самого медленного круга. Мы можем сделать это одновременно с расчетом индивидуального времени круга.
Все, что нам нужно сделать, это определить, является ли время круга, которое мы сейчас рассматриваем, медленнее, чем предыдущее самое медленное время. Если у нас еще нет самого медленного времени круга, значит, то, на которое мы смотрим, и есть оно!
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export type LapData = {
/* ... */
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const [laps, setLaps] = useState<number[]>([])
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
const reset = () => {
/* ... */
}
const lap = () => {
/* ... */
}
let slowestLapTime: number | undefined
const formattedLapData: LapData[] = laps.map((l, index) => {
const previousLap = laps[index + 1] || 0
const lapTime = l - previousLap
if (!slowestLapTime || lapTime > slowestLapTime) {
slowestLapTime = lapTime
}
return {
time: formatMs(lapTime),
lap: laps.length - index,
}
})
return {
start,
stop,
reset,
lap,
isRunning,
time: formatMs(time),
laps: formattedLapData,
currentLapTime: laps[0] ? formatMs(time - laps[0] || 0) : formatMs(time),
hasStarted: time > 0,
slowestLapTime: formatMs(slowestLapTime || 0),
}
}
Определение самого быстрого времени круга
Это прямо противоположный вариант определения самого медленного времени круга.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export type LapData = {
/* ... */
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
export const useStopWatch = () => {
const [time, setTime] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [startTime, setStartTime] = useState<number>(0)
const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
const [laps, setLaps] = useState<number[]>([])
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
/* ... */
}, [startTime])
const start = () => {
/* ... */
}
const stop = () => {
/* ... */
}
const reset = () => {
/* ... */
}
const lap = () => {
/* ... */
}
let slowestLapTime: number | undefined
let fastestLapTime: number | undefined
const formattedLapData: LapData[] = laps.map((l, index) => {
const previousLap = laps[index + 1] || 0
const lapTime = l - previousLap
if (!slowestLapTime || lapTime > slowestLapTime) {
slowestLapTime = lapTime
}
if (!fastestLapTime || lapTime < fastestLapTime) {
fastestLapTime = lapTime
}
return {
time: formatMs(lapTime),
lap: laps.length - index,
}
})
return {
start,
stop,
reset,
lap,
isRunning,
time: formatMs(time),
laps: formattedLapData,
currentLapTime: laps[0] ? formatMs(time - laps[0] || 0) : formatMs(time),
hasStarted: time > 0,
slowestLapTime: formatMs(slowestLapTime || 0),
fastestLapTime: formatMs(fastestLapTime || 0),
}
}
И вот у вас есть функционирующий пользовательский крючок для секундомера! Вы можете увидеть его в использовании в открытом приложении «Часы» (одно из многих открытых приложений React Native, которые у нас есть).
Следующие шаги
Итак, мы проделали всю эту работу, но у этой реализации есть проблема.
Что произойдет, если наше приложение будет закрыто? Настоящее приложение «Часы» продолжит отсчитывать время, но наше приложение сбросится.
Как бы вы решили эту проблему? Узнайте, как я это сделал, во второй части этого руководства.
Сообщите нам об этом в Twitter.