Hello world!
Zustand (читается как «цуштанд», что переводится с немецкого как «состояние») — это, на мой взгляд, один из лучших на сегодняшний день инструментов для управления состоянием приложений, написанных на React.
В этой статье я немного расскажу о том, как он работает.
Давайте начнем с примера использования zustand для реализации функционала отображения/скрытия модального окна.
Код проекта лежит здесь.
Демо:
Для работы с зависимостями я буду использовать Yarn.
Создаем шаблон React-приложения с помощью Vite:
# zustand-test - название приложения # react-ts - шаблон проекта, в данном случае React yarn create vite zustand-test --template react
Переходим в созданную директорию, устанавливаем основные зависимости и запускаем сервер для разработки:
cd zustand-test yarn yarn dev
Устанавливаем дополнительные зависимости:
yarn add zustand use-sync-external-store react-use
- react-use — большая коллекция кастомных хуков;
- use-sync-external-store — об этом мы поговорим чуть позже.
Определяем хук для управления состоянием модалки в файле hooks/useModal.js:
import { create } from 'zustand' const useModal = create((set) => ({ isOpen: false, open: () => set({ isOpen: true }), close: () => set({ isOpen: false }), })) export default useModal
Определяем компонент модалки в файле components/Modal.jsx:
import { useEffect, useRef } from 'react' import { useClickAway } from 'react-use' import useModal from '../hooks/useModal' export default function Modal() { // состояние модалки const modal = useModal() // ссылка на модалку const modalRef = useRef(null) // ссылка на содержимое модалки const modalContentRef = useRef(null) useEffect(() => { if (!modalRef.current) return // показываем/скрываем модалку в зависимости от значения индикатора `isOpen` // `showModal` и `close` - это нативные методы, предоставляемые HTML-элементом `dialog` if (modal.isOpen) { modalRef.current.showModal() } else { modalRef.current.close() } }, [modal.isOpen]) // скрываем модалку при клике за пределами ее содержимого useClickAway(modalContentRef, modal.close) if (!modal.isOpen) return null return ( <dialog style={{ padding: 0, }} ref={modalRef} > <div style={{ padding: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', }} ref={modalContentRef} > <div>Modal content</div> <button onClick={modal.close}>X</button> </div> </dialog> ) }
Определяем минимальные стили в файле index.css:
body { margin: 0; } #root { min-height: 100vh; display: grid; place-content: center; } dialog::backdrop { background-color: rgba(0, 0, 0, 0.4); }
Наконец, рендерим модалку в файле App.jsx:
import Modal from './components/Modal' import useModal from './hooks/useModal' function App() { const modal = useModal() return ( <> <button onClick={modal.open}>Open modal</button> <Modal /> </> ) } export default App
Это было легко, не правда ли? А все благодаря магии функции create?
Исходный код zustand находится здесь. Поскольку мы будем рассматривать только основной функционал, предоставляемый этим пакетом, нас интересует 2 файла — vanilla.ts и react.ts.
Код, содержащийся в файле vanilla.ts, представляет собой реализацию паттерна «Издатель/подписчик» (publisher/subscriber, pub/sub).
Создаем файл zustand/vanilla.js следующего содержания:
const createStoreImpl = (createState) => { // состояние let state // обработчики const listeners = new Set() // функция обновления состояния const setState = (partial, replace) => { // следующее состояние const nextState = typeof partial === 'function' ? partial(state) : partial // если состояние изменилось if (!Object.is(nextState, state)) { // предыдущее/текущее состояние const previousState = state // обновляем состояние с помощью `nextState` (если `replace === true` или значением `nextState` является примитив) // или нового объекта, объединяющего `state` и `nextState` state = replace ?? typeof nextState !== 'object' ? nextState : Object.assign({}, state, nextState) // запускаем обработчики listeners.forEach((listener) => listener(state, previousState)) } } // функция извлечения состояния const getState = () => state // функция подписки // `listener` - обработчик `onStoreChange` // см. код хука `useSyncExternalStoreWithSelector` - об этом чуть позже const subscribe = (listener) => { // добавляем/регистрируем обработчик listeners.add(listener) // возвращаем функцию отписки return () => listeners.delete(listener) } // функция удаления всех обработчиков const destroy = () => { listeners.clear() } const api = { setState, getState, subscribe, destroy } // инициализируем состояние state = createState(setState, getState, api) // возвращаем методы return api } // в зависимости от того, передается ли функция инициализации состояния, // возвращаем либо `api`, либо функцию `createStoreImpl` export const createStore = (createState) => createState ? createStoreImpl(createState) : createStoreImpl
Думаю, здесь все понятно. Двигаемся дальше.
Код, содержащийся в файле react.ts, представляет собой интеграцию или внедрение рассмотренного pub/sub в React fiber.
Создаем файл zustand/react.js следующего содержания:
// `CommonJS` import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector' import { createStore } from './vanilla.js' const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports export function useStore(api, selector = api.getState, equalityFn) { // получаем часть (срез) состояния const slice = useSyncExternalStoreWithSelector( api.subscribe, api.getState, api.getServerState || api.getState, selector, equalityFn, ) // и возвращаем его return slice } const createImpl = (createState) => { // получаем методы, возвращаемые функцией `createStore` const api = typeof createState === 'function' ? createStore(createState) : createState // определяем хук, вызывающий хук `useStore` с переданной // функцией-селектором (`selector`) для извлечения части состояния и // функцией сравнения (`equalityFn`) для определения необходимости повторного рендеринга const useBoundStore = (selector, equalityFn) => useStore(api, selector, equalityFn) // это нужно для того, чтобы иметь возможность // вызывать хук за пределами компонента - // `useModal.getState()` Object.assign(useBoundStore, api) return useBoundStore } // можно получить либо хук `useBoundStore`, либо функцию `createImpl` export const create = (createState) => createState ? createImpl(createState) : createImpl
Попробуйте заменить import { create } from 'zustand' на import { create } from '../zustand/react' в useModal.js и убедитесь, что с точки зрения функционала ничего не изменилось.
Вот где начинается магия?
Хук useSyncExternalStoreWithSelector — это продвинутая версия хука useSyncExternalStore (useSyncExternalStore и его разновидности почему-то лежат в отдельном пакете). Разница между ними состоит в том, что useSyncExternalStoreWithSelector принимает 2 дополнительных параметра:
selector— функция-селектор для извлечения части состояния (по умолчанию возвращается все состояние);equalityFn— функция для сравнения текущего и нового состояний, которая используется для определения необходимости повторного рендеринга.
Вызов useModal с селектором:
const isModalOpen = useModal((state) => state.isOpen)
Вызов useModal с селектором и функцией сравнения:
import { shallow } from 'zustand/shallow' const { open, close } = useModal(({ open, close }) => ({ open, close }), shallow)
Для чего нужен хук useSyncExternalStore?
Как правило, компоненты React читают данные из пропов, состояния и контекста. Однако иногда компоненту может потребоваться прочитать меняющиеся со временем данные из хранилища, находящегося за пределами React. Таким хранилищем может быть:
- сторонняя библиотека для управления состоянием (такая как
zustand), которая хранит состояние за пределами React; - браузерный API, предоставляющий мутируемое значение и события для подписки на его изменения.
useSyncExternalStore принимает 2 обязательных и 1 опциональный параметр:
subscribe(обязательный параметр) — функция, принимающая параметрcallbackи выполняющая подписку на хранилище.callbackвызывается при любом изменении хранилища. Это приводит к повторному рендерингу компонента.subscribeдолжна возвращать функцию отписки от хранилища;getSnapshot(обязательный параметр) — функция, возвращающая снимок (snapshot) состояния из хранилища, потребляемого компонентом. Если состояние не изменилось, повторные вызовыgetSnapshotдолжны возвращать одинаковые значения. Если новое состояние отличается от текущего, React выполняет повторный рендеринг компонента;getServerSnapshot(опциональный параметр) — функция, возвращающая начальный снимок состояния из хранилища. Она используется только в процессе серверного рендеринга контента и его гидратации на клиенте.
useSyncExternalStore возвращает снимок хранилища для использования в логике (цикле) рендеринга React.
Подробнее о рассматриваемом хуке можно почитать в этой статье.
Таким образом, useSyncExternalStore позволяет подписываться на изменения состояния, находящегося во внешнем хранилище, способом, совместимым с конкурентными возможностями React. Цикл рендеринга, в числе прочего, предполагает вызов одинаковой для первоначального и повторных рендерингов последовательности хуков, используемых компонентом. Одинаковая последовательность вызова (и количество) хуков обеспечиваются правилами использования хуков. Это логично: вызов хуков в другой последовательности или в меньшем/большем количестве приведет к несогласованности состояния компонента.
useSyncExternalStore делает наш pub/sub (внешнее хранилище) частью системы хуков, формирующей итоговое состояние компонента.
Код рассматриваемого хука можно найти здесь (функции mountSyncExternalStore и следующая за ней updateSyncExternalStore).
«Голая» mountSyncExternalStore выглядит так:
function mountSyncExternalStore( subscribe, getSnapshot, getServerSnapshot, ) { // волокно const fiber = currentlyRenderingFiber // текущий/выполняемый хук const hook = mountWorkInProgressHook() // следующий снимок состояния let nextSnapshot const isHydrating = getIsHydrating() if (isHydrating) { nextSnapshot = getServerSnapshot() } else { nextSnapshot = getSnapshot() } // читаем текущий снимок из хранилища на каждом рендеринге // это нарушает обычные правила React и работает только благодаря тому, // что обновления хранилища всегда являются синхронными hook.memoizedState = nextSnapshot const inst = { value: nextSnapshot, getSnapshot, } hook.queue = inst // здесь планируются эффекты для подписки на хранилище и // для обновления мутируемых полей экземпляра (`inst`), // которые обновляются при любом изменении `subscribe`, `getSnapshot` или значения // эти внутренности нас не интересуют return nextSnapshot }
Отличия updateSyncExternalStore от mountSyncExternalStore сводятся к следующему:
// предыдущий снимок const prevSnapshot = (currentHook || hook).memoizedState; // изменилось ли состояние? const snapshotChanged = !is(prevSnapshot, nextSnapshot); // если изменилось if (snapshotChanged) { hook.memoizedState = nextSnapshot; markWorkInProgressReceivedUpdate(); } const inst = hook.queue;
В качестве бонуса ловите слегка видоизмененную функцию shallow, позволяющую глубоко сравнивать объекты, которой можно найти большое количество применений:
function equal<T>(objA: T, objB: T): boolean { if (Object.is(objA, objB)) { return true } if ( typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null ) { return false } if ( (Array.isArray(objA) && !Array.isArray(objB)) || (Array.isArray(objB) && !Array.isArray(objA)) ) { return false } if (objA instanceof Map && objB instanceof Map) { if (objA.size !== objB.size) return false for (const [key, value] of objA) { if (!Object.is(value, objB.get(key))) { return false } } return true } if (objA instanceof Set && objB instanceof Set) { if (objA.size !== objB.size) return false for (const value of objA) { if (!objB.has(value)) { return false } } return true } if (objA instanceof Date && objB instanceof Date) { return Object.is(objA.getTime(), objA.getTime()) } const keysA = Object.keys(objA) if (keysA.length !== Object.keys(objB).length) { return false } return keysA.every((key) => equal(objA[key as keyof T], objB[key as keyof T])) }
Надеюсь, вы узнали что-то новое и не зря потратили время.
The end.
ссылка на оригинал статьи https://habr.com/ru/articles/750716/
Добавить комментарий