Покрытие основ

Вступление

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

Просто подумайте на секунду о следующей функции listen.

const listen = (el, type) => {
  const ch = chan()
  el.addEventListener(type, e => putAsync(ch, e))
  return ch
}

Он превратит любое событие в элементе DOM в канал. Начнем с основ.

Почему генераторы и каналы?

Прежде чем перейти к генераторам, сопрограммам и каналам, давайте взглянем на обычные обещания.

function getUsers() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        users: [{id: 1, name: 'test'}]
      })
    }, 1000)
  })
}

getUsers возвращает обещание и в случае успеха разрешает обещание с необходимыми данными, как только мы закончим. Это также хорошо работает при работе с более чем одним обещанием. Либо одно обещание опирается на другое обещание, либо для начала операции необходимо выполнить все обещания. Обе ситуации покрываются стандартными реализациями обещаний. Подумайте о том, чтобы связать обещания с затем для первой ситуации и Promise.all для последней.

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

asyncCallOne(() => {
    asyncCallTwo(() => {
        asyncCallThree(() => {
            asyncCallFour(() => {
                asyncCallFive(() => {
                  // do something here...
                })
            })
        })
    })
})

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

asyncCallOne(() => { // do some something... } )
    .then(asyncCallTwo)
    .then(asyncCallThree)
    .then(asyncCallFour)
    .then(asyncCallFive)
    .catch(() => {
        // handle any errors that happened a long the way
    })

Разрешить все асинхронные функции и продолжить работу тоже не проблема.

Promise.all([
 asyncCallOne, 
 asyncCallTwo, 
 asyncCallThree
]).then(values => {
  // do something with the values...
});

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

Генераторы

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

«Генераторы - это функции, которые можно приостанавливать и возобновлять, что позволяет использовать множество приложений».

(Http://www.2ality.com/2015/03/es6-generators.html)

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

Самый простой пример, демонстрирующий функцию генератора, - это следующий фрагмент кода.

function* getNumbers() {
  yield 1
  yield 5
  yield 10
}
// retrieving
const getThoseNumbers = getNumbers()
console.log(getThoseNumbers.next()) // {value:1, done:false}
console.log(getThoseNumbers.next()) // {value:5, done:false}
console.log(getThoseNumbers.next()) // {value:10, done:false}
console.log(getThoseNumbers.next()) // {value:undefined, done:true}

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

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

// iterate
for (let i of getNumbers()) {
  console.log(i) // 1 5 10
}
// destructering
let [a, b, c] = getNumbers()
console.log( a, b, c) // 1 5 10
// spread operator
let spreaded = [...getNumbers()]
console.log(spreaded) // [1, 5, 10]
// even works with reduce
// Ramda reduce for example
const reducing = reduce((xs, x) => [...xs, x], [], getNumbers())
console.log(reducing) // [1, 5, 10]

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

function* setGetNumbers() {
  const input = yield
    yield input
}
const setThoseNumbers = setGetNumbers()
console.log(setThoseNumbers.next(1)) //{value:undefined, done:false}
console.log(setThoseNumbers.next(2)) //{value: 2, done: false}
console.log(setThoseNumbers.next()) //{value: undefined, done: true}

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

Завершить генератор так же просто, как определить return внутри функции генератора.

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

function* callee() {
  yield 1
}
function* caller() {
  while (true) {
    yield* callee();
  }
}
const callerCallee = caller()
console.log(callerCallee.next()) // {value: 1, done: false}
console.log(callerCallee.next()) // {value: 1, done: false}
console.log(callerCallee.next()) // {value: 1, done: false}
console.log(callerCallee.next()) // {value: 1, done: false}

К настоящему времени мы должны иметь представление о генераторах.

Для более подробного описания и пошагового руководства по функциям генераторов в ES6 прочтите исчерпывающую статью Генераторы ES6 в деталях Акселя Раушмайера.

Генераторы, обещания и сопрограммы

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

function fetchUsers() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        users: [{id: 1, title: 'test'}]
      })
    }, 1000)
  })
}
function* getData() {
  const data = yield fetchUsers()
  yield data
}

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

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

function co(fn) {
  const obj = fn()
  return new Promise((resolve, reject) => {
    const run = result => {
      const { value, done } = obj.next(result)
      // check if done and return if finished
      if (done) return resolve(result)
      // retrieve the promise and call next with the result
      value
        .then(res => run(res))
        .catch(err => obj.throw(err))
    }
    // start
    run()
  })
}

Вот предыдущий пример с использованием упрощенной функции co.

function fetchUsers() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        users: [{id: 1, name: 'test'}]
      })
    }, 1000)
  })
}

function fetchOtherData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        other: [{id: 2, title: 'other data'}]
      })
    }, 1000)
  })
}

const get = co(function* getData() {
  const getAll = yield Promise.all([fetchOtherData(), fetchUsers()])
  // do something else...
  return getAll
}).then(data => console.log(data))

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

const get = co(function* getData() {
  const otherData = yield fetchOtherData()
  console.log('fetched other data: ', otherData)
  const users = yield fetchUsers(otherData)
  console.log('fetched users: ', users)
  return users
}).then(data => console.log(data))

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

Генераторы и каналы

Не объединяйте генераторы с обещаниями, объединяйте их с каналами!

Дэвид Нолен

(http://swannodette.github.io/2013/08/24/es6-generators-and-csp)

Хотя все предыдущие подходы к обработке асинхронного кода хорошо известны, интересно отметить, что в JavaScript о каналах, похоже, отошли на второй план. Clojure с core.async и a lso Go с горутинами уже довольно давно отстаивает каналы.

Тем не менее, есть несколько замечательных сообщений по этой теме в JavaScript, самая заметная из которых - Укрощение асинхронного зверя с помощью каналов CSP в JavaScript от Джеймса Лонгстера. Пожалуйста, прочтите вышеупомянутую статью для более глубокого понимания каналов. Он помогает лучше понять эту тему, чем этот пост.

Я просто процитирую прямо из ранее упомянутого:

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

Джеймс Лонгстер

Укрощение асинхронного зверя с помощью CSP на JavaScript

Когда вы читаете слово каналы, вы также обычно видите CSP, означающее Последовательные процессы связи. Я рекомендую прочитать Коммуникационные последовательные процессы Дэвида Нолена для лучшего понимания темы.

Мы будем использовать библиотеку js-csp для демонстрации того, как мы можем использовать преимущества, которые дает использование каналов в JavaScript.

Процессы общаются по каналам. Типичные каналы предлагают набор функций, но пока нам нужно знать только о put и take. Хотя мы можем помещать в очередь с помощью put, на другой стороне есть процесс, ожидающий с помощью take. Мы скоро увидим это более подробно. Все, о чем нам нужно подумать для начала, - это наличие канала и потребителя. Возьмите следующий упрощенный пример из документации js-csp, например.

const ch = csp.chan(1);
yield csp.put(ch, 42);
yield csp.take(ch); // 42
ch.close()
yield csp.take(ch); // csp.CLOSED

Мы создаем новый канал с размером буфера 1, затем вызываем put с префиксом yield и передаем канал и значение 42. Затем мы берем значение из канала и, наконец, закрываем канал. Следующая доходность не повлияет, так как канал уже закрыт.

Вот еще один пример, взятый прямо из документации js-csp.

var ch = go(function*(x) {
  yield timeout(1000);
  return x;
}, [42]);
console.log((yield take(ch)));

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

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

const listen = (el, type) => {
  const ch = chan()
  el.addEventListener(type, e => putAsync(ch, e))
  return ch
}

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

go(function*() {
  const input = document.getElementById('title')
  const display = document.getElementById('display')
  const ch = listen(input, 'keyup')
  while(true) {
    const e = yield take(ch)
    display.innerHTML = `From Input: ${e.target.value}`
  }
})

Использование генераторов и каналов с React

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

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

Ниже вы можете найти полный код без компонента Counter, который был взят из фантастических руководств по React / Elm-Architecture от Стефана Острейхера. Код счетчика можно найти здесь.

AppStart должен обрабатывать начальную визуализацию компонента React верхнего уровня и запускать горутину, которая ожидает любых новых обновлений в AppChannel. Сам AppChannel является обычным каналом без какой-либо буферизации или других особенностей. Итак, все, что мы делаем, это помещаем в AppChannel, как только событие запускает действие. Ничего особенного, но это работает.

// basic example demonstrating the power of channels and generators
import React from 'react'
import { render } from 'react-dom'
import { chan, go, take, put, putAsync } from 'js-csp'
import { curry } from 'ramda'
import Counter from './Counter'
// helper
const createRender = curry((node, app) => render(app, node))
// create one channel for now
const AppChannel = chan()
const doRender = createRender(document.getElementById('mountNode'))
// let start
const AppStart = ({ init, update, view }) => {
    let model = 0
    const signal = action => () => {
        model = update(action, model)
        putAsync(AppChannel, model)
    }
    // initial render...
    putAsync(AppChannel, init(model))
    go(function* () {
        while(true) {
            doRender(view(signal, yield take(AppChannel)))
        }
    })
}
// start
AppStart(Counter)

Вы также можете найти код здесь.

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

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

Создание приложения…

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

const getItems = () => {
  go(function* () {
    yield put(isLoading, true)
    const fetchedItems = yield* fetchItems()
    yield put(items, fetchedItems)
    yield put(isLoading, false)
  })
}

Давайте начнем с написания функции, которая позаботится о создании каналов

const createChannel = (action, store) => {
  const ch = chan()
  go(function* () {
    while(true) {
      const value = yield take(ch)
      yield put(AppChannel, action(store.get(), value));
    }
  })
  return ch
}

// helper function for passing an object and getting channels
const createChannels = (actions, store) =>
  mapObjIndexed(fn => createChannel(fn, store), actions)

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

const Actions = {
  isLoading: (model, isLoading) =>
    assoc('isLoading', isLoading, model),
  items: (model, items) => assoc('items', items, model),
  addItem: (model, title) =>
    assoc('items',
      [ ...prop('items', model),
        {title, id: getNextId(prop('items', model))}
      ],
      model),
}

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

const App = ({ items, isLoading }) => {
  if (isLoading) return (<p>loading...</p>)
  return (
    <div>
      <h2>Random Items List</h2>
      <ul>
        {items.map(item => (
          <li key={item.id} >{item.title}</li>
        ))}
      </ul>
      <input type='text' id='add' />
      <button onClick={() => putAsync(addItem, findText())}>
        Add Item
      </button>
      <button onClick={() => getItems()}>LoadItems</button>
    </div>
  )
}

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

const AppStart = (Component, store) => {
  // initial render...
  putAsync(AppChannel, store.get())
  go(function* () {
    while(true) {
      store.set(yield take(AppChannel))
      doRender(<Component {...store.get() } />)
    }
  })
}

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

const { isLoading, items, addItem } = createChannels(Actions, store)

Мы получаем взамен isLoading, items и addItem канал, с помощью которых мы можем обновлять состояние через каналы. Также важно отметить, что AppChannel вызывается со скользящим буфером для обработки только последнего значения.

// create App channel... and render function
const AppChannel = chan(buffers.sliding(1))
const doRender = createRender(document.getElementById('mountNode'))

Все, что нам нужно сделать сейчас, это вызвать AppStart сейчас.

AppStart(App, store)

Вот полный код, если вам интересно.

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

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

Приведенные примеры можно рассматривать как отправную точку для экспериментов с возможностями.

Outro

Это должно было быть введением в генераторы и каналы. Нам по-прежнему не хватает некоторых важных аспектов, таких как преобразователи. Возможно, имеет смысл написать последующий пост, посвященный каналам и преобразователям, включая более сложные примеры использования React. Дайте мне знать, если есть интерес к другим примерам.

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

Обновление:

Последующий пост Введение в каналы и преобразователи в JavaScript размещен в Интернете.

Ссылки

Генераторы ES6 в деталях

Генераторы ES6 обеспечивают параллелизм в стиле Go

Обратные вызовы против сопрограмм

Js-csp

Укрощение асинхронного зверя с помощью CSP на JavaScript

Почему сопрограммы не работают в сети

Никаких обещаний: асинхронный JavaScript только с генераторами

CSP и преобразователи в JavaScript

Core.async

Связь последовательных процессов

CSP - это адаптивный дизайн

Исследование решения обратных вызовов с помощью генераторов JavaScript