javascript

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

  • четверг, 14 февраля 2019 г. в 00:21:22
https://habr.com/ru/post/438112/
  • JavaScript


Сейчас уже мало кто пишет на 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 получился!



Осталось лишь добавить функции, которые будут описывать взаимосвязь. Возможно, вы ожидали, что события, данные, процессы будут разными сущностями в 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 (<section>
        <h2>Shop</h2>
        <div>
            Category: <select onChange={$('selectCategory')}>
                <option>All</option>
                <option>Electronics</option>
                <option>Photo</option>
                <option>Cars</option>
            </select>
        </div>
        <ul className="goods">
            { state.goods.map((item, i) => { 
                const cartI = state.cart.findIndex(a => a.id === item.id);
                return (<li key={i}>
                    { item.name }
                    <div>
                        { cartI === -1 && <button onClick={$("addToCart", item)}>Add to cart</button> }
                        { cartI !== -1 && <button onClick={$("removeFromCart", item.id)}>Remove from cart</button> }
                    </div>
                </li>);
            }) }
        </ul>
        <ul className="pages">
            { new Array(state.totalPages).fill(true).map((_, p) => {
                const page = Number(p) + 1;
                return (
                    <li className="page" onClick={$('goToPage', page)} key={p}>
                        { page }
                    </li>
                );
            }) }
        </ul>
    </section>
    <section>
        <h2>Cart</h2>
        <ul>
            { state.cart.map((item, i) => { 
                return (<li key={i}>
                    { item.name }
                    <div>
                        <button onClick={$("removeFromCart", item.id)}>Remove from cart</button>
                    </div>
                </li>);
            }) }    
        </ul>
    </section>);
});

export default App;

Конструкция


<select onChange={$('selectCategory')}>

означает, что при изменении поля будет "протолкнуто" значение в поток selectCategory. Но какое значение? По умолчанию это — event.target.value, если же нам нужно протолкнуть что-то другое, мы указываем его вторым аргументом, как тут:


<button onClick={$("addToCart", item)}>

Все здесь — и события, и данные, и процессы — это потоки. Срабатывание события вызывает перерасчет зависящих от него данных или событий, и так далее по цепочке. Значение зависимого потока вычисляется по формуле, которая может вернуть значение, либо промис (тогда mrr дождется его резолва).


API mrr очень лаконично и кратко — для большинства случаев нам нужно всего 3-4 базовых оператора, а многие вещи можно сделать и без них. Добавим сообщение об ошибке при неуcпешной загрузке списка товаров, которое будет показываться одну секунду:


hideErrorMessage: [() => new Promise(res => setTimeout(res, 1000)), 'requestGoods.error'],
errorMessageShown: [
    'merge',
    [() => true, 'requestGoods.error'],
    [() => false, 'hideErrorMessage'],
],

Соль, перец, сахар — по вкусу


Есть в mrr и синтаксический сахар, который необязателен для разработки, но может ее ускорить. Например, оператор toggle:


errorMessageShown: ['toggle', 'requestGoods.error', [() => new Promise(res => setTimeout(res, 1000)), 'showErrorMessage']],

Изменение в первом аргументе установит значение ячейки в true, во втором — в false.
Подход с "разложением" результатов выполнения асинхронной задачи по подъячейкам success и error также настолько распостранен, что для этого можно использовать специальный оператор promise (заодно автоматически избавляющий от race condition):


    requestGoods: [
        'promise', 
        (page = 1, category = 'all') => fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()), 
        'page', 'selectCategory', '$start'
    ],

Довольно большой функционал вместился всего в пару десятков строчек. Наш условный джун доволен — ему удалось написать рабочий код, который получился довольно компактным: вся логика поместилась в одной файле и на одном экране. Но синьйор недоверчиво щурится: эка невидаль… такое ведь можно и написать и на хуках/recompose/т.д.


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


Один подход, один интерфейс


Тут мы подошли к еще одной проблеме реакт-разаботки: существование разнородных подходов для управления состоянием локально (внутри компонента) и глобально на уровне всего приложения. Многие, уверен, сталкивались с дилеммой: реализовывать некую логику локально или глобально? Или другая ситуация: оказалось, что какой-то кусок локальных данных нужно сохранять глобально, и приходится переписать часть функционала, скажем, с рекомпоза на редакс...


Противопоставление это, конечно, искуственно, и в mrr его нет: он одинаково хорошо, а главное — единообразно! — подходит и для локального, и для глобального управления состоянием. Никакого глобального состояния, в общем-то, нам и не нужно, просто есть возможность обмениваться данными между компонентами, таким образом, состояние корневого компонента и будет "глобальным".


Схема нашего приложение теперь такова: корневой компонент, содержащий список товаров в корзине, и два вложенных: товары и корзина, причем глобальный компонент "слушает" потоки "добавить в корзину" и "удалить из корзины" из дочерних компонентов.


const App = withMrr({
    $init: {
        cart: [],
        currentPage: 'goods',
    },
    cart: ['merge', 
        [(item, arr) => [...arr, item], 'addToCart', '-cart'],
        [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'],
    ],
}, (state, props, $, connectAs) => {
    return (
        <div>
            <menu>
                <li onClick={$('currentPage', 'goods')}>Goods</li>
                <li onClick={$('currentPage', 'cart')}>Cart{ state.cart && state.cart.length ? '(' + state.cart.length + ')' : '' }</li>
            </menu>
            <div>
                { state.currentPage === 'goods' && <Goods {...connectAs('goods', ['addToCart', 'removeFromCart'], ['cart'])}/> }
                { state.currentPage === 'cart' && <Cart {...connectAs('cart', { 'removeFromCart': 'remove' }, ['cart'])}/> }
            </div>
        </div>
    );
})


const Goods = withMrr({
    $init: {
        goods: [],
        page: 1,
    },
    goods: [res => res.data, 'requestGoods.success'],
    requestGoods: [
        'promise', 
        (page = 1, category = 'all') => fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()), 
        'page', 'selectCategory', '$start'
    ],
    page: ['merge', 'goToPage', [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']],
    totalPages: [res => res.total, 'requestGoods.success'],
    category: 'selectCategory',
    errorShown: ['toggle', 'requestGoods.error', [cb => new Promise(res => setTimeout(res, 1000)), 'requestGoods.error']],
}, (state, props, $) => {
    return (<div>
        ...
    </div>);
});

const Cart = withMrr({}, (state, props, $) => {
    return (<div>
        <h2>Cart</h2>
        <ul>
            { state.cart.map((item, i) => { 
                return (<div>
                    { item.name }
                    <div>
                        <button onClick={$('remove', item.id)}>Remove from cart</button>
                    </div>
                </div>);
            }) }    
        </ul>
    </div>);
});

Удивительно, как мало всего изменилось! Мы просто разложили потоки по соответствующим компонентам и проложили "мостики" между ними! Соединяя компоненты с помощью функции mrrConnect, мы указываем маппинг для нисходящих и восходящих потоков:


connectAs(
    'goods',
    /* вверх */
    ['addToCart', 'removeFromCart'], 
    /* вниз */
    ['cart']
)

Здесь потоки addToCart и removeFromCart из дочернего компонента будут поступать в родительский, а поток cart — в обратном направлении. Мы не обязаны использовать одинаковые имена потоков — если они не совпадают, мы используем маппинг:


connectAs('cart', { 'removeFromCart': 'remove' })

Поток remove из дочернего компонента будет источником для потока removeFromCart в родительском.


Как видите, проблема выбора места хранения данных в случае mrr полностью снимается: вы храните данные там, где это логически обусловлено.


Тут еще раз нельзя не отметить недостаток Редакса: в нем вы обязаны сохранять все данные в едином центральном хранилище. Даже данные, которые, возможно, будут запрашиваться и использоваться только одним отдельным компонентом или его поддеревом! Если бы мы писали в "стиле редакса", мы бы вынесли также и загрузку и пагинацию товаров на глобальный уровень(справедливости ради — такой подход, благодаря гибкости mrr, также возможен и имеет право на жизнь, исходный код).


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


Другим преимуществом такого подхода есть то, что логика приложения объединена с представлением, что позволяет нам переиспользовать отдельные React компоненты как полнофункциональным виджеты, а не как "глупые" шаблоны. Также, сохрання минимум информации на глобальном уровне (в идеале это только данные сессии) и вынося большинство логики в отдельные компоненты страниц, мы сильно уменьшаем связанность кода. Конечно, такой подход не везде применим, но существует большое количество задач, где глобальный стейт крайне невелик и отдельные "экраны" почти полностью независимы друг от друга: например, разного рода админки и т.д. В отличии от Редакса, провоцирующего нас выносить все что нужно и не нужно на глобальный уровень, mrr позволяет хранить данные в отдельных поддеревьях, поощряя и делая возможной инкапсуляцию, таким образом наше приложение из монолитного "пирога" превращается в слоенный "торт".


Стоит оговориться: ничего революционно нового в предложенном подходе, конечно же, нет! Компоненты-самодостаточные виджеты — были одним из базовых подходов, применяемым с самого момента появления js-фреймворков. Существенная разница состоит лишь в том, что mrr следует декларативному принципу: компоненты могут лишь слушать потоки других компонентов, но не могут на них влиять (при чем как в направлении "снизу-вверх", так и "сверху вних", что отличается от flux-подхода). "Умные" компоненты, которые могут лишь обмениваться сообщениями с низлежащими и родительским компонентами, соотвествуют популярной, но малоизвестной во фронтенд-разработке модели акторов ( тема использования акторов и потоков на фронтенде неплохо разжевана в статье Введение в реактивное программирование ).
Конечно, это далеко не каноническая реализация акторов, но суть именно такова: в роли акторов выступают компоненты, обменивающиеся сообщениями через мрр потоки; компонент может(декларативно!) создавать и удалять дочерние акторы-компоненты благодаря virtual DOM и Реакту: render-функция, по сути, определяет структуру дочерних акторов.


Вместо стандартной для Реакта ситуации, когда мы из родительского компонента "опускаем" в дочерний некий колбэк через props, мы должны слушать поток дочернего компонента из родительского. То же самое и в обратном направлении, из родительского в дочерний. Например, вы можете спросить: а зачем передавать данные корзины cart в компонент Cart как поток, если мы можем, не мудрствуя лукаво, просто передать их как props? В чем разница? Действительно, такой подход также может использоваться, но лишь до тех пор, пока не возникнет потребность реагировать на изменение props. Если вам когда-нибудь доводилось использовать метод componentWillReceiveProps, то вы знаете о чем идет речь. Это эдакая "реактивность для бедных": вы слушаете абсолютно все изменения props, определяете, что же изменилось, и реагируете. Но метод этот скоро изчезнет из React'а, а потребность в реакции на "сигналы свыше" может возникнуть.


В mrr потоки "текут" не только вверх, но и вниз по иерархии компонентов, для того чтобы компоненты могли самостоятельно реагировать на изменения состояния. При этом вы можете использовать всю мощь реактивных инструментов mrr.


const Cart = withMrr({
    foo: [items => { 
    // что-нибудь делаем
    }, 'cart'],
}, (state, props, $) => { ... })

Добавляем немножко бюрократии


Проект разрастается, становится сложно уследить за названиями потоков, которые — о ужас! — хранятся в строках. Что ж, мы можем использовать константы для имен потоков, а также для операторов mrr. Теперь поломать приложение, допустив небольшую опечатку, становится сложнее.



import { withMrr } from 'mrr';
import { merge, toggle, promise } from 'mrr/operators';
import { cell, nested, $start$, passive } from 'mrr/cell';

const goods$ = cell('goods');
const page$ = cell('page');
const totalPages$ = cell('totalPages');
const category$ = cell('category');
const errorShown$ = cell('errorShown');
const addToCart$ = cell('addToCart');
const removeFromCart$ = cell('removeFromCart');
const selectCategory$ = cell('selectCategory');
const goToPage$ = cell('goToPage');

const Goods = withMrr({
    $init: {
        [goods$]: [],
        [page$]: 1,
    },
    [goods$]: [res => res.data, requestGoods$.success],
    [requestGoods$]: promise((page, category) => fetch('https://reqres.in/api/products?page=', page).then(r => r.json()), page$, category$, $start$),
    [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : prev, totalPages$, passive(page$)]),
    [totalPages$]: [res => res.total, requestGoods$.success],
    [category$]: selectCategory$,
    [errorShown$]: toggle(requestGoods$.error, [cb => new Promise(res => setTimeout(res, 1000)), requestGoods$.error]),
}, ...);

Что в черном ящике?


А что насчет тестирования? Логику, описанную в компоненте mrr, легко отделить от шаблона, и затем протестировать.


Сделаем, чтобы из нашего файла можно было экспортировать отдельно структуру mrr.


const GoodsStruct = {
    $init: {
        [goods$]: [],
        [page$]: 1,
    },
    ...
}

const Goods = withMrr(GoodsStruct, (state, props, $) => { ... });

export { GoodsStruct }

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


import { simpleWrapper} from 'mrr';  
import { GoodsStruct } from '../src/components/Goods';  

describe('Testing Goods component', () => {
    it('should update page if it\'s out of limit ', () => {
        const a = simpleWrapper(GoodsStruct);

        a.set('page', 10);
        assert.equal(a.get('page'), 10);

        a.set('requestGoods.success', {data: [], total: 5});
        assert.equal(a.get('page'), 5);

        a.set('requestGoods.success', {data: [], total: 10});
        assert.equal(a.get('page'), 5);
    })
})

Блеск и нищета реактивности


Стоит заметить, что реактивность является абстракцией более высокого уровня по сравнению с "ручным" формированием состояния на основе событий в Редаксе. Облегчая разработку, с одной стороны, она создает возможности выстрелить себе в ногу. Рассмотрим такой сценарий: юзер переходит на страницу №5, затем переключает фильтр "категория". Мы должны подгрузить список товаров выбранной категории на пятой странице, но может оказаться что товаров данной категории наберется всего только три страницы. В случае с "глупым" бекендом, алгоритм наших действий таков:


  • запросить данные page=5&category=%category%
  • взять из ответа значение количества страниц
  • если вернулось нулевое количество записей, запросить наибольшую доступную страницу

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


    [requestGoods$]: ['nested', (cb, page, category) => {
        fetch('https://reqres.in/api/products?page=', page).then(r => r.json())
        .then(res => cb('success', res))
        .catch(e => cb('error', e))
    }, page$, category$, $start$],
    [totalPages$]: [res => res.total, requestGoods$.success],
    [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : prev, totalPages$, passive(page$)]),

Если новое значение totalPages будет меньше, чем текущая страница, мы обновим значение page и тем самым инициируем повторное выполнение запроса к серверу.
Но если наша функция вернет то же самое значение, это все равно будет воспринято как изменение потока page с последующей рекулькуляцией всех зависимых потоков. Чтобы избежать этого, в mrr есть специальное значение — skip. Возвращая его, мы сигнализируем: изменений не произошло, обновлять ничего не нужно.


import { withMrr, skip } from 'mrr';

    [requestGoods$]: nested((cb, page, category) => {
        fetch('https://reqres.in/api/products?page=', page).then(r => r.json())
        .then(res => cb('success', res))
        .catch(e => cb('error', e))
    }, page$, category$, $start$),
    [totalPages$]: [res => res.total, requestGoods$.success],
    [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : skip, totalPages$, passive(page$)]),

Таким образом, одна небольшая ошибка может привести нас к бесконечному циклу: если мы будем возвращать не "skip", а "prev", все равно будет происходить изменение ячейки page и повторный запрос, и так по кругу. Сама возможность такой ситуации, конечно, не является "порочным недостатком" FRP или mrr, как возможность бесконечной рекурсии или цикла не свидетельствуют о порочности идей структурного программирования. Однако стоит понимать, что mrr все же требует некоего понимания механизма реактивности. Возвращаясь к известной метафоре о ножах, mrr является весьма острым ножом, который повышает эффективность работы, но также может ранить неумелого работника.


Кстати, дебажить mrr очень легко без установки каких-либо расширений:


const GoodsStruct = {
    $init: {
       ...
    },
    $log: true,
    ...
}

Просто добавим $log: true в структуру mrr, и все изменения ячеек будут выводиться в консоль, так что вы сможете проследить, что и как изменяется.


Такие концепции, как пассивное слушание или значение skip, не являются специфическими "костылями": они расширяют возможности реактивности настолько, чтобы с ее помощью можно было легко описывать всю логику приложения, не прибегая к помощи императивных подходов. Аналогичные механизмы есть, например, и в Rx.js, но их интерфейс там менее удобный. Более подробно пассивное слушание и различные виды операторов рассмотрены в предыдущей статье: Mrr: тотальное FRP для Реакта


Исходный код готового примера.


Итоги


  • благодаря абстракции FRP, выразительности и лаконичности на mrr можно лихо и быстро написать много функционала малым количеством кода, и это не будет лапшой
  • но изучать сложные концепции или десятки операторов для этого не нужно: достаточно базового понимания реактивности, а для начинающих есть даже общий алгоритм формализации
  • а если вы вдруг поймете, что ваш проект будет еще дорабатываться и развиваться, вы можете легко прорефакторить и улучшить структуру почти без переписывания кода
  • благодаря расположению всей логики управления состоянием в одном месте, обычно вместе с представлением, вы сможете также намного быстрее вникать в написанный кем-то код(столы и стулья наконец-то вместе!)
  • но логика изменения состояния легко отделима от представления и удобно тестируется
  • лозунг mrr также: "Храните данные там, где вам нужно!" Выбрав правильный уровень хранения данных вы избавите себя от многих сложностей
  • разбивая состояние приложения на слабосвязанные части, вы уменьшаете общую связанность и сложность
  • но будьте осторожны, если эти части начинают знать слишком много друг о друге, возможно стоит их объединить(мнимое разделение кода только усложняет структуру и уменьшает надежность). Не городите многоуровневые взаимозависимые конструкции!
  • из минусов также можно отметить: отсутствие, на данный момент, поддержки типизации, а также принцип TMTOWTDI: возможность написать один и тот же функционал несколькими разными способами, из-за чего задача выработки оптимальных и унифицированных подходов к разработке ложится на плечи самих разработчиков.

P.S.


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


import useMrr from 'mrr/hooks';

function Foo(props){
    const [state, $, connectAs] = useMrr(props, {
        $init: {
            counter: 0,
        },
        counter: ['merge', 
            [a => a + 1, '-counter', 'incr'],
            [a => a - 1, '-counter', 'decr']
        ],
    });
    return ( <div>
        Counter: { state.counter }
            <button onClick={ $('incr') }>increment</button>
            <button onClick={ $('decr') }>decrement</button>
            <Bar {...connectAs('bar')} />
    </div> );
}