Структурируйте приложение React, которое можно масштабировать, используя локальное состояние

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

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

Предыдущие знания

Есть несколько концепций, с которыми вам нужно разобраться, чтобы понять этот пост. Что-то я кратко объясню, а что-то нет. Это список тех, которые вы должны знать заранее:

Почему

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

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

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

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

Есть еще одна проблема с GSM: затяжные состояния. Это происходит потому, что состояние живет вне компонентов, а не создается и не разрушается ими. Это может вызвать некоторые проблемы с UX при входе на страницу и отображении устаревших данных.

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

Основные концепции

Основная концепция, на которой основана эта архитектура, — это Single Responsibility Principle из принципов SOLID. Их чаще всего можно найти в документации по объектно-ориентированному программированию, но их можно применять практически к любому мыслительному процессу.

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

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

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

Структура

Основа структуры — иметь состояния, принадлежащие компонентам, внутри компонентов, но в то же время внутри папки компонентов пользовательский интерфейс и состояние будут отдельными.

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

Типы

Во-первых, state/types.ts, это действительно важно, чтобы связать состояния и обновления, отправленные для его мутации.

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

Наконец, тип CustomDispatch создает тип для объекта dispatch, возвращаемого хуком useReducer (он создает тип (value: Update) => void).

Редуктор

Функция редуктора (внутри state/reducer.ts) обрабатывает обновление состояния на основе возможных отправляемых обновлений, технически она может быть реализована множеством разных способов, но я выбрал стиль switch, потому что он упрощает вывод типа (для кода), и это важная часть для предотвращения ошибок.

Таким образом, TypeScript может определить тип полезной нагрузки внутри блока case, то есть я могу использовать только update.quantity внутри блока case 'set_quantity'.

Действия

Теперь давайте посмотрим, как реализовать действие. Формат будет:

И вот как это будет выглядеть:

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

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

  • параметры генератора действий — это состояния и/или любые данные, используемые на хуке (выбранные данные, значения из контекста…)
  • параметры действия являются входными данными из пользовательского интерфейса

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

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

Точка соединения

Теперь давайте посмотрим, как связать все это вместе. Эта функция переходит к файлу state/index.ts, который является пользовательским хуком, который содержит состояние, определяет действия, которые будут выполняться в пользовательском интерфейсе, и обрабатывает любые побочные эффекты.

Этот бит является стандартным способом использования хука useReducer из React. Typescript определяет типы на основе функции редуктора и использует их для значений state и dispatch.

Наконец, в блоке возврата создаются действия. Вы можете видеть, что некоторые из них создаются встроенными, например openModal: () => dispatch({ type: 'open_modal' }), и вы можете справедливо указать, что это не соответствует структуре файла действия, представленной выше, но, учитывая сложность этого действия, я посчитал, что создание файла для него будет излишним.

Эта строка, addToCart: addToCart(dispatch, state, product), описывает, как создавать действия, определенные с помощью шаблона генератора действий.

Вот как соединить обработчик состояния с компонентом пользовательского интерфейса. Это будет на ProductDetails/index.tsx.

Вся эта структура добавляет разделение между слоями состояния и пользовательского интерфейса после Single Responsibility Principle. Например, тип действия addToCart будет () => Promise<void>, что означает, что пользовательский интерфейс не будет знать или беспокоиться о том, что произойдет, когда оно будет вызвано.

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

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

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

Некоторые замечания

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

Где разместить государство?

Общая проблема с этой архитектурой будет заключаться в том, где разместить состояние. Здесь нет строгого правила, так как оно зависит от требований, но, как правило, я бы посоветовал размещать состояние как можно ближе к компоненту и поднимать его только в том случае, если оно необходимо для совместного использования или для каких-либо бизнес-требований. (обычно ИП). В этой части документации React есть хорошее объяснение этого процесса.

Производительность

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

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

Инструментарий Redux использует selectors (используя reselect npm package) для доступа к состояниям, и их можно закодировать так, чтобы они были настолько широкими или точными, насколько вы хотите, но за этим нет никакой магии, они просто используют методы запоминания, чтобы предотвратить повторное использование. -renders, и я не говорю, что это плохой подход, просто это то, что вы также можете сделать с помощью инструментов, которые предоставляет React.

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

Правило импорта

Это правило призвано помочь при структурировании компонентов, определяя, какие из них являются общими, а какие принадлежат компоненту. Первое, что нужно знать, это то, что папки делятся на два типа: папки grouping и module. Вы можете использовать структуру папок в конце этого раздела, чтобы лучше понять, как использовать это правило.

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

  • src/api
  • src/helpers
  • src/components/base

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

import ... from 'src/api'; // this would not work

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

  • src/components/base/Button
  • src/components/layout/PrivateLayout
  • src/components/page/ProductDetails

Теперь, когда вы знаете типы папок, вот правило:

«Вы можете импортировать из файлов внутри групповых папок, но не из папок модулей».

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

При работе с API модулей Node.js любой файл считается одним, и вы контролируете, что выставлено или нет, с помощью ключевого слова export.

Это делает невозможным управление экспортом на уровне папки, поскольку наличие файла index.ts|js не помешает никому импортировать непосредственно из нужного файла.

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

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

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

Опорное бурение

Другой проблемой может быть prop drilling, когда вам нужно передать реквизиты от одного компонента к его дочерним элементам на нескольких уровнях. Эта часть из документации React дает хорошее объяснение, а здесь — несколько хороших советов о том, следует ли и когда использовать context, что является распространенным способом решения этой проблемы.

Если вы решите, что вам действительно нужно использовать контекст, вот мои предложения. Сначала переместите файл types.ts из папки state (следуя «правилу импорта»).

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

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

Теперь давайте посмотрим, как это может выглядеть. Допустим, вы хотите передать product, quantity и функции для его обновления.

Создайте тип для представления того, через что будет передаваться информация.

Затем создайте файл context.tsx.

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

Выводы

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

PS: Вот репозиторий кода на GitHub: https://github.com/AlejandroYanes/bazar-web

Создавайте приложения с повторно используемыми компонентами, такими как Lego.

Инструмент с открытым исходным кодом Bit помогает более чем 250 000 разработчиков создавать приложения с компонентами.

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

Подробнее

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

Микро-интерфейсы

Система дизайна

Совместное использование кода и повторное использование

Монорепо

Узнать больше