ZarahioN Presents

Answering why

Redux-Saga

Почему саги? React + Redux-saga. [Pt. 1]

Учитывая, что ты заглянул в запись с таким названием, что такое React уже знаешь. Шутка, не зря сами создатели назвали библиотеку redux-saga, а не react-saga — многие принципы и способ работы базируется вокруг именно редакса, который, является достаточно свободной библиотекой. Однако, чтобы понять принципы работы достаточно знать только о транзакциях (action\effect).

Поэтому, надеюсь ты знаешь что такое Redux.
(Но ничего, даже если React и Redux вызывают только дрожь и страдания, о них тоже будет понятно и доходчиво, как-нибудь в будущем)

Но что же такое (redux-)saga и зачем она нужна? Это, как говорят сами разработчики, попытка упростить вызов изменений в приложении использующем redux (или подобную ему модель в общем случае). Вместе с упрощением приходит и удобство тестирования, лучшая обработка ошибок и в целом более эффективная работа всего приложения.

И вместе с тем дополнительные проблемы для разработчиков, ибо саги хотят генераторы:

function* fetchQuote(action) {
    try {
        const quote = yield call(apiCall, action.data);
        yield put({type: "QuoteFetchSucceeded@Saga", quote: quote});
    } catch (e) {
        yield put({type: "QuoteFetchFailed@Saga", message: e.message});
    }
}

В глаза сразу бросается вопрос — «за что?!».

В глаза сразу бросаются пару моментов: — function* и yield. Впрочем, все достаточно тихо и миро — * просто указывает что это «генератор»(конструктор) генераторов — специальных конструкций (типо функций), которые могут возвращать несколько результатов на протяжении своей жизни, сохраняя свое состояние в месте возврата последнего значения.
(Или как-то так)

К примеру такой простой счетчик:

function* counter(){
    let i = 1;
    while(true)
        yield i++;
}
let count = counter();
var a = setInterval(() => console.log(count.next()), 500)

Как можно заметить, счетчик работает, при том, что у нас нет глобальной переменной с его значением (при желании код вызова генератора можно обернуть в замыкание, closure, избавляясь от инициализрованного экземпляра генератора, или даже полностью переделать через замыкание без генераторов, но это не суть в данном примере). Также можно на практике убедиться, что функция со * это не сам генератор, а «конструктор» — для работы нам нужна конкретная сущность отдельно взятого генератора.

Впрочем, мы не будем углубляться в суть генераторов, подметив лишь интересный для нас момент — конструкция, (объект) созданный генератором, позволяет нам получать промежуточные результаты выполнения, возвращаемые через подобное return-у ключевое слову yield, сохраняя свое внутреннее состояние на протяжении этого процесса. При этом повторять этот процесс можно сколь угодно долго — как видно на примере счетчика, он будет идти бесконечно поскольку у нас нет завершающего условия.

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

Что же, после этого короткого посвящения в ужасы прелести генераторов можно вернуться к сагам:

function* fetchQuote(action) {
    try {
        const quote = yield call(apiCall, action.data);
        yield put({type: "QuoteFetchSucceeded@Saga", quote: quote});
    } catch (e) {
        yield put({type: "QuoteFetchFailed@Saga", message: e.message});
    }
}

Первая остановка — yield call() — мы хотим сделать долгий запрос — отправляем функцию на исполнение, а, когда придет результат, продолжаем обработку, за счет возврат результата в quote (в генератор можно отправить что-то, вызвав .next(variable), значение при этом возвращается из yield на котором остановился генератор).

Таким образом мы приостанавливаем работу генератора пока идет долгий запрос, а когда он наконец завершается, saga (библиотека) доброжелательно возвращает нам результат выполнения запроса.

Call — просто вспомогательная функция от библиотеки, которая выполняет переданную в нее функцию сразу же возвращая нам простой объект (Эффект — Effect), что может быть важно, к примеру, при юнит-тестах. Никто не мешает просто напрямую сделать запрос.

Следующая остановка — yield put({type: "QuoteFetchSucceeded@Saga", quote: quote});

А вот это уже похоже на отправку Redux действия (dispatch(action)). Функция put указывает saga что нужно создать действие для редакса — вызвать dispatch с переданным объектом. Ожидаемо действие проходит, передавая с собой «цитату»:

