Аппликативные вычисления на основе данных

Мотивация

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

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

Другое решение - использование функционального реактивного программирования (FRP): мы представляем события пользовательского интерфейса как потоки , а затем объединяем эти потоки, используя различные функции, для создания других потоков. Состояние программы можно представить с помощью Behaviors : абстракции для представления значений, изменяющихся во времени.

Одним из популярных способов управления изменениями состояния и распространением является использование Observables: представьте себе своего рода Box, в который мы помещаем какое-то значение. Каждый раз, когда мы изменяем значение внутри поля, программа заботится об уведомлении кода пользователя. Наблюдаемые также можно комбинировать для создания других наблюдаемых, как мы объединяем ячейки в электронной таблице для создания других ячеек. Библиотека Observable также обычно предоставляет декларативный способ поддерживать UI в актуальном состоянии с данными в наблюдаемых объектах.

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

Предоставлено: статья Конала Эллиота.

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

Статью может быть немного сложно для тех, кто не знаком с языком программирования Haskell. В этом посте я попытаюсь представить концепцию с использованием JavaScript, надеюсь, доступным способом.

Ссылка на Gist, содержащую исходный код, находится в конце этой статьи

Во-первых, что мы подразумеваем под «управляемыми данными»?

Начнем с цитирования первого абзаца статьи.

Графические пользовательские интерфейсы (GUI) обычно программируются в «неестественном» стиле, в котором зависимости реализации инвертируются по сравнению с логическими зависимостями. Мы предполагаем, что это изменение является прямым следствием императивной ориентации на данные большинства библиотек графического интерфейса.

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

Рассмотрим этот простой пример

const a = 100
const b = divide(a, 10)
const c = multiply(a, sum(a, b))

В JavaScript приведенные выше строки соответствуют исполняемым операторам, которые должны выполняться последовательно (попробуйте поместить объявление c перед b, JavaScript не сможет разрешить b, потому что он еще не объявлен во время оценки c). В чистом языке FP, таком как Haskell, нет понятия исполняемых операторов. Мы не говорим компилятору выполнять приведенные выше строки последовательно (фактически, мы не говорим ему выполнять что-либо). Мы просто пишем определения для эквивалентностей. b = divide(a, 10) буквально означает, что два члена уравнения эквивалентны, и что компилятор может использовать divide(a, 10) вместо b всякий раз, когда b появляется в выражении. Мы не говорим о присваивании или хранении в памяти, просто об уравнениях.

В приведенном выше примере, чтобы получить значение c, мы требуем его (обычно через функцию main). Компилятор начинает вычислять c, заменяя его эквивалентным термином multiply(a, sum(a, b)). Затем, чтобы оценить новый термин, мы заменяем multiply его эквивалентным определением и так далее. Этот процесс продолжается до тех пор, пока мы не достигнем неприводимых терминов (примитивных терминов, которые нельзя заменить другими терминами, такими как константные литералы).

Эта форма оценки, также известная как оценка по запросу, имеет одно особое свойство: зависимости логически разрешаются в правильном порядке. Например, программа не может оценить c до b, потому что значение b необходимо для оценки c.

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

А теперь, что может означать инвертированные зависимости и ориентированная на атта ориентация?

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

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

// represents the program state
var a, b, c
input.addEventListener('change', () => {
  a = input.value
  b = divide(a, 10)
  c = multiply(a, sum(a, b))
})

Тогда мы получим тот же результат, что и выше. На самом деле есть тонкая и важная разница. Неочевидно иллюстрировать на императивном языке, таком как JavaScript. Чтобы сделать его более очевидным, давайте перепишем первый пример таким образом, чтобы имитировать язык программирования FP.

