Недавно в свободное время я разрабатывал приложение для тренировок. Одним из требований, которые я поставил для него, было создание таймера, чтобы пользователи могли отслеживать свои тренировки. Основная цель состояла в том, чтобы создать таймер, в котором можно было бы «воспроизвести», «приостановить» и «остановить» тренировку. Кроме того, ему потребуется хранить достаточно информации, чтобы такие вопросы, как «Сколько времени потребовалось пользователю для выполнения упражнения?» или «Сколько времени ушло на выполнение всей тренировки?» можно было ответить.

В этом сообщении блога я объясню простую реализацию компонента таймера в React, который можно расширить, чтобы ответить на вышеупомянутые вопросы. В конце есть ссылки на демо-версию CodeSandbox и репозиторий кода на GitHub.

План 💡

Основная идея заключалась в том, чтобы создать сущность, которая позволяла бы хранить всю необходимую информацию. Эта сущность будет хранить информацию о том, когда она запускалась, приостанавливалась и сколько времени работала. Давайте назовем эту сущность «временной записью» и определим ее следующим образом:

{
  startedAt: Integer, // The # of elapsed ms since the unix epoch
  elapsedMs: Integer // If paused, the # of ms this time entry ran
}

Затем тренировка будет определена как список записей времени. Другими словами, каждый раз, когда пользователь запускал таймер, он инициализировал запись времени и устанавливал startedAt на «сейчас». Он будет продолжать работать, если его не приостановить, и в этом случае количество миллисекунд, прошедших с момента его запуска, будет вычислено и сохранено в elaspedMs. Если таймер запустить снова, то будет создана новая запись времени. Наконец, для вычисления общего прошедшего времени потребуется просто сложить все записи времени «elapsedMs».

Редуктор таймера ⚒️

Давайте продолжим и реализуем это с помощью CRA, чтобы упростить процесс. Запустите npx create-react-app react-timer-app, чтобы создать приложение.

Я буду использовать шаблон State Reducer, как объяснил Кент С. Доддс. Давайте начнем с определения простого скелета редуктора таймера, действий, которые будет разрешено выполнять пользователю, и хука useTimer в App.js следующим образом:

const actionTypes = {
  tick: 'tick',
  play: 'play',
  pause: 'pause',
  stop: 'stop',
}
const initialState = {
  tick: null,
  timeEntries: [],
}
const timerReducer = (state, { type, payload }) => {
  switch (type) {
    case actionTypes.tick:
      return state
    case actionTypes.play:
      return state
    case actionTypes.pause:
      return state
    case actionTypes.stop:
      return state
    default:
      throw new Error(`Unhandled type: ${type}`)
  }
}
const useTimer = () => {
  const [state, dispatch] = useReducer(timerReducer, initialState)
  return {}
}
const Timer = () => {
  return null
}
const App = () => {
  return <Timer />
}

Акция «tick»

Действие tick будет использоваться для повторного рендеринга компонента <Timer/> каждую секунду. Для этого компонент будет использовать хук useInterval, реализованный Дэном Абрамовым в этой записи блога. Каждую секунду это действие будет запускаться с сейчас (количество миллисекунд, прошедших с эпохи Unix) в качестве полезной нагрузки. Затем полезная нагрузка назначается свойству tick состояния редуктора таймера.

case actionTypes.tick:
  return { ...state, tick: payload }
// The number of ms since the unix epoch (a.k.a. "now")
const now = () => new Date().getTime()

const useTimer = () => {
  const [state, dispatch] = useReducer(timerReducer, initialState)

  const tick = () => dispatch({ type: actionTypes.tick, payload: now() })

  return {
    tick,
  }
}

const Timer = () => {
  const { tick } = useTimer()

  useInterval(() => {
    tick()
  }, 1000)

  return null
}

Акция «play»

Действие play отвечает за запуск таймера в момент «сейчас». Однако перед реализацией этого действия необходимо определить несколько служебных функций.

Во-первых, давайте добавим эти функции, которые упростят работу с записью времени. Это поможет создать, остановить и легко определить «статус» записи времени:

// Create a new time entry starting "now" by default
const startTimeEntry = (time = now()) => ({
  startedAt: time,
  elapsedMs: null,
})
// Stop the given time entry at "now" by default
const stopTimeEntry = (timeEntry, time = now()) => ({
  ...timeEntry,
  elapsedMs: time - timeEntry.startedAt,
})
// Return true if a time entry is running, false otherwise
const isTimeEntryRunning = ({ elapsedMs }) => elapsedMs === null
// Return true if a time entry is paused, false otherwise
const isTimeEntryPaused = ({ elapsedMs }) => elapsedMs !== null

