Состояние 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-е.
