В прошлой заметке я представил общественности шаблон проектирования Мейоз, как способ управления состоянием одностраничного javascript приложения. Шаблон не связан с конкретной библиотекой рендеринга, и опирается исключительно на небольшую функцию stream и нативную реализацию объекта Array в javascript.

Идея шаблона заключается в использовании streams (потоков) для хранения состояния приложения, глобального или локального (например состояния html формы), связывания, свертки и комбинации потоков в функциональном стиле. Шаблон предлагает использовать определенные реактивные структуры данных, но не обязательно буквально так как это делает автор. В рамках шаблона можно реализовать любое удобное и понятное поведение приложения.

В этой заметке я перепишу небольшое приложение "Кубики", и вместо библиотеки mithril для рендеринга будем пользоваться reactjs. Будем использовать потоки для управления глобальным и локальным состоянием, а так же, сервисы — объекты, определяющие функции, которые выполняются всякий раз, когда изменяется состояние.

Приложение "Кубики"

Приложение имеет три странички:

  • "Домой" - домашняя страница на которой предлагают залогиниться;

  • "Войти" - страница с формой аутентификации;

  • "Кубики" - страница с кубиками.

На домашней странице есть ссылка "Войти", клик по ссылке меняет страницу на страницу с формой. Состояние формы будем обрабатывать и хранить в локальном потоке диспетчера. После того как пользователь ввел логин, пароль и отправил форму, текущей страницей становится страница "Кубики". Состояние табло этой страницы будет загружено из localStorage браузера, если для данного пользователя оно было сохранено ранее, если нет, то отображаем пустое табло.

На странице с кубиками есть ссылка "Выйти", клик по ссылке меняет страницу на домашнюю, а текущее состояние табло записывается в localStorage браузера, ключом будет имя пользователя.

Начальные определения (так же, как и в прошлой заметке)

// JSX не будем использовать
const e = React.createElement;
const I = x => x; // identity

// P(atcher) - замыкание для патча
const P = patch => state => Object.assign(state, patch);

// lift - замыкание для потока update
const lift = update => patch => update(P(patch));

// определения функций humanList, descString те же, что и раньше
const humanList = s => _;
const descString = R.pipe(_);

Поскольку для рендеринга выбран reactjs, то в данной реализации будем использовать поток cells - поток связанный с потоком состояний states функцией map.

const states = ...
const disp = ...
const createCell = state => ({ state, disp });
// поток cells обновляется всякий раз при изменении потока states
const cells = states.map(createCell);

Такая необходимость вызвана с тем, что reactjs - это библиотека с односторонним связыванием, и после событий DOM, обновлять картинку необходимо в ручную. В библиотеках рендеринга с двусторонним связыванием, необходимости в потоке cells нет, хотя для того, что бы API был одинаковым в любом случае, автор паттерна предлагает всегда использовать cells.

Функции view (компоненты)

У нас три странички, на каждую своя функция view (компонент).

const HomeView = function(props) {
  // обработчик клика по ссылке Войти
  const login = evt => props.cell.disp(['login', evt]);
  return e("div", {className: "app"},
    e("nav", {className:"header"},
      e("h1", null, "Boxes"),
      e('a', { href: '#' }, 'Домой'),
      e('a', { href: '#', onClick: login }, 'Войти')
    ),
    e('h2', {className: 'cent'},
      "Чтобы поиграть в кубики нужно залогиниться")
  )
}
const LoginView = function(props) {
  // обработчик клика по ссылке Домой
  const home = evt => props.cell.disp(['home', evt]);
  // обработчик изменений полей формы
  const change = evt => props.cell.disp(['changeForm', evt])
  // обработчик клика по кнопке формы Войти
  const submit = evt => props.cell.disp(['play', evt])

  return e("div", {className: "app"},
    e("nav", {className:"header"},
      e("h1", null, "Boxes"),
      e('a', { href: '#', onClick: home}, 'Домой'),
    ),
    e('form', {className: 'cent', onSubmit: submit},
      e('legend', null, "Форма входа: любой текст в полях правильный"),
      e('input',
        { name: 'username', type: 'text', required: true, placeholder: "username",
          onChange: change
      }),
      e('input',
        { name: 'password', type: 'password', required: true, placeholder: "password",
          onChange: change
      }),
      e('input', { type: 'submit', value: "Войти"} )
    )
  )
}
const PlayView = function(props) {
  const cell = props.cell;
  // добавит кубик
  const _add = (evt, color) => {
    evt.preventDefault();
    return cell.disp(['addBox', color])
  }
  // удалит кубик
  const _remove = (evt, idx) => {
    evt.preventDefault();
    return cell.disp(['removeBox', idx])
  }
  // обработчик клика по ссыке Выйти
  const logout = evt => cell.disp(['logout', evt])

  return e("div", {className: "app"},
    e("nav", {className:"header"},
      e("h1", null, "Boxes"),
      cell.state.colors.map(color =>
        e("button", {
            style: {'backgroundColor': `${color}`},
            onClick: evt => _add(evt, `${color}`)
          }, "+"
        )
      ),
      e('a', { href: '#', onClick: logout }, "Выйти")
    ),
    e("p", null, cell.state.description),
    e("div", {className: "desc"},
      cell.state.boxes.map((x, i) =>
        e("div", {
          className: "boxs",
          style: {'backgroundColor': `${x}`},
          onClick: evt => _remove(evt, i)
        })
      )
    )
  )
}

