Как стать автором
Обновить

Svelte + Redux + Redux-saga

Время на прочтение 6 мин
Количество просмотров 4.8K

Попытка жалкого подобия на хуки 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.js
export const GET_USER = '@@user/get'
export const FETCHING_USER = '@@user/fetch'
export const SET_USER = '@@user/set'

Редюсер:

//user.js
import {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.js
import {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.js
import {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.js
import {combineReducers} from "redux";
import user from "./user/user";

export const reducers = combineReducers({
  user
})

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

//saga.js
import {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.js
import {all} from 'redux-saga/effects'
import userSaga from "./user/saga";

export default function* rootSaga(){
  yield all([userSaga()])
}

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

//store.js
import {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)

// берем изначальное состояние из store
const initialState = store.getState()

// написали writable store для useSelector
export const useSelector = writable((selector)=>selector(initialState))

// написали writable store для useDispatch, хотя можно было и без этого
// но для симметрии использования оставил так
export const useDispatch = writable(() => store.dispatch)

// подписываемся на обновление store
store.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 и прыгать от экшена к экшену, всё хорошо работает.

Теги:
Хабы:
+7
Комментарии 19
Комментарии Комментарии 19

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн