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

Комментарии (0)