Русский
Русский
English
Статистика
Реклама

Redux-saga

Из песочницы Модульное и интеграционное тестирование в Redux Saga на примерах

29.07.2020 18:17:33 | Автор: admin

hero image


Redux чрезвычайно полезная библиотека, которая облегчает управление состоянием приложения. Среди многих дополнений, Redux-Saga подходит мне лучше всего. В проекте на React-Native, над которым я сейчас работаю, мне приходилось сталкиваться с множеством побочных эффектов. Они приносили бы мне головные боли в случае, если я поместил их в компоненты. С помощью этого инструмента создание сложных логических потоков с разветвлениями становится простой задачей. Но как насчет тестирования? Так же это просто, как и использование библиотеки? Хотя я не могу дать вам точный ответ, я покажу вам реальный пример проблем, с которыми я столкнулся.


Если вы не знакомы с тестированием саг, я рекомендую прочитать отдельную страницу в документации. В следующих примерах я использую redux-saga-test-plan, поскольку эта библиотека дает полную силу интеграционного тестирования наряду с модульным тестированием.


Немного о модульном тестировании


Модульное тестирование это не что иное, как тестирование небольшого фрагмента вашей системы, обычно функции, которая должна быть изолирована от других функций и, что более важно, от API.


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

// Только самые важные импортыimport {call, put, take} from "redux-saga/effects";export function* initApp() {    // Инициализация хранилища и     // получение последней сохраненной сессии    yield put(initializeStorage());    yield take(STORAGE_SYNC.STORAGE_INITIALIZED);    yield put(loadSession());    let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);    // Загрузка последнего проекта    if (session) {        yield call(loadProject, { projectId: session.lastLoadedProjectId });    } else {        logger.info({message: "No session available"});    }}

// Только самые важные импортыimport {testSaga} from "redux-saga-test-plan";it("должно загрузить последнюю сессию и вызвать `loadProject`", () => {    const projectId = 1;    const mockSession = {        lastLoadedProjectId: projectId    };    testSaga(initApp)        // `next` служит командой перехода на следующую конструкцию `yield`        // при этом мы можем передать аргумент,        // который будет выходным результатом действующего `yield`        // Фактически он просто запускает новую итерацию         //(не забываем что функции-генераторы возвращают итератор)        .next()        .put(initializeStorage())        .next()        .take(STORAGE_SYNC.STORAGE_INITIALIZED)        .next()        .put(loadSession())        .next()        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)        // Сохранение метки, чтобы иметь возможность вернуться        .save("до развилки")        // Передаем объект, который будет возвращен из `yield take...`        .next({session: mockSession})        .call(loadProject, {projectId})        .next()        .isDone()        // Возвращаемся назад к метке        .restore("до развилки")        // Проверяем, что функция сразу завершится,        // если последняя сессия будет отсутствовать        .next({})        .isDone();});

Из примера вы можете увидеть обычный способ проверки генераторов эффектов. Если бы требовалось заменить вызовы каких-то API, я бы сделал это, используя jest.fn.


Так как мы завершили, давайте перейдем к основному блюду!


Интеграционное тестирование


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


Тестирование дерева саг


Давайте рассмотрим следующий пример, который является адаптированной версией кода из моего проекта:


// Только самые важные импортыimport {call, fork, put, take, takeLatest, select} from "redux-saga/effects";// Корневая сагаexport default function* sessionWatcher() {    yield fork(initApp);    yield takeLatest(SESSION_SYNC.SESSION_LOAD_PROJECT, loadProject);}export function* initApp() {    // Инициализация хранилища и получение последней сохраненной сессии    yield put(initializeStorage());    yield take(STORAGE_SYNC.STORAGE_INITIALIZED);    yield put(loadSession());    let { session } = yield take(STORAGE_SYNC.STORAGE_SESSION_LOADED);    // Загрузка последнего проекта    if (session) {        yield call(loadProject, { projectId: session.lastLoadedProjectId });    } else {        logger.info({message: "Нет доступной сессии"});    }}export function* loadProject({ projectId }) {    // Загрузка последнего проекта и последующая попытка его обработки    yield put(loadProjectIntoStorage(projectId));    const project = yield select(getProjectFromStorage);    // Сохранение проекта, сессии с ново загрузившимся проектом и загрузка карты    try {        yield put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project});        yield fork(saveSession, projectId);        yield put(loadMap());    } catch(error) {        yield put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error});    }}export function getProjectFromStorage(state) {    // Вытягивает загруженный проект из состояния системы}export function* saveSession(projectId) {    // .... Вызывает внешние API    yield call(console.log, "Вызов API...");}

Здесь у нас есть корневая сага sessionWatcher, которая инициализирует приложение, вызывая initApp сразу после загрузки, а также ожидает команды по загрузке проекта по id. Проект загружается из хранилища, после чего мы сохраняем проект в состоянии системы и вызываем другую сагу, которая сохраняет сеанс и загружает карту. В примере показаны всевозможные проблемы, с которыми мы можем столкнуться в процессе тестирования:


  • Работа с несколькими сагами
  • Доступ к состоянию
  • Вызовы API, которых мы хотели бы избежать.

// Только самые важные импортыimport { expectSaga } from "redux-saga-test-plan";import { select } from "redux-saga/effects";import * as matchers from "redux-saga-test-plan/matchers";it("должно инициализировать приложение и загрузить последний проект из предыдущей сессии", () => {    // Стадия подготовки    const projectId = 1;    const anotherProjectId = 2;    const mockedSession = {        lastLoadedProjectId: projectId,    };    const mockedProject = "project";    // Тестируем `sessionWatcher`    // `silentRun` в конце возвращает промис с некоторыми плюшками для тестирования    // подробнее можете почитать в официальной документации    return (        expectSaga(sessionWatcher)            // Подмена генераторов эффектов            .provide([                // Заменить каждый генератор `select` эффекта, который использует                // `getProjectFromStorage` и на его месте вернуть `mockedProject`                // при этом обращать внимание как на вызываемую функцию так и на аргументы,                // которые мы можем передать в `select`,                // в нашем случае мы не передаем никаких                // Пример использования стандартного генератора эффектов                // из Redux-Saga, как фильтра                [select(getProjectFromStorage), mockedProject],                // Заменить каждый генератор `fork` эффекта, который вызывает `saveSession`                 // и ничего не вернуть (undefined)                // при этом обращать внимание только на вызываемую функцию,                // игнорирую аргументы                // Пример использования фильтров из Redux Saga Test Plan                [matchers.fork.fn(saveSession)],            ])            // Порядок не имеет значения            // Мы упоминаем только те генераторы эффектов, которые мы хотим проверить            // Тестирование инициализации            .put(initializeStorage())            .take(STORAGE_SYNC.STORAGE_INITIALIZED)            // Генерация команды, которую в данный момент ожидает первый `take` в `initApp`            // Генераторы команд ДОЛЖН вызываться в нужном порядке            .dispatch({ type: STORAGE_SYNC.STORAGE_INITIALIZED })            .put(loadSession())            .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)            .dispatch({ type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession })            // Тест загрузки проекта, вызванную `initApp`            .put(loadProjectFromStorage(projectId))            .put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })            .fork(saveSession, projectId)            .put(loadMap())            // Генерируем команду, которая будет перехвачена `takeLatest` в `sessionWatcher`            // и опять протестировать загрузку проекта            // Тест загрузки проекта, вызванную `sessionWatcher`            .dispatch({ type: SESSION_SYNC.SESSION_LOAD_PROJECT, projectId: anotherProjectId })            .put(loadProjectFromStorage(anotherProjectId))            .put({ type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject })            .fork(saveSession, anotherProjectId)            .put(loadMap())            // Запустить тест, подавить ошибку связанную с истечением времени            .silentRun()    );});

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


Первая функция, которую мы видим, provide использует фильтры, чтобы найти генераторы эффектов и впоследствии заменить их. Первый кортеж (пара значений) использует генератор эффекта select из библиотеки Redux Saga и в точности соответствует эффекту, вызывающего getProjectFromStorage. Если мы хотим большей гибкости, мы можем использовать средства сопоставления, которые предоставляются библиотекой Redux Saga Test Plan. Пример этого мы можем увидеть во втором кортеже, где мы говорим, что фильтрация будет происходить по вызываемой функции saveSession, игнорируя ее аргументы. Этот механизм позволяет нам избежать использования состояния системы, подмены вызовов нежелательных функций или API.


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


Цепочка заканчивается функцией silentRun, которая выполняет три вещи: запускает наш тест, подавляет ошибку тайм-аута и возвращает промис с дополнительными данными для последующих тестов, в нашем случае мы игнорируем их и просто возвращаем промис из теста.


Тестирование обработки ошибок


Чтобы смоделировать возникновение ошибки, мы можем использовать уже знакомую функцию provide вспомогательную от redux-saga-test-plan/providers, чтобы заменить генератор эффекта выбросом ошибки ошибкой.


