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