Далее давайте определим еще несколько служебных функций, но на этот раз для извлечения информации из состояния хука useTimer (также известного как «селекторы»):

// Get the current time entry, which is always the latest one
const getCurrTimeEntry = (state) =>
  state.timeEntries[state.timeEntries.length - 1]
// Return true if the timer is stopped, false otherwise
const isStopped = (state) => state.timeEntries.length === 0
// Return true if the timer is running, false otherwise
const isRunning = (state) =>
  state.timeEntries.length > 0 && isTimeEntryRunning(getCurrTimeEntry(state))
// Return true if the timer is paused, false otherwise
const isPaused = (state) =>
  state.timeEntries.length > 0 && isTimeEntryPaused(getCurrTimeEntry(state))
// Return the total number of elapsed ms
const getElapsedMs = (state) => {
  if (isStopped(state)) return 0
  return state.timeEntries.reduce(
    (acc, timeEntry) =>
      isTimeEntryPaused(timeEntry)
        ? acc + timeEntry.elapsedMs
        : acc + (now() - timeEntry.startedAt),
    0
  )
}

Эти методы позволяют легко узнать, что такое текущая запись времени, работает ли таймер, приостановлен или остановлен, и сколько времени прошло с момента его запуска.

Хорошо, это было много полезных функций! Давайте сосредоточимся на реализации действия play:

case actionTypes.play:
  if (isRunning(state)) return state
  return {
    ...state,
    timeEntries: state.timeEntries.concat(startTimeEntry(payload)),
  }

Действие play может быть выполнено только в том случае, если таймер в данный момент не запущен, поэтому состояние возвращается как есть, если только это не так. В противном случае новая запись времени «запускается» (создается) и добавляется в список записей времени.

Акция «pause»

Действие pause может быть выполнено только при запущенном таймере. Он найдет текущую запись времени выполнения (последнюю) и вычислит количество прошедших миллисекунд с момента его запуска до настоящего момента (т. Е. Сколько времени он выполнялся). Вот реализация:

case actionTypes.pause:
  if (isStopped(state)) return state
  if (isPaused(state)) return state
	
  const currTimeEntry = getCurrTimeEntry(state)
  return {
    ...state,
    timeEntries: state.timeEntries
      .slice(0, -1)
      .concat(stopTimeEntry(currTimeEntry)),
  }

Акция «stop»

Действие stop удаляет все существующие записи времени, чтобы остановить таймер, и может быть выполнено в любое время. Его реализация проста:

case actionTypes.stop:
  return { ...state, timeEntries: [] }

Крючок «useTimer» 🛠️

Теперь, когда редуктор таймера реализован, хук useTimer будет предоставлять свой API потребителям следующим образом:

const useTimer = () => {
  const [state, dispatch] = useReducer(timerReducer, initialState)
  const pause = () => dispatch({ type: actionTypes.pause, payload: now() })
  const play = () => dispatch({ type: actionTypes.play, payload: now() })
  const stop = () => dispatch({ type: actionTypes.stop })
  const tick = () => dispatch({ type: actionTypes.tick, payload: now() })
  const running = isRunning(state)
  const elapsedMs = getElapsedMs(state)
  return {
    pause,
    play,
    running,
    stop,
    tick,
    elapsedMs,
  }
}

Потребитель useTimer — это компонент <Timer/>, и его реализация может выглядеть так (очень упрощенно и без каких-либо стилей для краткости):

const Timer = () => {
  const { pause, play, running, stop, tick, elapsedMs } = useTimer()
  const zeroPad = (x) => (x > 9 ? x : `0${x}`)
  const seconds = Math.floor((elapsedMs / 1000) % 60)
  const minutes = Math.floor((elapsedMs / (1000 * 60)) % 60)
  const hours = Math.floor((elapsedMs / (1000 * 60 * 60)) % 24)
  useInterval(() => {
    tick()
  }, 1000)
  return (
    <div>
      <p>
        {zeroPad(hours)}:{zeroPad(minutes)}:{zeroPad(seconds)}
      </p>
      {running ? (
        <button onClick={pause}>pause</button>
      ) : (
        <button onClick={play}>play</button>
      )}
      <button onClick={stop}>stop</button>
    </div>
  )
}

Вывод 🤝

Хорошо, это было немного дольше, чем я ожидал. Идея использования записей времени для хранения состояния таймера может быть расширена, чтобы включать больше информации в каждую запись времени и, таким образом, иметь возможность отвечать на вопросы, подобные тем, которые я разместил во введении. Существует демонстрация CodeSandbox компонента <Timer/>, а также репозиторий GitHub со всем необходимым кодом. Оставьте комментарий ниже, если у вас есть вопрос или идея, чтобы поделиться 🙂.