Недавно в свободное время я разрабатывал приложение для тренировок. Одним из требований, которые я поставил для него, было создание таймера, чтобы пользователи могли отслеживать свои тренировки. Основная цель состояла в том, чтобы создать таймер, в котором можно было бы «воспроизвести», «приостановить» и «остановить» тренировку. Кроме того, ему потребуется хранить достаточно информации, чтобы такие вопросы, как «Сколько времени потребовалось пользователю для выполнения упражнения?» или «Сколько времени ушло на выполнение всей тренировки?» можно было ответить.
В этом сообщении блога я объясню простую реализацию компонента таймера в 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 со всем необходимым кодом. Оставьте комментарий ниже, если у вас есть вопрос или идея, чтобы поделиться 🙂.