// Только самые важные импортыimport {expectSaga} from "redux-saga-test-plan";import {select} from "redux-saga/effects";import * as matchers from "redux-saga-test-plan/matchers";import * as providers from "redux-saga-test-plan/providers";it("должно инициализировать приложение и обработать ошибку загрузки проекта", () => {    const projectId = 1;    const mockedSession = {        lastLoadedProjectId: projectId    };    const mockedProject = "project";    const mockedError = new Error("Оууу, кажется что-то пошло не так!");    return expectSaga(sessionWatcher)        .provide([            [select(getProjectFromStorage), mockedProject],            // Заменяем генератор эффекта ошибкой            [matchers.fork.fn(saveSession), providers.throwError(mockedError)]        ])        // Тестирование инициализации        .put(initializeStorage())        .take(STORAGE_SYNC.STORAGE_INITIALIZED)        .dispatch({type: STORAGE_SYNC.STORAGE_INITIALIZED})        .put(loadSession())        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)        .dispatch({type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession})        // Тест загрузки проекта, вызванную `initApp`        .put(loadProjectFromStorage(projectId))        .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})        // Ожидаем возникновение ошибки здесь        .fork(saveSession, projectId)        // Тестируем, что ошибка была обработана        .put({type: SESSION_SYNC.SESSION_ERROR_WHILE_LOADING_PROJECT, error: mockedError})        .silentRun();});

Использование модификаторов состояния и самого состояния


А как же глобальное состояние приложения, как нам протестировать код, используя модификаторы (reducers). С redux-saga-test-plan это становится тривиальной задачей. Во-первых, нам нужно представить модификатор состояния:


const defaultState = {    loadedProject: null,};export function sessionReducers(state = defaultState, action) {    if (!SESSION_ASYNC[action.type]) {        return state;    }    const newState = copyObject(state);    switch(action.type) {        case SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC: {            newState.loadedProject = action.project;        }    }    return newState;}

Во-вторых, мы немного изменим наш тест, добавив withReducer, который позволяет нам использовать динамическое состояние (вы можете предоставить состояние без модификатора, вызвав withState). Также мы добавим тест hasFinalState, который сравнивает состояние с ожидаемым.


// Только самые важные импортыimport {expectSaga} from "redux-saga-test-plan";import {select} from "redux-saga/effects";import * as matchers from "redux-saga-test-plan/matchers";it("должно инициализировать приложение и загрузить последний проект из предыдущей сессии", () => {    const projectId = 1;    const mockedSession = {        lastLoadedProjectId: projectId    };    const mockedProject = "project";    const expectedState = {        loadedProject: mockedProject    };    return expectSaga(sessionWatcher)        // Вы можете подключить корневой модификатор, чтобы        // динамически изменять состояние либо указать его статически с помощью `withState`        .withReducer(sessionReducers)        .provide([            [select(getProjectFromStorage), mockedProject],            [matchers.fork.fn(saveSession)]        ])        // Тестирование инициализации        .put(initializeStorage())        .take(STORAGE_SYNC.STORAGE_INITIALIZED)        .dispatch({type: STORAGE_SYNC.STORAGE_INITIALIZED})        .put(loadSession())        .take(STORAGE_SYNC.STORAGE_SESSION_LOADED)        .dispatch({type: STORAGE_SYNC.STORAGE_SESSION_LOADED, session: mockedSession})        // Тест загрузки проекта, вызванную `initApp`        .put(loadProjectFromStorage(projectId))        // Теперь мы можем пропустить некоторые тесты, которые изменяют состояние,        // так как мы его тестируем в конце        // .put({type: SESSION_ASYNC.SESSION_PROJECT_LOADED_ASYNC, project: mockedProject})        .fork(saveSession, projectId)        .put(loadMap())        // Тестирование конечного состояния        .hasFinalState(expectedState)        .silentRun();});

Это была локализация моей статьи с Medium.


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

Подробнее..

Эпическая сага про маленький custom hook для React (генераторы, sagas, rxjs) часть 3

11.12.2020 20:11:43 | Автор: admin

Часть 1. Кастомный хук

Часть 2. Генераторы

Redux-saga

Это middleware для управления сайд эффектами при работе с redux. В основе лежит механизм генераторов. Т.е. код ставится на паузу пока не будет выполнена определенная операция с эффектом - это объект с определенным типом и данными.

Можно представить себе redux-saga (middleware) как администратора камер хранения. В камеры хранения можно класть эффекты на неопределенный срок и забирать их оттуда, когда будет нужно. Есть такой посыльный put, который приходит к диспетчеру и просит положить в камеру хранения сообщение (эффект). Есть такой посыльный take, который приходит к диспетчеру и просит ему выдать сообщение с определенным типом (эффект). Диспетчер, по просьбе take, смотрит все камеры хранения и если этих данных нет, то take остаётся с диспетчером и ждёт, пока put не принесёт данные с нужным для take типом. Существуют разные виды таких посыльных (takeEvery и т.д.).

Основная идея камер хранения - это "развести" во времени отправителя и получателя (некий аналог асинхронных обработки).

Redux-saga - это просто инструмент, а вот главным тут является тот, кто посылает всех этих посыльных и обрабатывает данные, которые они приносят. Этим "кто-то" является функция-генератор (назову её пассажир), которая в справке называется saga и передаётся при запуске middleware. Запустить middleware можно двумя способами: с помощью middleware.run(saga, ...args) и runSaga(options, saga, ...args). Saga - это функция-генератор с логикой обработки эффектов.

Меня заинтересовала возможность использования redux-saga для обработки внешних событий без redux. Рассмотрю метод runSaga(...) подробнее:

runSaga(options, saga, ...args)

saga - это метод, в котором будет выполняться логика;

args - аргументы, которые будут переданы в saga;

options - объект, который "настраивает" работу redux-saga. Для данного хука использую всего три настройки:

channel - канал, из которого будут поступать внешние события;

dispatch - это метод, который при возникновении события, должен послать redux-saga эффект с помощью put.

getState - функция, которая используется для выборки данных из state, с которым используется redux-saga. В случае с хуком это будет локальный state.

Вариант 6. Redux-saga как канал обработки внешних сообщений

Логика работы хука с saga будет такова. Создаётся канал channel (камеры хранения) для хранения эффектов в процессе работы redux-saga. Создаётся канал, в который будут поступать внешние события от изображений - eventsChannel. Это два разных канала! Вернусь к аналогии с камерами хранения.

Создаются камеры хранения (channel), которым потом будет назначен администратор (redux-saga)

const sagaChannelRef = useRef(stdChannel());

При запуске runSaga() redux-saga назначается администратором созданных камер хранения.

runSaga(  {    channel: sagaChannelRef.current,    dispatch: () => {},    getState: () => {},  },  saga);

Камеры хранения созданы (channel), администратор назначен (redux-saga) и далее этим всем начинает пользоваться пассажир (происходит запуск функции-генератора saga)

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

