Вяз Архитектура с jQuery

Вернуться к основам

В двух последних сообщениях я показал, как можно применить архитектурный паттерн Elm с помощью React. Хотя шаблон довольно прост, мою реализацию не всегда легко понять, и я думаю, что сделал свои примеры излишне сложными для понимания, поэтому в этом посте я попытаюсь объяснить вещи немного лучше. Я также собираюсь придерживаться простого jQuery для создания элементов DOM, в этом посте не будет никакого React.

Вяз Архитектура

Основная идея архитектуры Elm - это компонент, который определяет три вещи: модель, функцию обновления и функцию просмотра. Функция представления берет текущую модель и возвращает представление DOM. У него также есть способ запустить обновление модели. В Elm функция просмотра принимает Signal.Address, который передается, например, в onClick вместе со значением действия. Возможные значения действия определяются самим компонентом. Это значение действия отправляется на указанный Signal.Address, который в конечном итоге запускает обновление. Функция обновления принимает текущее значение модели и значение действия и выполняет действие, возвращая новое значение модели. С новым значением модели функция представления вызывается снова, чтобы вернуть представление DOM, соответствующее этому новому значению модели.

Еще один счетчик

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

// our two actions
const Inc = 'inc'
const Dec = 'dec'

Функция обновления тоже довольно проста:

const update = (model, action) => {
  switch(action) {
    case Inc: return model + 1
    case Dec: return model - 1
    default: return model
  }
}

Теперь в представлении все становится интересным, потому что нам нужен какой-то способ запустить обновление, то есть какой-то способ диспетчеризации наших действий. Но прежде чем мы это сделаем, давайте посмотрим, как это работает со статическим представлением:

const view = model => $(
  '<div>'+ 
    '<button>+</button>'+
    '<span>'+model+'</span>'+
    '<button>-</button>'+
  '</div>'
)

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

const mount = ({model, update, view}, $root) => {
  const $dom = view(model)
  $root.empty().append($dom)
}

Теперь мы можем использовать это так, и наш компонент будет отрисован:

mount({model: 0, update, view}, $('#app'))

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

const view = (signal, model)=> $('<div/>').append(
  $('<button>+</button>').on('click', signal(Inc)),
  $('<span>'+model+'</span>'),
  $('<button>-</button>').on('click', signal(Dec))
)

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

const signal = action => () => {
  // todo
}

Если это сбивает с толку из-за стрелочных функций, это просто функция, возвращающая обратный вызов:

function signal(action) {
  // return a callback that we'll use as event handler
  return function() {
    // todo
  }
}

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

const mount = ({model, update, view}, $root) => {
  
  // initial state
  let state = model
  
  // dispatch function takes action, returns callback
  const signal = action => () => {
    // inside the callback we...
    // ...update the state
    state = update(state, action)
    
    // ...and rerender
    const $dom = view(signal, state)
    $root.empty().append($dom)
  }
  
  // initial render
  const $dom = view(signal, state)
  $root.empty().append($dom)
}

Итак, мы инициализируем состояние с начальным значением модели, и когда выполняется обратный вызов, мы просто обновляем состояние до нового значения модели, которое мы получаем, вызывая update с текущим состоянием и любым действием, которое мы получили. А затем мы просто вызываем функцию просмотра и обновляем DOM.

Вот и все.

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

Вложенные компоненты

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

Модель нашей пары счетчиков будет иметь свойство top и bottom, которое будет содержать модельное значение соответствующего счетчика. У самого компонента будет только одно действие для сброса обоих счетчиков. Однако нам также необходимо направить встречные действия на правильный компонент.

Чтобы понять, почему давайте сначала попробуем наивную реализацию функции просмотра:

const view = (signal, model) => $('<div/>').append(
  Counter.view(signal, model.top),
  Counter.view(signal, model.bottom),
  $('<button>Reset</button>').on('click', signal(Reset))
)

А пока у нас есть только одно действие для сброса счетчиков:

const Reset = 'reset'
const update = (model, action) => {
  switch (action) {
    case Reset: return {top: 0, bottom: 0}
    default: return model
  }
}

У нашего парного компонента также есть простая функция инициализации для инициализации модели:

const init = (top, bottom) => ({top, bottom})

И мы используем функцию монтирования так же, как и раньше, только с моделью пары счетчиков, update и view:

mount({model: init(23, 42), view, update}, $('#app'))

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

update({top: 23, bottom: 42}, 'inc')

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

const update = (model, action) => {
  switch (action) {
    case Reset: return {top: 0, bottom: 0}
    case Counter.Inc:
      // Which counter should we update?
      // Nope, this can't work.
      // And even if we knew, it's a terrible idea
      // because we shouldn't have to know how Counter
      // represents its actions.
      break
    default: return model
  }
}

Что нам действительно нужно, так это какое-то действие, которое обертывает встречное действие и знает, какой счетчик обновлять. Прежде всего, давайте использовать простые объекты вместо строк для представления действий нашей встречной пары:

const Reset = ({type: 'reset'})
const update = (model, action) => {
  switch (action.type) {
    case 'reset': return {top: 0, bottom: 0}
  }
}

Я просто использую строки непосредственно для типов действий в этом примере, на самом деле вы, вероятно, захотите использовать вместо них константы.

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

const Top = action => ({
  type: 'forward',
  prop: 'top',
  forward: action
})

Тогда мы можем легко работать с этим в нашей функции обновления:

case 'forward':
  return {
    ...model,
    [action.prop]: Counter.update(
      model[action.prop],
      action.forward
    )
  }

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

Counter.view(action => signal(Top(action)), model.top)

Но на самом деле это просто композиция функций, поэтому это эквивалентно:

Counter.view(compose(signal, Top), model.top)

Вы можете просто использовать compose из ramda, lodash и т. Д. Или определить свое собственное. Для этого примера давайте просто воспользуемся нашим собственным. Мы можем назвать его вперед, чтобы код был больше похож на аналог Elm:

const forward = (f, g) => x => f(g(x))

Тогда наша полная функция просмотра будет выглядеть так:

const view = (signal, model) => $('<div/>').append(
  Counter.view(forward(signal, Top), model.top),
  Counter.view(forward(signal, Bottom), model.bottom),
  $('<button>Reset</button>').on('click', signal(Reset))
)

И это все.

Полный код этого примера можно найти здесь.

Вывод

Надеюсь, эти примеры менее запутаны, чем в моих предыдущих постах. Буду признателен за любые отзывы. Если у вас есть вопросы, просто оставьте комментарий или свяжитесь со мной в Twitter.

Мои предыдущие сообщения по этой теме: