React-Saga “продвинутый” пример.

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

And that was a pleasant walk in a lush park, I must say.

Приступим:

export const types = {
    'start': 'StartTimer@Front',
    'stop': 'StopTimer@Front',
    'pause': 'PauseTimer@Front',
    'resume': 'ResumeTimer@Front',
    'stopped': 'TimerStopped@Saga',
    'timeUpdated': 'TimerUpdated@Saga',
}

Для начала действия: всего пользователь может отправить 4 действия (они помечены сферой @Front[end]) – старт и стоп таймера, чтобы начать его выполнение с самого начала, или пауза-продолжение – чтобы соответсвенно приостановить, без сбрасывания счетчика, и продолжить его выполнение. Также есть 2 действия (@Saga), которые производит сама сага: стоп, чтобы оповестить редуктор и сам таймер что пора остановиться и обнулить состояние, и обновление текущего времени.

Можно заметить, что я как и ранее работаю вопреки принятому стандарту в виде капитализированных констант. Также я использую два типа наименования: verb + noun – для действий, которые начинают какой-то процесс, как правило приходящие от пользователя, и noun(gerund) + past verb – для действий, которые должны напрямую влиять на состояние приложения – когда действие уже произошло и нужно оповестить элементы приложения и пользователя.

Feel free to translate it into classic redux types definitions for yourself.

Next stop:

function* ticking(action) {
    try {
        let timeElapsed, pomoTime, restTime;
        while (true){
            const { started } = yield race({
                started: take(types.start),
                unpaused: take(types.resume),
            });
            if (started) {
                pomoTime = started.pomoTime;
                restTime = started.restTime;
                timeElapsed = 0;
            }
            while (true) {
                let timePrev = Date.now();
                const { cancelled } = yield race({
                    ticked: call(delay, Math.round(Math.random() * 8) + 16),
                    cancelled: take([types.pause, types.stopped]),
                });
                if (cancelled)
                    break;
                timeElapsed += Date.now() - timePrev;
                if (timeElapsed > (pomoTime + restTime) * 1000)
                    yield fork(stopTimer);
                yield put({ type: timeUpdated, elapsed: timeElapsed });
            }
        }
    } catch (e) {
        yield put({type: "Welp, we did it"});
    }
}

Нас встречает уже знакомая конструкция:

const { started } = yield race({
    started: take(start),
    unpaused: take(types.resume),
});

Для запуска таймера мы ожидаем одно из двух действий – он только начал работу (types.start) или продолжил с паузы (types.resume). При этом в первом случае мы берем себе действие, чтобы знать о необходимости обнулить состояние таймера.

if (started) {
    pomoTime = started.pomoTime;
    restTime = started.restTime;
    timeElapsed = 0;
}

Помимо взятия данных с действия (чего делать не стоит, но на момент написания кода я не удосужился ознакомиться с одним из эффектов saga) мы обнуляем текущее значение таймера из настроек.
Я остановился на этом моменте для одного уточнения – у saga есть вспомогательная функция select, которая позволяет получить текущее состояние приложения.

Текущее с поправками – из-за принципа работы saga select выполняется после обработки действия редуктором, если он конечно есть, что может привести к неожиданному результату при получении данных. Подробнее об этом.

let timePrev = Date.now();
const { cancelled } = yield race({
    ticked: call(delay, Math.round(Math.random() * 8) + 16),
    cancelled: take([pause, types.stopped]),
});

race2; it was, yeah it was until 63aa3….

Мы создаем “гонку”, чтобы дать возможность пользователю остановить таймер. Элемент случайности добавлен сугубо для эстетического эффекта – цифры увеличиваются в хаотичном порядке, а не одинаковыми множителями в 8\16\33 миллисекунды.
Завершение парада:

if (cancelled)
    break;
timeElapsed += Date.now() - timePrev;
if (timeElapsed > (pomoTime + restTime) * 1000)
    yield fork(stopTimer);
yield put({ type: timeUpdated, elapsed: timeElapsed });

Если мы получили остановку – выйти из цикла работы таймера while(true). Я использую Date.now() вместо простой арифметики для капельку большей точности – если планировщик saga пропустит пару тиков из-за пика загрузки у пользователя, таймер продолжит работать с относительной точностью. Это сугубо экспериментальный ход, так как я не тестировал ни эффективность планировщика saga, ни работоспособности и точности таймера при пиковых нагрузках.

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

Я использую fork из-за одной интересной особенности кода и планировщика саг – если просто использовать call и остановить таймер, то сигнал о остановке может придти слишком рано и тогда наш таймер его пропустит, заметит что время позднее и надо останавливать уже во второй раз, пропустит сигнал снова и снова попытается остановится – и таким образом повесит всю страницу.
Поэтому приходится использовать небольшую задержку – достаточно большую, чтобы все эффекты saga успели отправиться, но достаточно маленькую, чтобы таймер не успел подумать что его никто не будет останавливать (16-24 мс в нашем случае).
Как часто бывает, я уверен что есть более элегантное решение этой проблемы, но на момент написания статьи я его не придумал, если знаешь чем можно помочь – добро пожаловать в комментарии или репо.

function* stopTimer() {
    yield delay(5);
    yield put({type: timeUpdated, elapsed: 0});
    yield put({type: types.stopped, finished: true});
}

Таким образом мы можем быть уверены, что таймер будет готов выслушать нас и остановиться по types.stopped.

Что же, на этом пока все, весь код доступен в репо, а на рабочий экземпляр посмотреть (и сломать) можно на этой страничке.

nil commento load