Lens JS как менеджер состояния приложения
Обзор библиотеки lens-js и эксперименты с котиками.
Данные — это, в действительности, важная часть Вашего будущего приложения или отдельной библиотеки. Важна их структура, целостность, а также и подходы к организации их хранения и обработки. Задача, прямо скажем, не тривиальна, особенно в масштабах корпоративных проектов. Одно из решений — использование менеджера состояния, об одном из которых, тут пойдёт речь.
Линзы
И так, что же такое «Линзы»? Проще всего ответить тезисно - линзы это:
принцип организации работы с данными, где те квантуются по отдельным узлам в одном большом направленном графе;
агрегатор (редьюсер), который занимается сборкой всех отдельных квантов по всем правилам функциональной парадигмы;
интерфейс, который обеспечивает доступ к данным каждого кванта;
и последнее, линза обеспечивает целостность данных и их актуальность в Вашем приложении.
Тут вот стоит отметить, что мы ещё не говорим о как-либо реализации. Линза — это не детерминированная библиотека. Реализаций линз — множество. Попробуйте их все!
Как это всё работает?
Линза представляет собой структуру (направленный граф) из множества контроллеров (узлов), за каждым из которых закреплён адрес в неких данных. Все контроллеры могут что-то записывать и считывать по своему адресу, а также взаимодействовать между собой, воссоздавая конечное состояние приложения.
Чем объяснять на пальцах, гораздо проще сделать это на каком-нибудь примере. Будем экспериментировать на котиках. Не волнуйтесь! Ни один котик не пострадает!
Следующие примеры взяты из проекта react-lens-cats, разработанного на основе линз. В нём используется библиотека — react-lens.
Давайте представим, что несколько котиков решили прокатиться на карусели. Нам нужно организовать их очередь и распределить занятые места между ними. Формально это означает следующее:
Нужно реализовать два массива, между которыми котики будут перемещаться.
Нужно отследить изменение в данных, чтобы наше приложение могло корректно отобразить актуальное состояние.
Пусть структура данных выглядит следующим образом:
export interface Cat {
name: string;
}
export interface Queue {
cats: Cat[]
}
export interface ILunapark {
street: Queue;
circle: Queue;
}
Давайте создадим наше состояние и объявим линзу в файле lens.ts
import { Lens } from '@vovikilelik/react-lens';
// Это наши котики
const murzic: Cat = { name: 'Murzic' };
const pushok: Cat = { name: 'Pushok' };
const sedric: Cat = { name: 'Sedric' };
const rizhik: Cat = { name: 'Rizhik' };
// Это наши начальные данные
const initData: ILunapark = {
street: { cats: [murzic, pushok, sedric, rizhik] },
circle: { cats: [] }
};
// Это сама линза
export const store = createStore(initData);
Пока всё легко и понятно? Но не будем отвлекаться, а то получится как на матане.
Как можно заметить, мы экспортируем только константу store
, относительно которой и будем работать с состоянием. Это синглтон, детка.
Для начала, создадим Lenapark.tsx
и попробуем просто получить доступ к свойствам нашего состояния, используя API линзы.
import { store } from './lens';
export Lunapark: React.FC = () => (
<div>
{ store.go('circle').go('cats').get().map(c => c.name).join(' ') }
{ store.go('street').go('cats').get().map(c => c.name).join(' ') }
</div>
);
Теперь мы можем видеть имена наших котиков. Выражение выглядит пугающе, но в процессе заполнения можно заметить, что благодаря типизации, имена полей предлагаются средой разработки, что весьма удобно.
Но постойте, судари! У нас тут что-то не так с кодом! И действительно, массивы circle
и street
одинаковы, а код избыточен. Выгоднее реализовать универсальный способ, который отобразит только типизированный массив котиков, откуда бы мы его не передали. А давайте, просто, передавать узел линзы, как параметр некого компонента, который уже и будет отображать элементы массива, вот так:
import { useLens, Store } from '@vovikilelik/react-lens';
import { Cat } from './lens';
export interface CatsProps {
cats: Store<Cat[]>;
}
// Мы создали компонент, который будет принимать типизированную линзу
export Cats: React.FC<CatsProps> = ({ cats }) => {
const [catsArray] = useLens(cats);
return (
<div>
{ catsArray.map(c => c.name).join(' ') }
</div>
);
}
Тогда файл Lunapark.tsx
будет выглядеть следующим образом:
import { lens } from './lens';
export Lunapark: React.FC = () => (
<div>
/* Каждый узел линзы можно передавать, как простой аргумент */
<Cats cats={lens.go('circle').go('cats')} />
<Cats cats={lens.go('street').go('cats')} />
</div>
);
Всё ещё сложно. Для чего мы постоянно обращаемся к корневой линзе? Нам же не так и важно, где хранятся массивы котиков? Можно пойти дальше и сделать Lenapark.tsx
тоже универсальным:
import { Store } from '@vovikilelik/react-lens';
import { ILunapark } from './lens';
export interface LunaparkProps {
store: Store<ILunapark>;
}
// Этот бывшый Test.tsx теперь универсальный Lunapark.tsx
export Lunapark: React.FC<LunaparkProps> = ({ store }) => (
<div>
<Cats cats={store.go('street').go('cats')} />
<Cats cats={store.go('circle').go('cats')} />
</div>
);
Как ловко мы тут всё инкапсулировали...
Другое дело! Теперь мы сможем использовать наш Lunapark.tsx
в на любом узле линзы, где встречается похожая структура - ILunapark
. Таким образом, мы можем создавать целые модели компонентов, которые будут также универсальны, как и рядовые. И действительно, поскольку каждый узел линзы является объектом, адресующим некие данные в глобальном состоянии, то его передача в качестве аргумента ничем не отличатся от передачи простого значения. Однако в случае линз, мы уже получаем механизм отслеживания изменений в узле по средствам useLens
.
Теперь дело за малым, переместить котиков из одного массива в другой. Давайте в нашем Lunapark.tsx
создадим кнопку с обработчиком, где мы и завершим нашу задумку.
/* Берём котика из очереди и удаляем из состояния */
const popCat = (lens: Store<Cat[]>): Cat | undefined => {
// Тут просто берём массив из линзы
const cats = lens.get();
const cat = cats.pop();
// А тут записываем его обрано, но уже без одного котика
lens.set(cats);
return cat;
}
/* Усаживаем на карусель */
const playCat = (lens: Store<Cat[]>, cat: Cat) => {
// Записываем в линзу прежний массив, но с новым котиком
lens.set([...lens.get(), cat]);
}
export Lunapark: React.FC<{ store: Store<ILunapark> }> = ({ store }) => {
// В этом методе, мы работает с котиками, относительно модели Store,
// где-то в состоянии, а не по детерминированному пути.
const onCatPlay = useCallback(() => {
const cat = popCat(store.go('street').go('cats'));
cat && playCat(store.go('circle').go('cats'), cat);
}, [store]);
return (
<div>
<Cats cats={store.go('street').go('cats')} />
<Cats cats={store.go('circle').go('cats')} />
<button onClick={onCatPlay} />
</div>
);
}
Обратите внимание, что обработка изменений уже реализована внутри линзы по средствам
useLens
, т. е. после изменения данных в узлахcats
, связанные компоненты будут обновлены.
А есть ещё какая-нибудь польза?
Линзы — это не строго привязанная, к какой-либо технологии, библиотека. Их успешно можно применять там, где не подходят привычные подходы. Например, при создании динамических сцен на BabylonJS или внутреннего состояния Web-компонентов и т. п. Линзы — не аналог Redux или похожих технологий и, как следствие, могут применяться совместно, как абстракция более низкого уровня. Всё дело в воображении…
Выводы
Что, уже? Да. Целью статьи было познакомить читателя с ещё одним подходом для организации состояния приложения. Мы рассмотрели самое-самое главное — потенциал к масштабируемости и области применения.
Давайте подытожим! С помощью линз можно:
реализовывать состояние приложения;
разрабатывать общие компоненты и модели;
организовывать событийно-ориентированные подпрограммы;
создавать абстракции для работы с другими подходами к управлению состоянием.