Сервисы

В шаблоне Мейоз сервис — это объект в котором определена функция run. События DOM (или в случае React.js - "синтетические события"), которые могут изменить состояние, происходят асинхронно, случайно, и являются результатом внешних факторов (действие пользователя в основном). В отличие от событий DOM, сервис предназначен для синхронного и регулярного изменения состояния. Например, для вычисления некоторых переменных состояния, запрос/отправка данных из/в внешних или внутренних (localStorage e.g.) источников, вызов диспетчера событий, и т.д.

Сервис может изменить состояние перед тем, как конечное состояние будет записано в поток states и поток cells. Для изменения состояния сервис должен вызвать диспетчер событий либо непосредственно disp(), либо из потока cell.disp(), точно так же, как это делают компоненты представления. После того как все сервисы отработают, DOM будет перерисован с учетом финального состояния приложения. Сервис может вызвать диспетчер синхронно или асинхронно, исходя их логики приложения.

Поскольку сервис стартует всякий раз при изменении состояния, и в свою очередь сам изменяет состояние, мы можем попасть в бесконечный цикл. Что бы этого не случилось, сервис должен быть результатом вызова (потоком) специальной предохранительной функции.

const dropRepeats = (states, onchange = I) => {
  // параметр onchange - функция проверки состояния
  // если состояние изменилось, результат будет отличатся
  // от предыдущего состояния, по умолчанию это функция Identity

  let prev = undefined;
  const result = m.stream();

  // мы связываем поток states таким образом,
  // что если состояние изменилось, то текущее измененное
  // состояние будет записано в поток result,
  // иначе поток result не изменится.
  states.map(state => {
    const next = onchange(state);
    if (next !== prev) {
      prev = next;
      result(state);
    }
  });
  // фактически — это замыкание для потока result
  return result;
};

Определим два сервиса, первый отвечает за инициализацию login-формы на страничке входа, второй, за сохранение текущего состояния табло с кубиками в localStorage при выходе (logout).

// оба сервиса запускаются при изменении текущего компонента представления
const loginService = {
  onchange: state => state.view,
  run: cell => {
    if (cell.state.view === "login") {
      // login компонент - инциализируем форму
      cell.disp(['initForm']);
    } else {
      cell.disp(['cleanupForm']);
    }
  }
};
const playService = {
  onchange: state => state.view,
  run: cell => {
    if (cell.state.view === "play") {
      // play компонент - загрузить состояние табло из localStorage
      cell.disp(['loadPlay'])
    }
  }
}

Объект приложения и потоки состояний

Объект определяющий общую структуру приложения, начальное состояние, сервисы и представления будем называть "приложением".

// application object
const app = {
  // начальное состяние приложения
  initial: {
    boxes: [],
    colors: ["red", "purple", "blue"],
    description: '',
    stat: [],
    formInitial: {},
    view: 'home'
  },
  // сервисы
  services: [loginService, playService],
  // представления
  views: {
    home: HomeView,
    login: LoginView,
    play: PlayView
  }
};

Собственно все потоки, управляющие состоянием приложения нужно определять, когда в области видимости есть объект приложения, и мы знаем начальное состояние, набор сервисов и представлений. Хотя сигнатуры потоков от этого никак не зависят.

// поток обновлений
const update = m.stream()

// сверточная функция
const fold = (state, patcher) => patcher(state);

// поток состояния - свертка последовательных состояний
const states = m.stream.scan(fold, app.initial, update);

// функция создания следующей клетки
const createCell = state => ({ state, disp });

// в поток cells записывается очередная клекта всякий раз при изменении потока states
const cells = states.map(createCell);

Actions - набор функций изменяющих состояние

В оригинальной реализации Мейоз, Actions - это объект с набором функций, каждая из которых, записывает некую очередную функцию в поток update, вызывая таким образом свертку потока состояний (state, patcher) => patcher(state);. На мой взгляд — это не удобно. Удобнее определить набор функций (не связанных непосредственно с потоком update), которым поток update доступен в виде замыкания update => patch => update(P(patch));.

const Actions = (state, update) => {
 const stup = lift(update);
 return {
    // preventDefault для события
    prevent(d) {
      let [e] = d;
      e.preventDefault();
      return false;
    },
    // устанавливаем текущей страницей Home
    home(d) {
      stup({ view: 'home'});
      return this.prevent(d);
    },
    // устанавливаем текущей страницей Login
    login(d) {
      stup({ view: 'login'});
      return this.prevent(d);
    },
    ...
    // при выходе мы хотим сохранить состояние табло в localStorage браузера
    savePlay(d) {
      let user = state().username, boxes= state().boxes; self = this;
      if(user) {
        setTimeout(
          () => {
            localStorage.setItem(user, JSON.stringify( boxes ));
            self.countStat([]);
            return self.home(d)
          },
          500
        )
      }
    },
    ...
  }
}

Полное определение объекта можно посмотреть в исходном коде примера.

Локальное состояние и состояние формы

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

Для начала посмотрим как устроена комбинация потоков.

// combine - комбинация потоков
// два независимых потока
const ints1 = m.stream(10);
const ints2 = m.stream(0);

// функция, которая вычисляет выходной (комбинированный) поток
const combiner = ( v1, v2 ) => v1() + v2();

// при изменении любого из потоков ints1, ints2
// в потоке combined будет сумма значений этих потоков
const combined = m.stream.combine( combiner, [ints1, ints2] );
console.log(combined()); // logs 10
ints2(120);
console.logs(combined()); // logs 130

Пользуясь комбинацией определим в функции Actions потоки и методы обработки событий для некоторой формы (потоки не привязаны к конкретной форме, просто механика контроля состояния).

const Actions = (state, update) => {
  // поток инициализации состояний формы
  const formId = m.stream('');

  // поток изменений в полях формы
  const formChanges = m.stream({});

  // функция обновления текущего состояния формы
  const updateForm = (form, changed) => {
    // target события onChange
    const target = changed().target;
    // value
    let value = target.value;
    // вернем новое состояние формы
    return Object.assign(form(), { [target.name]: value });
  };

  // поток состояния всей формы как комбинация потоков formId и formChanges
  // состояние обновляется при инициализации формы в formId('')
  // или после события onChange, которе записывает объект события в поток formChanges
  const formState = m.stream.combine((fid, newvalue, changed) => {
    // инициализация потока - пустой объект
    if (changed.length > 1)
      return {};

    // обработка изменеиий потока formId, первого из [formId, formChanges]
    // здесь мы запишем в поток значение state.formInitial
    let c = changed[0]();
    if (typeof c === 'string' || typeof c === 'number')
      // поток formId инициализирует состояние только если в этот поток
      // записана строчка или число
      return state().formInitial;

    // обработка изменений в потоке formChanges
    return updateForm(formState, newvalue);
  }, [formId, formChanges]);

  const stup = lift(update);
  return {
    // prevent default on event
    prevent(d) {
      let [e] = d;
      e.preventDefault();
      return false;
    },

    // метод для инициализации состояния формы
    initForm() {
      formId('');
      stup({formInitial: {}, form: formState});
      return false;
    },

    // метод обработки событий onChange
    changeForm(d) {
      let [e] = d;
      formChanges(e);
      return this.prevent(d);
    },

    // удалим форму при изменении текущей страницы
    cleanupForm() {
      let form = state().form || undefined;
      if (form) {
        let username = form().username || undefined;
        stup({form: undefined, username});
      }
      return false;
    },
    ...
  }
}

Таким образом состояние формы хранится локально в потоке formState, а в глобальном состоянии находится только ссылка на этот поток, все события обрабатывает поток диспетчера.

Инициализация сервисов и приложения

Для того чтобы сервисы заработали, нужно связать поток сервиса с состоянием приложения.

app.services.forEach((service) => {
  dropRepeats(states, service.onchange).map(state =>
    service.run(createCell(state))
  );
});

Кроме этого, определим функцию инициализации всего приложения, это может пригодиться в дальнейшем как унифицированный API для разных типов приложений. И определим глобальный компонент, который будет рендерится в браузере.

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

// Контейнер для рендеринга
const AppView = function(props) {
  return app.views[props.cell.state.view](props);
}

Монтируем контейнер к DOM.

const domContainer = document.getElementById('app');
const root = ReactDOM.createRoot(domContainer);

// обновляем страницу каждый раз когда изменяется состояние
// и в поток cells записывается очередная клетка
cells.map(cell => root.render( e(AppView, {cell} )));

Особенности реализации

У каждой js библиотеки для разработки SPA есть свои собственные встроенные способы управления состоянием. Кроме этого, для многих из них написаны самые разные сторонние менеджеры состояний со своими специальными правилами, преимуществами и недостатками.

Можно видеть, что Мейоз не является ни библиотекой, ни менеджером состояний. Это скорее некоторый набор идей, как это можно делать, не зависимо от способа рендеринга. Как это будет сделано зависит исключительно от типа приложения и архитектурных соображений.

Код который здесь написан во многом отличается от кода автора Мейоз, то есть, вы свободны в выборе стиля и конкретных решений. Можно переделать этот код, вынести асинхронный код например в сервисы, и он будет работать.

Код этого приложения можно посмотреть на githube.

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


  1. nin-jin
    12.01.2023 11:11
    -1

    Это всё, конечно, замечательно, но вас не смущает полный ререндер всего приложения на каждый чих?


    1. aughing Автор
      12.01.2023 11:25

      Это же просто модель, в космос она не полетит


      1. nin-jin
        12.01.2023 17:07
        -1


        1. aughing Автор
          13.01.2023 04:13
          -1

          Скорее так