function a() {
  return 100
}
function c() {
  return multiply(a(), sum((a(), b())
}
function b() {
  return divide(a(), 10)
}

Вы заметили, как мы поместили декларацию c перед b. Что произойдет, если мы вызовем c() на консоли? мы все еще получаем правильное значение? конечно да. Совершенно не имеет значения, в каком порядке мы разместим вышеуказанные объявления. Оценка функции всегда разрешает зависимости в правильном порядке.

Что произойдет, если мы изменим порядок во втором примере?

//...
input.addEventListener('change', () => {
  a = input.value
  c = multiply(a, sum(a, b))
  b = divide(a, 10)
})

Поскольку c обновляется до b, его значение вычисляется с использованием устаревшего значения b. Здесь вы заметите, что порядок, в котором мы размещаем вещи, имеет решающее значение. Мы должны расположить наши утверждения в правильном порядке зависимостей. Другими словами, вместо того, чтобы полагаться на простой вызов функции, который естественным образом разрешает свои зависимости в логическом порядке. Теперь у нас есть бремя самостоятельно обеспечивать порядок вещей. Или также: вместо того, чтобы начинать снизу - выходное значение, равное c - и извлекать все его входы, мы выбираем обратный путь: мы начинаем сверху - входное значение, которое равно a - и сами отправляем обновления на все выходы. . Вычисление происходит в обратном порядке.

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

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

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

Еще раз цитирую автора

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

также

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

Решение, представленное в этом посте, является более неформальным и упрощенным, чтобы сделать его доступным (особенно я скрываю IO). Также я не привожу часть авторской функциональной модели GUI (возможно, в другом посте).

Модель, описанная в статье, основана на трех концепциях.

  • Getters (экстракторы в документе) для получения некоторой части состояния
  • Уведомители абстрагируются от уведомления об изменении
  • Источники объединяют две предыдущие концепции для представления состояния, изменяющегося во времени.

Геттеры

Getter - это функция, которая не принимает параметров и возвращает значение.

Getter a :: () -> a

Я пишу аннотации типов в нотации псевдо-Haskell. Объявление выше означает, что Getter для данного типа a - это функция, которая ничего не принимает и возвращает этот тип.

Пример

// message :: Getter String
const message = () => 'Hello world!'
// scrollTop :: Element -> Getter Number
function scrollTop(element) {
  return () => element.scrollTop
}
current value of `a`
var a = 10
// getA :: Getter Number
const getA = () => a

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

Начнем с вычислений для одного значения. Учитывая Getter a и функцию f :: a -> b, мы получим Getter b, который вернет f(a), если исходный Getter вернет a.

// gmap :: ((a -> b), Getter a), Getter b
function gmap(fn, getter) {
  return () => fn(getter())
}

Обратите внимание, что gmap просто обозначает композицию функций

// compose :: ((a -> b), (x -> a)) -> (x -> b)
function compose(f, g) {
  return x => f(g(x))
}

Просто замените x в аннотации типа на ().

Например, учитывая текущее значение ввода DOM, нам нужен Getter с обрезанным текстом в верхнем регистре.

// inputValue :: HTMLInputElement -> Getter String
function inputValue(input) {
  return () => input.value
}
// getValue :: Getter String
const getValue = inputValue(document.getElementById('my-input'))
function sanitizeText(text) {
  return text.trim().toUpperCase()
} 
const getSanitizedText = gmap(sanitizeText, getValue)

Конечно, мы можем написать наш Getter напрямую как () => domInput.value.trim().toUpperCase(), но, делая это, мы теряем преимущество композиции. Посмотрим, как это сделать позже, в разделе «Источники».

Чтобы составить геттеры, мы определим gcombine функцию. Даны 2 входа Getter a и Getter b и двоичная функция f :: (a, b) -> c. Мы хотим получить Getter c со значением f(a, b).

// gcombine2 :: ((a, b) -> c, Getter a, Getter b) -> Getter c
function gcombine2(fn, getter1, getter2) {
  return () => fn(getter1(), getter2())
}

В качестве примера, учитывая 2 геттера для 2 входов DOM, мы получаем геттер для их сумм (я знаю, что мне не хватает творческих демонстраций, это просто для иллюстрации)

const getX = inputValue( document.getElementById('x-input') )
const getY = inputValue( document.getElementById('y-input') )
function sum(x, y) {
  return Number(x) + Number(y)
}
const getSumXY = gcombine2(sum, getX, getY)

Мы можем определить gcombine3 или gcombine4 для комбинирования с 3-мя или 4-мя функциями. Но давайте просто обобщим на переменное количество параметров.

// gcombine :: ((...a) -> b, ...Getter a) -> Getter b
function gcombine(fn, ...getters) {
  return () => fn(...getters.map(g => g()))
}

Примечание. Мы можем сделать сам параметр fn геттером для объединения с динамическими функциями.

И это все для Getters.

Уведомители

Далее мы познакомимся с средствами оповещения. Цитата автора

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

Геттер позволяет нам получить стабильную ссылку на некоторую часть состояния. А уведомитель информирует нас об изменении этого состояния.

Сначала давайте определим некоторые сигнатуры типов

Listener a :: a -> ()
Unsubscribe :: () -> ()
Notifier a :: Listener a -> Unsubscribe

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

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

function domNotifier(target, event) {
  return (listener) => {
    target.addEventListener(event, listener)
    return () => {
      target.removeEventListener(event, listener)
    }
  }
}
// inputChanged :: Notifier Event
const inputChanged = domNotifier(someDOMInput, 'change')
const unsubscribe = inputChanged(e => {
  console.log('Got a change event', e)
})

Как и в предыдущем разделе, мы определяем функцию карты. Учитывая Notifier a и функцию f :: a -> b, мы получим Notifier b. Получившаяся программа уведомлений будет уведомлять f(a) всякий раз, когда исходная программа уведомлений уведомляет a

// nmap :: (a -> b, Notifier a) -> Notifier b
function nmap(fn, notifier) {
  return listener => {
    return notifier(a => listener(fn(a)))
  }
}

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

Например, мы можем преобразовать inputChanged в нашем предыдущем примере из Notifier Event в Notifier String, сопоставив его с функцией, которая извлекает значение из события DOM.

const targetValue = event => event.target.value
const inputValueChanged = nmap(targetValue, inputChanged)

Мы не собираемся составлять уведомители, как это было с Getters (с использованием Applicative). Вместо этого мы выбираем операцию merge. Имея 2 уведомителя, мы создадим средство уведомления, которое уведомляет об изменениях из обоих источников (объединяя 2 источника в один).

// merge2 :: (Notifier a, Notifier b) -> Notifier (a | b)
function merge2(notifier1, notifier2) {
  return listener => {
    // subscribes the passed listener to both notifers
    const unsubscribe1 = notifier1(listener)
    const unsubscribe2 = notifier2(listener)
    // result function unsubscribe also from both notifiers
    return () => {
      unsubscribe1()
      unsubscribe1()
    }
  }
}

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

// merge :: (...Notifier a) -> Notifier a
function merge(...notifiers) {
  return listener => {
    const unsubscribes = notifiers.map(
      notifier => notifier(listener)
    )
    return () => {
      unsubscribes.forEach(unsubscribe => unsubscribe())
    }
  }
}

В качестве примера рассмотрим классическую демонстрацию вверх / вниз, когда мы подписываемся на клики по 2 кнопкам Up и Down

const btnUp = document.getElementById('btn-up')
const btnDown = document.getElementById('btn-down')
const upDown = merge2(
  nmap(() => 'Up', domNotifier(btnUp)),
  nmap(() => 'Down', domNotifier(btnDown))
)
upDown(type => console.log('Got a click on', type))

И для завершения определим еще одного оператора. Назовем это Never. Он просто представляет собой средство уведомления, которое не сообщает о каких-либо изменениях (мы будем использовать его позже для определения постоянных источников данных). Реализация - это просто функция noop.

// Never :: Notifier ()
const Never = () => {}

Теперь, когда у нас есть ингредиенты, перейдем к главному.

Источники

Чтобы представить источники данных, которые могут измениться, мы используем пару Getter & Notifier. Getter позволяет нам извлекать самую последнюю версию данных, в то время как Notifier уведомляет нас, когда с этими данными происходят какие-либо изменения.

// simple factory function
function Source(getter, notifier) {
  return {
    getter,
    notifier
  }
}

Простейшим примером Источников является постоянный Источник, данные которого никогда не меняются.

// sconst :: a -> Source a
function sconst(a) {
  return Source(
    () => a,  // always return the same data
    Never     // never report changes
  )
}

Другой пример, мы можем представить значение текстового ввода как Источник, который будет сообщать об изменениях, сделанных пользователем при каждом change событии (или также input событиях).

function inputValueSource(element, event) {
  return Source(
    () => element.value,
    domNotifier(element, event)
  )
}
const myInput = document.getElementById('my-input')
const myInputValue = inputValueSource(myInput, 'change')

Как и раньше. Давайте определим функцию карты. Учитывая Source a и функцию f :: a -> b, мы получим новый Source b, Getter которого возвращает f(a) (в то время как исходный Source'Getter возвращает a).

// smap ::  ((a -> b), Source a) -> Source b
function smap(fn, src) {
  return Source(
    gmap(fn, src.getter), // maps over the Getter
    src.notifier          // reuses the same Notifier
  )
}

Наша цель - преобразовать Source a в Source b с помощью некоторой функции a -> b. Например, представьте, что у нас есть источник числового значения, и мы хотим получить источник, представляющий двойное значение этого значения.

A :: Source Number
doubleOfA :: Source Number
doubleOfA = smap(x => x * 2, A)

Мы хотим, чтобы геттер нашего производного источника всегда возвращал двойное значение, возвращаемое геттером исходного источника. Вот почему в нашей реализации мы сопоставляем переданную функцию с получателем исходного источника.

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

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

Мы начнем с двоичных функций: учитывая некоторые источники ввода Source a и Source b и двоичную функцию f :: (a, b) -> c, мы получим источник, метод получения которого возвращает f(a, b) (при этом два метода получения источников ввода возвращают соответственно a и b). Кроме того, мы хотим, чтобы наш новый источник сообщал об изменениях всякий раз, когда любой из отчетов об источниках ввода изменяется, потому что изменение любого входного значения, скорее всего, приведет к изменению выходного значения.

// scombine2 :: ((a, b) -> c, Source a, Source b) -> Source c
function scombine2(fn, src1, src2) {
  return Source(
    gcombine2(fn, src1.getter, src2.getter),
    merge2(src1.notifier, src2.notifier)
  )
}

Реализация состоит из 2 источников по

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

И аналогично мы определяем обобщенную версию, которая работает с переменным количеством параметров.

// scombine :: ((...a) -> b, ...Source a) -> Source b
function scombine(fn, srcs) {
  return Source(
    gcombine(fn, ...srcs.map(src => src.getter)),
    merge(...srcs.map(src => src.notifier))
  )
}

Наконец, мы определим функцию react (runDD в документе), которая будет выполнять некоторые действия при изменении источника. В нашей реализации действию будет передано текущее значение Source в качестве параметра

// Action a :: a -> ()
// react :: (Source a, Action a) -> Unsubscribe
function react(src, action) {
  const listener = () => action(src.getter())
  // executes the action as initialization
  listener()
  // returns a function to unsubscribe the action
  return src.notifier(listener)
}

Источники обеспечивают низкий уровень и общую абстракцию, на которой мы можем строить более высокие уровни. Но они имеют прочную композиционную основу (да, математику). Вы можете определить источники нескольких типов (изменяемые переменные, поведение, данные сервера ...) и объединить их, используя общий метод комбинирования.

Некоторые примеры источников

  • Knockout / Mobx как наблюдаемые
  • Состояние Redux Store
  • Реактивное поведение

Knockout / Mobx как наблюдаемые

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

Начнем с определения упрощенной реализации эмиттеров событий.

function Emitter() {
  
  let listeners = []
  return {
    subscribe(listener) {
      listeners.push(listener)
      return () => {
        listeners.splice(listeners.indexOf(listener), 1)
      }
    },
    emit(data) {
      listeners.forEach(listener => listener())
    }
  }
}

Затем наш изменяемый экземпляр Source

function Mutable(seed) {
  const emitter = Emitter()
  const src = Source(
    () => seed,
    emitter.subscribe
  )
  // provide a method to mutate the state manually
  src.set = newValue => {
    seed = newValue
    emitter.emit()
  }
  return src
}

Теперь мы можем создавать и комбинировать их, используя возможность композиции Source.

const ma = Mutable(1)
const mb = Mutable(2)
const mc = scombine(
  (a, b) => a + b,
  ma, mb
)
react(mc, value => {
  console.log('c = ', value)
})
ma.set(100)

Как избежать сбоев

В приведенной выше реализации Mutable есть небольшая проблема. Предположим, у нас есть этот пример

const ma = Mutable(1)
// mb depends on ma
const mb = smap(
  a => a * 2,
  ma
)
// mc depends on both ma & mb
const mc = scombine(
  (a, b) => a + b,
  ma, mb
)
react(mc, value => {
  console.log('c = ', value)
})
ma.set(10)
/**
The console message is reported twice. Due to a redundant notification
=> 30
=> 30
**/

Когда мы устанавливаем значение ma, действие, зарегистрированное в react, выполняется дважды. Это происходит из-за нашей цепочки зависимостей

  • mb зависит от ma
  • mc зависит как от mb, так и от ma

Из-за того, как реализованы scombine и merge, мы дважды подписываемся на исходный источник ma. В первый раз react регистрирует своего слушателя в mb, который просто регистрирует его в ma (помните, что smap повторно использует уведомитель входного источника), во второй раз он подписывается непосредственно на ma. Из-за этого мы получаем избыточную подписку и уведомление.

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

function Emitter() {
  let listeners = new Set()
  return {
    subscribe(listener) {
      listeners = listeners.add(listener)
      return () => {
        listeners = listener.delete(listener)
      }
    },
    emit(data) {
      listeners.forEach(listener => listener())
    }
  }
}

Более общее Решение (представленное в документе) будет делать notifier члена Source самим Set. Затем мы можем определить merge как операцию, которая объединяет 2 набора. react подпишется на все элементы окончательного набора. Поскольку набор обеспечивает уникальность элементов внутри него, любой комбинированный источник, переданный в react, будет иметь ровно одну копию каждого уведомителя источника, даже если мы закончим рекомбинирование одного и того же источника более одного раза.

Состояние Redux Store

Определение экземпляра Source для Redux Stores тривиально, потому что у нас уже есть необходимые элементы: Getter и Notifier.

function reduxSource(store) {
  return Source(
    store.getState,
    store.subscribe
  )
}

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

const storeA = createStore(
  combineReducers({
    sliceOneA: ...,
    sliceTwoA: ...
  })
)
const storeB = createStore(
  combineReducers({
    sliceOneB: ...,
    sliceTwoB: ...
  })
)
const srcA = reduxSource(storeA)
const srcB = reduxSource(storeB)
const combinedSrc = scombine((stateA, stateB) => {
  return {stateA, stateB}
}, srcA, srcB)
/**
Will have a state shape of
{
  stateA: {
    sliceOneA: ...,
    sliceTwoA: ...
  },
  stateB: {
    sliceOneA: ...,
    sliceTwoA: ...
  }
}
Notifications will bubble up from both stores.
**/

Мы даже можем комбинировать источники Redux с другими типами источников, такими как Mutables.

Реактивное поведение

Реактивное поведение похоже на гибрид между свободными Mutables и инкапсулированным состоянием Redux. Чтобы создать поведение, нам нужно предоставить

  • Начальное значение
  • Список пар (уведомитель, редуктор) // редуктор :: (состояние, событие) - ›состояние

Получатель поведения сначала вернет начальное значение. Но каждый раз, когда Notifier в списке пар (Notifier, Reducer) сообщает о каком-либо событии, мы вызываем соответствующий редуктор с текущим значением Behavior и сообщенным событием.

Например, вот классическая демонстрация upDwon (пример тривиален, но может быть расширен для обработки более сложных случаев инкрементного состояния)

<button id=up>Up</button>
<button id=down>Down</button>
<div id=count></div>
const onUp = domNotifier(document.getElementById('up'), 'click')
const onDown = domNotifier(document.getElementById('down'), 'click')
const divCount = document.getElementById('count')
const count = Behavior(0,
  [onUp, (count, _) => count + 1],
  [onDown, (count, _) => count - 1]
)
react(count, value => {
  divCount.textContent = `Count = ${value}`
})

Возможная реализация поведения поверх источников

function Behavior(seed, ...cases) {
  cases.forEach(([notifier, reducer]) => {
    notifier(event => {
      seed = reducer(seed, event)
    })
  })
  return Source(
    () => seed,
    merge(...cases.map(([notifier, _]) => notifier))
  )
}

Дальнейшее развитие

Возможные идеи

  • scombine (и, следовательно, gcombine) повторно применяет функцию к каждому уведомлению, даже если данные не изменились. Мы можем запомнить функцию комбинирования, чтобы избежать бесполезных перерасчетов.
  • Добавьте поддержку динамических зависимостей: прямо сейчас вы должны предоставить источники ввода для scombine при вызове. Иногда невозможно заранее узнать список зависимостей (например, aSource, содержащий массив источников). Возможное решение могло бы быть объявлением члена уведомителя самого Источника как Источника. т.е. вместо Source a :: (Getter a, Notifier ()) у нас будет Source a :: (Getter a, Notifier () | Source (Notifier ()) )
  • Определите экземпляр Monad для Notifier (соединение, плоская карта) для выполнения побочных эффектов
  • Подключите источники к компонентам React

Суть, содержащая исходный код