Создание пользовательского крючка React: Секундомер

Хуки — это мощный способ абстрагировать логику из компонента, чтобы сделать его многократно используемым и упростить компонент. Сегодня у нас есть упражнение, которое поможет вам попрактиковаться в написании собственных хуков, создав секундомер с возможностью отслеживания пройденных кругов.

Этот пост был первоначально опубликован на сайте 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 будут полезны для ознакомления:

  1. Правила использования хуков
  2. Создание собственных хуков

Наш хук будет называться 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.

Оцените статью
devanswers.ru
Добавить комментарий