Petr Tcoi

+14
с 2018
11 подписчиков
27 подписок

Принцип DRY (Don’t Repeat Yourself) - пожалуй, одно из первых правил, которые мы усваиваем как разработчики. Его идея кажется безупречной: не повторяй код, не дублируй знания, всё, что повторяется, вынеси в общее место.На практике это означает: если кусок логики повторяется в нескольких местах, его нужно сделать общим - в виде функции, класса, моду…

Делюсь своим опытом по организации перевода контента на разные языки. Сейчас работаю над многоязычным сайтом со стеком NextJS i18n + MongoDB (Mongoose). На сайте довольно много текста, который изредка обновляется.

Для перевода текстов на проекте используется Google Translate. Поэтому первоначально была создана простая функция для перевода вид…

1

Недавно закончил очередной онлайн-магазин отопительного оборудования. Хочу поделиться стэком технологий, который использовал для него.

Сайт:

1
2

Оператор `satisfies` указывает TS на то, какой тип переменной мы ожидаем без переопределения ее собственного типа. В некоторых случаях такая возможность оказывается очень полезной. Рассмотрим простой пример, где данный оператор может оказаться полезным.

У нас есть список продуктов `listA` и нужно подготовить объект, ключи которого были бы про…

Одним из преимуществ NextJS является возможность совмещать frontend и backend в рамках одного проекта и использовать общие типы и интерфейсы. К сожалению, серверная часть фреймворка работает в отрыве от фронта, являясь во многом самостоятельным приложением. Поэтому, обеспечение typesafe требует дополнительной работы. В этой статья я поделюсь своим…

1

В этой статье я хочу поделиться своим опытом разработки приложений с применением подхода FSD (Feature-Sliced Design). Здесь не будут рассматривать ее детально, так как на этот счет есть достаточно хороших материалов, начиная с официального сайта (изображения в этой записи взяты именно с него), и заканчивая статьями на Хабре.

2

Выдался удобный случай попробовать создать небольшой сайт с помощью фреймворка AstroJS - магазин радиаторов velarshop.ru (больше просто онлайн-каталог).

В основе работы Astro лежит "островная архи…

В React распространенным методом изменения свойств объектов является применение spread оператора. Его синтаксис подкупает своей простой и понятностью.

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

1

В предыдущей статье рассматривался пример использования библиотеки ramda вместе с React / Redux. Здесь я поделюсь своим опытом в использовании другой замечательной библиотеки fp-ts.

1

С последней моей записи прошло довольно много времени. Дело в том, что я все же нашел свою первую работу и теперь времени на сторонние проекты у меня стало меньше. Тем не менее, постараюсь более менее регулярно продолжать писать на интересные для меня (надеюсь, и для вас) темы.

Я давно хотел попробовать что-то из мира функционального программ…

Всем привет. Я - Петр Цой. Ищу первую работу на React. В качестве моего резюме выступает сайт petrtcoi.com. Ссылка на GitHub.

2
","lang":""}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":5,"favorites":0,"reposts":0,"views":281,"hits":355,"reads":null,"online":0},"dateFavorite":0,"hitsCount":355,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/1248402-moi-eksperiment-s-htmx-i-astro","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":2}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":1077880,"customUri":null,"subsiteId":206685,"title":"Небольшой пример использования оператора satisfies в TypeScript","date":1710499256,"dateModified":1710499256,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

Оператор `satisfies` указывает TS на то, какой тип переменной мы ожидаем без переопределения ее собственного типа. В некоторых случаях такая возможность оказывается очень полезной. Рассмотрим простой пример, где данный оператор может оказаться полезным.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

У нас есть список продуктов `listA` и нужно подготовить объект, ключи которого были бы произвольными строками, а значениями - любое из входящих в список.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const listA = ['apples', 'oranges', 'peaches'] as const;\n\ntype ListA = (typeof listA)[number]; // \"apples\" | \"oranges\" | \"peaches\"\n\nconst listMap1: Record = {\n a: 'apples',\n b: 'oranges',\n c: 'peaches',\n d: 'apples',\n};","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Теперь нам нужно из объекта `listMap1` вывести тип ключей, входящих в него. И здесь возникает проблема, так как в самом начале мы определили его тип как `Record<string, ListA>`. То есть ключ - это любая строка:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"type Key1 = keyof typeof listMap1;\n\nconst keyOne: Key1 = 'Y'; // const keyOne: string","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Мы не получим ошибки, хотя должны были бы. Здесь и пригождается оператор `satisfies`, который указывает TS, что мы не определяем тип переменной, но просто ожидаем, что в ключах будут строки, а в значениях что-то из списка фруктов.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const listMap2 = {\n a: 'apples',\n b: 'oranges',\n c: 'peaches',\n d: 'apples',\n} satisfies Record;","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В этом случае приведенный раннее код будет работать как и положено:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"type Key2 = keyof typeof listMap2;\n\nconst keyTwo: Key2 = 'Y'; // const keyTwo: \"a\" | \"b\" | \"c\" | \"d\"","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

мы получим ошибку `Type '\"Y\"' is not assignable to type '\"a\" | \"b\" | \"c\" | \"d\"'`, что нам и требовалось.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

#typescript

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":1,"reposts":0,"views":315,"hits":2027,"reads":null,"online":0},"dateFavorite":0,"hitsCount":2027,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/1077880-nebolshoi-primer-ispolzovaniya-operatora-satisfies-v-typescript","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":807843,"customUri":null,"subsiteId":206685,"title":"NextJS API + React Query + Zod","date":1693818157,"dateModified":1693818157,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

Одним из преимуществ NextJS является возможность совмещать frontend и backend в рамках одного проекта и использовать общие типы и интерфейсы. К сожалению, серверная часть фреймворка работает в отрыве от фронта, являясь во многом самостоятельным приложением. Поэтому, обеспечение typesafe требует дополнительной работы. В этой статья я поделюсь своим опытом решения данного вопроса.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Организация папок"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"│\n├── database\n│ ├── items\n│ │ ├── api\n│ │ │ ├── get-item-by-id\n│ │ │ │ ├── getItemByIdClient.ts\n│ │ │ │ ├── getItemByIdServer.ts\n│ │ │ │ ├── config.ts\n│ │ │ │ └── index.ts\n│ │ │ └── index.ts\n│ │ ├── lib\n│ │ │ ├── getItemById.ts\n│ │ │ └── index.ts\n│ │ ├── hooks\n│ │ │ ├── use-get-item-by-id\n│ │ │ │ ├── queryKey.ts\n│ │ │ │ ├── useGetItemById.ts\n│ │ │ │ └── index.ts\n│ │ │ └── index.ts\n│ │ ├── items.types.ts\n│ │ └── index.ts\n│ └── index.ts","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Пройдемся по этим уровням, начиная от базового.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"items.types.ts"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для определения структуры использется zod. В моем последнем проекте использовался Strapi, поэтому выглядело все примерно так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// в каком виде данные хранятся в БД\nexport type DbItem = z.infer;\nexport const zDbItem = z.object({\n\tid: z.number().int().positive(),\n\tname: z.string().nonempty({ message: \"City name cannot be empty\" }),\n\tsku: z.string().optional().or(z.null()),\n\tdescription: z.string().optional().or(z.null()),\n});\n\n// в какоми виде данные возвращяются из базы при запросе\nexport type DbItemQuery = z.infer;\nexport const zDbItemQuery = zDbItem.pick({ id: true }).merge(\n\tz.object({\n\t\tattributes: zDbItem.omit({ id: true, ...omitDates })\n })\n);\n\n// если необходимо, то здесь же можно прописать объект для наполнения ответа\nexport const itemQueryPopulate = {\n\tbrand: { fields: [\"id\", \"name\"] },\n\tmanufacturer: { fields: [\"id\", \"name\", \"rating\"] }, \n}","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"/lib - взимодействие с базой данных"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В файлах методов прописываются схемы zod и ожидаемого ответа. Например:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// .../lib/getItemById.ts\n\nexport type GetProps = z.infer;\nexport const zGetProps = z.object({\n\tid: z.coerce.number().int().positive(),\n});\n\nexport type GetResult = z.infer;\nexport const zGetResult = zDbItemQuery;\n\nexport const getProductById = async (props: GetProps): Promise => {\n const {id} = props;\n ....\n return item\n\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Теперь у нас есть изолированные в отдельной папке методы для работой с БД. Если что-то поменяется в ее логике или нужно будет оптимизировать какой-нибудь запрос, то это можно будет сделать именно здесь, сохранив типы аргументов и ответа.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"/api - связка frontend и backend"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"api\n ├── get-item-by-id\n ├── getItemByIdClient.ts\n ├── getItemByIdServer.ts\n ├── config.ts\n └── index.ts","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Главным же здесь является файл config.ts содержащий в себе информацию об используемых функциями типах и интерфейсах, а также о пути к api route, который будет вызываться со стороны frontend.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// .../api/get-item-by-id/config.ts\n\nimport { z } from \"zod\";\nimport { paths } from \"configs\";\n\nimport { zGetResult, zGetProps } from \"../../lib/getItemById\";\n\n\n\n// path\nexport const PATH = paths.api.items.root;\n\n// type Props\nexport type TPropsServer = z.infer;\nexport const zPropsServer = zGetProps;\n\nexport type TPropsClient = z.infer;\nexport const zPropsClient = zPropsServer;\n\nexport type TResult = z.infer;","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

По сути это набор re-exports нужный для того, чтобы упростить организацию кода.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Аргументы для Client и Server в данном примере одинаковы, но могут различаться, если, например, для выполнения Server-функции требуется еще id пользователя. Он будет получен из cookies в файле route.ts и передан в функцию getItemByIdServer.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Да. Необходимая часть кода находится в другой папке. Пока приходится организовывать все таким образом. Когда Server Actions перестанут находится в статусе экспериментальных, от этого звена можно бдует избавиться. Также, я еще не пробовал библиотеку ts-rest, котора нацелена на эту же проблему.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Файл выглядит примерно так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// app/api/items/[id]/route.ts\n\nexport async function GET(req: NextRequest) {\n\n\tconst { pathname } = new URL(req.url);\n\tconst id = pathname\n\t\t.split(\"/\")\n\t\t.filter(part => part !== \"\")\n\t\t.at(-1);\n\n\tconst result = await getItemByIdServer({ id });\n\n\treturn NextResponse.json(result, { status: result.statusCode });\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Функция getItemByIdServer возвращает не только ответ, но и Http-код ответа:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// getItemByIdServer.ts\n\nexport default async function getItemByIdServer(props: TPropsServer): Promise {\n\t\n\tconst checkProps = zQueryProps.safeParse(props);\n\n\tif (!checkProps.success) {\n\t\treturn({ code: \"WrongData\", message: checkProps.error }, HttpStatusCode.BadRequest);\n\t}\n\n const { data } = checkProps\n\n\ttry {\n\t\tconst result = await getCompanyById(data);\n\t\tif (isSuccess(result)) {\n\t\t\treturn (result.payload, HttpStatusCode.Ok);\n\t\t}\n\n\t\treturn ({ code: result.payload.code }, HttpStatusCode.BadRequest);\n\n\t} catch (e) {\n\t\treturn({ code: ErrCode.UnknownError, message: \"Неизвестная ошибка\" }, HttpStatusCode.InternalServerError);\n\t}\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь содержится вся бизнес-логика приложения. Функция на стороне frontend выполняет простую роль передачи запроса на сервер.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// getItemByIdClient.ts\n\nexport default async function queryCompanyClient(data: QueryProps): Promise {\n\tconst res = await axios.get(`${ROOT_PATH}/${data.id}`, { validateStatus: () => true });\n\n\tif (res.status === HttpStatusCode.Ok && isSuccess(res.data)) {\n\t\tconst data = zDbCompanyQuery.parse(res.data.payload);\n\t\treturn data;\n\t}\n\n\tthrow res.data.payload;\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь вместо обычного возврата кода ошибки происходит throw error, так как функция будет использоваться в хуке useQuery от ReactQuery.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Как серверная часть, так и клиентская, осуществляют валидацию полученных данных через zod.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"/hooks - добавляем ReactQuery"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Хуки располагаются в отдельных папках

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"│ │ ├── hooks\n│ │ │ ├── use-get-item-by-id\n│ │ │ │ ├── queryKey.ts\n│ │ │ │ ├── useGetItemById.ts\n│ │ │ │ └── index.ts","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В файле queryKey просто прописывается и экспортируется ключ, который будет использоваться для данного хука. Ее можно будет исопльзовать в других частях приложения, например, если нуждо будет сделать invalidate для запроса.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// .../queryKey.ts\n\nexport const QUERY_KEY = \"item-get-by-id\";","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Сам хук простой - он лишь вызывает уже созданную ранее функцию для frontend части:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"import { useQuery } from \"@tanstack/react-query\";\nimport { GetItemByIdPropsClient, getItemByIdClient } from \"shared/database\";\nimport { QUERY_KEY } from \"./queryKey\";\n\nexport type UseGetItemProps = GetItemByIdPropsClient;\n\nexport const useGetItemById = ({ id }: UseGetItemProps) => {\n\tconst queryKey = [QUERY_KEY, id];\n\n\treturn useQuery(queryKey, () => getItemByIdClient({ id }), {\n\t\tsuspense: true,\n\t});\n};","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

и все это экспортируется в файле index.ts

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// index.ts\n\nexport { QUERY_KEY as QUERY_KEY_GET_ITEM } from \"./queryKey\";\nexport { useGetItemById } from \"./useGetItemById\";","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Теперь в компоненте достаточно вызывать хук, чтобы получить необходимые данные:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const { data: item } = useGetItemById({ id })","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Заключение"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Соответственно, разобраться в коде, а также провести его рефакторинг становится проще, так как сразу понятно, какого типа задачи где решаются.

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":1,"reposts":0,"views":369,"hits":326,"reads":null,"online":0},"dateFavorite":0,"hitsCount":326,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/807843-nextjs-api-react-query-zod","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":1}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":744160,"customUri":null,"subsiteId":206685,"title":"Мой опыт работ c архитектурой FSD","date":1688368843,"dateModified":1688368843,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

В этой статье я хочу поделиться своим опытом разработки приложений с применением подхода FSD (Feature-Sliced Design). Здесь не будут рассматривать ее детально, так как на этот счет есть достаточно хороших материалов, начиная с официального сайта (изображения в этой записи взяты именно с него), и заканчивая статьями на Хабре.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Базовая идея архитектуры"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Весь код разбивается на слои:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["app - верхний уровень приложения;","pages - отдельные страницы или экраны приложения;","widgets - блоки интерфейса, из которых состоит страница. Например: верхнее меню, корзина с товарами и т.д. В идеале, страница (page) должна быть максимально \"тонкой\" и просто располагать внутри себя виджеты, каждый из которых работает независимо от других;","features - бизнес-фича, представляющая какую-то ценность для пользователя. Например добавление и удаление товаров из корзины, расчет итоговой суммы и скидки.","entities - если упрощенно, то это данные проекта: товар, пользователь, запись в блог и т.д.","shared - ресурсы, которые используются внутри приложения всеми остальными слоями. Сюда могут выходить какие-нибудь утилиты, интерфейсы, конфиги сторонних сервисов (подключение к БД, Twilio CLI и т.п.)"],"type":"UL"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"bf77a20c-b956-5219-b3c5-870d95adf807","width":700,"height":390,"size":24738,"type":"jpg","color":"222222","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь важна иерархия. Элементы могут импортировать и использовать внутри себя только элементы, находящиеся в слое более низкого уровня. Так элемент, находящийся в слое features может использовать элементы из слоев entities и shared, но из своего же слоя или более высоких слоев он ничего применять уже не может. Благодаря этому правилу проект приобретает понятную и четкую структуру.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, внося правки в виджет \"Корзина\" мы можем не опасаться, что они как-то затронут виджет \"Список товаров\" или изменят логику фичи (feature) ответственной за расчет размера скидки. Изменения коснутся только самого виджета и страниц, на которых он располагается. Соответственно, чем на более низкий уровень мы опускаемся, тем более глобальными и опасными становятся правки.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Шаг 1. Слой Shared: сторонние сервисы"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Настройка подключения к базе данных, аутентификации, служб для отправки SMS и т.п. Для этих целей у меня создана отдельная папка \"shared/services\".

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"shared\n├── services\n│ ├── pinata\n│ ├── prisma\n│ │ ├── config\n│ │ │ ├── prisma.ts\n│ │ ├── index.ts\n│ ├── twilio","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Также можно сразу прописать основные типы, связанные с работой REST API и т.п.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"shared\n├── types\n│ ├── api\n│ ├── result","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Теперь, когда все сторонние сервисы, с которыми будет взаимодействовать наше приложение, аккуратно собраны в одном месте, можно приступать ко второму шагу.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Шаг 2. Определение Entities"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, бизнес-сущность NFT (в моем примере использовалась Prisma)

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"entities\n├── collection\n├── drop\n├── nft\n│ ├── db\n│ │ ├── dbGetNftBasic.ts\n│ │ ├── dbGetNftBasicList.ts\n│ │ ├── dbGetNftMint.ts\n│ │ ├── ...\n│ ├── selectors\n│ ├── types\n│ ├── ui\n│ │ ├── BuyButton\n│ ├── utils\n│ ├── index.ts","lang":""}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["db - здесь прописываются все обращения к базе данных, которые будут использоваться в приложении. Это позволяет как упростить тестирование, так и проводить оптимизацию запросов, если потребуется","selectors - селекторы, используемые для запросов к БД через Prisma","types - типы, сгенерированные из селекторов. В принципе, можно объединить selectors и types в одну папку, при желании.
","ui - если есть какие-то общие UI-компоненты. В моем случае это была кнопка, переводящая посетителя на страницу для оформление покупки NFT. Компонент кнопки расположен именно здесь так как используется в нескольких фичах, а обмен кода между фичами запрещен (так как находятся в одном слое features).","utils - вспомогательные функции","index.ts - здесь определяется, что из всего вышеперечисленного будет доступно для остального кода."],"type":"UL"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Шаг 3. Widgets и Features"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Эти два слоя содержат в себе уже бизнес-логику. Я объединил эти два слоя в один шаг, так как граница между ними не всегда очевидна. Формально, разграничение должно быть следующим:

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Feature: какое-то ценное действие вроде регистрации пользователя или формы редактирования товара.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

На практике здесь много пограничных ситуаций. Поэтому я рекомендую по умолчанию любой элемент рассматривать как Widget и размещать весь связанный с ним код в его папке. Если какая-то часть виджета окажется восстребованной в другом виджете, то она выводиться в отдельную feature - опускается на слой ниже, чтобы стать доступной для всех widgets.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Порядок действий выходит следующий:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["После того, как прописали все связи со сторонними сервисами и бизнес-сущности, начинаем создавать виджет в отдельной папке, помещая в нее абсолютно весь необходимый код, не стараясь выделать его части в отдельные элементы, размещенные вне этой папки.","В идеальном варианте виджет целиком сможет замкнуться внутри себя, взаимодействовать только с нашими entites и свободно размещаться на любой странице. Весь код, связанный с ним, будет размещен в одном месте и гарантированно не влиять на другие части приложения.","Если, при добавлении других виджетов выясняется, что ему требуется использовать код нашего виджета, например, форма для ввода данных банковской карты и обработка запроса на списание денежных средств, то эта форма выносится как отдельный элемент в слой feature. Теперь разные виджеты могут использовать ее в своих целях, а виджет, от куда ее извлекли, сохранился таким же изолированным, как и был.","В случае, когда по какой-то причине, отправка запроса на списанеи денежных средств с карты потребуется в другой feature, мы перенесем его на самый низкий уровень - shared. Получится что-то вроде (за работу с картами отвечает сервис authorizenet):"],"type":"OL"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"shared\n├── services\n│ ├── authorizenet\n│ │ ├── config\n│ │ ├── utils\n│ │ │ ├── chargeCreditCard.ts\n│ │ ├── index.ts\n│ ├── twilio","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Шаг 4. Pages"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

По сути - самый верхний уровень. Страницы должны быть максимально \"тонкими\" и отвечать только за загрузку связанных с ними данных(загрузка с сервера информации о товаре, на основе productSlug в url страницы, например), а также порядок размещения виджетов.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Итого"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

При первом прочтении архитектура FSD вызывала больше вопросов, чем ответов. Но, на практике, оказалась довольно понятной и простой в работе.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Существенная часть кода группируется по папкам общего назначения entities и shared. После чего, оставшиеся его элементы стараемся разместить в изолированных виджетах и, по мере необходимости, опускаем на слои более низких уровней.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Код, составленный таким образом, довольно легко поддерживать. Вы сразу можете определить \"опасные\" и \"безопасные\" для правки части, а также понять, на что именно повлияют изменения. Изменяя форму для ввода данных банковской карты, расположенную в слое features, вы можете совершенно не переживать за непредвиденные эффекты в других features и, тем более, коде, расположенным в более низких слоях. Достаточно лишь будет проверить те несколько виджетов, где форма используется. Если утилита размещена в папке какого-то виджета, это значит, что она используется только для этого виджета и нигде более.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Сравните, насколько это удобнее, чем иметь дело с переполненными папками components и lib.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для своих проектов я теперь всегда стараюсь привести код к данной архитектуре, вне зависимости от размера приложения.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

#fsd #featuresliceddesign

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":5,"favorites":2,"reposts":0,"views":524,"hits":7736,"reads":null,"online":0},"dateFavorite":0,"hitsCount":7736,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/744160-moi-opyt-rabot-c-arhitekturoi-fsd","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":2}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":688280,"customUri":null,"subsiteId":206685,"title":"State management для AstroJS","date":1683867648,"dateModified":1683867648,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

Выдался удобный случай попробовать создать небольшой сайт с помощью фреймворка AstroJS - магазин радиаторов velarshop.ru (больше просто онлайн-каталог).

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В основе работы Astro лежит \"островная архитектура\": каждая страница сайта рассматривается не как единное целое, но как набор \"островов\", отвечающих за отображение своей части страницы. Большинство таких островов представляет собой сгенерированный при деплое статичный HTML, который передается клиенту сразу же. Острова, где требуется работа JavaScript, загружают его уже после отрисовки, добавляя себе нужную интерактивность. Таким образом удается достичь потрясающей скорости в работе приложения.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, такие оценки Google PageSpeed имеет страница товара

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"6c2d434a-c130-5701-bb35-ec596fa31e9a","width":1916,"height":405,"size":49594,"type":"jpg","color":"296ea3","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Приятной особенностью является также то, что в качестве интерактивных компонентов могут использоваться как компоненты, использующие Vanilla JavaScript, так и компоненты, написанные с помощью React, Vue, Solid, Preact, Svelte.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для своего стека я выбрал Preact, так как он максимально близок к знакомому мне React и имеет минимальные размеры.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Подобные компоненты желательно делать небольшими и располагать как можно дальше в структуре приложения. У нас получаются атомарные \"острова\", общающиеся между собой исключительно через общий state и через localStorage, когда речь идет о смене страниц.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Структура приложения"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

State здесь нужен только для сохранения инфорамции о том, какие товары добавленны в корзину, а также о выбанных посетителем опций (цвет радиатора, материал корпуса и т.п.).

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Так как Astro - это набор отдельных страниц, то для сохранения state при переходах между страницами я использовал версию @nanostores/persistent. Данная библиотека синхронизирует данные с localStorage и обращается к нему за актуальными даными при каждой новой загрузке страницы.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"b667f390-a719-5e3d-9669-134df0c96e47","width":1920,"height":1080,"size":164263,"type":"jpg","color":"f0efef","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Красным обозначены блоки, меняющие state:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Опции товаров: цвет, тип подключения и т.п.","Добавление / удаление товаров в корзину"],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Синим выделены блоки, потребляющие state:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Наименования товаров и их цены, меняющиеся в зависимости от выбранных фильтров.","Корзина покупок, отображающая число и сумму добавленных в нее товаров."],"type":"UL"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Выбор опций товаров"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Базовым элементом state являются опции товаров. От них зависит как цена, так и наименования позиций (item titles).

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для каждой опции создаем отдельную папку со следующей структурой:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"│\n├── features\n│ ├── options\n│ │ ├── SelectConnection\n│ │ ├── SelectGrill\n│ │ ├── SelectColor\n│ │ │ ├── store\n│ │ │ │ ├── color.ts\n│ │ │ ├── SelectColor.tsx\n│ │ │ ├── index.ts\n│ │","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"/src/features/options/SelectColor/store/store.ts\n\nimport { сolors } from \"@entities/Сolor\"\nimport { persistentAtom } from \"@nanostores/persistent\"\nimport { computed } from 'nanostores'\n\nconst version = await import.meta.env.PUBLIC_LOCAL_STORAGE_VERSION\n\nconst colorId = persistentAtom(`velarshop_color_active/${ version }`, \"\")\n\nconst color = computed(colorId, (colorId) => {\n return colors.find((color) => color.id === colorId)\n})\n\nconst colorPostfix = computed(color, (color) => {\n if (!color) return ''\n return `, ${ color.name }`\n})\n\nconst colorPricePerSection = computed(color, (color) => {\n if (!color) return 0\n return parseInt(color.price_section)\n})\n\nexport {\n colorId,\n colorPostfix,\n colorPricePerSection\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь я использовал версию хранилища - version - на случай, если будет обновление прайс-листа или цветовой палитры, чтобы старые данные из localStorage не смешивались с обновленными.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь есть только одна переменная colorId - собственно то, что выбирает пользователь. Остальные переменные являются производными от нее. Их значения автоматически пересчитываются каждый раз, когда пользователь меняет цвет.

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["color - содержит все данные о цвете. Выбирается из массива, на основании colorId; эта переменная не экспортируется и используется для получения следующих двух.
","colorPrefix - высчитывается уже из color и используется в приложении для отображения item title (VelarP30HRAL9016 или VelarP30HRAL9005, например)","colorPricePerSection - цена покраски одной секции радиатора; используется при расчете конечной стоимости радиатора"],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

colorPrefix просто добавляется в конце отображаемого title товаров.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

colorPricePerSection используется немного сложнее и участвует в расчете конечной стомости каждого товара.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Расчет стоимости радиатора"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, для чугунных радиаторов у нас выводится такая таблица:

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"89e86990-6213-5da6-abeb-1bfdea4ff7d7","width":2242,"height":1356,"size":83543,"type":"png","color":"f0eeee","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для этого создаем отдельный store, отвечающий за расчет цены товара на основе имеющихся параметров:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"/src/features/item/ItemTotalCost/store/store.ts\n\nimport { computed } from 'nanostores'\nimport { colorPricePerSection } from '@features/options/SelectColor'\n\n.... \n// другие опции\n...\n\nconst getColorCost = computed(colorPricePerSection, (colorPricePerSection) =>\n (model: ModelJson, radiator: RadiatorJson) =>\n colorPricePerSection * (parseInt(radiator?.sections || \"0\"))\n ))\n\n...\n\nconst getItemTotalCost = computed(\n [ getColorCost, getConnectionCost, getSomeOtherCost ],\n ( getColorCost, getConnectionCost, getSomeOtherCost ) => \n (model: ModelJson, radiator: RadiatorJson) => (\n getColorCost(model,radiator) + getConnectionCost(model,radiator) + ...\n )\n)\n\nexport { getItemTotalCost }","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Корзина покупок"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Корзина покупок также вынесена в отдельную папку, содержащую элемент <BuyButton />, отвечающий за добавление товара в корзину и store, отвечающий за расчет общей суммы покупки.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Store выглядит следующим образом:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"/src/features/order/ShoppingCart/store/store.ts\n\n\nimport { persistentAtom } from \"@nanostores/persistent\"\nimport { computed } from 'nanostores'\nimport type { ShoppingCart } from \"@entities/shopping-cart\"\n\nconst version = await import.meta.env.PUBLIC_LOCAL_STORAGE_VERSION\n\nconst storeShoppingCart = persistentAtom(`velarshop_shopping_cart/${ version }`, { items: [] }, {\n encode: JSON.stringify,\n decode: JSON.parse,\n})\n\nconst storeCartTotalPrice = computed(storeShoppingCart, (shoppingCart) => {\n return shoppingCart.items.reduce((total, item) => total + item.price * item.qnty, 0)\n})\n\nconst storeCartTotalQnty = computed(storeShoppingCart, (shoppingCart) => {\n return shoppingCart.items.reduce((total, item) => total + item.qnty, 0)\n})\n\nconst storeUniqueItemsQnty = computed(storeShoppingCart, (shoppingCart) => {\n return shoppingCart.items.length\n})\n\nexport {\n storeShoppingCart,\n storeCartTotalPrice,\n storeCartTotalQnty,\n storeUniqueItemsQnty\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Единственной особенностью является добавление encode / decode свойств при создании storeShoppingCart. Так как это не примитив, но массив объектов, то для его правильного хранения в local storage необходимо указать, каким образом преобразовывать данные перед их сохранением и при извлечении.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Итого"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Работа с AstroJS оказалсь довольно простой и приятной. Необходимость встраивать JSX компоненты в виде изолированных блоков помогает поддерживать правильную архитектуру приложения в целом.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если сравнивать с NextJS, то, по крайней мере для небольших и простых сайтов, Asto гораздо проще и приятней. Если добавить сюда еще и потрясающие показатели сайта на Google PageSpeed, то выбор в пользу данного фреймворка станет еще очевидней.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

P.S. У меня еще не было опыта работы с новыми функциями Next13 (с папкой app). Поэтому сравнение с Astro выходит не совсем честным.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

#astrojs #preact #nanostores

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":2,"reposts":0,"views":803,"hits":278,"reads":null,"online":0},"dateFavorite":0,"hitsCount":278,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/688280-state-management-dlya-astrojs","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":655375,"customUri":null,"subsiteId":206685,"title":"Производительность ...spread оператора","date":1680626921,"dateModified":1680626921,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

В React распространенным методом изменения свойств объектов является применение spread оператора. Его синтаксис подкупает своей простой и понятностью.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Тем не менее, нужно понимать, что, в некоторых случаях, использование spread оператора может заметно сказаться на производительности вашего приложения. Разберем такой пример: мы получаем на входе массив объектов и нам необходимо создать из него один объект вида ключ-значение.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const { faker } = require('@faker-js/faker')\n\n// generate fakeData\nconst fakeData = []\nfor (let i = 0; i < 5; i++) {\n fakeData.push({ key: faker.datatype.uuid(), name: faker.address.cityName() })\n}\n\n/* fakeData:\n[\n { key: '0bc9a57c-7fd1-449a-8d98-6396f722535a', name: 'Abilene' },\n { key: '2ac57365-bc80-45a1-8033-9efd33de4a52', name: 'Aloha' },\n { key: 'a7d64eaa-0202-4c18-ade1-f43b0853c29c', name: 'Johns Creek' },\n { key: '129a89a6-490a-48b1-9394-7d143926e7d0', name: 'Chicopee' },\n { key: '2d606536-7727-496d-bbee-9663b89f40b9', name: 'Covina' }\n]\n*/\n\n// generate object with reduce method\nconst object = fakeData.reduce((acc, { key, name }) => {\n return { ...acc, [key]: name }\n }, {})\n\n/* object:\n{\n '83e12032-7558-467e-b840-ead992754df4': 'Jackson',\n '4fe2ce86-b202-4891-8b2f-7fa154b4b448': 'Idaho Falls',\n 'de1d95c0-3c25-4b8c-9e1d-a8bc20409d45': 'El Centro',\n 'b54dd7d7-b021-4fc5-9de6-633ee4e240bf': 'Fort Pierce',\n 'dbee592f-79a0-461a-b477-40ae53f0ff53': 'Palm Springs'\n}\n*/","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если запустить код, то все сработает очень быстро. Но потенциальная проблема здесь кроется в том, что при каждом цикле reduce происходит полное копирование всего объекта.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Добавим код для простой оценки времени выполнения функции и увеличим размер массива:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"...\nfunction funcSpread() {\n return fakeData.reduce((acc, { key, name }) => {\n return { ...acc, [key]: name }\n }, {})\n}\n\nconsole.time('execution time')\nfuncSpread()\nconsole.timeEnd('execution time')","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

У меня вышли следующие цифры:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["при длине массива 5, время выполнения составила 0.05 мс","1000 -> 115 мс","5000 -> 2.4 секунды!"],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Скорее всего, что подобное время выполнения не допустимо ни для frontend ни для backend. Для того, чтобы ускорить работу кода, нам нужно будет отказаться от принципов иммутабельности и просто вносить мутации в уже имеющийся объект:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function funcMutate() {\n return fakeData.reduce((acc, { key, name }) => {\n acc[key] = name\n return acc\n }, {})\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Данная функция обработает массив из 5000 объектов уже за 2.5 мс. То есть, в 1000 раз быстрее!

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Чуть медленнее, но также довольно быстро, с данной задачей справится метод _.set от популярной библиотеки lodash:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const _ = require('lodash')\nfunction funcLodash() {\n return fakeData.reduce((acc, { key, name }) => {\n return _.set(acc, key, name)\n }, {})\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Время выполнения составило 4.6 мс. Что также довольно быстро.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Первым делом, я попробовал использовать ramda:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const R = require('ramda')\nfunction funcRamda() {\n return fakeData.reduce((acc, { key, name }) => {\n return R.assoc(key, name, acc)\n }, {})\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Время выполнения составило 1.4 секунды. Что все еще медленно, но все же быстрее, чем работа spread оператора.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Следующая популярная библиотека - immutable.js. Данная библиотека работает со своими структурами данных. Поэтому здесь мы еще дальше отойдем от \"чистоты\" эксперимента, и будем составлять не объект, а Map от immutable.js.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const { Map } = require('immutable')\n\nfunction funcImmutable() {\n return fakeData.reduce((acc, { key, name }) => {\n return acc.set(key, name)\n }, Map({}))","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Результат - 13 мс. Что очень близко к нашему лучшему результату. Узким местом Immutable.js считается перевод полученных данных в обычный объект. Но, в данном случае, на общей производительности это сказалось очень мало:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const mapData = funcImmutable()\nconst object = mapData.toObject()","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь мы получим почта такой же результат. Иммутабельные данные очень быстрые!

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Последним вариантом будет Immer. Сначала используем прямолинейный подход:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const { produce } = require('immer')\n\nfunction funcImmer() {\n return fakeData.reduce((acc, { key, name }) => {\n return produce(acc, draft => { draft[key] = name })\n }, {})\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"function funcImmer2() {\n return produce({}, draft => {\n fakeData.forEach(({ key, name }) => {\n draft[key] = name\n })\n })\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

результат - 8.5 мс. Это все еще медленнее, чем funcMutate или funcLodash, но довольно близко.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Выводы?"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Значительного увеличения производительности можно добиться отказавшись от принципа иммутабельности данных. Если этот вариант вам не подходит, то хорошим решением могут быть Immer или Immutable.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

#javascript #ramda #immutable #lodash #immer

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":0,"reposts":0,"views":421,"hits":539,"reads":null,"online":0},"dateFavorite":0,"hitsCount":539,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/655375-proizvoditelnost-spread-operatora","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":1}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":601453,"customUri":null,"subsiteId":206685,"title":"Функциональное программирование с библиотекой fp-ts","date":1675722703,"dateModified":1675722703,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

В предыдущей статье рассматривался пример использования библиотеки ramda вместе с React / Redux. Здесь я поделюсь своим опытом в использовании другой замечательной библиотеки fp-ts.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Это мои первые шаги в данном направлении. Буду признателен за любые комментарии и замечания.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Функциональное программирование - программирование с контейнерами"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если ramda - это больше про композицию функций, то fp-ts - это уже про работу с функторам, монадами и т.п. Это довольно абстрактные концепции, которые, для простоты, я определил как просто контейнеры.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Рассмотрим базовый контейнер Option, отвечающий за переменную, которая может быть, а может и отсутствовать. Он хранит контекст в поле _tag, принимающем два значения: \"None\" или \"Some\". Первое значение _tag принимает, если интересующая нас переменная равна undefined или null. Второе значение, если переменная имеет какое-то иное значение (то есть, существует).

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

пример с undefined:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"import * as Option from 'fp-ts/Option';\nconst x = undefined // переменная x \"отсутствует\"\nconst container = Option.of(x) // помещаем ее в контейнер Option\n// теперь его значение\n// {_tag: \"None\"}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

пример с существующим значением:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"import * as Option from 'fp-ts/Option';\nconst x = 55 // переменная x имеется\nconst container = Option.of(x) // помещаем ее в контейнер Option\n// теперь его значение\n// {_tag: \"Some\", value: 55}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Сейчас нам не доступно значение переменной x напрямую. Оно спрятано в контейнере Option и, чтобы для него добраться, нужно использовать функцию map. Например, нам нужно получить результат от умножения полученной переменной на 2:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"import { pipe } from 'fp-ts/function';\n...\nconst result = pipe (\n container,\n Option.map((y) => y * 2)\n)","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

На выходе мы получим не результат умножения, но результат умножения, спрятанный в контейнере Option. Тип переменной result следующий:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const result: Option.Option","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Порядок действий у функции map следующий:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Принимает функцию foo, которую нужно применить к хранящемуся в контейнере значению","Смотрит на значение _tag контейнера","Если он имеет значение \"None\", то просто возвращает это контейнер обратно и ничего не делает.","Если он имеет значение \"Some\", то выполняет функцию foo, передавая туда в качестве аргумента значение из переменной value контейнера.","Полученный результат равен null / undefined? Возвращает Option.None, то есть {_tag: \"None\"}","Полученный результат не равен null / undefined? Возвращает его в виде Option.Some, то есть {_tag: \"Some\", value: foo(x)}."],"type":"UL"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Функторы и монады"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Контейнер, имеющий подобную функцию map, называется функтором.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В некоторых случаях, функция, передаваемая в map, может сама возвращать контейнер, а не полученное значение напрямую:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const result = pipe (\n container,\n Option.map((y) => { return Option.of(y * 5)})\n)","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const result: Option.Option>","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Чтобы этого избежать, надо извлечь Option.Optionиз лишнего слоя Option. Функция, которая сначала выполняет переданную функцию, а потом \"снимает\" с полученного результата \"лишний слой\" контейнера, называется flatMap или chain (в случае с fp-ts).

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Что это дает"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Помещение переменной и всех результатов вычислений в контейнеры позволяет избежать применения в коде постоянных проверок вида

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"if (x == null) {\n throw ...\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Мы знаем, что если где-то и возникнет undefined или null, они будут запечатаны в контейнер Option и при применении к ним любой функции они будут просто отсеяны методом map и переданы далее. И так до самого конца кода, где можно будет уже проверить, что лежит в контейнере: некоторый результат (Option.Some) или ошибка (Option.None).

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Вероятность того, что где-то в коде мы забудем произвести нужную проверку, при этом исключается. Главное, не извлекать наши значения из контейнера Option. Мы можем писать код так, будто никаких ошибок не происходит. Если же где-то возникнет undefined, то такая переменная будет проигнорирована всеми функциями, передаваемыми в контейнер через map или chain.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Пример с REST API"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Логика примерно следующая:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Проверяем, получен ли itemId","Проверяем, существует ли товар с указанным itemId","Вызываем метод, проверяющий заголовки запроса и определяющий, авторизован ли пользователь (для простоты он не принимает req, просто выдает какие-то данные о пользователе)","Проверяем, существует ли такой пользователь в нашей БД","Проверяем, хватает ли денег на счете клиента для покупки товара","Производим соответствующие записи в БД"],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Пример кода:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const handler = async (req: CustomNextApiRequest, res: NextApiResponse) => {\n const body = makeSerializable(req.body);\n const method = makeSerializable(req.method);\n\n if (method !== 'POST') {\n res.status(405).send('Wrong method');\n return;\n }\n\n if (!body.itemId) {\n res.status(400).send('Missed data');\n return;\n }\n\n const sessionUserId = await getSessionUserId();\n if (!sessionUserId) {\n res.status(403).send('No signed in user');\n return;\n }\n\n const user = await getUser(sessionUserId);\n if (!user) {\n res.status(400).send('Cant find user');\n return;\n }\n\n const item = await getItem(body.itemId);\n if (!item) {\n res.status(400).send('Cant find item');\n return;\n }\n\n if (!isBalanceSufficient(item, user)) {\n res.status(400).send('Balance is not sufficient');\n return;\n }\n\n /** some db actions */\n\n res.status(200).send('OK');\n\n};","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Решение с помощью fp-ts и TaskEither"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для решения этой задачи с помощью fp-ts нам понадобиться модуль TaskEither. Он состоит из двух частей:

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Either"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Аналогична Option, но в отличие от последней возвращает не Option.None / Option.Some, а значения Either.Left и Either.Right. Some и Right принципиально ничем не отличаются - это просто контейнеры для хранения данных. Вариант с Either.Left, в отличие от None, может хранить еще и строку с текстом ошибки.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Чаще всего значение передается в Either через метод с говорящим именем fromNullable. Он принимает первым аргументом строку с текстом ошибки и вторым аргументом интересующее нас значение, которые может быть нулевым (nullable)

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"E.fromNullable('No data')(565); // { _tag: 'Right', right: 565 }\nE.fromNullable('No data')(null) // { _tag: 'Left', left: 'No data' }","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Either предпочтительнее Option, так как позволяет нам выводить информацию о природе ошибки.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Task"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Task - это обертка для асинхронных задач.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"T.of(getItem) // T.Task<(id: number) => Promise>","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Предполагается, что Task всегда выполняется успешно. Для того, чтобы обрабатывать \"плохие\" результаты, используется TaskEither. То есть Task, который может возвращать как положительный результата Either.Right, так и отрицательный - Either.Left.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Шаг 1. Обработка User"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Этот кусок кода немного сложнее, чем работа с Item, поэтому сразу разберу его, и тогда код с item станет сразу понятен.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Задача - получить пользователя, завернутого в контейнер Either. То есть, либо пользователь есть (Either.Right), либо пользователя нет (Either.Left с указанием ошибки в формате string).

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const user: E.Either // нам нужно получить это","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Код выглядит следующим образом:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"import * as TE from 'fp-ts/TaskEither';\n\nconst user = await pipe(\n getSessionUserId, // () => Promise\n TE.fromTask, // TE.TaskEither\n TE.chain(\n flow( // get number | null\n TE.fromNullable('Not logged In'), // TE.TaskEither\n TE.chain(\n flow( // get usereId\n getUser, // return Promise\n (user) => () => user,\n TE.fromTask, // TE.TaskEither\n TE.chain(TE.fromNullable('Cant find user')), // TE.TaskEither\n )),\n ),\n ), // TE.TaskEither\n )();","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Разберем по шагам, что здесь происходит:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const user = await pipe(...)()","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Так как на выходе мы должны получить TaskEither, то я сразу вызывал полученный результат (await ()), чтобы получить просто Either.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"pipe (\n getSessionUserId, \n TE.fromTask, \n...)","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Берем функцию getSessionUserId и помещаем его в контейнер (TaskEither) методом TE.fromTask. Теперь можно безопасно с ним работать.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"pipe (\n getSessionUserId, \n TE.fromTask, \n TE.chain (\n flow (\n ...\n )\n ...\n )\n...)","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"flow(foo,...)\n(x) => pipe(x, foo...)","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Переменная, передаваемая в flow имеет значение undefined | number, поэтому первым делом оборачиваем ее в контейнер:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"flow (\n TE.fromNullable('Not logged In') // TE.TaskEither\n ...\n)","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если значение getSessionUserId было undefined, то следующий ниже код TE.chain(...) пропускается и далее передается лишь сообщение о ошибке ('Not logged In').

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В случае, пользователь был авторизован, то мы используем значение его id для дальнейшей работы внутри очередного блока chain -> flow.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"flow(\n getUser, // return Promise\n (user) => () => user,\n TE.fromTask, // TE.TaskEither\n TE.chain(TE.fromNullable('Cant find user')),\n)),","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь мы также вызываем асинхронную функцию getItem, и переводим ее в контейнер TaskEither. Так как для этого нужна сигнатура () => Promise, то появляется строка (user) => () => user.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Потом, опять, chain и проверка полученного результата с указанием текста ошибки на случай, если пользователь не будет найден.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"TE.chain(TE.fromNullable('Cant find user'))","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Работа с item"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Получение данных о запрашиваемом Item очень похоже. Только вместо getSessionUserId мы просто проверяем, передавался ли itemId в запросе.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const item = await pipe(\n body.itemId,\n TE.fromNullable('Item ID is missed'), // TE.TaskEither\n TE.chain( // pass itemId as 35 to flow\n flow(\n getItem, // Promise\n (item) => () => item, // () => Promise\n TE.fromTask, // TE.TaskEither\n TE.chain(TE.fromNullable('Cant find item')),\n )),\n )();","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Проверка баланса"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Остался последний шаг, проверить, хватает ли денег на балансе пользователя для покупки данного предмета. Здесь мы не можем обойтись обычным pipe или flow, так как в проверке участвуют две переменные, помещенные в контейнер Either.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для таких случаем используется запись Do: мы обозначаем, какие переменные будем использовать и как их назовем, потом вызываем функцию с этими переменными E.chain(...). Выглядит так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"pipe(\n E.Do,\n E.bind(\"_item\", () => item),\n E.bind(\"_user\", () => user),\n E.chain(({ _user, _item }) => isBalanceSufficient(_item, _user)\n ? E.left('Balance is not sufficient')\n : E.right('OK')\n ),\n E.fold(\n (result) => res.status(400).send(result),\n (result) => res.status(200).send(result)\n )\n);","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Работа функции довольно простая: если баланса достаточно, то возвращаем \"хорошее\" значение E.right. Если не достаточно - то \"плохое\" E.left с указанием ошибки.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Последняя функция E.fold() принимает контейнер E.Either и проверяет его значение (result). Если значение завернуто в E.left, то выполняется первая функция: ответ со строкой, содержащей описание ошибки, и статусом 400. Если результат положительный E.right, то возвращаем \"ОК\" со статусом 200.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Код полностью"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Полностью функция выглядит так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const handler = async (req: CustomNextApiRequest, res: NextApiResponse) => {\n const body = makeSerializable(req.body);\n const method = makeSerializable(req.method);\n\n if (method !== 'POST') {\n res.status(405).send('Wrong method');\n return;\n }\n\n\n const item = await pipe(\n body.itemId,\n TE.fromNullable('Item ID is missed'), // TE.TaskEither\n TE.chain( // pass itemId as 35 to flow\n flow(\n getItem, // Promise\n (item) => () => item, // () => Promise\n TE.fromTask, // TE.TaskEither\n TE.chain(TE.fromNullable('Cant find item')),\n )),\n )();\n\n\n const user = await pipe(\n getSessionUserId, // () => Promise\n TE.fromTask, // TE.TaskEither\n TE.chain(\n flow( // get number | null\n TE.fromNullable('Not logged In'), // TE.TaskEither\n TE.chain(\n flow( // get usereId\n getUser, // return Promise\n (user) => () => user,\n TE.fromTask, // TE.TaskEither\n TE.chain(TE.fromNullable('Cant find user')), // TE.TaskEither\n )),\n ),\n ), // TE.TaskEither\n )();\n\n\n\n pipe(\n E.Do,\n E.bind(\"_item\", () => item),\n E.bind(\"_user\", () => user),\n E.chain(({ _user, _item }) => isBalanceSufficient(_item, _user)\n ? E.left('Balance is not sufficient')\n : E.right('OK')\n ),\n x => x,\n E.fold(\n (result) => res.status(400).send(result),\n (result) => res.status(200).send(result)\n )\n );\n return;\n};\n\nexport default handler;","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, что произойдет, если прийдет запрос без itemId:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["На этапе TE.fromNullable('Item ID is missed'), так как значение body.itemId = undefined, мы получим { _tag: 'Left', left: 'Item ID is missed' }","Так как значение Left, то выполнение функции, заключенной в TE.chain, пропускается.","На этом этапе - E.bind(\"_item\", () => item), - мы передаем имеющееся значение в переменную _item","При \"распаковке\" внутреннего содержания _item в строке E.chain(...) контейнер, опять таки, видит, что это E.left, поэтому пропускает ее дальше.","На этапе E.fold(...) из-за того, что значение E.left, выполняется первая функция, передающая содержащееся внутри сообщение об ошибке."],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

На выходе мы получаем ответ от сервера \"Item ID is missed\" с кодом 400.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Заключение"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Несмотря на то, что библиотека fp-ts является довольно популярной и имеет подробную документацию, разобраться с ней сразу оказалось сложно. Большая благодарность @souperman без советов которого я бы не смог составить и этот пример.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Если найду какие-нибудь интересные паттерны, расскажу о них в новой статье.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

#fp-ts #typescript #functor #monad

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":1,"reposts":0,"views":592,"hits":996,"reads":null,"online":0},"dateFavorite":0,"hitsCount":996,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/601453-funkcionalnoe-programmirovanie-s-bibliotekoi-fp-ts","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":1}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":577176,"customUri":null,"subsiteId":206685,"title":"Немного Ramda для React и Redux","date":1672945970,"dateModified":1672945970,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

С последней моей записи прошло довольно много времени. Дело в том, что я все же нашел свою первую работу и теперь времени на сторонние проекты у меня стало меньше. Тем не менее, постараюсь более менее регулярно продолжать писать на интересные для меня (надеюсь, и для вас) темы.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Я давно хотел попробовать что-то из мира функционального программирования. Если для библиотек вроде Immutable.js я не нашел пока места при решении своих повседневных задач, то вот Ramda для меня оказалась очень удобной.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Ramda для управления логикой приложения"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Оператор pipe, а также ifElse, cond, andThen / otherwise, позволяют создавать краткие и выразительные функции, состоящие из более простых частей.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, нам требуется функция, которая бы делала GET запрос на сервер и получала бы массив каких-либо данных (пользователи, товары, продажи и т.д.) - назовем их entities. При этом требуется проверить, что статус ответа равен 200.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Функция GET запроса выглядит так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"async function fetchData(entity: string) {\n return await axios.get(`http://localhost:3002/${entity}`, { validateStatus: () => true });\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"export const fetchEntity = async (entity: string): Promise<{ data: T[]; }> => {\n return R.pipe(\n R.always(entity),\n fetchData,\n R.andThen(\n R.ifElse(\n (data) => data.status !== 200,\n () => { throw new Error(`${entity} fetch error`); },\n R.pick(['data']),\n )),\n R.otherwise(() => { throw new Error(`${entity} fetch error`); }),\n )();\n};","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Функция R.pipe последовательно выполняет функции, переданные в нее в качестве аргументов.","R.always(entity) - функция, ничего не делает, просто возвращает переменную entity (так как требуется именно функция). Это аналог записи () => entity.","fetchData принимает ответ с предыдущей функции - entity в качестве своего аргумента и делает GET запрос на сервер.","R.andThen и R.otherwise - аналог записи .then и .catch. В случае, если произошла ошибка, то R.otherwise вызывает функцию, пробрасывающую ошибку.","R.ifElse принимает 3 функции в качестве аргументов: первая функция должна вернуть true или false, исходя из чего запускается 2-ая или 3-я функции. Запись (data) => ...: здесь в качестве аргумента принимается ответ от предыдущей функции fetchData.","R.pick(['data']) является последней функцией в списке. Она получает объект и возвращает значение по ключу. В данном случае - это data, где должен содержаться интересующий нас массив."],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В чем преимущества подобной записи функции для меня?

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Во-вторых, ее довольно легко расширять, добавляя новые функции в любое место среди аргументов R.pipe.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Ramda для Redux Slices"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Рекомендуемым инструментом при использовании Redux является библиотека @reduxjs/toolkit, которая берет на себя часть рутинных действий по подготовке к работе.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Использование Redux Toolkit работу действительно проще. Но, лично у меня, дискомфорт вызывает использование библиотеки Immer для изменения state хранилища. Immer оборачивает state в proxy и, соответственно, менять так, будто нас не интересует иммутабельность. Все мутации будут перехвачены на уровне прокси и на выходе мы получим новый state, старый при этом никак изменен не будет.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Например, если при запросе на сервер нам нужно поменять fetchingStatus на pending, то это может выглядеть так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"builder.addCase(fetchInitialData.pending, (state, _action) => {\n state.fetchingStatus = 'Fetching'\n})","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Мне такой подход кажется не совсем удачным:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["Код получается не таким, каким хотелось бы его видеть. А именно, без мутаций state, причем в Redux.","Использование переменной state вводит в заблуждение. Уместнее было бы называть ее stateProxy, чтобы избежать возможной путаницы."],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Пока что я остановился на таком подходе:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["получаю state из прокси с помощью функции current из библиотеки @reduxjs/toolkit","на его основе получаю новый state, который и возвращаю (то есть, работаю с ним \"по-старинке\")"],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Приведенный выше пример выглядит так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"builder.addCase(fetchInitialData.pending, (stateProxy, _action) => {\n const state = current(stateProxy)\n return R.set(R.lensProp('fetchingStatus'), 'Fetching', state)\n})","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Он получился более многословным, чем предыдущий вариант, но, при этом, и более однозначным.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"builder.addCase(fetchInitialData.fulfilled, (stateProxy, action) => {\n const state: AircompanyState = current(stateProxy)\n return R.pipe(\n R.always(state),\n R.mergeLeft(action.payload),\n R.set(R.lensProp('fetchingStatus'), 'Success')\n )()\n})","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В дальнейшем попробую найти для Ramda более широкое применение и попробовать библиотеку fp-ts, которая вроде аналогичная, но имеет более качественную типизацию для работы с Typescript (в некоторых случаях, к сожалению, с Ramda не всегда удается обойтись без as).

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":0,"reposts":0,"views":697,"hits":224,"reads":null,"online":0},"dateFavorite":0,"hitsCount":224,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/577176-nemnogo-ramda-dlya-react-i-redux","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}},{"type":"entry","data":{"id":555968,"customUri":null,"subsiteId":206685,"title":"Тестирование React. Часть 3: Storybook","date":1670338220,"dateModified":1670338220,"blocks":[{"type":"text","cover":true,"hidden":false,"anchor":"","data":{"text":"

Всем привет. Я - Петр Цой. Ищу первую работу на React. В качестве моего резюме выступает сайт petrtcoi.com. Ссылка на GitHub.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Это заключительная статья, посвященная тестированию моего демонстрационного сайта. На этот раз настроим тестирование с помощью Storybook.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Настройка Storybook"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Настройка темы"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Для установки Storybook можно следовать официальной инструкции.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Так как у нас используется смена темы через CSS-пременные, то нам нужно дополнительно настроить wrapper, который будет менять значение атрибута data-theme в корневом теге html. Для этого создадим специальный декоратор:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// .storybook/decorators/uiThemeDecorator.tsx\nimport { DecoratorFn } from \"@storybook/react\"\nimport React from \"react\"\nimport { setUiTheme } from '../../src/assets/utils/setUiTheme'\nimport { ThemeColorSchema } from '../../src/assets/types/ui.type'\n\n\nexport const uiThemeDecorator: DecoratorFn = (Story, options) => {\n const { UiTheme } = options.args\n\n if (UiTheme !== undefined && UiTheme in ThemeColorSchema) {\n setUiTheme(UiTheme)\n } else {\n setUiTheme(ThemeColorSchema.dark)\n }\n\n return (\n \n )\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Добавляем это декоратор в файл preview.js

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// .storybook/preview.js\n\nimport { uiThemeDecorator } from './decorators/uiThemeDecorator'\nimport '../src/assets/styles/_styles.css'\n\n...\nexport const decorators = [uiThemeDecorator]","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Стили импортируем для того, чтобы смена темы работала корректно.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// src/utils/storybookUiThemeControl.ts\nimport { ThemeColorSchema } from \"../types/ui.type\"\n\nexport const UiThemeControl = {\n UiTheme: {\n options: ThemeColorSchema,\n control: { type: 'radio' },\n }\n}\n\nexport type UiThemeType = { UiTheme: ThemeColorSchema }","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Настройка Viewports"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

В тот же файл добавим разрешения экранов, которые нас интересует. Так как у нас только один breakpoint: 800px, то мы добавляем только 2 разрешения. Прописываем их в переменной customViewports и добавляем в parameters.viewport. Окончательно файл выглядит так:

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// .storybook/preview.js\n\nimport { uiThemeDecorator } from './decorators/uiThemeDecorator'\n\nimport '../src/assets/styles/_styles.css'\n\n\nconst customViewports = {\n desktop: {\n name: 'Desktop',\n styles: {\n width: '801px',\n height: '963px',\n },\n },\n mobile: {\n name: 'Mobile',\n styles: {\n width: '800px',\n height: '801px',\n },\n },\n}\n\n\nexport const parameters = {\n actions: { argTypesRegex: \"^on[A-Z].*\" },\n controls: {\n matchers: {\n color: /(background|color)$/i,\n date: /Date$/,\n },\n },\n viewport: {\n viewports: customViewports,\n },\n}\n\n\n\nexport const decorators = [uiThemeDecorator]","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Создание Story"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Теперь все готово, чтобы создать первую историю. Для примера возьмем компонент WorkSingle, отвечающий за отображение WorkSingle.stories.tsx

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"// src/components/PageMain/WorkList/WorkSingle/WorkSingle.stories.tsx\n\nimport React from 'react'\nimport { Meta, Story } from '@storybook/react'\n\nimport WorkSingle from './WorkSingle'\nimport { WorkSingleProps } from './WorkSingle'\nimport { Work } from '../../../../assets/types/work.type'\nimport { UiThemeControl, UiThemeType } from '../../../../assets/utils/storybookUiThemeControl'\nimport { ThemeColorSchema } from '../../../../assets/types/ui.type'\n\nexport default {\n component: WorkSingle,\n title: 'MainPage/WorkSingle',\n\n argTypes: {\n ...UiThemeControl,\n work: {\n name: 'Single works props',\n }\n },\n} as Meta\n...","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь проходит основная настройка:

"}},{"type":"list","cover":false,"hidden":false,"anchor":"","data":{"items":["прописываем title истории. В названии нужно использовать / для группировки историй.Здесь история будет относится к группе MainPage.","В argTypes указываются доступные для пользователя настройки компонентов. Сюда включили переключатель темы и добавили свойство work (я не нашел, как в Storybook можно работать с вложенными свойствами компонентов, поэтому здесь будет использоваться просто JSON представление свойства)."],"type":"UL"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Далее создаестя шаблон Template для отображения компонента, значения его аргументов по-умолчанию и базовый компонент Default, принимающий все эти значения.

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"const Template: Story = (args) => {\n return (\n \n )\n}\n\nconst defaultWork: Work = {\n title: 'Первая работа',\n publishDate: '22.11.2022',\n description: 'Описание работы с выделением ключевых слов. Должно работать для всех слов, входящих в текст. Не важно, одно это слово или их несколько.',\n keywords: ['слов'],\n links: {\n devto: 'https://dev.to',\n vcru: 'https://vs.ru',\n local: 'https://petrtcoi.com'\n }\n}\n\nexport const Default = Template.bind({})\nDefault.args = {\n work: defaultWork,\n UiTheme: ThemeColorSchema.dark,\n}","lang":""}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"code","cover":false,"hidden":false,"anchor":"","data":{"text":"export const Without_DevTo_Link = Template.bind({})\nWithout_DevTo_Link.args = {\n ...Default.args,\n work: {\n ...defaultWork,\n links: {\n vcru: 'https://vs.ru',\n local: 'https://petrtcoi.com'\n }\n }\n}\n\nexport const With_Two_Keywords = Template.bind({})\nWith_Two_Keywords.args = {\n ...Default.args,\n work: {\n ...defaultWork,\n keywords: ['слов', 'работы']\n }\n}","lang":""}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Запуск Storybook"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Выполняем команду npm run storybook и по адресу http://localhost:6006/ открывается панель Storybook.

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"b0196abc-6bec-57b3-bffa-6c7f443fabd7","width":880,"height":505,"size":18784,"type":"png","color":"edf3f4","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Здесь в левой части отображаются истории, сгруппированные согласно их обозначению в title: 'MainPage/WorkSingle', а также их вариации: Default, Without Dev To Link, With Two Keywords.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Тестирование с помощью Storybook"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Но Storybook может использоваться и при автоматическом тестировании. Для этого созданные нами в **.stories.tsxкомпоненты можно использовать в обычных юнит-тестах, отдавая на render сразу сконфигурированные компоненты. Но я не нашел большой пользы в таком подходе: добавляется работы и логика тестов распыляется по разным файлам, что, на мой взгляд, плохо сочетается с идеей небольших и легких тестов.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Второй вариант использования Storybook, наоборот, мне показался очень привлекательным. Речь идет о визуальном тестировании. Это тот же screenshot тест, как в playwright, но на уровне отдельных компонентов.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Настройка Chromatiс максимально проста, а его бесплатного уровня для меня достаточно, поэтому использовал его. После регистрации на сервисе и установки, как написано в инструкции, достаточно просто выполнить команду npm run chromatic.

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"media","cover":false,"hidden":false,"anchor":"","data":{"items":[{"title":"","image":{"type":"image","data":{"uuid":"9a9e1180-1c33-5ab2-b842-53de0587af55","width":880,"height":488,"size":21761,"type":"png","color":"252525","hash":"","external_service":[]}}}]}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

Такая проверка позволяет выявлять ошибки \"не видимые\" для базовых юнит-тестов на основе @testing-library.

"}},{"type":"header","cover":false,"hidden":false,"anchor":"","data":{"style":"h2","text":"Заключение"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

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

"}},{"type":"text","cover":false,"hidden":false,"anchor":"","data":{"text":"

#react #reactjs #storybook

"}}],"summaryContent":null,"isExistSummaryContent":false,"warningFromEditor":null,"warningFromEditorTitle":null,"counters":{"comments":0,"favorites":3,"reposts":0,"views":136,"hits":2350,"reads":null,"online":0},"dateFavorite":0,"hitsCount":2350,"isCommentsEnabled":true,"isLikesEnabled":true,"isRemovedByUserRequest":false,"isFavorited":false,"isPinned":false,"repostId":null,"repostData":null,"subscribedToTreads":false,"isEditorial":false,"isAudioAvailable":false,"audioUrl":null,"isAudioAvailableToGenerate":false,"commentEditor":{"enabled":true,"who":null,"text":"","until":null,"reason":null,"type":"everybody"},"isBlur":false,"isPublished":true,"isDisabledAd":false,"withheld":[],"ogTitle":null,"ogDescription":null,"url":"https://vc.ru/id206685/555968-testirovanie-react-chast-3-storybook","author":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"subsite":{"id":206685,"name":"Petr Tcoi","nickname":null,"description":null,"uri":"","avatar":{"type":"image","data":{"uuid":"ffbf4738-e2bd-4ce6-12ae-5b41b2ec2db6","width":200,"height":200,"size":70783,"type":"png","color":"dfc5b2","hash":"","external_service":[]}},"cover":null,"achievements":[{"title":"Год на vc.ru","code":"registration_1_year","description":"Первый год с vc.ru. Получена 24 июля 2025.","previewUuid":"0d11c244-49de-50e7-894e-b9b27945d42b","formats":{"glb":"https://static.vc.ru/achievements/fish.glb","usdz":"https://static.vc.ru/achievements/fish.usdz"},"viewData":{"contentColor":"#C67AA3","textMaxWidth":0.634765625,"textX":0.5888671875,"textY":0.54296875,"logoX":0.5859375,"logoY":0.6669921875,"logoXNoText":0.6044921875,"logoYNoText":0.5439453125},"id":5216331,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/5216331"},{"title":"3 года на vc.ru","code":"registration_3_years","description":"Провёл 3 года вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"d9d72ac5-bcb5-55e0-8c72-b99251e5cdd9","formats":{"glb":"https://static.vc.ru/achievements/shark.glb","usdz":"https://static.vc.ru/achievements/shark.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.5205078125,"textY":0.341796875,"logoX":0.5205078125,"logoY":0.4609375,"logoXNoText":0.5,"logoYNoText":0.3662109375},"id":1592244,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/1592244"},{"title":"5 лет на vc.ru","code":"registration_5_years","description":"Провёл 5 лет вместе с vc.ru. Получена 23 июля 2025.","previewUuid":"a9140d54-73b8-5f40-afa8-449fbaafd42b","formats":{"glb":"https://static.vc.ru/achievements/whale.glb","usdz":"https://static.vc.ru/achievements/whale.usdz"},"viewData":{"contentColor":"#8E6F09","textMaxWidth":0.66796875,"textX":0.533203125,"textY":0.658203125,"logoX":0.533203125,"logoY":0.77734375,"logoXNoText":0.4375,"logoYNoText":0.66015625},"id":365536,"userId":206685,"count":0,"shareImage":"https://api.vc.ru/achievements/share/365536"}],"lastModificationDate":1764910260,"isSubscribed":false,"isSubscribedToNewPosts":false,"isMuted":false,"isAvailableForMessenger":true,"badgeId":null,"isDonationsEnabled":false,"isPlusGiftEnabled":true,"isUnverifiedBlogForCompanyWithoutPro":false,"isRemovedByUserRequest":false,"isFrozen":false,"isDisabledAd":false,"isPlus":false,"isVerified":false,"isPro":false,"yandexMetricaId":null,"badge":null,"isOnline":false,"tgChannelShortname":null,"isUnsubscribable":true,"type":1,"subtype":"personal_blog"},"reactions":{"counters":[{"id":1,"count":2}],"reactionId":0},"isNews":false,"source":null,"clusters":[],"donations":{"amount":0,"isDonated":false},"commentsSeenCount":null}}],"cursor":"PuR2GsZKFTvhhG9UAIfk4NrVS6IzQAa09I6wtbz/zlbeaPDONLWpx05Y6mhGlW4=","isAnonymized":true}};