Автор материала, перевод которого мы сегодня публикуем, говорит, что GitHub-репозиторий, над которым работал он и ещё несколько фрилансеров, получил, по разным причинам, около 8200 звёзд за 3 дня. Этот репозиторий попал на первое место в HackerNews и в GitHub Trending, за него отдали голоса 20000 пользователей Reddit.
В данном репозитории отражена методика разработки фулстек-приложений, которой посвящена эта статья.
Предыстория
Я собирался написать этот материал уже довольно давно. Полагаю, что лучшего момента, чем этот, когда наш репозиторий пользуется серьёзной популярностью, мне не найти.
№ 1 в GitHub Trending
Я работаю в команде фрилансеров. В наших проектах используется React/React Native, NodeJS и GraphQL. Этот материал предназначен для тех, кто хочет узнать о том, как мы разрабатываем приложения. Кроме того, он будет полезен тем, кто в будущем присоединится к нашей команде.
Сейчас я расскажу об основных принципах, которые мы используем при разработке проектов.
Чем проще — тем лучше
«Чем проще — тем лучше», — это легче сказать, чем сделать. Большинство разработчиков отдают себе отчёт в том, что простота — это важный принцип разработки ПО. Но этому принципу не всегда легко следовать. Если код устроен просто — это облегчает поддержку проекта и упрощает командную работу над этим проектом. Кроме того, соблюдение этого принципа помогает в работе с кодом, который был написан, скажем, полгода назад.
Вот какие ошибки, касающиеся рассматриваемого принципа, мне приходилось встречать:
- Неоправданное стремление к выполнению принципа DRY. Иногда копирование и вставка кода — это вполне нормально. Не нужно абстрагировать каждые 2 фрагмента кода, которые чем-то похожи друг на друга. Я и сам совершал эту ошибку. Все, пожалуй, её совершали. DRY — это хороший подход к программированию, но выбор неудачной абстракции способен лишь ухудшить ситуацию и усложнить кодовую базу. Если вы хотите узнать подробности об этих идеях — рекомендую почитать материал «AHA Programming» Кента Доддса.
- Отказ от использования имеющихся инструментов. Один из примеров этой ошибки — использование
reduce
вместоmap
илиfilter
. Конечно, с помощьюreduce
можно воспроизвести поведениеmap
. Но это, вероятно, приведёт к росту размера кода, и к тому, что другим людям будет сложнее понять этот код, учитывая то, что «простота кода» — понятие субъективное. Иногда может понадобиться использовать именноreduce
. А если сравнить скорость обработки набора данных с использованием объединённых в цепочку вызововmap
иfilter
, и с использованиемreduce
, то окажется, что второй вариант работает быстрее. В варианте сreduce
набор значений приходится просматривать один раз, а не два. Перед нами — спор производительности и простоты. Я, в большинстве случаев, отдал бы предпочтение простоте и стремился бы к тому, чтобы избежать преждевременной оптимизации кода, то есть, выбрал бы паруmap
/filter
вместоreduce
. А если бы оказалось так, что конструкция изmap
иfilter
стала узким местом системы, перевёл бы код наreduce
.
Многие из идей, о которых речь пойдёт ниже, направлены на то, чтобы сделать кодовую базу как можно более простой и поддерживать её в таком состоянии.
Держите схожие сущности рядом друг с другом
Этот принцип, «принцип колокации», применим ко многим частям приложения. Это и структура папок, в которых хранится код клиента и сервера, это и хранение кода проекта в одном репозитории, это и принятие решений о том, какой именно код оказывается в некоем файле.
▍Репозиторий
Рекомендуется держать код клиента и сервера в одном и том же репозитории. Это просто. Не стоит усложнять то, что усложнять не нужно. При таком подходе удобно организовать согласованную командную работу над проектом. Я работал над проектами, для хранения материалов которых использовались различные репозитории. Это — не катастрофа, но монорепозитории делают жизнь гораздо легче.
▍Структура проекта клиентской части приложения
Мы пишем фулстек-приложения. То есть — и код клиента, и код сервера. В структуре папки типичного клиентского проекта предусмотрены отдельные директории для компонентов, контейнеров, действий, редьюсеров и маршрутов.
Действия и редьюсеры присутствуют в тех проектах, в которых используется Redux. Я стремлюсь к тому, чтобы обходиться без этой библиотеки. Я уверен в том, что существуют качественные проекты, в которых используется такая же структура. В некоторых из моих проектов имеются отдельные папки для компонентов и контейнеров. В папке компонентов может храниться нечто вроде файлов с кодом таких сущностей, как BlogPost
и Profile
. В папке контейнеров имеются файлы, хранящие код контейнеров BlogPostContainer
и ProfileContainer
. Контейнер получает данные с сервера и передаёт их «глупому» дочернему компоненту, задача которого заключается в том, чтобы вывести эти данные на экран.
Это — рабочая структура. Она, по крайней мере, однородна, а это очень важно. Это ведёт к тому, что разработчик, присоединившийся к работе над проектом, быстро поймёт то, что в нём происходит, и то, какую роль играют его отдельные части. Минус этого подхода, из-за которого я в последнее время стараюсь им не пользоваться, заключается в том, что он заставляет программиста постоянно перемещаться по кодовой базе. Например, у сущностей ProfileContainer
и BlogPostContainer
нет ничего общего, но их файлы находятся рядом друг с другом и при этом далеко от тех файлов, в которых они по-настоящему используются.
Я с некоторых пор стремлюсь к тому, чтобы размещать файлы, содержимое которых планируется использовать совместно, в одних и тех же папках. Такой подход структурирования проекта основан на группировке файлов на основании реализуемых ими возможностей. Благодаря этому подходу можно значительно облегчить себе жизнь, если, например, поместить в одну папку родительский компонент и его «глупый» дочерний компонент.
Обычно мы используем папки routes
/ screens
и папку components
. В папке для компонентов обычно хранится код таких элементов, как Button
или Input
. Этот код может быть использован на любой странице приложения. Каждая папка, находящаяся в папке для маршрутов, представляет собой отдельную страницу приложения. При этом файлы с кодом компонентов и с кодом логики приложения, относящиеся к данному маршруту, находятся внутри той же самой папки. А код компонентов, которые используются на нескольких страницах, попадает в папку components
.
В пределах папки маршрута можно создавать дополнительные папки, в которых сгруппирован код, ответственный за формирование разных частей страницы. Это имеет смысл в тех случаях, когда маршрут представлен большим объёмом кода. Тут, однако, мне хотелось бы предупредить читателя о том, что не стоит создавать структуры из папок с очень большим уровнем вложенности. Это усложняет перемещение по проекту. Глубокие вложенные структуры папок — это один из признаков чрезмерного усложнения проекта. Надо отметить, что использование специализированных инструментов, вроде команд поиска, даёт программисту удобные средства для работы с кодом проекта и для поиска того, что ему нужно. Но структура файлов проекта также оказывает влияние на удобство работы с ним.
Структурируя код проекта, можно группировать файлы, опираясь не на маршрут, а на реализуемые этими файлами возможности проекта. В моём случае этот подход отлично показывает себя на проектах-одностраничниках, реализующих множество возможностей на своей единственной странице. Но надо отметить, что группировка материалов проекта по маршрутам проще. Этот подход не требует особенных умственных усилий для того, чтобы принимать решения о том, какие именно сущности следует размещать рядом друг с другом, и для того, чтобы что-то искать.
Если пойти по пути группировки кода дальше, то можно решить, что код контейнеров и компонентов оправданно будет поместить в один и тот же файл. А можно пойти и ещё дальше — положить в один файл код двух компонентов. Полагаю, вы вполне можете думать сейчас о том, что рекомендовать такие вещи — это прямо-таки кощунство. Но в реальности всё далеко не так плохо. На самом деле, такой подход вполне себя оправдывает. И если вы используете хуки React, или сгенерированный код (или — и то и другое), я бы порекомендовал вам именно такой подход.
На самом деле, вопрос о том, как именно разложить код по файлам, имеет не первостепенную важность. Настоящий вопрос заключается в том, почему вообще может понадобиться делить компоненты на «умные» и «глупые». Какие выгоды можно извлечь от такого разделения? На этот вопрос можно дать несколько ответов:
- Приложения, устроенные таким образом, легче тестировать.
- При разработке таких приложений легче использовать инструменты наподобие Storybook.
- «Глупые» компоненты можно использовать с множеством разных «умных» компонентов (и наоборот).
- «Умные» компоненты можно использовать на разных платформах (например — на платформах React и React Native).
Всё это — реальные доводы в пользу разделения компонентов на «умные» и «глупые», но они применимы далеко не ко всем ситуациям. Например, мы часто, при создании проектов, используем Apollo Client с хуками. Для того чтобы такие проекты тестировать, можно либо создавать моки ответов Apollo, либо моки хуков. То же самое касается и Storybook. Если говорить о смешивании и совместном использовании «умных» и «глупых» компонентов, то я, на самом деле, никогда этого на практике не встречал. В том, что касается кроссплатформенного использования кода, был один проект, в котором я собирался сделать нечто подобное, но так и не сделал. Это должен был быть монорепозиторий Lerna. В наши дни вместо этого подхода вполне можно выбрать React Native Web.
В результате можно сказать, что в разделении компонентов на «умные» и «глупые» есть определённый смысл. Это — важная концепция, о которой стоит знать. Но часто о ней не нужно особенно сильно беспокоиться, особенно учитывая недавнее появление хуков React.
Сильная сторона совмещения в одной сущности возможностей «умных» и «глупых» компонентов заключается в том, что это ускоряет разработку, и в том, что это упрощает структуру кода.
Более того, если возникнет такая необходимость, некий компонент всегда можно разделить на два отдельных компонента — «умный» и «глупый».
Стилизация
Мы используем для стилизации приложений emotion / styled components. Всегда есть соблазн выделить стили в отдельный файл. Я видел, как некоторые разработчики так и поступают. Но, после того как я испробовал оба подхода, я в итоге не нашёл причин для перемещения стилей в отдельный файл. Как и в случае со многим другим, о чём мы тут говорим, разработчик может облегчить себе жизнь, совмещая в одном файле стили и компоненты, к которым они относятся.
▍Структура проекта серверной части приложения
Всё вышесказанное справедливо и в отношении структурирования кода серверной части приложения. Типичная структура, которой лично я стараюсь избегать, может выглядеть примерно так:
src
│ app.js # Точка входа в приложение
└───api # Контроллер маршрутов Express для всех конечных точек приложения
└───config # Переменные среды и средства конфигурирования
└───jobs # Объявление заданий для agenda.js
└───loaders # Разделение кода на модули
└───models # Модели баз данных
└───services # Бизнес-логика
└───subscribers # Обработчики событий для асинхронных задач
└───types # Файлы объявлений типов (d.ts) для Typescript
Мы в своих проектах обычно применяем GraphQL. Поэтому в них используются файлы, в которых хранятся модели, сервисы и распознаватели. Вместо того чтобы разбрасывать их по разным местам проекта, я собираю их в одной папке. Чаще всего эти файлы будут использоваться совместно, и с ними будет легче работать в том случае, если они будут храниться в одной и той же папке.
Не переписывайте по многу раз определения типов
Мы используем в своих проектах множество решений, так или иначе имеющих отношение к типам данных. Это TypeScript, GraphQL, схемы баз данных, и иногда MobX. В результате может оказаться так, что типы для одних и тех же сущностей описывают по 3-4 раза. Подобных вещей стоит избегать. Надо стремиться к использованию инструментов, автоматически генерирующих описания типов.
На сервере для этой цели можно воспользоваться комбинацией TypeORM/Typegoose и TypeGraphQL. Этого хватит для описания всех используемых типов. TypeORM/Typegoose позволит описать схему базы данных и соответствующие типы TypeScript. TypeGraphQL поможет в создании типов GraphQL и TypeScript.
Вот пример определения типов TypeORM (MongoDB) и TypeGraphQL в одном файле:
import { Field, ObjectType, ID } from 'type-graphql'
import {
Entity,
ObjectIdColumn,
ObjectID,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm'
@ObjectType()
@Entity()
export default class Policy {
@Field(type => ID)
@ObjectIdColumn()
_id: ObjectID
@Field()
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date
@Field({ nullable: true })
@UpdateDateColumn({ type: 'timestamp', nullable: true })
updatedAt?: Date
@Field()
@Column()
name: string
@Field()
@Column()
version: number
}
GraphQL Code Generator также умеет генерировать множество различных типов. Мы используем этот инструмент для создания типов TypeScript на клиенте, а так же — хуков React, выполняющих обращения к серверу.
Если вы используете MobX для управления состоянием приложения, то вы, воспользовавшись парой строк кода, можете получить автоматически сгенерированные TS-типы. Если же вы, к тому же, пользуетесь и GraphQL, то вам стоит взглянуть на новый пакет — MST-GQL, который генерирует дерево состояния из GQL-схемы.
Совместное использование этих инструментов убережёт вас от переписывания больших объёмов кода и поможет избежать типичных ошибок.
Другие решения, такие как Prisma, Hasura и AWS AppSync, тоже могут помочь избежать дублирования объявлений типов. У использования подобных инструментов, конечно, есть свои плюсы и минусы. В создаваемых нами проектах подобные средства используются не всегда, так как нам нужно развёртывать код на собственных серверах организаций.
Прибегайте всегда, когда это возможно, к средствам автоматического генерирования кода
Если взглянуть на код, который создают без использования вышеописанных средств для автоматического генерирования кода, то окажется, что программистам постоянно приходится писать одно и тоже. Главный совет, который я могу дать по этому поводу, заключается в том, что нужно создавать сниппеты для всего, чем вы часто пользуетесь. Если вы часто вводите команду console.log
— создайте сниппет, вроде cl
, который автоматически превращается в console.log()
. Если вы этого не сделаете и попросите меня помочь вам с отладкой кода, меня это сильно расстроит.
Существует множество пакетов со сниппетами, но несложно и создавать собственные сниппеты. Например — с помощью Snippet generator.
Вот код, который позволяет добавить некоторые из моих любимых сниппетов в VS Code:
{
"Export default": {
"scope": "javascript,typescript,javascriptreact,typescriptreact",
"prefix": "eid",
"body": [
"export { default } from './${TM_DIRECTORY/.*[\/](.*)$$/$1/}'",
"$2"
],
"description": "Import and export default in a single line"
},
"Filename": {
"prefix": "fn",
"body": ["${TM_FILENAME_BASE}"],
"description": "Print filename"
},
"Import emotion styled": {
"prefix": "imes",
"body": ["import styled from '@emotion/styled'"],
"description": "Import Emotion js as styled"
},
"Import emotion css only": {
"prefix": "imec",
"body": ["import { css } from '@emotion/styled'"],
"description": "Import Emotion css only"
},
"Import emotion styled and css only": {
"prefix": "imesc",
"body": ["import styled, { css } from ''@emotion/styled'"],
"description": "Import Emotion js and css"
},
"Styled component": {
"prefix": "sc",
"body": ["const ${1} = styled.${2}`", " ${3}", "`"],
"description": "Import Emotion js and css"
},
"TypeScript React Function Component": {
"prefix": "rfc",
"body": [
"import React from 'react'",
"",
"interface ${1:ComponentName}Props {",
"}",
"",
"const ${1:ComponentName}: React.FC<${1:ComponentName}Props> = props => {",
" return (",
" <div>",
" ${1:ComponentName}",
" </div>",
" )",
"}",
"",
"export default ${1:ComponentName}",
""
],
"description": "TypeScript React Function Component"
}
}
Сэкономить время, помимо сниппетов, могут помочь генераторы кода. Их можно создавать самостоятельно. Мне для этого нравится использовать plop.
В Angular есть собственные встроенные генераторы кода. С помощью инструментов командной строки можно создать новый компонент, состоящий из 4 файлов, в которых представлено всё то, что можно ожидать найти в компоненте. Жаль, что в React нет такой вот стандартной возможности, но нечто подобное можно создать и самостоятельно, используя plop. Если каждый новый создаваемый вами компонент должен быть представлен в виде папки, содержащей файл с кодом компонента, файл с тестом и файл Storybook, генератор поможет создать всё это одной командой. Это во многих случаях значительно облегчает жизнь разработчика. Например, при добавлении новой возможности на сервер достаточно выполнить одну команду в командной строке. После этого автоматически будут созданы файлы сущности, сервисов и распознавателей, содержащие все необходимые базовые конструкции.
Ещё одна сильная сторона генераторов кода заключается в том, что они способствуют единообразию в командной разработке. Если все используют один и тот же plop-генератор, то код у всех будет получаться весьма однородным.
Автоматическое форматирование кода
Форматирование кода — простая задача, но её, к сожалению, не всегда решают правильно. Не тратьте время, вручную выравнивая код или вставляя в него точки с запятой. Используйте Prettier для автоматического форматирования кода при выполнении коммитов.
Итоги
В этом материале я рассказал вам о некоторых вещах, о которых мы узнали за годы работы, за годы проб и ошибок. Существует множество подходов к структурированию кодовой базы проектов. Но среди них нет такого, который можно назвать единственно правильным.
Самое главное, что я хотел до вас донести, заключается в том, что программисту стоит стремиться к простоте организации проектов, к их однородности, к использованию понятной структуры, с которой легко работать. Это упрощает командную разработку проектов.
Уважаемые читатели! Что вы думаете об идеях, касающихся разработки фулстек-приложений на JavaScript, изложенных в этом материале?
Автор: ru_vds