React Junior
207 subscribers
37 photos
462 links
Изучение React с нуля
加入频道
Redux-saga. Блокирующие и неблокирующие эффекты. Call vs Fork

В redux-saga есть прекрасный эффект takeEvery, который позволяет подписаться на каждый вызов события.

Логика у него довольно простая, попробуем реализовать его своими силами с помощью уже знакомых простых эффектов take и call:


function* worker() {
yield delay(1000);
yield console.log('click');
}

function* watcher() {
while (true) {
yield take('CLICK');
yield call(worker);
}
}


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

Но этот код будет работать не так, как предполагается. Догадались, почему?

Эффект call является блокирующим. Это значит, что когда он запускается, функция-генератор приостанавливает свою работу, ждет, когда он выполнится, и только потом возобновляет свое выполнение.

- произошло событие CLICK
- запустилась сага worker
- выполнение worker занимает некоторое время, так как там тоже есть блокирующий эффект delay
- когда worker выполнился (минимум через 1 секунду), продолжается выполнение watcher - цикл идет на следующий круг

Если событие CLICK поступит во время выполнения саги worker, его просто никто не заметит. То есть логика эффекта takeEvery не повторяется.

Чтобы это починить, нужно заменить блокирующий эффект call на неблокирующий fork. Он создаст отдельную "ветку" для новой саги, а выполнение родительской саги сразу же продолжится.


function* watcher() {
while (true) {
yield fork('CLICK');
yield call(worker);
}
}


Демонстрация разницы: https://codesandbox.io/s/redux-saga-call-vs-fork-react-junior-8yhf4l?file=/src/app/saga.js

Важно понять, что с неблокирующими эффектами мы не можем использовать, например, блок try-catch, так как выполнение неблокирующей саги происходит вне контекста родительской саги - в "конкурентном" режиме.

#redux #управлениесостоянием #примерыкода #saga
👍4
Redux-saga. Отмена эффекта

Теперь попробуем повторить поведение встроенного эффекта takeLatest. Помимо того, что нам нужно отслеживать событие и запускать для него воркер, необходимо еще останавливать ранее запущенные воркеры. Для этого есть эффект cancel.

Помним, что все функции эффектов возвращают простой объект эффекта. Именно этот объект и нужно передать в cancel, чтобы отменить эффект.


let unique = 1;

export function* clickWorker(counter) {
yield delay(1000);
yield console.log("click", counter);
}

export function* clickWatcher() {
let lastEffect;

while (true) {
yield take("CLICK");
if (lastEffect) {
yield cancel(lastEffect);
}
lastEffect = yield fork(clickWorker, unique1++);
}
}


Демо здесь: https://codesandbox.io/s/redux-saga-cancel-react-junior-hlx6hh?file=/src/app/saga.js

#redux #управлениесостоянием #примерыкода #saga
👍3
Redux-saga. Отмена эффекта (продолжение)

Мы научились запускать неблокирующий эффект fork и затем при необходимости отменять его с помощью cancel. Но что если до отмены наша форкнутая сага успела что-то сделать, например, отправила запрос на сервер. При отмене саги было бы хорошо отменить и этот запрос.

Пример из документации - фоновое сохранение данных, например, текста поста в админке вашего сайта. При событии START_BACKGROUND_SYNC запускается процесс синхронизации - запрос отправляется раз в какое-то время. При событии STOP_BACKGROUND_SYNC нужно этот процесс остановить, а также отменить последний отправленный запрос.

Для этого в форкнутой саге нужно использовать эффект cancelled + try-finally.


function* bgSync() {
try {
while (true) {
// получаем нужные данные/отправляем нужные запросы
// повторяем каждые 5 секунд
yield delay(5000)
}
} finally {
// блок сработает, если сага была отменена
if (yield cancelled())
// логика отмены
}
}

function* main() {
// ждем события начала синхронизации
while ( yield take('START_BACKGROUND_SYNC') ) {

// форкаем сагу с логикой синхронизации
const bgSyncTask = yield fork(bgSync)

// ждем события конца синхронизации
yield take('STOP_BACKGROUND_SYNC')

// отменяем синхронизацию
yield cancel(bgSyncTask)
}
}


Эффект cancel останавливает работу генератора, поэтому он перепрыгивает сразу в блок finally. Тут мы и проверяем, вызвано ли завершение работы тем, что произошла отмена.

#redux #управлениесостоянием #примерыкода #saga
🔥3
Redux-saga. Прикрепление саг к родителю

Наконец-то дошли до архитектуры - посмотрим, зачем нам нужно такое дерево саг.

Мы уже знакомы с эффектом fork, который позволяет запускать новую ветку саг, не блокируя выполнение родителя. А есть еще spawn. В чем между ними разница?

- fork (от слова 'вилка') создает новую ветку, которая "прикреплена" (attached) к родителю.
- spawn (от слова 'порождать') создает новую "отделенную" (detached) ветку.

То есть разница заключается в наличии связи с "родительской" сагой, которая создала новую ветку.

- Сага является завершенной, если завершено ее собственное тело, а также все прикрепленные к ней ветки.
- Если в прикрепленной ветке возникает ошибка, то она всплывает вверх - к родительской саге (и дальше, если ее не обработать).
- При отмене саги с помощью эффекта cancel, отменяются также все прикрепленные к ней ветки.

Важно: обработка ошибки в блоке try-catch возможна только для блокирующих вызовов (эффект call`). Отдельные ветки (`fork`, `spawn`) выполняются асинхронно, поэтому с ними такое не сработает.

Ветки, созданные с помощью spawn, не имеют никакой связи со своим родителем и похожи больше на отдельные "корневые" саги - таким образом создаются новые ветки в эффекте takeEvery.

#redux #управлениесостоянием #saga
2🔥1
Redux-saga. Композиция саг

👉 All

Чтобы запустить несколько саг параллельно, используем эффект all:


const results = yield all([call(task1), call(task2), ...]);
yield put(showResults(results));


Идея такая же, как в Promise.all - дожидаемся, пока все саги в массиве выполнятся, и продолжаем исполнять код дальше.

👉 Race

Есть и аналог Promise.race - эффект race, но с немного другим синтаксисом:


const {posts, timeout} = yield race({
posts: call(fetchApi, '/posts'),
timeout: delay(1000)
})

if (posts)
yield put({type: 'POSTS_RECEIVED', posts})
else
yield put({type: 'TIMEOUT_ERROR'})


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

Удобно сочетать race с эффектом cancel, чтобы отменять "проигравшие" гонку саги.

Не следует использовать эффект fork внутри race, так как он неблокирующий и поэтому всегда будет "выполняться" первым.

#redux #управлениесостоянием #примерыкода #saga
🔥1
Redux-saga. Корневая сага

Еще пару слов об организации корневой саги с учетом новых знаний про блокирующие/неблокирующие эффекты, а также про attached/detached ветки.

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

🔹Параллельный запуск с all


function* rootSaga() {
yield all([
saga1(),
saga1()
])
}


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

🔸Неблокирующие вызовы с fork


yield fork(saga1)
yield fork(saga2)
yield fork(saga3)


🔹All + fork

Чтобы решить проблему блокирования корневой саги эффектом all, можно сочетать его с fork, чтобы вызовы всех саг были неблокирующими:


yield all([ fork(saga1), fork(saga2), fork(saga3) ])


Так все саги запустятся параллельно, но при этом all не будет ждать, пока все они выполнятся.

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

🔸Отделенные ветки со spawn

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


yield spawn(saga1)
yield spawn(saga2)
yield spawn(saga3)


🔹Перезапуск при падении

И еще один примерчик на сладкое - возможность перезапуска саги, если она не завелась:


const sagas = [
saga1,
saga2,
saga3,
];

yield all(sagas.map(saga =>
spawn(function* () {
while (true) {
try {
yield call(saga)
break
} catch (e) {
console.log(e)
}
}
}))
);


Ветки создаются с помощью spawn, чтобы не влиять на родителя и не блокировать его выполнение.
Каждая дочерняя сага запускается с помощью блокирующего эффекта call, поэтому мы можем использовать блок try-catch для обработки ошибок. Если сага удачно запустилась, то на этом все и заканчивается, если же нет, то происходит новый виток бесконечного цикла while(true) и выполняется новая попытка.

В целом все эти примеры актуальны не только для корневой саги, но и для любых других.

#redux #управлениесостоянием #примерыкода #saga
👍2
Redux-saga. ActionChannel

Эффект takeEvery позволяют ловить и обрабатывать каждый экшен - все обработчики выполняются параллельно.

take + fork делает то же самое, а take + call позволяет игнорировать новые события, пока не будет обработано предыдущее.

А что делать, если мы не хотим пропускать события, но при этом необходимо обрабатывать их последовательно, а не параллельно?

Для этого в саге есть эффект actionChannel - канал экшенов. Нужно создать канал, а потом подписаться на него (а не на сам экшен). Канал будет буферизировать поступающие экшены, пока не обработан предыдущий.


function* watchRequests() {
// создаем канал
const requestChan = yield actionChannel('REQUEST')

while (true) {
// подписываемся на него
const {payload} = yield take(requestChan)

// обрабатываем экшен
yield call(handleRequest, payload)
}
}


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

#redux #управлениесостоянием #примерыкода #saga
👍2
Redux-saga. EventChannel

ActionChannel из прошлого поста предназначен для работы с redux-стором и обычными экшенами - он просто особым образом их обрабатывает.

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

Создается такой канал с помощью функции eventChannel:


import { eventChannel, END } from 'redux-saga'

function createCountdownChannel(seconds) {
const channel = eventChannel(function(emit) {
const interval = setInterval(function() {
seconds--;

if (seconds > 0) emit(seconds);
else emit(END);
}, 1000);

return function() {
clearInterval(interval);
}
})

return channel;
}


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

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

Чтобы закрыть канал (если события перестали поступать, нужно использовать константу `END`).

Функция-подписчик должна вернуть метод для сброса эффектов (как в хуке useEffect в React).

Использование канала событий:


export function* saga() {
const channel = yield call(createCountdownChannel, 10)

try {
while (true) {
let seconds = yield take(channel)
console.log(`countdown: ${seconds}`)
}
} finally {
console.log('countdown terminated')
}
}


Канал можно закрыть и извне, вызвав его метод close:


channel.close()


#redux #управлениесостоянием #примерыкода #saga
👍2
Redux-saga. Простые каналы

У нас уже были каналы для экшенов из стора (ActionChannel), каналы для событий из внешних источников (EventChannel).

И в дополнение ко всему этому есть самые простые каналы - Channel. Их отличие в том, что изначально они ни на что не подписаны, ни на стор, ни на внешний источник событий. Это совершенно пустой канал, в котором нет ни одного события.

Одна сага может его создать, другая сага может на него подписаться. Пушить события в него нужно вручную.

Зачем все это нужно?

Документация предлагает нам следующий кейс: с текущими инструментами мы можем либо обрабатывать экшены последовательно, не начиная новый, пока не закончится предыдущий (`take`), или же параллельно все (`takeEvery`). Но не можем, например, обрабатывать параллельно только 3 экшена. И пока не закончится обработка хотя бы одного из нех, не начинать следующий.

Вот для этого нам и нужны каналы.


import { channel } from 'redux-saga'

function* watcher() {
const chan = yield call(channel); // создаем канал

// подписываемся на этот канал три раза
yield fork(handleRequest, chan);
yield fork(handleRequest, chan);
yield fork(handleRequest, chan);

while (true) {
// ловим экшен REQUEST и кладем в канал
const {payload} = yield take('REQUEST')
yield put(chan, payload)
}
}

function* handler(chan) {
while (true) {
// ловим событие из канала
const payload = yield take(chan)
...
}
}


Что тут происходит?

Мы создаем канал, создаем три слушателя для этого канала. Затем как обычно ловим нужный экшен и просто кладем его в канал.
При попадании экшена в канал, его получит первый свободный слушатель (если свободны несколько, получит все равно только один). Если все слушатели уже заняты, событие останется висеть в канале до тех пор, пока кто-то не освободится.

#redux #управлениесостоянием #примерыкода #saga
👍2
Redux-saga. Multicast-каналы

И наконец в дополнение ко всем уже разобранным каналам у нас есть multicast-каналы. Они работают так же, как и обычные каналы (channel), только уведомление о поступившем событии получают ВСЕ подписчики.

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


import { multicastChannel } from 'redux-saga'

function* watchRequests() {
const channel = yield call(multicastChannel)

// подписываем разные обработчики
yield fork(handler1, channel)
yield fork(handler2, channel)

while (true) {
const { payload } = yield take('REQUEST')
yield put(channel, payload)
}
}


#redux #управлениесостоянием #примерыкода #saga
👍2
Redux-saga. Кастомный I/O

Обычно мы подключаем саги к стору приложения с помощью миддлвара. Эффекты take и put связываются со стором и слушают его/диспатчат в него новые экшены.

Но можно запустить сагу отдельно от стора, если это необходимо.

Для этого нам нужно создать кастомный "стор" с полями
- channel,
- dispatch
- и getState.

То есть у этого стора должен быть канал, в который будут поступать события - его создаем с помощью функции stdChannel. Этот канал нужно соединить с внешним I/O.

Для запуска вызываем функцию runSaga и передаем ей новый стор и собственно сагу.


import { runSaga, stdChannel } from 'redux-saga'

const emitter = new EventEmitter()
const channel = stdChannel()
emitter.on("action", channel.put)

const myIO = {
channel,
dispatch(output) {
emitter.emit("action", output)
},
getState() {
return state
}
}

runSaga(
myIO,
function* saga() { ... },
)


#redux #управлениесостоянием #примерыкода #saga
👍2
Redux-saga. Рецепты

На страничке Recipes https://redux-saga.js.org/docs/recipes в документации redux-saga есть несколько полезных сниппетов:

- throttling
С использованием встроенного эффекта throttle.

- debouncing
Сохраняем активный воркер и отменяем его (`cancel`), если пришло новое событие, либо используем встроенный эффект takeLatest.

- повторное выполнение неудачных XHR-запросов
С использованием встроенного эффекта retry или комбинацией базовых эффектов call, take, delay и блока try-catch.

- функционал Отменить последнее действие
С помощью spawn и race.

- batching нескольких экшенов
С использованием библиотеки redux-batched-actions.

#redux #управлениесостоянием #документация #saga
👍3
Redux ToolKit: краткий конспект

Собираюсь повторить RTK Query (а затем разобраться с React Query). Поэтому краткая выжимка по Redux Toolkit:

1. Мыслим слайсами

Функция createSlice создает сразу и action creators, и редьюсер, и даже набор селекторов, если мы работаем с EntityAdapter.

2. Асинхронщина

Вся асинхронщина через createAsyncThunk + extraReducers.

3. Селекторы

Селекторы напрямую в RTK не входят, для них используем reselect.

4. Связь с компонентами

В компонентах используем классические хуки useSelector и useDispatch.

5. Создание хранилища

Стор создается с помощью configureStore.

***

В целом это все, в основном удобный синтаксический сахар, ничего сверхъестественного поверх базового Redux нет.

#управлениесостоянием #redux
👍5
React Query vs RTK Query: всестороннее сравнение

Статья (англ.): https://www.frontendmag.com/insights/react-query-vs-rtk-query/

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

Сходство

🔹Оба инструмента используют лучшие performance-практики работы с удаленными данными:

- кеширование запросов
- предотвращение дублирующихся запросов
- повторные запросы в случае ошибок

🔹Оба инструмента предлагают решения для самых распространненных задач получения данных:

- пагинация - обновление списка данных
- оптимистические обновления

Отличие

Отличие заключается в архитектуре инструментов.

🔸React Query - это независимая библиотека. Ее функциональность реализована в виде хуков, то есть привязана к жизненному циклу React-компонентов.

🔸RTK Query - это инструмент, созданный поверх Redux Toolkit. Он работает в парадигме Redux: через редьюсеры и экшены, взаимодействует с глобальным стейтом приложения.

Кривая обучения

Автор считает, что и React Query, и RTK Query интуитивно понятны. Это довольно спорное утверждение, так как чтобы понять и то, и другое, требуется немного отформатировать свое понимание работы с данными.

Что касается RTK Query, тут вообще требуется сначала разобраться с Redux Toolkit - а это дело непростое.

Когда что использовать (привет, кэп)

RTK Query лучше использовать, если:

- В приложении уже используется Redux и Redux Toolkit. Тогда RTK Query доступна из коробки и уже интегрирована с прочими инструментами.
- Требуется сложная логика управления данными. RTK Query - это более мощный инструмент и на нем можно построить более сложную систему.

React Query лучше использовать, если:

- В приложении не используется глобальное состояние или запросы данных с ним не взаимодействуют. React Query не предоставляет никаких способов создания глобального стейта или взаимодействия с ним, все только внутри компонента.
- Требуется простая логика обработки данных.
- Не хочется долго разбираться.
- Не хочется тянуть зависимости в виде Redux Toolkit.

#управлениесостоянием #redux
👍1