В поисках серебрянной пули: акторы+FRP в Реакте

Сейчас уже мало кто пишет на Perl’е, но известная максима Ларри Уолла «Keep simple things easy and hard thing possible» стала общепринятой формулой эффективной технологии. Ее можно трактовать в аспекте не только сложности задач, но и подхода: идеальная технология должна, с одной стороны, позволять быструю разработку средних и малых приложений (в т.ч. «write-only»), с другой — предоставлять инструменты для вдумчивого девелопмента сложных приложений, где на первое место ставиться надежность, поддерживаемость и структурированность. Или даже, переводя в человеческую плоскость: быть доступной для джунов, и в то же время удовлетворять запросы синьйоров.

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


«Вы будто бы храните все столы в одной комнате, а стулья — в другой»
— Юха Паананен, творец библиотеки Bacon.js, о Редаксе

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

Mrr — это функционально-реактивная библиотека, которая исповедует принцип «все — это поток». Основные достоинства, которые дает фунционально-реактивный подход в mrr: лаконичность, выразительность кода, а также унифицированный подход для синхронных и асинхронных превращений данных.

На первый взгляд, это не звучит как технология, которая будет легко доступна начинающим: концепция потока может быть сложной для понимания, она не так распостранена на фронтенде, ассоциируясь главным образом с такими матанными библиотеками, как Rx. А главное, не совсем понятно, как объяснить потоки исходя из базовой схемы «действие-реакция-обновление DOM». Но… мы не будем абстрактно говорить о потоках! Поговорим о более понятных вещах: событиях, состоянии.


Готовим по рецепту

Не залезая в дебри FRP, мы будем следовать простой схеме формализации предметной области:


  • составить список данных, которые описывают состояние страницы и будут использоваться в юзер-интерфейсе, а также их типы.
  • составить список событий, который происходят или генерируются юзером на странице, и типы данных, которые будут с ними передаваться
  • составить список процессов, которые будут происходить на странице
  • определить взаимозависимости между ними.
  • описать взаимозависимости с помощью соответствующих операторов.

При этом знания библиотеки нам понадобяться только на самом последнем этапе.

Итак, давайте возьмем упрощенный пример веб-магазина, в котором есть список товаров с пагинацией и фильтрацией по категории, а также корзина.


  1. Данные, на основе которых будет строиться интерфейс:


    • список товаров (массив)
    • выбранная категория (строка)
    • количество страниц с товарами (число)
    • список товаров которые есть в корзине (массив)
    • текущая страница (число)
    • количество товаров в корзине (число)

  2. События (под «событиями» имеют ввиду только моментальные события. Действия, которые происходят некоторое время — процессы — нужно разложить на отдельные события):


    • открытие страницы (void)
    • выбор категории (строка)
    • добавление товара в корзину (объект «товар»)
    • удаление товара из корзины (id товара, который удаляется)
    • переход на следующую страницу списка товаров (число — номер страницы)

  3. Процессы: это действия, которые начинаются, а затем могут завершиться разными событиями сразу или через некоторое время. В нашем случае это будет загрузка данных о товарах с сервера, которая может повлечь два события: успешное завершение и завершение с ошибкой.


  4. Взаимозависимости между событиями и данными. Например, список товаров будет зависеть от события: «успешная загрузка списка товаров». А «начало загрузки списка товаров» — от «открытие страницы», «выбор текущей страницы», «выбор категории». Составим список вида [элемент]: [… зависимости]:

    {
        requestGoods: ['page', 'category', 'pageLoaded'],
        goods: ['requestGoods.success'],
        page: ['goToPage', 'totalPages'],
        totalPages: ['requestGoods.success'],
        cart: ['addToCart', 'removeFromCart'],
        goodsInCart: ['cart'],
        category: ['selectCategory']
    }
    

Ой… да ведь это уже почти код на mrr получился!

_xjgs7dbqnztuaffvtgm96auncm.png

Осталось лишь добавить функции, которые будут описывать взаимосвязь. Возможно, вы ожидали, что события, данные, процессы будут разными сущностями в mrr —, но нет, все это — потоки! Наша задача лишь правильно их связать.
Как видим, у нас есть два типа зависимостей: «данные» от «события»(например, page от goToPage) и «данные» от «данных» (goodsInCart от cart). Для каждого из них есть соответствующие подходы.

Проще всего с «данными от данных»: тут просто добавляем чистую функцию-«формулу»:

goodsInCart: [arr => arr.length, 'cart'],

При каждом изменении массива cart значение goodsInCart будет пересчитываться.

Если у нас данные зависят от одного события, то все тоже довольно просто:


category: 'selectCategory',
/*
то же саме что 
category: [a => a, 'selectCategory'],
*/
goods: [resp => resp.data, 'requestGoods.success'],
totalPages: [resp => resp.totalPages, 'requestGoods.success'],

Конструкция вида [функция, … потоки-аргументы] — основа mrr. Для интуитивного понимания, проводя аналогию с Экселем, потоки в mrr называются также ячейками, а функции, по которым они вычисляются — формулами.

Если же у нас данные зависят от нескольких событий, мы должны трансформировать их значения индивидуально, а затем объединить в один поток с помощью оператора merge:

/*
да, оператор merge - просто строка, это нормально
*/
    page: ['merge', 
        [a => a, 'goToPage'], 
        [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']
    ],
    cart: ['merge', 
        [(item, arr) => [...arr, item], 'addToCart', '-cart'],
        [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'],
    ],

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

Все потоки либо строятся на основе других потоков, либо эмитируются из DOM. Но как быть с потоком «открытие страницы»? К счастью, использовать componentDidMount не придется: в mrr есть специальный поток $start, который и сигнализирует о том, что компонент был создан и примонтирован.

«Процессы» вычисляются асинхронно, при этом мы эмитируем те или иные события из них, тут нам поможет оператор «nested»:

requestGoods: ['nested', (cb, page, category) => {
    fetch("...")
    .then(res => cb('success', res))
    .catch(e => cb('error', e));
}, 'page', 'category', '$start'],

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

cb('success', res)

внутри формулы «requestGoods» повлечет обновление ячейки «requestGoods.success».

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

{
    goods: [],
    page: 1,
    cart: [],
},

Добавим разметку. Мы создаем React компонент с помощью функции withMrr, которая принимает схему реактивных связей и render-функцию. Для того чтобы «положить» значение в поток, мы используем функцию $, создающую (и кэширующую) хэндлеры событий. Теперь наше вполне рабочее приложение выглядит так:

import { withMrr } from 'mrr';

const App = withMrr({
    $init: {
        goods: [],
        cart: [],
        page: 1,
    },
    requestGoods: ['nested', (cb, page = 1, category = 'all') => {
        fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json())
        .then(res => cb('success', res))
        .catch(e => cb('error', e))
    }, 'page', 'selectCategory', '$start'],
    goods: [res => res.data, 'requestGoods.success'],
    page: ['merge', 'goToPage', [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']],
    totalPages: [res => res.total_pages, 'requestGoods.success'],
    category: 'selectCategory',
    cart: ['merge', 
        [(item, arr) => [...arr, item], 'addToCart', '-cart'],
        [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'],
    ],
}, (state, props, $) => {
    return (

Shop

Category:
    { state.goods.map((item, i) => { const cartI = state.cart.findIndex(a => a.id === item.id); return (
  • { item.name }
    { cartI === -1 && } { cartI !== -1 && }
  • ); }) }
    { new Array(state.totalPages).fill(true).map((_, p) => { const page = Number(p) + 1; return (
  • { page }
  • ); }) }

Cart

    { state.cart.map((item, i) => { return (
  • { item.name }
  • ); }) }
); }); export default App;

Конструкция