SPA потоки и состояния: шаблон Мейоз

от автора

Состояние SPA (одностраничное приложение на javascript) и управление состоянием — бесконечная тема. У всякого популярного js-фрейморка есть пара, тройка решений на этот счет. «Их есть» и без фреймворков и библиотек, с помощью несложной функции и javascript, просто способ управления состоянием (паттерн, шаблон). Автор назвал его Мейоз. Название довольно так себе на мой взгляд, но автору виднее.

Основа паттерна — поток (stream), на удивление простая, но «крутая» идея реактивного функционального программирования. Одной структуры данных и нескольких функций достаточно, чтобы управлять состоянием приложения, не особо «заморачиваясь».

Stream — поток

Реактивное программирование — программирование с использованием асинхронного потока данных. Поток — реактивная структура данных, типа ячейки в таблице Excel. Если значение ячейки С2 равно сумме значений в ячейках A1 и B1, то при изменении значений в любой из них, значение С2 изменится. Аналогично поток может зависеть от других потоков, изменения в которых, изменяют зависимый. В статье будем использовать Mithril.stream.

// поток это функция const username = m.stream() // сейчас путо console.log(username()) // logs undefined  // значение аргумента – текущие данные в потоке username("John") console.log(username()) // logs "John"  // поток хранит только последний аргумент username("John Doe") console.log(username()) // logs "John Doe"  // значением может быть любой объект js const state = m.stream({}); // объект const update = m.stream( v => v+1 ); // функция const prop = m.stream(Symbol('prop')); // символ 

Пара замечательных функций (операторов) на потоках:

// map - связывание потоков const ints = m.stream(); const mul = v => v * 5; const ints_five_times = ints.map(mul); ints(2); // при изменении основного потока ints // в потоке ints_five_times будет число умноженное на 5 console.log(ints_five_times()); // logs 10  // scan - свертка потоков const fold = (acc, value) => acc + value; const folded_ints = m.stream.scan(fold, 0, ints); // при изменениии отсновного потока ints // в потоке folded_ints будет сумма всех последовательных значений ints console.log(folded_ints()); // logs 2 ints(5); console.log(folded_ints()); // logs 7 ints(10) console.logs(folded_ints()); // logs 17

Паттерн Мейоз

Суть шаблона заключается в том, что текущее состояние (глобальное или локальное) находится в потоке в виде объекта js. Поток состояния — это свертка, которая вычисляется всякий раз, когда происходит обновление в другом потоке — потоке обновлений состояния.

// объект содержащий начальное состояние приложения const app = {   initial: {     boxes: [],     colors: ["red", "purple", "blue"],     description: '',     stat: []   }, };  // поток обновлений const update = m.stream()  // сверточная функция const fold = (state, patcher) => patcher(state);  // поток состояния – свертка последовательных состояний const states = m.stream.scan(fold, app.initial, update);

Поскольку значением потока update может быть любой объект, то в данном случае, таким объектом будет функция с единственным параметром — текущим состоянием, которая должна вернуть новый объект состояния (по смыслу свертки scan).

Определим набор функций, которые изменяют состояние:

const Actions = {   addBox(update, color) {     // в поток update помещаем функцию     update( state => Object.assign(state,       { boxes: state.boxes.concat( color )}     ))   },   removeBox(update, idx) {     update( state => Object.assign(state,       boxes: state.boxes.filter((x, j) => idx != j)     ))   } } // Всякий раз, когда мы вызываем // Actions.addBox(update, "red"); // в массив boxes будет добавляться строка "red" // и в поток states будет записываться новое состояние // Actions.removeBox(update, 3); // из массива boxes будет удаляться элемент с индексом 3 // если он там есть 

На этом собственно суть шаблона заканчивается, есть поток изменений, есть поток состояний, есть функции для изменения состояний. Все остальное зависит от вашей фантазии, и особенностей проектируемого приложения. Для демонстрации шаблона, сделаем тестовое приложение.

Тестовое приложение

Идею и часть кода тестового приложения я позаимствовал у James Forbs-а. Идея приложения — есть набор из кубиков разного цвета в виде строки меню, и табло под ним. Кликаем по кубику в меню — такой же появляется на табло. Дополнительно на табло выводится текстовая строка описывающая количество и тип всех выбранных кубиков. Клик по кубику на табло удаляет его, и соответственно изменяется строка описания. Можно думать об этом, как о выборе типа блюда (пиццы) из меню, например.

Несколько замечаний относительно оригинальной идеи шаблона и плана реализации. Мне не очень нравится авторская идея потока cells (клетки):

const update = m.stream(); const states = scan(...); const getState = () => states(); const createCell = (state) => ({ state, getState, update }); const cells = states.map(createCell);

И поскольку реализация шаблона — дело творческое, cells я не буду использовать, но предлагаю использовать поток disp (диспетчер кастомных событий). По моему мнению, диспетчер событий хорошо подходит для управления в компонентах. Оригинальный подход подробно описан на сайте автора шаблона.

Определим несколько вспомогательных функций для управления состоянием:

// P(atcher) - замыкание для патча const P = patch => state => Object.assign(state, patch);  // lift - замыкание для потока update const lift = update => patch => update(P(patch));  // поток кастомных событий const disp = m.stream(); 

Потоки update, states, функция fold, начальное состояние (app.initial) будут таким же, как указано выше. Переопределим только actions, превратив объект в функцию, возвращающую объект. В приложение будем использовать библиотеку Ramda, чтобы не писать кучу лишнего кода (префикс R).

const I = x => x; // identity  // анализ массива и вывод текста с союзом s const humanList = s => xs => xs.length > 1 ?   `${xs.slice(0, -1).join(", ")} ${s} ${xs.slice(-1)}` :   xs.join("");  // формируем строчку описания const descString = R.pipe(   R.toPairs,   R.groupBy(R.last),   R.map(R.map(R.head)),   R.map(humanList("and")),   R.toPairs,   R.map(R.join(" ")),   humanList("and"),   x => x + "." )  // returns actions object const Actions = (state, update) => {   // определим вспомогательную функцию   const stup = lift(update);   // поскольку lift - замыкание, то вызов stup с объктом патча как параметром   // вызывает обновление update, и соответвенно всего состояния    return {     // событие добавить кубик на табло     addBox(color) {       return this.countStat(         state().boxes.concat(color[0])       );     },     // удалить кубик     removeBox(idx) {       return this.countStat(         state().boxes.filter((x, j) => idx[0] != j)       );     },     // посчитать статистику и вывести текст     countStat(boxes) {       let stat = R.countBy(I, boxes), description = descString(stat);       stup({boxes, stat, description});       return false;     }   }; }; 

Далее инициализируем приложение, этот код можно сделать более общим, но для демонстрации сути убрано все лишнее.

// функция инициализации const initApp = (actions) => {   // инициализация диспетчера событий   disp.map(av => {     let [event, ...args] = av;     return actions[event] ? actions[event](args) : m.stream.SKIP;   }); }; // собственно инициализация приложения initApp(Actions(states, update));

Для рендеринга я буду использовать библиотеку mithril, поскольку мне привычнее функциональный стиль определения компонентов и автоматическая перерисовка после событий DOM.

// определяем компонет const BoxesView = function(state, disp) {    // обработчик клика добавить   const add = (evt, color) => {     evt.preventDefault();     return disp(['addBox', color])   }   // обработчик клика удалить   const remove = (evt, idx) => {     evt.preventDefault();     return disp(['removeBox', idx])   }   return {     view: function() {       return m(".app", [         m("nav.header", [           m("h1", "Boxes"),           state().colors.map(color =>             m("button", {               style: `background-color: ${color}`,               onclick: evt => add(evt, `${color}`)               }, "+"             )           )         ]),         m("p", state().description),         m(".desc",           state().boxes.map((x, i) =>             m(".boxs", {               style: `background-color: ${x}`,               onclick: evt => remove(evt, i)               }             )           )         )       ])     }   } } 

Монтируем компонент к элементу (states и disp у нас видны глобально):

m.mount(   document.getElementById('app'),   BoxesView(states, disp) );

Применение шаблона для react можно найти на сайте автора шаблона. Там же, есть описание очень неплохого инструмента meiosis-tracer, который может работать как часть вашего приложения и/или как расширение для Chrome. Tracer позволяет в реальном времени наблюдать за потоками в целевом приложении.

Код тестового приложения можно посмотреть на github-е.


ссылка на оригинал статьи https://habr.com/ru/articles/707018/


Комментарии

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

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