const eventsChannel = yield call(getImageLoadingSagas, imgArray);
function getImageLoadingSagas(imagesArray) {  return eventChannel((emit) => {    for (const img of imagesArray) {      const imageChecker = new Image();      imageChecker.addEventListener("load", () => {        emit(true);      });      imageChecker.addEventListener("error", () => {        emit(true);      });      imageChecker.src = img.url;    }    setTimeout(() => {      //закрытие канала по таймеру      emit(END);    }, 100000);    return () => {    };  }, buffers.expanding(10));}

Т.е. пассажир (функция-генератор saga) просит диспетчера (redux-saga) принимать сообщения не только от посыльного put, но и от другого источника (eventsChannel). Сообщения от этого источника (eventChannel) у диспетчера (redux-saga) будет забирать, специально выделенный для этого, посыльный take, который стоит рядом с диспетчером и ждёт от него сообщения.

yield take(eventsChannel);

Как только диспетчеру (redux-saga) приходит сообщение от eventChannel, он тут же отдает его take, который возвращает сообщение пассажиру (функции-генератору saga). Сам take остаётся рядом с пассажиром и ждёт от него указаний.

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

yield call(putCounter);
function* putCounter() {  dispatch({    type: ACTIONS.SET_COUNTER,    data: stateRef.current.counter + stateRef.current.counterStep,  });  yield take((action) => {    return action.type === "STATE_UPDATED";  });}

Чем занимается пассажир putCounter (функция-генератор putCounter). Диспатчит действие состояния хука и затем посылает посыльного take к диспетчеру (redux-saga) за сообщением с типом STATE_UPDATED и ждёт этого посыльного.

В этом месте остановимся и ещё раз опишем получившийся хоровод (сделаем срез на этот момент).

Посыльный take(eventChannel) стоит (ожидает пока не выполнится итерация цикла в функции-генераторе saga) рядом с пассажиром saga (функцией-генератором saga). Пассажир saga (функция-генератор saga) ожидает пока пассажир putCounter (функция-генератор putCounter) не освободится. Пассажир putCounter (функция-генератор putCounter), в свою очередь, ждёт посыльного take, который стоит рядом с диспетчером (redux-saga) и ждёт посыльного put, который должен принести сообщение с типом STATE_UPDATED. Короче "Дом, который построил Джек".

Таким образом весь хоровод "застыл" в ожидании одного-единственного сообщения STATE_UPDATED. Кстати, в канале eventChannel могут возникать события во время этого застывшего состояния. Если не использовать буфер с каналом eventChannel, то эти события останутся незамеченными для нашего диспетчера (redux-saga). Но буфер у нас есть, поэтому в это время в тамбуре перед камерами хранения (буфере) толпятся сообщения от eventChannel.

И этого посыльного put отправляет хук useEffect

useEffect(() => {...    sagaChannelRef.current.put({ type: "STATE_UPDATED" }); ...}, [state]);

Посыльный put приносит сообщение STATE_UPDATED диспетчеру (redux-saga).

Диспетчер (redux-saga) отдаёт его take, которого прислал пассажир putCounter.

Пассажир putCounter сообщает пассажиру saga, что он освободился.

Пассажир saga, отправляет посыльного take за следующим сообщением от eventChannel

Take забирает следующее сообщение у диспетчера, который взял его из буфера.

Круг замкнулся.

Исходный код хука с redux-saga в качестве канала обработки событий
import { useReducer, useEffect, useRef } from "react";import { reducer, initialState, ACTIONS } from "./state";import { runSaga, eventChannel, stdChannel, buffers, END } from "redux-saga";import { call, take } from "redux-saga/effects";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [state, dispatch] = useReducer(reducer, initialState);  const stateRef = useRef(state);  const sagaChannelRef = useRef(stdChannel());  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  useEffect(() => {    const imgArray = document.querySelectorAll("img");    if (imgArray.length > 0) {      dispatch({        type: ACTIONS.SET_COUNTER_STEP,        data: Math.floor(100 / imgArray.length) + 1,      });      function* putCounter() {        dispatch({          type: ACTIONS.SET_COUNTER,          data: stateRef.current.counter + stateRef.current.counterStep,        });        yield take((action) => {          return action.type === "STATE_UPDATED";        });      }      function* saga() {        const eventsChannel = yield call(getImageLoadingSagas, imgArray);        try {          while (true) {            yield take(eventsChannel);            yield call(putCounter);          }        } finally {          //channel closed        }      }      runSaga(        {          channel: sagaChannelRef.current,          dispatch: () => {},          getState: () => {},        },        saga      );    }  }, []);  useEffect(() => {    stateRef.current = state;    if (stateRef.current.counterStep != 0 && stateRef.current.counter != 0) {      sagaChannelRef.current.put({ type: "STATE_UPDATED" });    }    if (counterEl) {      stateRef.current.counter < 100        ? (counterEl.innerHTML = `${stateRef.current.counter}%`)        : hidePreloader(preloaderEl);    }  }, [state]);  return;};function getImageLoadingSagas(imagesArray) {  return eventChannel((emit) => {    for (const img of imagesArray) {      const imageChecker = new Image();      imageChecker.addEventListener("load", () => {        emit(true);      });      imageChecker.addEventListener("error", () => {        emit(true);      });      imageChecker.src = img.url;    }    setTimeout(() => {      //закрытие канала по таймеру      emit(END);    }, 100000);    return () => {          };  }, buffers.expanding(10));}const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

Я был трезвый, когда это писал. Чтобы прочитать, пить тоже не нужно.

Вариант 7. Redux-saga + useReducer = useReducerAndSaga

В варианте 6 сага использовалась исключительно как менеджер событий. Мне хотелось подключить его к управлению state хука. Погуглив нашёл такой вариант хука useReducerAndSaga

Описывать на примере камер хранения не буду, просто приведу исходный код

Исходный код useReducerAndSaga.js
import { useReducer, useEffect, useRef } from "react";import { runSaga, stdChannel, buffers } from "redux-saga";export function useReducerAndSaga(reducer, state0, saga, sagaOptions) {  const [state, reactDispatch] = useReducer(reducer, state0);  const sagaEnv = useRef({ state: state0, pendingActions: [] });  function dispatch(action) {    console.log("useReducerAndSaga: react dispatch", action);    reactDispatch(action);    console.log("useReducerAndSaga: post react dispatch", action);    // dispatch to sagas is done in the commit phase    sagaEnv.current.pendingActions.push(action);  }  useEffect(() => {    console.log("useReducerAndSaga: update saga state");    // sync with react state, *should* be safe since we're in commit phase    sagaEnv.current.state = state;    const pendingActions = sagaEnv.current.pendingActions;    // flush any pending actions, since we're in commit phase, reducer    // should've handled all those actions    if (pendingActions.length > 0) {      sagaEnv.current.pendingActions = [];      console.log("useReducerAndSaga: flush saga actions");      pendingActions.forEach((action) => sagaEnv.current.channel.put(action));      sagaEnv.current.channel.put({ type: "REACT_STATE_READY", state });    }  });  // This is a one-time effect that starts the root saga  useEffect(() => {    sagaEnv.current.channel = stdChannel();    const task = runSaga(      {        ...sagaOptions,        channel: sagaEnv.current.channel,        dispatch,        getState: () => {          return sagaEnv.current.state;        }      },      saga    );    return () => task.cancel();  }, []);  return [state, dispatch];}

Все генераторы были выдесены в отдельный файл sagas.js

Исходный код sagas.js
import { eventChannel, buffers } from "redux-saga";import { call, select, take, put } from "redux-saga/effects";import { ACTIONS, getCounterStep, getCounter, END } from "./state";export const getImageLoadingSagas = (imagesArray) => {  return eventChannel((emit) => {    for (const img of imagesArray) {      const imageChecker = new Image();            imageChecker.addEventListener("load", () => {        emit(true);      });      imageChecker.addEventListener("error", () => {        emit(true);      });      imageChecker.src = img.src;    }    setTimeout(() => {      //закрытие канала по таймеру      emit(END);    }, 100000);    return () => {};  }, buffers.fixed(20));};function* putCounter() {  const currentCounter = yield select(getCounter);  const counterStep = yield select(getCounterStep);  yield put({ type: ACTIONS.SET_COUNTER, data: currentCounter + counterStep });  yield take((action) => {    return action.type === "REACT_STATE_READY";  });}function* launchLoadingEvents(imgArray) {  const eventsChannel = yield call(getImageLoadingSagas, imgArray);  while (true) {    yield take(eventsChannel);    yield call(putCounter);  }}export function* saga() {  while (true) {    const { data } = yield take(ACTIONS.SET_IMAGES);    yield call(launchLoadingEvents, data);  }}

Немножко изменился state. Был добавлен action SET_IMAGES и селекторы для counter и counterStep

Исходный код state.js
const SET_COUNTER = "SET_COUNTER";const SET_COUNTER_STEP = "SET_COUNTER_STEP";const SET_IMAGES = "SET_IMAGES";export const initialState = {  counter: 0,  counterStep: 0,  images: [],};export const reducer = (state, action) => {  switch (action.type) {    case SET_IMAGES:      return { ...state, images: action.data };    case SET_COUNTER:      return { ...state, counter: action.data };    case SET_COUNTER_STEP:      return { ...state, counterStep: action.data };    default:      throw new Error("This action is not applicable to this component.");  }};export const ACTIONS = {  SET_COUNTER,  SET_COUNTER_STEP,  SET_IMAGES,};export const getCounterStep = (state) => state.counterStep;export const getCounter = (state) => state.counter;

Благодаря этому рефакторингу, код хука usePreloader стал компактным.

Исходный код usePreloader.js
import { useEffect } from "react";import { reducer, initialState, ACTIONS } from "./state";import { useReducerAndSaga } from "./useReducerAndSaga";import { saga } from "./sagas";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [state, dispatch] = useReducerAndSaga(reducer, initialState, saga);  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  useEffect(() => {    const imgArray = document.querySelectorAll("img");    if (imgArray.length > 0) {      dispatch({        type: ACTIONS.SET_COUNTER_STEP,        data: Math.floor(100 / imgArray.length) + 1,      });      dispatch({        type: ACTIONS.SET_IMAGES,        data: imgArray,      });    }  }, []);  useEffect(() => {    if (counterEl) {      state.counter < 100        ? (counterEl.innerHTML = `${state.counter}%`)        : hidePreloader(preloaderEl);    }  }, [state.counter]);  return;};const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

Итого

В этой части статьи показано:

  • что такое redux-saga

  • как использовать redux-saga без redux

  • как использовать redux-saga для управления состоянием хука

Ссылка напесочницу

Ссылка нарепозиторий

Продолжение следует... RxJS...

Подробнее..
Категории: Javascript , Reactjs , Hook , Redux-saga , Put , Take , Channel , Eventchannel

Svelte Redux Redux-saga

07.02.2021 02:13:24 | Автор: admin

Попытка жалкого подобия на хуки useSelector, useDispatch, как в react-redux.

Большинство из нас сталкивались с redux, а те, кто использовал его в ReactJS могли пощупать хуки useSelector, useDispatch, в ином случае через mstp, mdtp + HOC connect. А что со svelte? Можно навернуть, или найти что-то похожее на connect, по типу svelte-redux-connect, описывать огромные конструкции, которые будем отдавать в тот самый connect:

const mapStateToProps = state => ({  users: state.users,  filters: state.filters});const mapDispatchToProps = dispatch => ({  addUser: (name) => dispatch({    type: 'ADD_USER',    payload: { name }  }),  setFilter: (filter) => dispatch({    type: 'SET_FILTER',    payload: { filter }  }) });

Прямо какие-то страшные флэшбэки до середины 2018, до введения хуков :). Хочу хуки в svelte. Что мы можем из него взять? Хм... store у svelte глобальный, не нужны никакие провайдеры с контекстом (шучу, нужны для разделения контекстов, но пока выкинем). Значит так: мы создаем redux-store, потом попробуем написать наши жалкие хуки для удобства использования.

Итак, наши константы:

//constants.jsexport const GET_USER = '@@user/get'export const FETCHING_USER = '@@user/fetch'export const SET_USER = '@@user/set'

Редюсер:

//user.jsimport {FETCHING_USER, SET_USER} from "./constants";const initialState = {  user: null,  isFetching: false}export default function user(state = initialState, action = {}){  switch (action.type){    case FETCHING_USER:    case SET_USER:      return {        ...state,        ...action.payload      }    default:      return state  }}

Экшены:

//actions.jsimport {FETCHING_USER, GET_USER, SET_USER} from "./constants";export const getUser = () => ({  type: GET_USER})export const setUser = (user) => ({  type: SET_USER,  payload: {    user  }})export const setIsFetchingUser = (isFetching) => ({  type: FETCHING_USER,  payload: {    isFetching  }})

Селекторы. К ним вернемся отдельно:

//selectors.jsimport {createSelector} from "reselect";import path from 'ramda/src/path'export const selectUser = createSelector(  path(['user', 'user']),  user => user)export const selectIsFetchingUser = createSelector(  path(['user', 'isFetching']),  isFetching => isFetching)

И главный combineReducers:

//rootReducer.jsimport {combineReducers} from "redux";import user from "./user/user";export const reducers = combineReducers({  user})

Теперь надо прикрутить redux-saga, а в качестве api у нас будет https://randomuser.me/api/. Во время тестирования всего процесса, эта апи очень быстро работала, а я очень сильно хотел посмотреть на лоадер подольше (у каждого свой мазохизм), поэтому я завернул таймаут в промис на 3 сек.

//saga.jsimport {takeLatest, put, call, cancelled} from 'redux-saga/effects'import {GET_USER} from "./constants";import {setIsFetchingUser, setUser} from "./actions";import axios from "axios";const timeout = () => new Promise(resolve => {  setTimeout(()=>{    resolve()  }, 3000)})function* getUser(){  const cancelToken = axios.CancelToken.source()  try{    yield put(setIsFetchingUser(true))    const response = yield call(axios.get, 'https://randomuser.me/api/', {cancelToken: cancelToken.token})    yield call(timeout)    yield put(setUser(response.data.results[0]))    yield put(setIsFetchingUser(false))  }catch (error){    console.error(error)  }finally {    if(yield cancelled()){      cancelToken.cancel('cancel fetching user')    }    yield put(setIsFetchingUser(false))  }}export default function* userSaga(){  yield takeLatest(GET_USER, getUser)}
//rootSaga.jsimport {all} from 'redux-saga/effects'import userSaga from "./user/saga";export default function* rootSaga(){  yield all([userSaga()])}

И наконец инициализация store:

//store.jsimport {applyMiddleware, createStore} from "redux";import {reducers} from "./rootReducer";import {composeWithDevTools} from 'redux-devtools-extension';import {writable} from "svelte/store";import createSagaMiddleware from 'redux-saga';import rootSaga from "./rootSaga";const sagaMiddleware = createSagaMiddleware()const middleware = applyMiddleware(sagaMiddleware)const store = createStore(reducers, composeWithDevTools(middleware))sagaMiddleware.run(rootSaga)// берем изначальное состояние из storeconst initialState = store.getState()// написали writable store для useSelectorexport const useSelector = writable((selector)=>selector(initialState))// написали writable store для useDispatch, хотя можно было и без этого// но для симметрии использования оставил такexport const useDispatch = writable(() => store.dispatch)// подписываемся на обновление storestore.subscribe(()=>{  const state = store.getState()  // при обновлении store обновляем useSelector, тут нет никакой мемоизации,   // проверки стейтов, обработки ошибок и прочего очень важного для оптимизации  useSelector.set(selector => selector(state))})

Всё. Самое интересное начинается с 18 строки. После того, как приходит понятие того, что мы написали, возникает вопрос - если я буду использовать useSelector в 3 разных компонентах с разными данными из store - у меня будут обновляться все компоненты сразу? Нет, обновятся и перерисуются данные, которые мы используем. Даже если логически предположить, что при каждом чихе в store у нас меняется ссылка на функцию, то и обновление компонента по идее должно быть, но его нет. Я честно не до конца разобрался как это работает, но я доберусь до сути, не ругайтесь :)

Хуки готовы, как использовать?

Начнем c useDispatch. Его вообще можно было не заворачивать в svelte-store и сделать просто
export const useDispatch = () => store.dispatch, только по итогу с useSelector мы используем store bindings, а с useDispatch нет - сорян, всё же во мне есть частичка маленького перфекционизма. Используем хук useDispatch в App.svelte:

<!--App.svelte--><script>  import {getUser} from "./store/user/actions";  import {useDispatch} from "./store/store";  import Loader from "./Loader.svelte";  import User from "./User.svelte";  // создаем диспатчер  const dispatch = $useDispatch()  const handleClick = () => {    // тригерим экшен    dispatch(getUser())  }</script><style>    .wrapper {        display: inline-block;        padding: 20px;    }    .button {        padding: 10px;        margin: 20px 0;        border: none;        background: #1d7373;        color: #fff;        border-radius: 8px;        outline: none;        cursor: pointer;    }    .heading {        line-height: 20px;        font-size: 20px;    }</style><div class="wrapper">    <h1 class="heading">Random user</h1>    <button class="button" on:click={handleClick}>Fetch user</button>    <Loader/>    <User/></div>
Кнопока которая тригерит экшенКнопока которая тригерит экшен

Вот такая вот загогулина у меня свёрстана. При нажатии на кнопку Fetch user, тригерим экшен GET_USER. Смотрим в Redux-dev-tools - экшен вызвался, всё хорошо. Смотрим network - запрос к апи выполнен, тоже всё хорошо:

Теперь нужно показать процесс загрузки и полученного нами пользователя. Используем useSelector:

<!--Loader.svelte--><script>    import {useSelector} from "./store/store";    import {selectIsFetchingUser} from "./store/user/selector";// Только в такой конструкции мы можем получить из store данные,     // выглядит не так страшно и не лагает, я проверял :3    $: isFetchingUser = $useSelector(selectIsFetchingUser)</script><style>    @keyframes loading {        0% {            background: #000;            color: #fff;        }        100% {            background: #fff;            color: #000;        }    }    .loader {        background: #fff;        box-shadow: 0px 0px 7px rgba(0,0,0,0.3);        padding: 10px;        border-radius: 8px;        transition: color 0.3s ease-in-out, background 0.3s ease-in-out;        animation: loading 3s ease-in-out forwards;    }</style>{#if isFetchingUser}    <div class="loader">Loading...</div>{/if}

Лоадер рисуется. Данные из store прилетают, теперь надо показать юзера:

<!--User.svelte--><script>    import {useSelector} from "./store/store";    import {selectIsFetchingUser,selectUser} from "./store/user/selector";    $: user = $useSelector(selectUser)    $: isFetchingUser = $useSelector(selectIsFetchingUser)</script><style>    .user {        background: #fff;        box-shadow: 0px 0px 7px rgba(0,0,0,0.3);        display: grid;        padding: 20px;        justify-content: center;        align-items: center;        border-radius: 8px;    }    .user-image {        width: 100px;        height: 100px;        background-position: center;        background-size: contain;        border-radius: 50%;        margin-bottom: 20px;        justify-self: center;    }</style>{#if user && !isFetchingUser}    <div class="user">        <div class="user-image" style={`background-image: url(${user.picture.large});`}></div>        <div>{user.name.title}. {user.name.first} {user.name.last}</div>    </div>{/if}

Пользователя так же получили.

Итог

Запилили какие-никакие подобия на хуки, вроде удобно, но не известно как это отразится в будущем, если сделать из этого mini-app на пару страниц. Саги так же пашут. Через redux devtools можно дебажить redux и прыгать от экшена к экшену, всё хорошо работает.

Подробнее..
Категории: Javascript , Redux , Hooks , Svelte , Redux-saga , Sveltejs

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru