Как работает Zustand

от автора

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *