Статьи

Redux без реакции — Государственное управление в Vanilla JavaScript

Эта статья была рецензирована Vildan Softic . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Печенье в форме тетрис на противне

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

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

Настройка

Возможно, вы слышали о популярной комбинации React.js и Redux для создания быстрых и мощных веб-приложений с использованием новейших передовых технологий.

React — это библиотека с открытым исходным кодом, основанная на компонентах, для создания пользовательских интерфейсов. В то время как React является только слоем представления (а не полной структурой, такой как Angular или Ember) , Redux управляет состоянием вашего приложения. Он функционирует как контейнер с предсказуемым состоянием , где все состояние хранится в одном дереве объектов и может быть изменено только с помощью так называемого действия . Если вы совершенно новичок в этой теме, я рекомендую ознакомиться с этой иллюстративной статьей .

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

Redux без реакции — приложение с нуля

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

Рассматриваемое приложение является первым клоном тетриса, который имеет несколько различных представлений. Фактическая логика игры сделана в Redux, в то время как офлайновые возможности предоставляются localStorage Репозиторий можно найти на GitHub , хотя приложение все еще находится в активной разработке, и я пишу эту статью, работая над ней.

Определение архитектуры приложения

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

SRC / скрипты /

 actions/
├── game.js
├── score.js
└── ...
components/
├── router.js
├── pageControls.js
├── canvas.js
└── ...
constants/
├── game.js
├── score.js
└── ...
reducers/
├── game.js
├── score.js
└── ...
store/
├── configureStore.js
├── connect.js
└── index.js
utils/
├── serviceWorker.js
├── localStorage.js
├── dom.js
└── ...
index.js
worker.js

Моя разметка разделена на другой каталог и, в конечном счете, отображается одним файлом index.html Структура похожа на scripts/

SRC / наценка /

 layouts/
└── default.html
partials/
├── back-button.html
└── meta.html
pages/
├── about.html
├── settings.html
└── ...
index.html

Управление и доступ к магазину

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

Моя первая итерация вроде бомбила. Я не знаю, почему я подумал, что это будет хорошей идеей, но я поместил хранилище в его собственный модуль ( scripts/store/index.js В итоге я пожалел об этом и быстро справился с круговыми зависимостями . Проблема заключалась в том, что хранилище не было должным образом инициализировано, когда компонент пытался получить к нему доступ. Я собрал диаграмму, чтобы продемонстрировать поток зависимостей, с которым я имел дело:

Redux без React - Ошибка дерева зависимостей

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

Модуль магазина выглядел так:

scripts/store/index.js(☓ плохо)

 import { createStore } from 'redux'
import reducers from '../reducers'

const store = createStore(reducers)

export default store
export { getItemList } from './connect'

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

scripts/store/connect.js(☓ плохо)

 import store from './'

export function getItemList () {
  return store.getState().items.all
}

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

Решение

То, что кажется очевидным сейчас, заняло у меня некоторое время, чтобы понять. Я решил эту проблему, переместив инициализацию в точку входа моего приложения ( scripts/index.js

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

Redux без React - успех потока Redux

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

Давайте пройдемся по изменениям:

scripts / store / configureStore.js (✓ хорошо)

 import { createStore } from 'redux'
import reducers from '../reducers'

export default function configureStore () {
  return createStore(reducers)
}

Я сохранил модуль, но вместо этого экспортировал функцию с именем configureStore Обратите внимание, что это только основная концепция; Я также использую расширение Redux DevTools и загружаю постоянное состояние через localStorage

scripts / store / connect.js (✓ хорошо)

 export function getItemList (store) {
  return store.getState().items.all
}

Вспомогательные функции connect Сначала я не решался использовать это решение, потому что подумал: «Какой смысл тогда вспомогательная функция?» . Теперь я думаю, что они хороши и достаточно высокого уровня, что делает все более читабельным.

скрипты / index.js

 import configureStore from './store'
import { PageControls, TetrisGame } from './components'

const store = configureStore()
const pageControls = new PageControls(store)
const tetrisGame = new TetrisGame(store)

// Further initialization logic.

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

Компоненты

Я решил работать с двумя видами компонентов: презентационные и контейнерные .
Презентационные компоненты не делают ничего кроме чистой обработки DOM; они не знают о магазине. С другой стороны, компоненты контейнера могут отправлять действия или подписываться на изменения.

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

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

скрипты / компоненты / pageControls.js

 import { $$ } from '../utils'
import { startGame, endGame, addScore, openSettings } from '../actions'

export default class PageControls {
  constructor ({ selector, store } = {}) {
    this.$buttons = [...$$('button, [role=button]')]
    this.store = store
  }

  onClick ({ target }) {
    switch (target.getAttribute('data-action')) {
    case 'endGame':
      this.store.dispatch(endGame())
      this.store.dispatch(addScore())
      break
    case 'startGame':
      this.store.dispatch(startGame())
      break
    case 'openSettings':
      this.store.dispatch(openSettings())
      break
    default:
      break
    }

    target.blur()
  }

  addEvents () {
    this.$buttons.forEach(
      $btn => $btn.addEventListener('click', this.onClick.bind(this))
    )
  }
}

Приведенный выше пример является одним из этих компонентов. Он имеет список элементов (в данном случае все элементы с атрибутом data-action Ничего больше. Другие модули могут затем прослушивать изменения в хранилище и обновлять себя соответственно. Как уже упоминалось, если компонент также сделал обновления DOM, я бы отделил его.

Теперь позвольте мне показать вам четкое разделение компонентов обоих типов.

Обновление DOM

Один из самых больших вопросов, которые у меня возникли при запуске проекта, — как на самом деле обновить DOM. React использует быстрое представление DOM в памяти, называемое Virtual DOM, чтобы свести обновления DOM к минимуму.

Я на самом деле думал сделать то же самое, и я вполне мог бы перейти на Virtual DOM , если мое приложение будет расти больше и тяжелее DOM, но сейчас я делаю классические манипуляции с DOM, и это прекрасно работает с Redux.

Основной поток выглядит следующим образом:

  • Новый экземпляр компонента контейнера инициализируется и передается в store
  • Компонент подписывается на изменения в магазине
  • И использует другой компонент представления для отображения обновлений в DOM

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

скрипты / index.js

 import configureStore from './store'
import { ScoreObserver } from './components'

const store = configureStore()
const scoreObserver = new ScoreObserver(store)

scoreObserver.init()

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

скрипты / компоненты / scoreObserver / index.js

 import { isRunning, getScoreList, getCurrentScore } from '../../store'
import ScoreBoard from './$board'
import ScoreLabel from './$label'

export default class ScoreObserver {
  constructor (store) {
    this.store = store
    this.$board = new ScoreBoard()
    this.$label = new ScoreLabel()
  }

  updateScore () {
    if (!isRunning(this.store)) {
      return
    }

    this.$label.updateLabel(getCurrentScore(this.store))
  }

  // Used in a different place.
  updateScoreBoard () {
    this.$board.updateBoard(getScoreList(this.store))
  }

  init () {
    this.store.subscribe(this.updateScore.bind(this))
  }
}

Имейте в виду, что это простой компонент; другие компоненты могут иметь более сложную логику и заботиться о вещах. Что здесь происходит? Компонент ScoreObserverstore Метод init$label

Метод updateScoreBoard Не имеет смысла обновлять список каждый раз, когда происходит изменение, так как представление все равно не активно. Существует также компонент маршрутизации, который обновляет или деактивирует различные компоненты при каждом изменении представления. Его API выглядит примерно так:

 // scripts/index.js

route.onRouteChange((leave, enter) => {
  if (enter === 'scoreboard') {
    scoreObserver.updateScoreBoard()
  }

  // more logic...
})

Примечание: $$$document.querySelector

скрипты / компоненты / scoreObserver / $ board.js

 import { $ } from '../../utils'

export default class ScoreBoard {
  constructor () {
    this.$board = $('.tetrys-scoreboard')
  }

  emptyBoard () {
    this.$board.innerHTML = ''
  }

  createListItem (txt) {
    const $li = document.createElement('li')
    const $span = document.createElement('span')
    $span.appendChild(document.createTextNode(txt))
    $li.appendChild($span)
    return $li
  }

  updateBoard (list = []) {
    const fragment = document.createDocumentFragment()
    list.forEach((score) => fragment.appendChild(this.createListItem(score)))
    this.emptyBoard()
    this.$board.appendChild(fragment)
  }
}

Опять же, базовый пример и базовый компонент. Метод updateBoard()

скрипты / компоненты / scoreObserver / $ label.js

 import { $ } from '../../utils'

export default class ScoreLabel {
  constructor () {
    this.$label = $('.game-current-score')
    this.$labelCount = this.$label.querySelector('span')
    this.initScore = 0
  }

  updateLabel (score = this.initScore) {
    this.$labelCount.innerText = score
  }
}

Этот компонент выполняет почти то же самое, что и ScoreBoard

Другие ошибки и советы

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

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

Вывод

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

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

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

Что вы думаете о моем подходе? Вы использовали Redux один с другой настройкой обработки представления? Я хотел бы получить ваши отзывы и обсудить это в комментариях.


Если вам нужна дополнительная информация о Redux, ознакомьтесь с нашим мини-курсом « Перезапись и тестирование Redux для решения проблем проектирования» . В этом курсе вы создадите приложение Redux, которое получает твиты, организованные по темам, через соединение с веб-сокетом. Чтобы получить представление о том, что в магазине, ознакомьтесь с бесплатным уроком ниже.