{
    "type(pin)": "QuoteFetchFailed@Saga",
    "quote(pin)": "UnAPI-ed quote text, cause Yoda text converter we wanted to use got bonked and refused to respond with anything other than 503"
}

К сожалению, мы протестировали наш try-catch блок, к счастью — он работает.

Как только мы подходим к put в saga, мы переходим в царство редакса — а на его тему нужны совсем другие уроки. Впрочем, остался еще один «непростой» момент в работе с сагами — выбор селектора.

function* mySaga() {
    yield takeLatest("QuoteFetchRequest@Saga", fetchQuote);
    // vs
    yield takeEvery("QuoteFetchRequest@Saga", fetchQuote);
}

export default mySaga;

Есть два стандартных варианта: takeLatest и takeEvery. Их сигнатуры совпадают (ActionTypeHandler, WorkerGenerator), первое — название действия, которое ты будешь создавать из редакса, второе — генератор, который мы недавно разбирали — функция, которая будет последовательно обрабатывать это действие.

Согласно названиям они имеют важное отличие — takeLatest обработает (возьмет) только результат последнего отправленного запроса, takeEvery — все результаты. Это бывает полезно, когда пользователи спамят кнопки, а тебе слишком лень блокировать кнопку на время запроса.

…И присоединение этого селектора к redux store:

import mySaga from './saga'
// 1.
const sagaMiddleware = createSagaMiddleware()

let composer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
    reducer,
    composer(
        // 2.
        applyMiddleware(
            sagaMiddleware
        )
    ),
)
// 3.
sagaMiddleware.run(mySaga)

Что же, пора приступить к решающим вопросам: почему saga и почему не saga?

Почему саги

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

Однако, в «правильном» виде требуется отдельный файл под саги, отдельный «рабочий» — обработчик и создатель событий — это опять же увеличивается развернутость и многословность кода; однако, сам редакс грешит этим в такой мере, что добавление еще одного мушкетера к многословной троице скорее всего не станет проблемой, при этом:

Сага решает проблему сложных действий внутри компонента:

handleButtonClick(){
    let data = this.state.variable;
    request.post(API_URL, data)
    //+ установить в состояние что идет работа и возможно произвести другие вызовы к интерфейсу
    .then(response => this.props.responseRecieved(response))
    .catch(errorHandler)
}
connect(
    ...,
    ..responseRecieved(data) => dispatch({type: RESP_TYPE, data})
)()
@render
<button onClick={handleButtonClick}>Press me!</button>

// или снося вес в действие
connect(
    ...,
    ..requestStarted(data) => (dispatch) => {
        apiCall(data)
        .then(response => dispatch({type: REQ_TYPE, data}))
        .catch(errorHandler)
    }
)
@render
<button
    onClick={
        () => this.props.requestStarted(this.state.variable)
    }
>Press me!</button>

После скромного опыта с сагами мне как-то.. некомфортно было это писать, мне даже кажется что с thunk и запросами в компоненте это делается по-другому…

Альтернатива с использованием саги:

@render
<button
    onClick={() => this.props.requestStarted(this.state.variable)}
>Press me!</button>

connect(
    ...,
    ..requestStarted(data) => dispatch({type: REQ_TYPE, data})
)

@saga
function* requestData(action) {
    try {
        const result = yield call(apiCall, action.data);
        yield put({type: RESP_TYPE, quote: quote});
    } catch (e) { errorHandler(e) }
}

function* reqSaga() {
    yield takeLatest(REQ_TYPE, requestData);
}

Почему не саги

  • Генераторы это «страшно»
  • Многословность и лишний файл, от этого никак практически не избавиться.
  • Это лишняя библиотека.

Буду ждать больше причин за и против в комментариях, ну а я, если в будущем меня столкнет с Redux, точно возьму с собой и Saga.

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

Исходный код полностью доступен в репо, история коммитов представляет порядок создания приложения вместе с написанием статеек.

Отправка параметров и несколько одновременных запросов. React + Redux-saga. [Pt. 2]

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

Но это скучно.

Ведь мы все хотим совершать десятки запросов по несколько мегабайт каждый. Приступим.

