Простой поток данных в приложениях React с использованием Flux и Backbone: руководство с примерами

Опубликовано: 2022-03-11

React.js — фантастическая библиотека. Иногда кажется, что это лучшее, что было после нарезки Python. Однако React — это только одна часть стека интерфейсных приложений. Он мало что может предложить, когда дело доходит до управления данными и состоянием.

Facebook, создатели React, предложили некоторые рекомендации в виде Flux. Flux — это «Архитектура приложения» (а не фреймворк), построенная вокруг одностороннего потока данных с использованием React Views, Action Dispatcher и Stores. Шаблон Flux решает некоторые основные проблемы, воплощая важные принципы управления событиями, которые значительно упрощают анализ, разработку и обслуживание приложений React.

Здесь я представлю базовые примеры потока управления Flux, расскажу, чего не хватает Store, и как использовать модели и коллекции Backbone, чтобы заполнить пробел «Flux-совместимым» способом.

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

Введение в Flux от Facebook

Backbone — отличная и хорошо проверенная небольшая библиотека, включающая представления, модели, коллекции и маршруты. Это де-факто стандартная библиотека для структурированных интерфейсных приложений, и она была связана с приложениями React с тех пор, как последний был представлен в 2013 году. До сих пор большинство примеров React за пределами Facebook.com включали упоминания о том, что Backbone используется в тандеме.

К сожалению, полагаться только на Backbone для управления всем потоком приложений за пределами React's Views представляет неприятные сложности. Когда я впервые начал работать над кодом приложения React-Backbone, «сложные цепочки событий», о которых я читал, не заставили себя долго ждать, чтобы поднять голову, как у гидры. Отправка событий из пользовательского интерфейса в модели, а затем из одной модели в другую и обратно затрудняет отслеживание того, кто кого изменял, в каком порядке и почему.

Этот учебник по Flux продемонстрирует, как шаблон Flux справляется с этими проблемами с впечатляющей легкостью и простотой.

Обзор

Девиз Flux — «однонаправленный поток данных». Вот удобная диаграмма из документации Flux, показывающая, как выглядит этот поток:

Facebook Flux использует модель «однонаправленного потока данных», которая немного отличается в сочетании с React и Backbone.

Важным моментом является то, что данные перетекают из React --> Dispatcher --> Stores --> React .

Давайте посмотрим, что представляет собой каждый из основных компонентов и как они соединяются:

Документы также предлагают это важное предостережение:

Flux больше похож на паттерн, чем на фреймворк, и не имеет жестких зависимостей. Однако мы часто используем EventEmitter в качестве основы для Store и React для наших представлений. Единственная часть Flux, недоступная где-либо еще, — это Dispatcher. Этот модуль доступен здесь, чтобы дополнить ваш набор инструментов Flux.

Итак, Flux состоит из трех компонентов:

  1. Представления ( React = require('react') )
  2. Диспетчер ( Dispatcher = require('flux').Dispatcher )
  3. Магазины ( EventEmitter = require('events').EventEmitter )
    • (или, как мы скоро увидим, Backbone = require('backbone') )

Виды

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

Диспетчер

Flux Dispatcher — это единое место, где обрабатываются все события, изменяющие ваши Store. Чтобы использовать его, вы должны register в каждом магазине один обратный вызов для обработки всех событий. Затем, всякий раз, когда вы хотите изменить Store, вы dispatch событие.

Как и React, Dispatcher кажется мне хорошей и хорошо реализованной идеей. Например, приложение, которое позволяет пользователю добавлять элементы в список дел, может включать следующее:

 # in TodoDispatcher.coffee Dispatcher = require("flux").Dispatcher TodoDispatcher = new Dispatcher() # That's all it takes!. module.exports = TodoDispatcher
 # in TodoStore.coffee TodoDispatcher = require("./TodoDispatcher") TodoStore = {items: []} TodoStore.dispatchCallback = (payload) -> switch payload.actionType when "add-item" TodoStore.items.push payload.item when "delete-last-item" TodoStore.items.pop() TodoStore.dispatchToken = TodoDispatcher.registerCallback(TodoStore.dispatchCallback) module.exports = TodoStore
 # in ItemAddComponent.coffee TodoDispatcher = require("./TodoDispatcher") ItemAddComponent = React.createClass handleAddItem: -> # note: you're NOT just pushing directly to the store! # (the restriction of moving through the dispatcher # makes everything much more modular and maintainable) TodoDispatcher.dispatch actionType: "add-item" item: "hello world" render: -> React.DOM.button { onClick: @handleAddItem }, "Add an Item!"

Это позволяет очень легко ответить на два вопроса:

  1. В: Какие события изменяют MyStore ?
    • A: Просто проверьте случаи в операторе switch в MyStore.dispatchCallback .
  2. В: Каковы все возможные источники этого события?
    • О: Просто найдите этот actionType .

Это намного проще, чем, например, поиск MyModel.set , MyModel.save , MyCollection.add и т. д., где найти ответы на эти основные вопросы очень сложно и очень быстро.

Диспетчер также позволяет выполнять обратные вызовы последовательно простым синхронным способом с помощью waitFor . Например:

 # in MessageStore.coffee MyDispatcher = require("./MyDispatcher") TodoStore = require("./TodoStore") MessageStore = {items: []} MessageStore.dispatchCallback = (payload) -> switch payload.actionType when "add-item" # synchronous event flow! MyDispatcher.waitFor [TodoStore.dispatchToken] MessageStore.items.push "You added an item! It was: " + payload.item module.exports = MessageStore

На практике я был потрясен, увидев, насколько чище мой код при использовании Dispatcher для изменения моих Store, даже без использования waitFor .

Магазины

Таким образом, данные поступают в Stores через Dispatcher. Понятно. Но как данные передаются из хранилищ в представления (т. е. React)? Как указано в документации Flux:

[The] представление прослушивает события, которые транслируются хранилищами, от которых оно зависит.

Хорошо, отлично. Точно так же, как мы регистрировали обратные вызовы в наших хранилищах, мы регистрируем обратные вызовы в наших представлениях (которые являются компонентами React). Мы говорим React render всякий раз, когда в Store происходит изменение, которое было передано через его props .

Например:

 # in TodoListComponent.coffee React = require("react") TodoListComponent = React.createClass componentDidMount: -> @props.TodoStore.addEventListener "change", => @forceUpdate() , @ componentWillUnmount: -> # remove the callback render: -> # show the items in a list. React.DOM.ul {}, @props.TodoStore.items.map (item) -> React.DOM.li {}, item

Потрясающий!

Итак, как мы можем генерировать это событие "change" ? Итак, Flux рекомендует использовать EventEmitter . Из официального примера:

 var MessageStore = merge(EventEmitter.prototype, { emitChange: function() { this.emit(CHANGE_EVENT); }, /** * @param {function} callback */ addChangeListener: function(callback) { this.on(CHANGE_EVENT, callback); }, get: function(id) { return _messages[id]; }, getAll: function() { return _messages; }, // etc...

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

Недостающая часть

В моделях и коллекциях Backbone уже есть все, что есть в магазинах Flux на основе EventEmitter.

Посоветовав вам использовать необработанный EventEmitter, Flux рекомендует воссоздавать примерно 50-75% моделей и коллекций Backbone каждый раз, когда вы создаете Store. Использование EventEmitter для ваших магазинов похоже на использование чистого Node.js для вашего сервера, когда уже существуют хорошо построенные микрофреймворки, такие как Express.js или эквивалентные, чтобы позаботиться обо всех основах и шаблонах.

Точно так же, как Express.js построен на Node.js, модели и коллекции Backbone построены на EventEmitter. И в нем есть все, что вам почти всегда нужно: Backbone генерирует события change и имеет методы запросов, геттеры и сеттеры и все такое. Кроме того, Джереми Ашкенас из Backbone и его армия из 230 участников справились со всеми этими задачами намного лучше, чем я, вероятно, смогу это сделать.

В качестве примера для этого руководства по Backbone я преобразовал приведенный выше пример MessageStore в версию Backbone.

Это объективно меньше кода (нет необходимости дублировать работу) и субъективно более ясно и лаконично (например, this.add(message) вместо _messages[message.id] = message ).

Итак, давайте использовать Backbone для магазинов!

Паттерн FluxBone: хранилища Flux по магистрали

Это руководство является основой подхода, который я с гордостью назвал FluxBone , архитектуры Flux, использующей Backbone для магазинов. Вот базовый шаблон архитектуры FluxBone:

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

Это руководство по Backbone предназначено для изучения того, как Backbone и Flux работают вместе в приложениях React.

Давайте воспользуемся примерами Backbone и Flux, чтобы рассмотреть каждую часть этого по очереди:

1. Хранилища — это созданные Модели или Коллекции Магистралей, которые зарегистрировали обратный вызов в Диспетчере.

 # in TodoDispatcher.coffee Dispatcher = require("flux").Dispatcher TodoDispatcher = new Dispatcher() # That's all it takes! module.exports = TodoDispatcher
 # in stores/TodoStore.coffee Backbone = require("backbone") TodoDispatcher = require("../dispatcher") TodoItem = Backbone.Model.extend({}) TodoCollection = Backbone.Collection.extend model: TodoItem url: "/todo" # we register a callback with the Dispatcher on init. initialize: -> @dispatchToken = TodoDispatcher.register(@dispatchCallback) dispatchCallback: (payload) => switch payload.actionType # remove the Model instance from the Store. when "todo-delete" @remove payload.todo when "todo-add" @add payload.todo when "todo-update" # do stuff... @add payload.todo, merge: true # ... etc # the Store is an instantiated Collection; a singleton. TodoStore = new TodoCollection() module.exports = TodoStore

2. Компоненты никогда напрямую не изменяют Store (например, без .set() ). Вместо этого компоненты отправляют действия диспетчеру.

 # components/TodoComponent.coffee React = require("react") TodoListComponent = React.createClass handleTodoDelete: -> # instead of removing the todo from the TodoStore directly, # we use the Dispatcher TodoDispatcher.dispatch actionType: "todo-delete" todo: @props.todoItem # ... (see below) ... module.exports = TodoListComponent

3. Компоненты запрашивают хранилища и привязываются к своим событиям для запуска обновлений.

 # components/TodoComponent.coffee React = require("react") TodoListComponent = React.createClass handleTodoDelete: -> # instead of removing the todo from the TodoStore directly, # we use the dispatcher. #flux TodoDispatcher.dispatch actionType: "todo-delete" todo: @props.todoItem # ... componentDidMount: -> # the Component binds to the Store's events @props.TodoStore.on "add remove reset", => @forceUpdate() , @ componentWillUnmount: -> # turn off all events and callbacks that have this context @props.TodoStore.off null, null, this render: -> React.DOM.ul {}, @props.TodoStore.items.map (todoItem) -> # TODO: TodoItemComponent, which would bind to # `this.props.todoItem.on('change')` TodoItemComponent { todoItem: todoItem } module.exports = TodoListComponent

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

Пример миксина

Написание this.on(...) и this.off(...) каждый раз, когда вы добавляете FluxBone Store к компоненту, может немного устареть.

Вот пример React Mixin, который, хотя и чрезвычайно наивен, безусловно, сделал бы быструю итерацию еще проще:

 # in FluxBoneMixin.coffee module.exports = (propName) -> componentDidMount: -> @props[propName].on "all", => @forceUpdate() , @ componentWillUnmount: -> @props[propName].off "all", => @forceUpdate() , @
 # in HelloComponent.coffee React = require("react") UserStore = require("./stores/UserStore") TodoStore = require("./stores/TodoStore") FluxBoneMixin = require("./FluxBoneMixin") MyComponent = React.createClass mixins: [ FluxBoneMixin("UserStore"), FluxBoneMixin("TodoStore"), ] render: -> React.DOM.div {}, "Hello, #{ @props.UserStore.get('name') }, you have #{ @props.TodoStore.length } things to do." React.renderComponent( MyComponent { UserStore: UserStore TodoStore: TodoStore } , document.body.querySelector(".main") )

Синхронизация с веб-API

В исходной диаграмме Flux вы взаимодействуете с веб-API только через ActionCreators, которые требуют ответа от сервера перед отправкой действий в Dispatcher. Это никогда не устраивало меня; разве Магазин не должен первым узнавать об изменениях, раньше сервера?

Я решил перевернуть эту часть диаграммы: Store взаимодействуют напрямую с RESTful CRUD API через Backbone sync() . Это удивительно удобно, по крайней мере, если вы работаете с реальным RESTful CRUD API.

Целостность данных поддерживается без проблем. Когда вы .set() создаете новое свойство, событие change запускает повторную визуализацию React, оптимистично отображая новые данные. Когда вы пытаетесь .save() его на сервер, событие request позволяет вам отобразить значок загрузки. Когда что-то происходит, событие sync сообщает вам, что нужно удалить значок загрузки, или событие error позволяет узнать, что нужно сделать все красным. Здесь вы можете увидеть вдохновение.

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

Для менее стандартных задач взаимодействие через ActionCreators может иметь больше смысла. Я подозреваю, что Facebook не делает много «просто CRUD», и в этом случае неудивительно, что они не ставят Магазины на первое место.

Заключение

Команды инженеров в Facebook проделали замечательную работу, чтобы продвинуть веб-интерфейс с помощью React, а введение Flux позволяет взглянуть на более широкую архитектуру, которая действительно масштабируется: не только с точки зрения технологии, но и с точки зрения инженерии. Умное и осторожное использование Backbone (согласно примеру этого руководства) может заполнить пробелы в Flux, сделав его удивительно простым для любого, от независимых магазинов до крупных компаний, создавать и поддерживать впечатляющие приложения.

Связанный: Как компоненты React упрощают тестирование пользовательского интерфейса