Почему саги? 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.

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

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

nil commento load