function* fetchQuote(action) {
    try {
        const quote = yield call(requestYodified, action.text);
        const spellcheckedQuote = yield call(spellcheck, quote.data);

        yield put({type: success, quote: quote.data, spellcheckData: spellcheckedQuote.data});
    } catch (e) {
        yield put({type: fail, message: e.message});
    }
}

Как можно заметить, изменения по сравнению с прошлой версией огромны.

Поставив шутки в угол, кроме небольшого переименования переменных, добавлена всего одна строка. При этом мы вернулись к call и даже передали оба раза второй аргумент — переменную, которую мы хоть отдать функции совершающей запрос. Если мы захотим добавить больше данных — передаем больше переменных или оборачиваем все в объект — к чему душа больше лежит.

К сожалению апи от Йоды опять полетел именно в тот момент, когда я попытался проверить работу последовательных запросов.

Ненадежен, Йода, ты же как…

Поэтому почему бы не расширить немного обработку ошибок — к примеру, чтобы совсем не разочаровывать пользователя сообщением о ошибке, мы проверим как у него с орфографией!

...
catch (e) {
    try {
        const spellcheckedQuote = yield call(spellcheck, action.text);
        yield put({
            type: spellchecked, results: spellcheckedQuote.data
        });
    }
    finally {
        yield put({type: fail, message: e.message});
    }
}

Таким образом мы можем, к примеру, вывести результат выполнения части запросов, если это более выгодно по сравнению с полной остановкой из-за ошибки где-то на промежуточном этапе работы. А за время работы над «неудачным» сценарием, Йода поднялся и приготовился к работе — можно отдать ему какой-нибудь текст для перевода в человеческий — к примеру, заголовок нашего приложения «Welcome to React sagas!», спустя несколько долгих секунд раздумий Йода наконец отвечает «To react sagas welcome! Herh herh herh.». Вот и замечательно.

    {
    type: 'QuoteFetchRequest@Front',
    text: 'Welcome to React sagas!'
    }

    {
    type: 'QuoteFetchSucceeded@Saga',
    quote: 'To react sagas welcome! Herh herh herh.',
    spellcheckData: {
        original: 'To react sagas welcome! Herh herh herh.',
        suggestion: 'To react sagas welcome Hersh her her.',
        corrections: {
        Herh: [
        'Hersh', ...
        ],
        herh: [
        'her.', ...
        ] } }
    }

С последовательными запросами мы разобрались — добавить еще десяток-другой в цепочку, чтобы заставить пользователя ждать минутами, дело техники и сокровенной комбинации ctrl+c -> ctrl+v. Осталось научиться делать одновременно много, как некогда почивший салат.

function* fetchQuoteWhilSpellchecking(action) {
    try {
        const [quote, spellcheckedText] = yield all([
            call(requestYodified, action.text),
            call(spellcheck, action.text)
        ]);

        yield put({type: success, quote: quote.data, spellcheckData: spellcheckedText.data});
    } catch (e) {
        yield put({type: fail, message: e.message});
    }
}

Как вновь видно — изменений очень много.

Функция all — очередной вспомогательный элемент saga, позволяет создать несколько запросов и, дождавшись выполнения каждого, продолжить выполнение саги с полученными значениями. Запись вида const [a, b, c] = [...] — альтернатива деструктуризации для массивов, ожидаемо переводит массив вида [3, 2, 4] в три переменные — a = 3, ...

Наяривание до десятка запросов опять же происходит старым, добрым и немого уставшим ctrl+c -> ctrl+v. Но помни, пользователь очень любит ждать и обладает наилучшим железом, чем больше количество и чем толще запросы выше качество запросов — тем лучше.

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

В следующем выпуске — Саги: интересные части. Эффекты, отложенное исполнение, комбинирование и предсказуемое ожидание действий.

Код опять же доступен в репо, помимо полного кода, там появился «индикатор» загрузки.

Эффекты и саги. React + Redux-saga. [Pt. 3]

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

call, put и многие другие являются фабриками Эффектов (Effect) — специальных объектов, напоминающих действия (action) из redux, только предназначенные для самой библиотеки saga.

Почему эффекты? Я бы сказал та же причина, что и у самого редакса — простота, плоскость и тестируемость — за счет мгновенного возвращения простого объекта эффекты позволяют тестировать только код генератора, без необходимости ожидать выполнения запросов или их подмены.

Как я уже упомянул, эффекты сильно напоминают собой действия:

{
  CALL: {
    fn: log,
    args: [42],
    context: null
  }
}

Так, к примеру, выглядит возврат из call (немного обрезанный, но тем не менее). Как видно «тип» эффекта прописан ключом объекта.

Впрочем к чему весь этот разговор — эффекты, вспомогательные функции или фабрики, какая разница?

Знакомьтесь, наша новая фабрика take:

function* typing(action) {
    try {
        yield put({type: typingStart});
        yield take(typingStopped);
        yield put({type: typingEnd});
    } catch (e) {
        yield put({type: "Welp, we did it"});
    }
}

После написания этого примера, я начинаю понимать, насколько

В любом случае, фабрика («вспомогательная функция» мне нравилось больше) take позволяет дождаться действия редакса изнутри обработчика. Применения? Бесконечны! О чем и говорит мой креативный пример.

Как? Мы же в мире генераторов. Исходный код я, конечно же, не смотрел, но предполагаю что saga перенаправляет вызов .next нашего обработчика-генератора на появление указанного действия и «БАМ!» совершенно магическим образом мы получаем это действие внутри обработчика как только оно появляется благодаря dispatch-у.

На самом деле это очень удобный инструмент для поддержания нашей иллюзии синхронности — мы можем взять любое действие — то же начало ввода пользователем — сделать что-то — изменить интерфейс, к примеру — и продолжить выполнение саги уже после другого действия. Более привычный подход заключается в разделении логики на два обработчика — вход в состояние по первому действию и выход из него по второму (более сложная десятистраничная логика приветствуется с распростертыми объятиями).

Однако, текущий вариант не слишком отличается от классического подхода и достаточно бесполезен в чистом виде, но это не конец. saga позволяет помимо прочего, использовать элементы «многопоточного» программирования — fork или race, помимо некоторых других.

Мы даже уже использовали all, который позволяет дождаться выполнения всех эффектов (обещаний) и получить все результаты в один массив. В saga есть его близкий родственник — race. Они похожи, только вот race ждет завершения любого из эффектов и возвращает объект с именованным результатом:

function* typing(action) {
try {
    while (true){
        yield take(textUpdated);
        yield put({type: typingStarted});
        while (true){
            const {updated, timeouted} = yield race({
                updated: take(textUpdated),
                timeouted: call(delay, 1000),
            })
            if (timeouted) {
                yield put({type: typingEnded});
                break;
            }
        }
    }
} catch (e) {
    yield put({type: "Welp, we did it"});
}
}

Как ни пугающе, но у нас теперь два бесконечных цикла (и скорее всего способ переписать это в более презентабельном виде).

Первый цикл нужен для прослушивания действия textUpdated. Как только мы его получили, можно вступить во второй цикл, перед которым мы отправляем в редакс сообщение, что пользователь начал печатать. Внутри мы создаем условие гонки — или пользователь продолжает печатать — updated— и мы ничего не делая перезапуская внутренний цикл, или проходит 1 секунда — delay(1000[ms]) — и мы отправляем сообщение, что пользователь перестал печатать и выходим во внешний цикл прослушивания.

Почему это лучше?

Несколько причин:

  1. Нам больше не нужно такое в компоненте:
start="1.">
clearTimeout(this.typingTimeout);
this.typingTimeout = setTimeout(() => typingStopped(), 1000);

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

  1. Код отвечающий за состояние печатания выглядит проще (за исключением двух циклов while(true) которые все же немного портят картину) и находится в одном единственном месте.
  2. Нам не нужна еще одна библиотека чтобы сделать задержку или отложенное действие. К примеру, если использовать all вместо race то в результате у нас получится массив со всеми собранными за секунду действиями — очень напоминает буфер из RxJS и подобных. Комбинируя несколько простых эффектов можно получить неплохой набор часто используемых инструментов.

start="2.">

Почему нет?

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

На этом, я думаю, наш небольшой цикл уроков по redux-saga завершается. Осталось пару фабрик и вспомогательных функций, которые мы не разобрали, вроде fork, join или cancel, однако они имеют схожее поведение и очень похожи на свои аналоги из мира многопоточного программирования. Если нужда в их разборе появится, то мы несомненно этим займемся.

Опять же — код доступен в репоа документация по сагам на их сайте.

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.

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