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