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

UPD (2 февраля 2020 г.): Марк Эриксон, сопровождающий ядра Redux, обратился к нам и сделал несколько заметок по содержанию, а я внес соответствующие изменения. Наиболее важно то, что ветка master имеет версию Redux на TypeScript, но миграция еще не завершена, и опубликованный пакет Redux по-прежнему является ванильным JS.

Прежде чем продолжить чтение, честно предупреждаем: Redux написан на TypeScript, и я буду предполагать знание TS до конца статьи. Я также не стал бы объяснять, как использовать Redux, поэтому, если вы никогда не слышали о нем, я предлагаю сначала прочитать документацию. Однако вам не обязательно знать React, поскольку я имею дело исключительно с Redux. И, наконец, я не связан с командой Redux и не являюсь основным участником. Я открыл несколько PR, но эта статья - чисто мое субъективное мнение, которое вы не должны рассматривать как официальную.

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

Структура проекта

Прежде чем приступить к изучению самого исходного кода, позвольте мне показать вам, как устроен этот проект. Как и в любом пакете Node, у него есть package.json файл:

Я пропустил несколько разделов (keywords и devDependencies), поскольку они сейчас не важны. Итак, что мы можем узнать, просто посмотрев на package.json? Ну, имя пакета redux (да), это открытый исходный код под лицензией MIT, его единственная зависимость - @babel/runtime, и он тестируется с jest. Изучив раздел скриптов, мы видим, что исходный код отформатирован с помощью prettier, дополнен eslint, скомпилирован с typescript и упакован с rollup. Подробный сценарий, выполняющий все проверки и тесты, - это сценарий prepare. Не стесняйтесь запускать npm run prepare в корне проекта (если вы клонировали репо) и наблюдать за результатом. Если разработчики ничего не напортачили, следует выполнить следующие задачи:

  1. Очистите выходные каталоги (/dist /types /es /coverage /lib)
  2. Статическая проверка типов TypeScript
  3. Проверьте форматирование с помощью Prettier
  4. Проверьте качество кода с помощью ESlint
  5. Создайте производственную версию с помощью Rollup
  6. Тестовый проект с Jest

Вы можете быть сбиты с толку, зачем нам встраивать redux в производственную версию. В конце концов, он импортируется в проекты, которые позже сами компилируются в производственные версии. Это необходимо для работы redux во время выполнения (разные версии Node, браузеры, другие среды выполнения JS) и в разных средах в среде выполнения JS (например, тестирование). Это хорошая практика, и я рекомендую вам использовать такие инструменты, как rollup или bob, при разработке собственных библиотек.

Свойства main, unpkg и module определяют точки входа для разных сред выполнения. Для обычного узла используется main. unpkg предназначен для упаковщика unpkg, а module - для сред, которые никогда не поддерживают модули ES6. Все это возможно благодаря rollup. Наконец, свойство types указывает на определение типов TypeScript для обеспечения линтинга и проверки типов в любом проекте, импортирующем Redux.

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

  • .babelrc - Конфигурационный файл транспилятора Babel, который определяет, как код должен быть передан и какой уровень совместимости желателен.
  • .editorconfig - общий файл конфигурации, понятный большинству основных редакторов кода и IDE. Используется для обеспечения согласованности между машинами для разработки
  • .eslintignore, .eslintrc.js - файлы конфигурации для ESLint. Укажите используемые правила и игнорируемые файлы
  • .gitbook.yaml - конфигурация для GitBook, решения для документации по уценке (я не уверен, используется ли он на самом деле)
  • .prettierrc - конфиг для Prettier, задает правила форматирования кода
  • .travis.yml - конфигурация для TravisCI, устанавливает среду и сообщает Трэвису, какой скрипт запускать (это prepare скрипт)
  • netlify.yml - конфигурация Netlify, которая используется для развертывания веб-сайта с документами
  • rollup.config.js - конфигурация для свертки, которая определяет цели и сопоставления каталогов
  • tsconfig.json - конфиг для транспилятора TypeScript

Последнее, о чем я собираюсь говорить в этом разделе, - это структура каталогов. Вот список каталогов в корне проекта и их назначение:

  • build, dist, es, lib, types - выходы производственной версии, о которых я говорил ранее
  • docs - документы API в формате markdown
  • examples - сборник реальных примеров использования. Каждый из них представляет собой отдельный пакет NPM.
  • website - сайт документации на базе Docasaurus. Предоставляет сам веб-сайт и берет документы из папки / docs. Также отдельный пакет NPM
  • src - исходный код (наконец)

Теперь вы должны иметь представление о том, как структурирована и построена такая популярная и стабильная библиотека, как Redux. Теперь я расскажу о том, как он работает с CI / CD.

Инфраструктура проекта

Проект размещен на Github в рамках организации ReduxJS. Он включает другие сопутствующие библиотеки, такие как react-redux, Reselect и redux-toolkit.

Используемое CI-решение - TravisCI, и вы можете отслеживать его здесь. Он запускается при каждом запросе на перенос и запускает сценарий prepare, который я представил ранее. Это гарантирует, что тесты проходят все время, код в репозитории всегда единообразно отформатирован и никаких регрессий не вносилось.

Документация размещена на Netlify, и она также выполняет некоторые проверки. При каждом запросе на вытягивание он проверяет отсутствие небезопасных http-ссылок в исходном коде и развертывает новую версию документации на временном URL-адресе, чтобы каждый мог посмотреть и сотрудничать.

Я не нашел ни решений на компакт-дисках для публикации новой версии пакета, ни каких-либо автоматических приращений версии, поэтому я предполагаю, что это делается вручную. UPD: подтверждено, нет автоматизации управления версиями / публикациями.

Основные особенности

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

Должно быть довольно ясно, что здесь происходит. В строках 1–8 указаны импортированные основные функции Redux, с которыми вы должны быть знакомы. Затем, в строках с 10 по 39, определения типов повторно экспортируются. Они используются разработчиками TypeScript для строгого определения хранилищ типов, редукторов и создателей действий. Наконец, в строках 41–48 указан основной экспорт, используемый в средах JS и TS, который содержит основные функции. Все идет нормально.

createStore

Затем давайте исследуем функцию createStore, исходный код которой находится в src / createStore.ts (упрощенно):

Вы можете увидеть сигнатуру типа функции в строках 16–25. Это выглядит крайне запутанным, поскольку в значительной степени полагается на обобщенные типы. Это сделано для того, чтобы сделать возможной строгую типизацию в средах TS за счет читабельности исходного кода. S - это определение типа для самого корневого состояния, A - это общее определение для действий (обратите внимание, что оно расширяет Action, чтобы гарантировать, что каждое действие имеет определенный тип). Я не буду сейчас рассматривать Ext и StateExt.

В строках 26–54 есть логика проверки. Он проверяет, что:

  1. Вы не передаете несколько энхансеров (строки 26–35). Это сделано для упрощения логики, и поэтому вам нужно использовать applyMiddleware для объединения нескольких промежуточных программ в одно.
  2. Проверяет, является ли начальное состояние функцией, и в этом случае оно рассматривается как усилитель (строки 37-40).
  3. Если присутствует энхансер, проверяет, является ли это функцией. Если да, передает себя в качестве аргумента усилителю (чтобы дать ему возможность предоставить настраиваемую логику) и возвращается. Если это не функция, выдает ошибку, потому что это не имеет смысла (строки 45–54).

Поскольку статья называется «Code Review», я также дам несколько комментариев и свое мнение о качестве кода. Я бы сразу вынес логику проверки за пределы функции createStore. Сейчас он находится на 280 строках, и это слишком много строк и ответственности для одной функции. Хотя я не собираюсь рассматривать модульное тестирование в этой статье (но дайте мне знать, если нужно), это, помимо прочего, делает тестирование особенно сложным. UPD: мне было указано, что логика валидации больше нигде не используется и не имеет смысла делать ее повторно используемой. Я согласен, но меня больше всего беспокоила удобочитаемость. Не поймите меня неправильно, читаемость сама по себе отличная, я просто ищу способы продвинуть ее еще дальше.

В строках 56–60 вы можете увидеть устанавливаемые основные переменные. currentReducer устанавливается в reducer (который содержит корневой редуктор, который вы передаете в качестве аргумента), currentState содержит объект состояния, isDispatching, ну, сообщает вам, отправляется ли действие прямо сейчас. У нас также есть currentListeners и nextListeners, которые на первый взгляд очень сбивают с толку. Слушатели - это функции обратного вызова, которые запускаются при отправке действия (даже если оно не изменило содержимое состояния). currentListeners содержит список таких функций обратного вызова и используется при отправке. currentListeners является неизменным, то есть вы не можете напрямую изменять его содержимое, но вы можете просто переназначить весь список. nextListeners является изменяемым и мелкой копией currentListeners. Каждый раз, когда вы добавляете или удаляете слушателя, он изменяет nextListeners. Наконец, когда вы отправляете действие, непосредственно перед запуском обратных вызовов nextListeners назначается currentListeners (строка 206). Это сделано для предотвращения ошибок параллелизма, связанных с добавлением / удалением слушателей при отправке действия. Непонятная часть состоит в том, почему нам нужно проверять isDispatching в subscribe / unsubscribe функциях, если такая защита существует? Это проблема эфирного кода или комментариев, которые в данном случае оказываются бесполезными.

Функция ensureCanMutateNextListeners (строки 69-73) проверяет, ссылаются ли nextListeners и currentListeners на один и тот же объект, и, если они есть, создает мелкую копию currentListeners и присваивает ей nextListeners. Он вызывается перед любой мутацией nextListeners, чтобы убедиться, что эти изменения не распространяются на currentListeners по причинам, которые я объяснил ранее.

Функция getState в строках 80-87 должна быть вам знакома: вы, вероятно, использовали ее где-то в своем коде. Это также очень просто: проверьте, отправляются ли какие-либо действия прямо сейчас, и, если нет, верните текущее состояние. Причина, по которой вам не рекомендуется использовать его напрямую, вместо того, чтобы полагаться на подписчиков / оболочки, такие как react-redux, как раз потому, что он будет генерировать, если что-то отправляется. Это будет особенно болезненно при написании асинхронного кода или использовании промежуточного программного обеспечения, такого как redux-thunk. В качестве побочного примечания: не путайте функцию getState, предоставляемую redux-thunk, и прямой вызов getState в объекте хранилища. redux-thunk обеспечит необходимую охрану, чтобы не допустить исключения.

UPD: redux-thunk не обеспечивает никаких гарантий как таковых, но по архитектуре, поскольку он охватывает функцию dispatch, они не нужны: getState гарантированно будет законным в преобразователе.

В строках 115–153 есть функция подписки. Он реализует функции слушателей, которые я представил ранее. Это также до боли просто: проверьте, что ничего не отправляется (хотя это кажется избыточным), и поместите нового слушателя в список nextListeners. Он возвращает функцию unsubscribe, которую вызываемый объект будет использовать для удаления себя из списка слушателей. Он проверяет, подписан ли слушатель по-прежнему (в случае, если вызываемый объект сохранил ссылку на unsubscribe и вызвал его более одного раза), проверяет, отправляется ли что-нибудь, и, если все очищается, удаляет слушателя из nextListeners и (!) Устанавливает currentListeners к null. Это сделано для того, чтобы быть абсолютно уверенным, что неподписанный слушатель больше никогда не будет вызван. Это кажется излишним (поскольку в любом случае этого не должно происходить при нормальных обстоятельствах), но я верю, что у разработчиков были для этого веские причины.

Наконец, функция диспетчеризации (строки 180–213), святой Грааль состояния redux. Сначала в строках (181–186) проверяется, является ли действие простым объектом. То есть, если действие сериализуемое. Это очень важно для концепции redux и позволяет использовать другие интересные функции, такие как сохранение состояния или путешествия во времени. Затем (строки 188–193) убедитесь, что для действия определено свойство type, поскольку без него действие не имело бы никакого смысла. Напоследок знакомая проверка, не рассылается ли что-нибудь. Теперь самые важные строки кодовой базы redux:

try { 
  isDispatching = true 
  currentState = currentReducer(currentState, action) 
} finally { 
  isDispatching = false 
}

Он начинается с установки флага isDispatching в значение true и применения действия к состоянию. Вы можете видеть, что нет проверок на изменчивость состояния (если ссылка, полученная от редуктора, указывает на то же состояние), и, я думаю, она должна быть. Не то, что выдает ошибку, но, по крайней мере, регистрирует предупреждение. После уменьшения состояния, независимо от того, как оно прошло, флаг isDispatching восстанавливается до истинного.

После завершения настройки в строке 218 отправляется действие «@@ INIT». Это делается для заполнения состояния начальным состоянием из редукторов. Поскольку вы не можете импортировать этот тип действия и, следовательно, не можете добавить для него случай переключения, это приводит к принудительному использованию ветки по умолчанию в ваших случаях переключения, которая возвращает начальное состояние.

Наконец, все объединяется в один store объект, который возвращает функция createStore. Все сделано!

applyMiddleware

Следующая функция, которую мы рассмотрим, - это applyMiddleware. Это позволяет вводить пользовательскую логику для процесса диспетчеризации действий. Вы могли использовать его вместе с такими промежуточными программами, как redux-thunk, redux-saga, redux-logger и т. Д. Вот его исходный код (упрощенный):

Этот файл намного короче, чем остальная часть кодовой базы redux. Функция applyMiddleware принимает список применяемых промежуточных программ и возвращает функцию обратного вызова, которая будет вызываться в createStore (строка 47). Эта функция принимает саму функцию createStore и возвращает другой обратный вызов (!). Этот последний обратный вызов примет тот же набор аргументов, что и функция createStore, и создаст хранилище с функцией createStore и присоединит к нему промежуточное ПО. Я считаю, что это, мягко говоря, излишняя сложность. Я вижу, что эта логика избыточна для промежуточного программного обеспечения, но она позволяет более тонкую настройку, которая может потребоваться другим, чем усилители хранилища промежуточного программного обеспечения. Другая проблема заключается в том, что действие «@@ INIT» будет отправлено в функции createStore до применения промежуточного программного обеспечения.

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

В строках 22–25 создается объект middlewareAPI. Он предоставляет функцию getState из магазина и пользовательскую функцию отправки. Он будет передан промежуточному программному обеспечению, чтобы дать им точки вмешательства для настраиваемого поведения. Например, redux-thunk внедряет эти функции в ваши создатели асинхронных действий.

В строках 26–27 создается цепочка промежуточного программного обеспечения. Это выполняется путем перебора списка middlewares и вызова каждого промежуточного программного обеспечения с созданным ранее объектом middlewareAPI. Эта цепочка представляет собой список функций, которые принимают действие, что-то с ним делают (например, await) и возвращают действие, которое может быть или не быть исходным. В строке 27, последней строке перед возвратом, функция dispatch перезаписывается составной цепочкой промежуточного программного обеспечения. Composed означает, что список функций превращается в одну функцию, которая связывает их одну за другой. Это делается с помощью функции compose, также являющейся частью базы кода redux.

Наконец, объект хранилища распаковывается, функция отправки перезаписывается, и ваше расширенное хранилище redux готово к работе!

Заключительные примечания

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