diff --git a/.all-contributorsrc b/.all-contributorsrc index 32b2e8282a..5c3deaf428 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -7,7 +7,7 @@ "login": "sergeysova", "name": "Sergey Sova", "avatar_url": "https://avatars.githubusercontent.com/u/5620073?v=4", - "profile": "https://sova.dev/", + "profile": "https://sergeysova.com/", "contributions": [ "blog", "doc", diff --git a/.stylelintrc.js b/.stylelintrc.js index 1b8c30867e..a2d6caeb49 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,12 +1,11 @@ module.exports = { extends: [ - "stylelint-config-standard-scss", "stylelint-config-recommended", + "stylelint-config-standard-scss", "stylelint-config-recess-order", ], rules: { "color-hex-length": "long", - "at-rule-no-unknown": true, "selector-class-pattern": null, }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc94ad32f..9a6a6b8a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,71 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Since last release][since-last-release] + -### Added +## [2.1.0] - 2024-10-31 + +The new revision of Feature-Sliced Design is here! The main difference with FSD 2.0 is the new approach to decomposition — “pages first”. + +### What's “pages-first”? + +You do “pages first” by keeping more code in pages. For example, large blocks of UI, forms and data logic that are not reused on other pages should now stay in the slice of the page that they are used in. The division by segments (`ui`, `api`, `model`, etc.) still applies to all this code, and we encourage you to further split and organize code into folders inside of segments — don't just pile all the code into a single file. + +In the same way, widgets are no longer just a compositional layer, instead they should also store code that isn't currently needed outside of that widget, including its own stores, business logic, and API interactions. + +When you have a need to reuse code in several widgets or pages, consider putting it in Shared. If that code involves business logic (i. e. managing specific modal dialogs), consider breaking it up into infrastructural code, like the modal manager, and the business code, like the content of the modals. The infrastructure can then go to Shared, and the content can stay in the pages that use this infrastructure. + +### How is it different? + +In FSD 2.0 we explained how to identify entities and features in your application, and then combine them in widgets and pages. Over time we started disliking this approach, mostly for the following reasons: + +- Code cohesion is much worse in this approach + - You need to jump around several folders just to make changes to a single user flow + - Unused code is harder to delete because it's somewhere else +- Finding entities and features is still an advanced skill that needs to be developed over time + - It requires understanding of the business context, which not all developers want to bother with + - On the other hand, splitting by pages is natural and requires little training + - Different developers have different understandings of these concepts, which leads to everyone having their own idea of FSD, which causes conflict and misunderstanding + +### Is it hard to migrate from FSD 2.0? + +This is a non-breaking change, so you don’t even necessarily need to migrate your current FSD projects to FSD 2.1, but we still think the new way of thinking will lead to a more cohesive and less opinionated structure. We’ve compiled a few steps you can take in [the migration guide](https://feature-sliced.design/docs/guides/migration/from-v2-0). -- New article about how to use FSD with Next.js (#644). +### What else happened since the last release? + +The cross-import notation (`@x`) that was an experimental proposal for a long time has now been standardized! Its official name is **Public API for cross-imports**. You can use it to create explicit connections between entities. There's [a new section in our documentation all about this new notation](https://feature-sliced.design/docs/reference/public-api#public-api-for-cross-imports). + +Another exciting new thing in the FSD ecosystem is our architectural linter, [Steiger](https://github.com/feature-sliced/steiger). It's still in active development, but it is production-ready. + +A couple more minor clarifications to the docs were made as well: + +1. Application-aware things like the route constants, the API calls, or company logo, are now explicitly allowed in Shared. Business logic is still not allowed, but these things are not considered to be business logic. +2. Imports between segments in App and Shared were always allowed, but it's been made explicit too. + +And here's what happened to the documentation website: + +#### Added + +- Slightly rewritten and expanded overview page to give some details about FSD right away (#685). +- New partial translations: Korean (#739, #736, #735, #742, #732, #730, #715), Japanese (#728). - The tutorial was rewritten. Technical details were stripped out, more FSD theory has been added (#665). +- Guides on how to deal with common frontend issues like page layouts (#708), types (#701), authentication (#693). +- Guides on how to use FSD with Nuxt (#710, #689, #683, #679), SvelteKit (#698), Next.js (#699, #664, #644), and TanStack Query (#673). +- A new feedback widget, powered by PushFeedback! Go give it a try and let us know what you think of the new pages (#695). +- Comparison of FSD with Atomic Design (#671). + +#### Changed + +- The migration guide from a custom architecture (formerly known as "from legacy") has been actualized (#725). + +#### Removed + +- The decomposition cheatsheet is now unlisted for an undefined period of time. It proved to be more harmful than useful, but maybe it can be saved later (#649). ## [2.0.0] - 2023-10-01 > **Note** -> This release note is retrospective, meaning that prior to this release, the Feature-Sliced Design project did not keep a changelog. Below is a summary of the most prominent recent changes, but there is no FSD v1. Prior to FSD, there has been a project called ["Feature Slices"](https://featureslices.dev/v1.0.html), and it is considered to be the v1 of FSD. +> This release note is retrospective, meaning that prior to this release, the Feature-Sliced Design project did not keep a changelog. Below is a summary of the most prominent recent changes, but there is no FSD v1. Prior to FSD, there has been a project called ["Feature Slices"](https://feature-sliced.github.io/featureslices.dev/v1.0.html), and it is considered to be the v1 of FSD. ### Deprecated @@ -41,5 +95,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The overview page has been rewritten to be more concise and informative (#512, #515, #516). - FSD has updated its branding, and there are now guidelines to the brand usage. The standard spelling of the name is now "Feature-Sliced Design" (#496, #499, #500, #465). -[since-last-release]: https://github.com/feature-sliced/documentation/compare/v2.0.0...HEAD +[since-last-release]: https://github.com/feature-sliced/documentation/compare/v2.1.0...HEAD +[2.1.0]: https://github.com/feature-sliced/documentation/releases/tag/v2.1.0 [2.0.0]: https://github.com/feature-sliced/documentation/releases/tag/v2.0.0 diff --git a/DEV.md b/DEV.md index c9c70bdf51..c540678a38 100644 --- a/DEV.md +++ b/DEV.md @@ -7,6 +7,7 @@ This website is built using [Docusaurus 2](https://docusaurus.io/), a modern sta - [Russian docs version](i18n/ru) - [English docs version](i18n/en) - [Uzbek docs version](i18n/uz) +- [Japanese docs version](i18n/ja) ## Installation @@ -20,6 +21,8 @@ pnpm install pnpm start # for default locale pnpm start:ru # for RU locale pnpm start:en # for EN locale +pnpm start:uz # for UZ locale +pnpm start:ja # for JA locale ``` > About [docusaurus/i18n commands](https://docusaurus.io/docs/i18n/git#translate-the-files) diff --git a/README.md b/README.md index 7a2ab24c35..e8792d4bdb 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index dd249ac168..0000000000 --- a/babel.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: [require.resolve("@docusaurus/core/lib/babel/preset")], -}; diff --git a/config/docusaurus/extensions.js b/config/docusaurus/extensions.js index fd52101074..1909f746b1 100644 --- a/config/docusaurus/extensions.js +++ b/config/docusaurus/extensions.js @@ -43,7 +43,7 @@ const presets = [ showLastUpdateTime: true, versions: { current: { - label: `v2.0.0 🍰`, + label: `v2.1`, }, }, sidebarItemsGenerator, diff --git a/config/docusaurus/i18n.js b/config/docusaurus/i18n.js index bea96e6a50..2cef5f9050 100644 --- a/config/docusaurus/i18n.js +++ b/config/docusaurus/i18n.js @@ -3,7 +3,7 @@ const { DEFAULT_LOCALE } = require("./consts"); /** @type {import('@docusaurus/types').DocusaurusConfig["i18n"]} */ const i18n = { defaultLocale: DEFAULT_LOCALE, - locales: ["ru", "en", "uz", "kr"], + locales: ["ru", "en", "uz", "kr", "ja"], localeConfigs: { ru: { label: "Русский", @@ -17,6 +17,9 @@ const i18n = { kr: { label: "한국어", }, + ja: { + label: "日本語", + }, }, }; diff --git a/config/docusaurus/navbar.js b/config/docusaurus/navbar.js index a0fe088f8e..4230a532ef 100644 --- a/config/docusaurus/navbar.js +++ b/config/docusaurus/navbar.js @@ -37,11 +37,11 @@ const navbar = { dropdownActiveClassDisabled: true, dropdownItemsAfter: [ { - to: "https://featureslices.dev/v1.0.html", + to: "https://feature-sliced.github.io/featureslices.dev/v1.0.html", label: "v1.0", }, { - to: "https://featureslices.dev/v0.1.html", + to: "https://feature-sliced.github.io/featureslices.dev/v0.1.html", label: "v0.1", }, { diff --git a/config/docusaurus/routes.js b/config/docusaurus/routes.js index 9051a065ec..1a657eabbe 100644 --- a/config/docusaurus/routes.js +++ b/config/docusaurus/routes.js @@ -10,7 +10,7 @@ const SECTIONS = { }, MIGRATION: { shortPath: "/docs/guides/migration", - fullPath: "/docs/guides/migration/from-legacy", + fullPath: "/docs/guides/migration/from-custom", }, }; @@ -109,17 +109,17 @@ const LEGACY_ROUTES = [ { title: "Decouple of entities", from: "/docs/concepts/decouple-entities", - to: "/docs/reference/isolation/decouple-entities", + to: "/docs/reference/layers#import-rule-on-layers", }, { title: "Low Coupling & High Cohesion", from: "/docs/concepts/low-coupling", - to: "/docs/reference/isolation/coupling-cohesion", + to: "/docs/reference/slices-segments#zero-coupling-high-cohesion", }, { title: "Cross-communication", from: "/docs/concepts/cross-communication", - to: "/docs/reference/isolation", + to: "/docs/reference/layers#import-rule-on-layers", }, { title: "App splitting", @@ -249,7 +249,7 @@ const LEGACY_ROUTES = [ { title: "Migration from Legacy", from: "/docs/guides/migration-from-legacy", - to: "/docs/guides/migration/from-legacy", + to: "/docs/guides/migration/from-custom", }, ], }, @@ -264,6 +264,30 @@ const LEGACY_ROUTES = [ }, ], }, + { + group: "Rename 'legacy' to 'custom'", + details: + "'Legacy' is derogatory, we don't get to call people's projects legacy", + children: [ + { + title: "Rename 'legacy' to custom", + from: "/docs/guides/migration/from-legacy", + to: "/docs/guides/migration/from-custom", + }, + ], + }, + { + group: "Deduplication of Reference", + details: + "Cleaned up the Reference section and deduplicated the material", + children: [ + { + title: "Isolation of modules", + from: "/docs/reference/isolation", + to: "/docs/reference/layers#import-rule-on-layers", + }, + ], + }, ]; // @returns { from, to }[] @@ -314,7 +338,7 @@ const _TOTAL_ROUTES = [ "/docs/guides/examples/theme", "/docs/guides/examples/types", "/docs/guides/examples/white-labels", - "/docs/guides/migration/from-legacy", + "/docs/guides/migration/from-custom", "/docs/guides/migration/from-v1", "/docs/guides/tech/with-nextjs", "/docs/", diff --git a/docusaurus.config.js b/docusaurus.config.js index 798c2ddb50..20c3d1fb16 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -57,6 +57,9 @@ module.exports = { darkTheme: prismThemes.oneDark, }, }, + future: { + experimental_faster: true, + }, }; // Remove configs if there are not secrets passed diff --git a/i18n/en/docusaurus-plugin-content-docs/current.json b/i18n/en/docusaurus-plugin-content-docs/current.json index 9af1eb6531..599055a3cf 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current.json +++ b/i18n/en/docusaurus-plugin-content-docs/current.json @@ -1,6 +1,6 @@ { "version.label": { - "message": "v2.0.0 🍰", + "message": "v2.1", "description": "The label for version current" }, "sidebar.getstartedSidebar.category.Tutorials": { diff --git a/i18n/en/docusaurus-plugin-content-docs/current/get-started/faq.md b/i18n/en/docusaurus-plugin-content-docs/current/get-started/faq.md index a09a1600a1..2f9b4bda5b 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/get-started/faq.md +++ b/i18n/en/docusaurus-plugin-content-docs/current/get-started/faq.md @@ -13,7 +13,7 @@ You can ask your question in our [Telegram chat][telegram], [Discord community][ ### Is there a toolkit or a linter? -There is an official ESLint config — [@feature-sliced/eslint-config][eslint-config-official], and an ESLint plugin — [@conarti/eslint-plugin-feature-sliced][eslint-plugin-conarti], created by Aleksandr Belous, a community member. You're welcome to contribute to these projects or start your own! +Yes! We have a linter called [Steiger][ext-steiger] to check your project's architecture and [folder generators][ext-tools] through a CLI or IDEs. ### Where to store the layout/template of pages? @@ -58,10 +58,10 @@ Rather yes than no Answered [here](/docs/guides/examples/auth) +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools [import-rule-layers]: /docs/reference/layers#import-rule-on-layers [reference-entities]: /docs/reference/layers#entities -[eslint-config-official]: https://github.com/feature-sliced/eslint-config -[eslint-plugin-conarti]: https://github.com/conarti/eslint-plugin-feature-sliced [motivation]: /docs/about/motivation [telegram]: https://t.me/feature_sliced [discord]: https://discord.gg/S8MzWTUsmp diff --git a/i18n/en/docusaurus-plugin-content-docs/current/get-started/overview.mdx b/i18n/en/docusaurus-plugin-content-docs/current/get-started/overview.mdx index a35f521066..2083b8cde5 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/get-started/overview.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/get-started/overview.mdx @@ -65,15 +65,15 @@ Layers, slices, and segments form a hierarchy like this: Layers are standardized across all FSD projects. You don't have to use all of the layers, but their names are important. There are currently seven of them (from top to bottom): -1. App\* — everything that makes the app run — routing, entrypoints, global styles, providers. -2. Processes (deprecated) — complex inter-page scenarios. -3. Pages — full pages or large parts of a page in nested routing. -4. Widgets — large self-contained chunks of functionality or UI, usually delivering an entire use case. -5. Features — _reused_ implementations of entire product features, i.e. actions that bring business value to the user. -6. Entities — business entities that the project works with, like `user` or `product`. -7. Shared\* — reusable functionality, especially when it's detached from the specifics of the project/business, though not necessarily. +1. **App\*** — everything that makes the app run — routing, entrypoints, global styles, providers. +2. **Processes** (deprecated) — complex inter-page scenarios. +3. **Pages** — full pages or large parts of a page in nested routing. +4. **Widgets** — large self-contained chunks of functionality or UI, usually delivering an entire use case. +5. **Features** — _reused_ implementations of entire product features, i.e. actions that bring business value to the user. +6. **Entities** — business entities that the project works with, like `user` or `product`. +7. **Shared\*** — reusable functionality, especially when it's detached from the specifics of the project/business, though not necessarily. -_\* — these layers, App and Shared, unlike the other layers, don't have slices, and are made up of segments directly._ +_\* — these layers, **App** and **Shared**, unlike the other layers, don't have slices, and are made up of segments directly._ The trick with layers is that modules on one layer can only know about and import from modules from the layers strictly below. @@ -131,7 +131,7 @@ It's advised to refrain from adding new large entities while refactoring or refa [tutorial]: /docs/get-started/tutorial [examples]: /examples -[migration]: /docs/guides/migration/from-legacy +[migration]: /docs/guides/migration/from-custom [ext-steiger]: https://github.com/feature-sliced/steiger [ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools [ext-telegram]: https://t.me/feature_sliced diff --git a/i18n/en/docusaurus-plugin-content-docs/current/get-started/tutorial.md b/i18n/en/docusaurus-plugin-content-docs/current/get-started/tutorial.md index 346217a6a5..662020d924 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/get-started/tutorial.md +++ b/i18n/en/docusaurus-plugin-content-docs/current/get-started/tutorial.md @@ -38,7 +38,7 @@ As such, our Pages folder will look like this: The key difference of Feature-Sliced Design from an unregulated code structure is that pages cannot reference each other. That is, one page cannot import code from another page. This is due to the **import rule on layers**: -*A module in a slice can only import other slices when they are located on layers strictly below.* +*A module (file) in a slice can only import other slices when they are located on layers strictly below.* In this case, a page is a slice, so modules (files) inside this page can only reference code from layers below, not from the same layer, Pages. diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/auth.md b/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/auth.md index 5a96d32275..bf8a33b9e2 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/auth.md +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/auth.md @@ -189,7 +189,7 @@ To store the token in the User entity, create a reactive store in the `model` se Since the API client is usually defined in `shared/api` or spreaded across the entities, the main challenge to this approach is making the token available to other requests that need it without breaking [the import rule on layers][import-rule-on-layers]: -> A module in a slice can only import other slices when they are located on layers strictly below. +> A module (file) in a slice can only import other slices when they are located on layers strictly below. There are several solutions to this challenge: diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/types.md b/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/types.md index cd6390be4b..78e3748c6b 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/types.md +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/examples/types.md @@ -305,7 +305,7 @@ export const slice = createSlice({ extraReducers: (builder) => { builder.addCase(fetchSong.fulfilled, (state, action) => { // And handle the same fetch result by inserting the artists here - usersAdapter.upsertMany(state, action.payload.users) + artistAdapter.upsertMany(state, action.payload.artists) }) }, }) diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/index.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/index.mdx index 17d7beeeca..319ebbf93f 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/index.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/index.mdx @@ -25,10 +25,10 @@ import { ToolOutlined, ImportOutlined, BugOutlined, FunctionOutlined } from "@an /> + 📁 src + + + +## Before you start {#before-you-start} + +The most important question to ask your team when considering to switch to Feature-Sliced Design is — _do you really need it?_ We love Feature-Sliced Design, but even we recognize that some projects are perfectly fine without it. + +Here are some reasons to consider making the switch: + +1. New team members are complaining that it's hard to get to a productive level +2. Making modifications to one part of the code **often** causes another unrelated part to break +3. Adding new functionality is difficult due to the sheer amount of things you need to think about + +**Avoid switching to FSD against the will of your teammates**, even if you are the lead. +First, convince your teammates that the benefits outweigh the cost of migration and the cost of learning a new architecture instead of the established one. + +Also keep in mind that any kind of architectural changes are not immediately observable to the management. Make sure they are on board with the switch before starting and explain to them why it might benefit the project. + +:::tip + +If you need help convincing the project manager that FSD is beneficial, consider some of these points: +1. Migration to FSD can happen incrementally, so it will not halt the development of new features +2. A good architecture can significantly decrease the time that a new developer needs to get productive +3. FSD is a documented architecture, so the team doesn't have to continuously spend time on maintaining their own documentation + +::: + +--- + +If you made the decision to start migrating, then the first thing you want to do is to set up an alias for `📁 src`. It will be helpful later to refer to top-level folders. We will consider `@` as an alias for `./src` for the rest of this guide. + +## Step 1. Divide the code by pages {#divide-code-by-pages} + +Most custom architectures already have a division by pages, however small or large in logic. If you already have `📁 pages`, you may skip this step. + +If you only have `📁 routes`, create `📁 pages` and try to move as much component code from `📁 routes` as possible. Ideally, you would have a tiny route and a larger page. As you're moving code, create a folder for each page and add an index file: + +:::note + +For now, it's okay if your pages reference each other. You can tackle that later, but for now, focus on establishing a prominent division by pages. + +::: + +Route file: + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +Page index file: + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +Page component file: + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## Step 2. Separate everything else from the pages {#separate-everything-else-from-pages} + +Create a folder `📁 src/shared` and move everything that doesn't import from `📁 pages` or `📁 routes` there. Create a folder `📁 src/app` and move everything that does import the pages or routes there, including the routes themselves. + +Remember that the Shared layer doesn't have slices, so it's fine if segments import from each other. + +You should end up with a file structure like this: + +
+ 📁 src +
    +
  • +
    + 📁 app +
      +
    • +
      + 📁 routes +
        +
      • 📄 products.jsx
      • +
      • 📄 products.[id].jsx
      • +
      +
      +
    • +
    • 📄 App.jsx
    • +
    • 📄 index.js
    • +
    +
    +
  • +
  • +
    + 📁 pages +
      +
    • +
      + 📁 product +
        +
      • +
        + 📁 ui +
          +
        • 📄 ProductPage.jsx
        • +
        +
        +
      • +
      • 📄 index.js
      • +
      +
      +
    • +
    • 📁 catalog
    • +
    +
    +
  • +
  • +
    + 📁 shared +
      +
    • 📁 actions
    • +
    • 📁 api
    • +
    • 📁 components
    • +
    • 📁 containers
    • +
    • 📁 constants
    • +
    • 📁 i18n
    • +
    • 📁 modules
    • +
    • 📁 helpers
    • +
    • 📁 utils
    • +
    • 📁 reducers
    • +
    • 📁 selectors
    • +
    • 📁 styles
    • +
    +
    +
  • +
+
+ +## Step 3. Tackle cross-imports between pages {#tackle-cross-imports-between-pages} + + + + +Find all instances where one page is importing from the other and do one of the two things: + +1. Copy-paste the imported code into the depending page to remove the dependency +2. Move the code to a proper segment in Shared: + - if it's a part of the UI kit, move it to `📁 shared/ui`; + - if it's a configuration constant, move it to `📁 shared/config`; + - if it's a backend interaction, move it to `📁 shared/api`. + +:::note + +**Copy-pasting isn't architecturally wrong**, in fact, sometimes it may be more correct to duplicate than to abstract into a new reusable module. The reason is that sometimes the shared parts of pages start drifting apart, and you don't want dependencies getting in your way in these cases. + +However, there is still sense in the DRY ("don't repeat yourself") principle, so make sure you're not copy-pasting business logic. Otherwise you will need to remember to fix bugs in several places at once. + +::: + +## Step 4. Unpack the Shared layer {#unpack-shared-layer} + +You might have a lot of stuff in the Shared layer on this step, and you generally want to avoid that. The reason is that the Shared layer may be a dependency for any other layer in your codebase, so making changes to that code is automatically more prone to unintended consequences. + +Find all the objects that are only used on one page and move it to the slice of that page. And yes, _that applies to actions, reducers, and selectors, too_. There is no benefit in grouping all actions together, but there is benefit in colocating relevant actions close to their usage. + +You should end up with a file structure like this: + +
+ 📁 src +
    +
  • 📁 app (unchanged)
  • +
  • +
    + 📁 pages +
      +
    • +
      + 📁 product +
        +
      • 📁 actions
      • +
      • 📁 reducers
      • +
      • 📁 selectors
      • +
      • +
        + 📁 ui +
          +
        • 📄 Component.jsx
        • +
        • 📄 Container.jsx
        • +
        • 📄 ProductPage.jsx
        • +
        +
        +
      • +
      • 📄 index.js
      • +
      +
      +
    • +
    • 📁 catalog
    • +
    +
    +
  • +
  • +
    + 📁 shared (only objects that are reused) +
      +
    • 📁 actions
    • +
    • 📁 api
    • +
    • 📁 components
    • +
    • 📁 containers
    • +
    • 📁 constants
    • +
    • 📁 i18n
    • +
    • 📁 modules
    • +
    • 📁 helpers
    • +
    • 📁 utils
    • +
    • 📁 reducers
    • +
    • 📁 selectors
    • +
    • 📁 styles
    • +
    +
    +
  • +
+
+ +## Step 5. Organize code by technical purpose {#organize-by-technical-purpose} + +In FSD, division by technical purpose is done with _segments_. There are a few common ones: + +- `ui` — everything related to UI display: UI components, date formatters, styles, etc. +- `api` — backend interactions: request functions, data types, mappers, etc. +- `model` — the data model: schemas, interfaces, stores, and business logic. +- `lib` — library code that other modules on this slice need. +- `config` — configuration files and feature flags. + +You can create your own segments, too, if you need. Make sure not to create segments that group code by what it is, like `components`, `actions`, `types`, `utils`. Instead, group the code by what it's for. + +Reorganize your pages to separate code by segments. You should already have a `ui` segment, now it's time to create other segments, like `model` for your actions, reducers, and selectors, or `api` for your thunks and mutations. + +Also reorganize the Shared layer to remove these folders: +- `📁 components`, `📁 containers` — most of it should become `📁 shared/ui`; +- `📁 helpers`, `📁 utils` — if there are some reused helpers left, group them together by function, like dates or type conversions, and move theses groups to `📁 shared/lib`; +- `📁 constants` — again, group by function and move to `📁 shared/config`. + +## Optional steps {#optional-steps} + +### Step 6. Form entities/features from Redux slices that are used on several pages {#form-entities-features-from-redux} + +Usually, these reused Redux slices will describe something relevant to the business, for example, products or users, so these can be moved to the Entities layer, one entity per one folder. If the Redux slice is related to an action that your users want to do in your app, like comments, then you can move it to the Features layer. + +Entities and features are meant to be independent from each other. If your business domain contains inherent connections between entities, refer to the [guide on business entities][business-entities-cross-relations] for advice on how to organize these connections. + +The API functions related to these slices can stay in `📁 shared/api`. + +### Step 7. Refactor your modules {#refactor-your-modules} + +The `📁 modules` folder is commonly used for business logic, so it's already pretty similar in nature to the Features layer from FSD. Some modules might also be describe large chunks of the UI, like an app header. In that case, you should migrate them to the Widgets layer. + +### Step 8. Form a clean UI foundation in `shared/ui` {#form-clean-ui-foundation} + +`📁 shared/ui` should ideally contain a set of UI elements that don't have any business logic encoded in them. They should also be highly reusable. + +Refactor the UI components that used to be in `📁 components` and `📁 containers` to separate out the business logic. Move that business logic to the higher layers. If it's not used in too many places, you could even consider copy-pasting. + +## See also {#see-also} + +- [(Talk in Russian) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-legacy.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-legacy.mdx deleted file mode 100644 index 67ee065c12..0000000000 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-legacy.mdx +++ /dev/null @@ -1,120 +0,0 @@ ---- -sidebar_position: 3 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# Migration from legacy - - - -> The article aggregates the experience of several companies and projects on moving to Feature-Sliced Design with different initial conditions - -## Why? - -> How much does the move need? "Death by a thousand cuts" and those debt. What is missing? How can the methodology help? - -> See the talk of [Ilya Klimov about the need and procedure for refactoring](http://youtu.be/aOiJ3k2UvO4) - -![approaches-themed-bordered](/img/approaches.png) - -## What's the plan? - -### 1. Unification of the code base - -```diff -- ├── products/ -- | ├── components/ -- | ├── containers/ -- | ├── store/ -- | ├── styles/ -- ├── checkout/ -- | ├── components/ -- | ├── containers/ -- | ├── helpers/ -- | ├── styles/ -+ └── src/ - ├── actions/ - ├── api/ -+ ├── components/ -+ ├── containers/ - ├── constants/ - ├── epics/ -+ ├── i18n/ - ├── modules/ -+ ├── helpers/ -+ ├── pages/ -- ├── routes/ -- ├── utils/ - ├── reducers/ -- ├── redux/ - ├── selectors/ -+ ├── store -+ ├── styles/ - ├── App.jsx - └── index.jsx -``` - -### 2. Putting together the destructive decoupled - -```diff - └── src/ -- ├── actions/ - ├── api/ -- ├── components/ -- ├── containers/ -- ├── constants/ -- ├── epics/ -+ ├── entities/{...} -+ | ├── ui -+ | ├── model/{actions, selectors, ...} -+ | ├── lib - ├── i18n/ - | # We can temporarily put the remaining segments here -+ ├── modules/{helpers, constants} -- ├── helpers/ - ├── pages/ -- ├── reducers/ -- ├── selectors/ -- ├── store/ - ├── styles/ - ├── App.jsx - └── index.jsx -``` - -### 3. Allocate scopes of responsibility - -```diff - └── src/ -- ├── api/ -+ ├── app/ -+ | ├── index.jsx -+ | ├── style.css - ├── pages/ -+ ├── features/ -+ | ├── add-to-cart/{ui, model, lib} -+ | ├── choose-delivery/{ui, model, lib} -+ ├── entities/{...} -+ | ├── delivery/{ui, model, lib} -+ | ├── cart/{ui, model, lib} -+ | ├── product/{ui, model, lib} -+ ├── shared/ -+ | ├── api/ -+ | ├── lib/ # helpers -+ | | ├── i18n/ -+ | ├── config/ # constants -- ├── i18n/ -- ├── modules/{helpers, constants} - └── index.jsx -``` - -### 4. Final ? - -> About the remaining problems and how much it is worth eliminating them - -## See also - -- [(Talk) Ilya Klimov-The Rat Race of endless refactoring: how not to let technical debt kill motivation and product](https://youtu.be/aOiJ3k2UvO4) -- [(Talk) Ilya Azin - Architecture of Frontend projects](https://youtu.be/SnzPAr_FJ7w) - - There is also discussed approaches for architecture and costs of refactoring \ No newline at end of file diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md b/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md index 3bd4324d5b..4c0a98a649 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md @@ -1,8 +1,8 @@ --- -sidebar_position: 4 +sidebar_position: 2 --- -# Migration from v1 +# Migration from v1 to v2 ## Why v2? @@ -158,10 +158,10 @@ Now it is much easier to [observe the principle of low coupling][refs-low-coupli - [New ideas v2 with explanations (atomicdesign-chat)][ext-tg-v2-draft] - [Discussion of abstractions and naming for the new version of the methodology (v2)](https://github.com/feature-sliced/documentation/discussions/31) -[refs-low-coupling]: /docs/reference/isolation/coupling-cohesion +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion [refs-adaptability]: /docs/about/understanding/naming -[ext-v1]: https://featureslices.dev/v1.0.html +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html [ext-tg-spb]: https://t.me/feature_slices [ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven [ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md b/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md new file mode 100644 index 0000000000..af057a421e --- /dev/null +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 3 +--- + +# Migration from v2.0 to v2.1 + +The main change in v2.1 is the new mental model for decomposing an interface — pages first. + +In v2.0, FSD would recommend identifying entities and features in your interface, considering even the smallest bits of entity representation and interactivity for decomposition. Then you would build widgets and pages from entities and features. In this model of decomposition, most of the logic was in entities and features, and pages were just compositional layers that didn't have much significance on their own. + +In v2.1, we recommend starting with pages, and possibly even stopping there. Most people already know how to separate the app into individual pages, and pages are also a common starting point when trying to locate a component in the codebase. In this new model of decomposition, you keep most of the UI and logic in each individual page, maintaining a reusable foundation in Shared. If a need arises to reuse business logic across several pages, you can move it to a layer below. + +Another addition to Feature-Sliced Design is the standardization of cross-imports between entities with the `@x`-notation. + +## How to migrate {#how-to-migrate} + +There are no breaking changes in v2.1, which means that a project written with FSD v2.0 is also a valid project in FSD v2.1. However, we believe that the new mental model is more beneficial for teams and especially onboarding new developers, so we recommend making minor adjustments to your decomposition. + +### Merge slices + +A simple way to start is by running our linter, [Steiger][steiger], on the project. Steiger is built with the new mental model, and the most helpful rules will be: + +- [`insignificant-slice`][insignificant-slice] — if an entity or feature is only used in one page, this rule will suggest merging that entity or feature into the page entirely. +- [`excessive-slicing`][excessive-slicing] — if a layer has too many slices, it's usually a sign that the decomposition is too fine-grained. This rule will suggest merging or grouping some slices to help project navigation. + +```bash +npx steiger src +``` + +This will help you identify which slices are only used once, so that you could reconsider if they are really necessary. In such considerations, keep in mind that a layer forms some kind of global namespace for all the slices inside of it. Just as you wouldn't pollute the global namespace with variables that are only used once, you should treat a place in the namespace of a layer as valuable, to be used sparingly. + +### Standardize cross-imports + +If you had cross-imports between in your project before (we don't judge!), you may now take advantage of a new notation for cross-importing in Feature-Sliced Design — the `@x`-notation. It looks like this: + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +For more details, check out the [Public API for cross-imports][public-api-for-cross-imports] section in the reference. + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx index 08855b6869..929bdcaf51 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx @@ -66,7 +66,7 @@ Thus, the file structure will look like this: │ │ │ │ ├── home-page.vue │ │ │ ├── index.ts ``` -Finally, let's add a root to the config: +Finally, let's add a route to the config: ```ts title="app/router.config.ts" import type { RouterConfig } from '@nuxt/schema' @@ -111,8 +111,8 @@ Now, you can create routes for pages within `app` and connect pages from `pages` For example, to add a `Home` page to your project, you need to do the following steps: - Add a page slice inside the `pages` layer -- Add the corresponding root inside the `app` layer -- Align the page from the slice with the root +- Add the corresponding route inside the `app` layer +- Connect the page from the slice with the route To create a page slice, let's use the [CLI](https://github.com/feature-sliced/cli): @@ -126,7 +126,7 @@ Create a ``home-page.vue`` file inside the ui segment, access it using the Publi export { default as HomePage } from './ui/home-page'; ``` -Create a root for this page inside the `app` layer: +Create a route for this page inside the `app` layer: ```sh diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx index 1bb64f57f3..88931cfa3f 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx @@ -34,8 +34,7 @@ the purest division will be by entity. In this case, we suggest using the follow ``` If there are connections between the entities (for example, the Country entity has a field-list of City entities), -then you can use an [experimental approach to organized cross-imports -via @x-notation](https://github.com/feature-sliced/documentation/discussions/390#discussioncomment-5570073) or consider the alternative solution below. +then you can use the [public API for cross-imports][public-api-for-cross-imports] or consider the alternative solution below. ### Alternative solution — keep it in shared @@ -433,3 +432,5 @@ export const apiClient = new ApiClient(API_URL); - [(GitHub) Sample Project](https://github.com/ruslan4432013/fsd-react-query-example) - [(CodeSandbox) Sample Project](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) - [About the query factory](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx index 08c81813da..886b2381ee 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx @@ -44,10 +44,10 @@ Thus, your file structure should look like this: │ ├── app │ │ ├── index.html │ │ ├── routes -│ ├── pages # Папка pages, закреплённая за FSD +│ ├── pages # FSD Pages folder ``` -Now, you can create roots for pages within `app` and connect pages from `pages` to them. +Now, you can create routes for pages within `app` and connect pages from `pages` to them. For example, to add a home page to your project, you need to do the following steps: - Add a page slice inside the `pages` layer @@ -60,13 +60,13 @@ To create a page slice, let's use the [CLI](https://github.com/feature-sliced/cl fsd pages home ``` -Create a ``home-page.vue`` file inside the ui segment, access it using the Public API +Create a ``home-page.svelte`` file inside the ui segment, access it using the Public API ```ts title="src/pages/home/index.ts" -export { default as HomePage } from './ui/home-page'; +export { default as HomePage } from './ui/home-page.svelte'; ``` -Create a root for this page inside the `app` layer: +Create a route for this page inside the `app` layer: ```sh @@ -82,7 +82,7 @@ Create a root for this page inside the `app` layer: │ │ │ ├── index.ts ``` -Add your page component inside the `index.svelte` file: +Add your page component inside the `+page.svelte` file: ```html title="src/app/routes/+page.svelte" +``` + +## 環境宣言ファイル(`*.d.ts`) + +一部のパッケージ、例えば[Vite][ext-vite]や[ts-reset][ext-ts-reset]は、アプリケーションで動作するために環境宣言ファイルを必要とします。通常、これらは小さくて簡単なので、特にアーキテクチャを必要とせず、単に`src/`に置くことができます。`src`をより整理されたものにするために、App層の`app/ambient/`に保存することもできます。 + +他のパッケージは単に型を持たず、その型を未定義として宣言する必要があるか、あるいは自分で型を作成する必要があるかもしれません。これらの型の良い場所は`shared/lib`で、`shared/lib/untyped-packages`のようなフォルダーです。そこに`%LIBRARY_NAME%.d.ts`というファイルを作成し、必要な型を宣言します。 + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// このライブラリには型がなく、自分で型を書くのは億劫です。 +declare module "use-react-screenshot"; +``` + +## 型の自動生成 + +外部ソースから型を生成することは、しばしば便利です。例えば、OpenAPIスキーマからバックエンドの型を生成することができます。この場合、これらの型のためにコード内に特別な場所を作成します。例えば、`shared/api/openapi`のようにします。これらのファイルが何であるか、どのように再生成されるかを説明するREADMEをこのフォルダーに含めておくと理想的です。 + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/examples/white-labels.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/examples/white-labels.mdx new file mode 100644 index 0000000000..b536ecddbc --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/examples/white-labels.mdx @@ -0,0 +1,11 @@ +--- +sidebar_position: 8 +sidebar_class_name: sidebar-item--wip +unlisted: true +--- + +import WIP from '@site/src/shared/ui/wip/tmpl.mdx' + +# ホワイトラベル + + diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/index.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/index.mdx new file mode 100644 index 0000000000..f6b7376683 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/index.mdx @@ -0,0 +1,46 @@ +--- +hide_table_of_contents: true +pagination_prev: get-started/index +--- + +# 🎯 ガイド + +実践指向 + +

+Feature-Sliced Designの適用に関する実践的なガイドと例です。このセクションでは、移行ガイドや悪習のハンドブックも説明されています。具体的な何かを実現しようとしているときや、FSDを「実戦」で見たいときに最も役立ちます。 +

+ +## 主な内容 {#main} + +import NavCard from "@site/src/shared/ui/nav-card/tmpl.mdx" +import { ToolOutlined, ImportOutlined, BugOutlined, FunctionOutlined } from "@ant-design/icons"; + + + + + + \ No newline at end of file diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/_category_.yaml b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/_category_.yaml new file mode 100644 index 0000000000..8d2bd9c676 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/_category_.yaml @@ -0,0 +1,2 @@ +label: コードの臭いと問題 +position: 4 diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx new file mode 100644 index 0000000000..92183efece --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx @@ -0,0 +1,13 @@ +--- +sidebar_position: 4 +sidebar_class_name: sidebar-item--wip +pagination_next: reference/index +--- + +import WIP from '@site/src/shared/ui/wip/tmpl.mdx' + +# クロスインポート + + + +> クロスインポートは、レイヤーや抽象化が本来の責任以上に多くの責任を持ち始めると発生する。そのため、FSDは新しいレイヤーを設けて、これらのクロスインポートを分離することを可能にしている。 diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx new file mode 100644 index 0000000000..28f3bddc42 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx @@ -0,0 +1,96 @@ +--- +sidebar_position: 2 +sidebar_class_name: sidebar-item--wip +--- + +import WIP from '@site/src/shared/ui/wip/tmpl.mdx' + +# デセグメンテーション + + + +## 状況 {#situation} + +プロジェクトでは、特定のドメインに関連するモジュールが過度にデセグメント化され、プロジェクト全体に散らばっていることがよくあります。 + +```sh +├── components/ +| ├── DeliveryCard +| ├── DeliveryChoice +| ├── RegionSelect +| ├── UserAvatar +├── actions/ +| ├── delivery.js +| ├── region.js +| ├── user.js +├── epics/ +| ├── delivery.js +| ├── region.js +| ├── user.js +├── constants/ +| ├── delivery.js +| ├── region.js +| ├── user.js +├── helpers/ +| ├── delivery.js +| ├── region.js +| ├── user.js +├── entities/ +| ├── delivery/ +| | ├── getters.js +| | ├── selectors.js +| ├── region/ +| ├── user/ +``` + + +## 問題 {#problem} + +問題は、**高い凝集性**の原則の違反と、**変更の軸**の過度な拡張として現れます。 + +## 無視する場合 {#if-you-ignore-it} + +- 例えば、配達に関するロジックに触れる必要がある場合、このロジックが複数の箇所に分散していることを考慮しなければならず、コード内で複数の箇所に触れる必要がある。これにより、**変更の軸**が過度に引き伸ばされる +- ユーザーに関するロジックを調べる必要がある場合、**actions、epics、constants、entities、components**の詳細を調べるためにプロジェクト全体を巡回しなければならない +- 暗黙関係と拡大するドメインの制御不能 + - このアプローチでは、視野が狭くなり、「定数のための定数」を作成し、プロジェクトの該当ディレクトリをごちゃごちゃさせてしまうことに気づかないことがよくある + +## 解決策 {#solution} + +特定のドメイン/ユースケースに関連するすべてのモジュールを近くに配置することです。 + +これは特定のモジュールを調べる際に、そのすべての構成要素がプロジェクト全体に散らばらず、近くに配置されるためです。 + +> これにより、コードベースとモジュール間の関係の発見しやすさと明確さが向上します。 + +```diff +- ├── components/ +- | ├── DeliveryCard +- | ├── DeliveryChoice +- | ├── RegionSelect +- | ├── UserAvatar +- ├── actions/ +- | ├── delivery.js +- | ├── region.js +- | ├── user.js +- ├── epics/{...} +- ├── constants/{...} +- ├── helpers/{...} + ├── entities/ + | ├── delivery/ ++ | | ├── ui/ # ~ components/ ++ | | | ├── card.js ++ | | | ├── choice.js ++ | | ├── model/ ++ | | | ├── actions.js ++ | | | ├── constants.js ++ | | | ├── epics.js ++ | | | ├── getters.js ++ | | | ├── selectors.js ++ | | ├── lib/ # ~ helpers + | ├── region/ + | ├── user/ +``` + +## 参照 {#see-also} +* [(記事) Cohesion and Coupling: the difference](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/routes.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/routes.mdx new file mode 100644 index 0000000000..10dc329e10 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/routes.mdx @@ -0,0 +1,42 @@ +--- +sidebar_position: 3 +sidebar_class_name: sidebar-item--wip +--- + +import WIP from '@site/src/shared/ui/wip/tmpl.mdx' + +# ルーティング + + + +## 状況 {#situation} + +ページへのURLが、pages層より下の層にハードコーディングされています。 + +```tsx title="entities/post/card" + + + + ... + +``` + + +## 問題 {#problem} + +URLがページ層に集中しておらず、責任範囲において適切な場所に配置されていません。 + +## 無視する場合 {#if-you-ignore-it} + +URLを変更する際に、URL(およびURL/リダイレクトのロジック)がpages層以外のすべての層に存在する可能性があることを考慮しなければなりません。 + +また、これは単純な商品カードでさえ、ページからの一部の責任を引き受けることを意味し、プロジェクト全体にロジックが分散してしまいます。 + +## 解決策 {#solution} + +URLやリダイレクトの処理をページ層およびそれ以上の層で定義することです。 + +URLを下層の層には、コンポジション/プロパティ/ファクトリーを通じて渡すことができます。 diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/_category_.yaml b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/_category_.yaml new file mode 100644 index 0000000000..32b7491cea --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/_category_.yaml @@ -0,0 +1,2 @@ +label: 移行 +position: 2 diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-custom.md b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-custom.md new file mode 100644 index 0000000000..c0a0f2e9e2 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-custom.md @@ -0,0 +1,306 @@ +--- +sidebar_position: 1 +sidebar_label: カスタムアーキテクチャからの移行 +--- + +# カスタムアーキテクチャからの移行 + +このガイドは、カスタムのアーキテクチャからFeature-Sliced Designへの移行に役立つアプローチを説明します。 + +以下は、典型的なカスタムアーキテクチャのフォルダ構造です。このガイドでは、これを例として使用します。フォルダの内容が見えるように、フォルダの横にある青い矢印をクリックすることができます。 + +
+ 📁 src +
    +
  • +
    + 📁 actions +
      +
    • 📁 product
    • +
    • 📁 order
    • +
    +
    +
  • +
  • 📁 api
  • +
  • 📁 components
  • +
  • 📁 containers
  • +
  • 📁 constants
  • +
  • 📁 i18n
  • +
  • 📁 modules
  • +
  • 📁 helpers
  • +
  • +
    + 📁 routes +
      +
    • 📁 products.jsx
    • +
    • 📄 products.[id].jsx
    • +
    +
    +
  • +
  • 📁 utils
  • +
  • 📁 reducers
  • +
  • 📁 selectors
  • +
  • 📁 styles
  • +
  • 📄 App.jsx
  • +
  • 📄 index.js
  • +
+
+ +## 開始前に {#before-you-start} + +Feature-Sliced Designへの移行を検討する際に、チームに最も重要な質問は「本当に必要か?」です。私たちはFeature-Sliced Designが好きですが、いくつかのプロジェクトはそれなしでうまくいけることを認めています。 + +移行を検討すべき理由はいくつかあります。 + +1. 新しいチームメンバーが生産的なレベルに達するのが難しいと不満を言う。 +2. コードの一部を変更すると、**しばしば**他の無関係な部分が壊れる。 +3. 巨大なコードベースのため、新しい機能を追加するのが難しい。 + +**同僚の意に反してFSDに移行することは避けてください**。たとえあなたがチームリーダーであっても、まずは同僚を説得し、移行の利点がコストを上回ることを理解させる必要があります。 + +また、アーキテクチャの変更は、瞬時には経営陣には見えないことを覚えておいてください。始める前に、経営陣が移行を支持していることを確認し、この移行がプロジェクトにどのように役立つかを説明してください。 + +:::tip + +プロジェクトマネージャーをFSDの有用性に納得させるためのアイデアをいくつか紹介します。 + +1. FSDへの移行は段階的に行えるため、新機能の開発を止めることはありません。 +2. 良いアーキテクチャは、新しい開発者が生産性を達成するのにかかる時間を大幅に短縮できます。 +3. FSDは文書化されたアーキテクチャであるため、チームは独自の文書を維持するために常に時間を費やす必要がありません。 + +::: + +--- + +もし移行を始める決断をした場合、最初に行うべきことは`📁 src`のエイリアスを設定することです。これは、後で上位フォルダを参照するのに便利です。以降のテキストでは、`@`を`./src`のエイリアスとして扱います。 + +## ステップ1。コードをページごとに分割する {#divide-code-by-pages} + +ほとんどのカスタムアーキテクチャは、ロジックのサイズに関係なく、すでにページごとに分割されています。もし`📁 pages`がすでに存在する場合は、このステップをスキップできます。 + +もし`📁 routes`しかない場合は、`📁 pages`を作成し、できるだけ多くのコンポーネントコードを`📁 routes`から移動させてみてください。理想的には、小さなルートファイルと大きなページファイルがあることです。コードを移動させる際には、各ページのためのフォルダを作成し、その中にインデックスファイルを追加します。 + +:::note + +現時点では、ページ同士が互いにインポートすることは問題ありません。後でこれらの依存関係を解消するための別のステップがあります。今はページごとの明確な分割を確立することに集中します。 + +::: + +ルートファイル + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +ページのインデックスファイル + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +ページコンポーネントファイル + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## ステップ2。 ページ以外のすべてを分離する {#separate-everything-else-from-pages} + +`📁 src/shared`フォルダを作成し、`📁 pages`や`📁 routes`からインポートされないすべてをそこに移動します。`📁 src/app`フォルダを作成し、ページやルートをインポートするすべてをそこに移動します。 + +Shared層にはスライスがないことを覚えておいてください。したがって、セグメントは互いにインポートできます。 + +最終的には、次のようなファイル構造になるはずです。 + +
+ 📁 src +
    +
  • +
    + 📁 app +
      +
    • +
      + 📁 routes +
        +
      • 📄 products.jsx
      • +
      • 📄 products.[id].jsx
      • +
      +
      +
    • +
    • 📄 App.jsx
    • +
    • 📄 index.js
    • +
    +
    +
  • +
  • +
    + 📁 pages +
      +
    • +
      + 📁 product +
        +
      • +
        + 📁 ui +
          +
        • 📄 ProductPage.jsx
        • +
        +
        +
      • +
      • 📄 index.js
      • +
      +
      +
    • +
    • 📁 catalog
    • +
    +
    +
  • +
  • +
    + 📁 shared +
      +
    • 📁 actions
    • +
    • 📁 api
    • +
    • 📁 components
    • +
    • 📁 containers
    • +
    • 📁 constants
    • +
    • 📁 i18n
    • +
    • 📁 modules
    • +
    • 📁 helpers
    • +
    • 📁 utils
    • +
    • 📁 reducers
    • +
    • 📁 selectors
    • +
    • 📁 styles
    • +
    +
    +
  • +
+
+ +## ステップ3。 ページ間のクロスインポートを解消する {#tackle-cross-imports-between-pages} + +あるページが他のページから何かをインポートしているすべての箇所を見つけ、次のいずれかを行います。 + +1. インポートされているコードを依存するページにコピーして、依存関係を取り除く。 +2. コードをShared層の適切なセグメントに移動する + - UIキットの一部であれば、`📁 shared/ui`に移動。 + - 設定の定数であれば、`📁 shared/config`に移動。 + - バックエンドとのやり取りであれば、`📁 shared/api`に移動。 + +:::note + +**コピー自体はアーキテクチャの問題ではありません**。実際、時には新しい再利用可能なモジュールに何かを抽象化するよりも、何かを複製する方が正しい場合もあります。ページの共通部分が異なってくることがあるため、その場合、依存関係が妨げにならないようにする必要があります。 + +ただし、DRY("don't repeat yourself" — "繰り返さない")の原則には意味があるため、ビジネスロジックをコピーしないようにしてください。そうしないと、バグを複数の箇所で修正することになることを頭に入れておく必要があります。 + +::: + +## ステップ4。 Shared層を分解する {#unpack-shared-layer} + +この段階では、Shared層に多くのものが入っているかもしれませんが、一般的にはそのような状況を避けるべきです。理由は、Shared層に依存している他の層にあるコードが存在している可能性があるため、そこに変更を加えることは予期しない結果を引き起こす可能性が高くなります。 + +特定のページでのみ使用されるすべてのオブジェクトを見つけ、それらをそのページのスライスに移動します。そして、_これにはアクション、リデューサー、セレクターも含まれます_。すべてのアクションを一緒にグループ化することには意味がありませんが、関連するアクションをその使用場所の近くに置くことには意味があります。 + +最終的には、次のようなファイル構造になるはずです。 + +
+ 📁 src +
    +
  • 📁 app (変更なし)
  • +
  • +
    + 📁 pages +
      +
    • +
      + 📁 product +
        +
      • 📁 actions
      • +
      • 📁 reducers
      • +
      • 📁 selectors
      • +
      • +
        + 📁 ui +
          +
        • 📄 Component.jsx
        • +
        • 📄 Container.jsx
        • +
        • 📄 ProductPage.jsx
        • +
        +
        +
      • +
      • 📄 index.js
      • +
      +
      +
    • +
    • 📁 catalog
    • +
    +
    +
  • +
  • +
    + 📁 shared (再利用されるオブジェクトのみ) +
      +
    • 📁 actions
    • +
    • 📁 api
    • +
    • 📁 components
    • +
    • 📁 containers
    • +
    • 📁 constants
    • +
    • 📁 i18n
    • +
    • 📁 modules
    • +
    • 📁 helpers
    • +
    • 📁 utils
    • +
    • 📁 reducers
    • +
    • 📁 selectors
    • +
    • 📁 styles
    • +
    +
    +
  • +
+
+ +## ステップ5。 コードを技術的な目的に基づいて整理する {#organize-by-technical-purpose} + +FSDでは、技術的な目的に基づく分割がセグメントによって行われます。よく見られるセグメントはいくつかあります。 + +- `ui` — インターフェースの表示に関連するすべて: UIコンポーネント、日付のフォーマット、スタイルなど。 +- `api` — バックエンドとのやり取り: リクエスト関数、データ型、マッパーなど。 +- `model` — データモデル: スキーマ、インターフェース、ストレージ、ビジネスロジック。 +- `lib` — 他のモジュールに必要なライブラリコード。 +- `config` — 設定ファイルやフィーチャーフラグ。 + +必要に応じて独自のセグメントを作成できます。ただし、コードをその性質によってグループ化するセグメント(例: `components`、`actions`、`types`、`utils`)を作成しないようにしてください。代わりに、コードの目的に基づいてグループ化してください。 + +ページのコードをセグメントに再分配します。すでに`ui`セグメントがあるはずなので、今は他のセグメント(例えば、アクション、リデューサー、セレクターのための`model`や、サンクやミューテーションのための`api`)を作成するときです。 + +また、Shared層を再分配して、次のフォルダを削除します。 + +- `📁 components`、`📁 containers` — その内容のほとんどは`📁 shared/ui`になるべきです。 +- `📁 helpers`、`📁 utils` — 再利用可能なヘルパー関数が残っている場合は、目的に基づいてグループ化し、これらのグループを`📁 shared/lib`に移動します。 +- `📁 constants` — 同様に、目的に基づいてグループ化し、`📁 shared/config`に移動します。 + +## 任意のステップ {#optional-steps} + +### ステップ6。 複数のページで使用されるReduxスライスからエンティティ/フィーチャーを形成する {#form-entities-features-from-redux} + +通常、これらの再利用可能なReduxスライスは、ビジネスに関連する何かを説明します(例えば、プロダクトやユーザーなど)。したがって、それらをエンティティ層に移動できます。1つのエンティティにつき1つのフォルダです。Reduxスライスが、ユーザーがアプリケーションで実行したいアクションに関連している場合(例えば、コメントなど)、それをフィーチャー層に移動できます。 + +エンティティとフィーチャーは互いに独立している必要があります。ビジネス領域に組み込まれたエンティティ間の関係がある場合は、[ビジネスエンティティに関するガイド][business-entities-cross-relations]を参照して、これらの関係を整理する方法を確認してください。 + +これらのスライスに関連するAPI関数は、`📁 shared/api`に残すことができます。 + +### ステップ7。 モジュールをリファクタリングする {#refactor-your-modules} + +`📁 modules`フォルダは通常、ビジネスロジックに使用されるため、すでにFSDのフィーチャー層に似た性質を持っています。一部のモジュールは、アプリケーションの大きな部分(例えば、アプリのヘッダーなど)を説明することもあります。この場合、それらをウィジェット層に移動できます。 + +### ステップ8。 `shared/ui`にUI基盤を正しく形成する {#form-clean-ui-foundation} + +理想的には、`📁 shared/ui`にはビジネスロジックが含まれていないUI要素のセットが含まれるべきです。また、非常に再利用可能である必要があります。 + +以前`📁 components`や`📁 containers`にあったUIコンポーネントをリファクタリングして、ビジネスロジックを分離します。このビジネスロジックを上位層に移動します。あまり多くの場所で使用されていない場合は、コピーを検討することもできます。 + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md new file mode 100644 index 0000000000..282faaecdc --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 2 +--- + +# v1からv2への移行 + +## なぜv2なのか? {#why-v2} + +初期の**feature-slices**の概念は、2018年に提唱されました。 + +それ以来、FSD方法論は多くの変革を経てきましたが、基本的な原則は保持されています。 + +- *標準化された*フロントエンドプロジェクト構造の使用 +- アプリケーションを*ビジネスロジック*に基づいて分割 +- *孤立した機能*の使用により、暗黙の副作用や循環依存を防止 +- モジュールの「内部」にアクセスすることを禁止する*公開API*の使用 + +しかし、以前のバージョンのFSD方法論には依然として**弱点が残っていました**。 + +- ボイラープレートの発生 +- コードベースの過剰な複雑化と抽象化間の明確でないルール +- プロジェクトのメンテナンスや新しいメンバーのオンボーディングを妨げていた暗黙のアーキテクチャ的決定 + +新しいバージョンのFSD方法論([v2][ext-v2])は、**これらの欠点を解消しつつ、既存の利点を保持することを目的としています**。 + +2018年以降、[**feature-driven**][ext-fdd]という別の類似の方法論が[発展してきました][ext-fdd-issues]。それを最初に提唱したのは[Oleg Isonen][ext-kof]でした。 + +2つのアプローチの統合により、**既存のプラクティスが改善され、柔軟性、明確さ、効率が向上しました**。 + +> 結果として、方法論の名称も「feature-slice**d**」に変更されました。 + +## なぜプロジェクトをv2に移行する意味があるのか? {#why-does-it-make-sense-to-migrate-the-project-to-v2} + +> `WIP:` 現在の方法論のバージョンは開発中であり、一部の詳細は*変更される可能性があります*。 + +#### 🔍 より透明でシンプルなアーキテクチャ {#-more-transparent-and-simple-architecture} + +FSD(v2)は、**より直感的で、開発者の間で広く受け入れられている抽象化とロジックの分割方法を提供しています**。 + +これにより、新しいメンバーの参加やプロジェクトの現状理解、アプリケーションのビジネスロジック分配に非常に良い影響を与えます。 + +#### 📦 より柔軟で誠実なモジュール性 {#-more-flexible-and-honest-modularity} + +FSD(v2)は、**より柔軟な方法でロジックを分配することを可能にしています**。 + +- 孤立した部分をゼロからリファクタリングできる +- 同じ抽象化に依存しつつ、余計な依存関係の絡みを避けられる +- 新しいモジュールの配置をよりシンプルにできる *(layer → slice → segment)* + +#### 🚀 より多くの仕様、計画、コミュニティ {#-more-specifications-plans-community} + +`core-team`は最新の(v2)バージョンのFSD方法論に積極的に取り組んでいます。 + +したがって、以下のことが期待できます。 + +- より多くの記述されたケース/問題 +- より多くの適用ガイド +- より多くの実例 +- 新しいメンバーのオンボーディングや方法論概念の学習のための全体的な文書の増加 +- 方法論の概念とアーキテクチャに関するコンベンションを遵守するためのツールキットのさらなる発展 + +> もちろん、初版に対するユーザーサポートも行われますが、私たちにとっては最新のバージョンが最優先です。 + +> 将来的には、次のメジャーアップデートの際に、現在のバージョン(v2)へのアクセスが保持され、**チームやプロジェクトにリスクをもたらすことはありません**。 + +## Changelog + +### `BREAKING` Layers + +FSD方法論は上位レベルでの層の明示的な分離を前提としています。 + +- `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` +- *つまり、すべてがフィーチャーやページとして解釈されるわけではない* +- このアプローチにより、層のルールを明示的に設定することが可能になる + - モジュールの**層が高いほど**、より多くの**コンテキスト**を持つことができる + + *(言い換えれば、各層のモジュールは、下層のモジュールのみをインポートでき、上層のモジュールはインポートできない)* + - モジュールの**層が低いほど**、変更を加える際の**危険性と責任**が増す + + *(一般的に、再利用されるのは下層のモジュールらからである)* + +### `BREAKING` Shared層 + +以前はプロジェクトのsrcルートにあったインフラストラクチャの `/ui`, `/lib`, `/api` 抽象化は、現在 `/src/shared` という別のディレクトリに分離されています。 + +- `shared/ui` - アプリケーションの共通UIキット(オプション) + - *ここで`Atomic Design`を使用することは引き続き許可されている* +- `shared/lib` - ロジックを実装するための補助ライブラリセット + - *引き続き、ヘルパー関数の「ごみ屋敷」を作らずに* +- `shared/api` - APIへのアクセスのための共通エントリポイント + - *各フィーチャー/ページにローカルに記述することも可能だが、推奨されない* +- 以前と同様に、`shared`にはビジネスロジックへの明示的な依存関係があってはならない + - *必要に応じて、この依存関係は`entities`、またはそれ以上の層に移動する必要がある* + +### `新規` Entities層, Processes層 + +v2では、**ロジックの複雑さと強い結合の問題を解消するために、新しい抽象化が追加されました**。 + +- `/entities` - **ビジネスエンティティ**の層で、ビジネスモデルやフロントエンド専用の合成エンティティに関連するスライスを含む + - *例:`user`, `i18n`, `order`, `blog`* +- `/processes` - アプリケーション全体にわたる**ビジネスプロセス**の層 + - **この層はオプションであり、通常は*ロジックが拡大し、複数のページにまたがる場合に使用が推奨される*** + - *例:`payment`, `auth`, `quick-tour`* + +### `BREAKING` 抽象化と命名 + +具体的な抽象化とその命名に関する[明確なガイドライン][refs-adaptability]が定義されています。 + +#### Layers + +- `/app` — **アプリケーションの初期化層** + - *以前のバリエーション: `app`, `core`, `init`, `src/index`* +- `/processes` — **ビジネスプロセスの層** + - *以前のバリエーション: `processes`, `flows`, `workflows`* +- `/pages` — **アプリケーションのページ層** + - *以前のバリエーション: `pages`, `screens`, `views`, `layouts`, `components`, `containers`* +- `/features` — **機能部分の層** + - *以前のバリエーション: `features`, `components`, `containers`* +- `/entities` — **ビジネスエンティティの層** + - *以前のバリエーション: `entities`, `models`, `shared`* +- `/shared` — **再利用可能なインフラストラクチャコードの層** 🔥 + - *以前のバリエーション: `shared`, `common`, `lib`* + +#### Segments + +- `/ui` — **UIセグメント** 🔥 + - *以前のバリエーション:`ui`, `components`, `view`* +- `/model` — **ビジネスロジックのセグメント** 🔥 + - *以前のバリエーション:`model`, `store`, `state`, `services`, `controller`* +- `/lib` — **補助コードのセグメント** + - *以前のバリエーション:`lib`, `libs`, `utils`, `helpers`* +- `/api` — **APIセグメント** + - *以前のバリエーション:`api`, `service`, `requests`, `queries`* +- `/config` — **アプリケーション設定のセグメント** + - *以前のバリエーション:`config`, `env`, `get-env`* + +### `REFINED` 低結合 + +新しいレイヤーのおかげで、モジュール間の[低結合の原則][refs-low-coupling]を遵守することがはるかに簡単になりました。 + +*それでも、モジュールを「切り離す」ことが非常に難しい場合は、できるだけ避けることが推奨されます*。 + +## 参照 {#see-also} + +- [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"][ext-kof-fdd] + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming + +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md new file mode 100644 index 0000000000..a29c7f7d2f --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 3 +--- + +# v2.0からv2.1への移行 + +v2.1の主な変更点は、インターフェースを分解するための「ページファースト」という新しいメンタルモデルです。 + +v2.0では、FSDは分解のためにエンティティ表現やインタラクティビティの最小部分まで考慮し、インターフェース内のエンティティとフィーチャーを特定することを推奨していました。そうしてから、エンティティとフィーチャーからウィジェットやページが構築されていきました。この分解モデルでは、ほとんどのロジックはエンティティとフィーチャーにあり、ページはそれ自体にはあまり重要性のない構成層に過ぎませんでした。 + +v2.1では、分解をページから始めること、または場合によってはページで止めることを推奨します。ほとんどの人はすでにアプリを個々のページに分ける方法を知っており、ページはコードベース内のコンポーネントを見つける際の一般的な出発点でもあります。この新しい分解モデルでは、各個別のページにほとんどのUIとロジックを保持し、Sharedに再利用可能な基盤を維持します。複数のページでビジネスロジックを再利用する必要が生じた場合は、それを下層のレイヤーに移動できます。 + +Feature-Sliced Designへのもう一つの追加は、`@x`表記を使用したエンティティ間のクロスインポートの標準化です。 + +## 移行方法 {#how-to-migrate} + +v2.1には破壊的な変更はなく、FSD v2.0で書かれたプロジェクトもFSD v2.1の有効なプロジェクトです。しかし、新しいメンタルモデルがチームや特に新しい開発者のオンボーディングにとってより有益であると考えているため、分解に対して小さな調整を行うことを推奨します。 + +### スライスのマージ + +移行を始めるための簡単な方法は、プロジェクトでFSDのリンターである[Steiger][steiger]を実行することです。Steigerは新しいメンタルモデルで構築されており、最も役立つルールは次のとおりです。 + +- [`insignificant-slice`][insignificant-slice] — エンティティ、またはフィーチャーが1ページでのみ使用されている場合、このルールはそのエンティティ、またはフィーチャーをページに完全にマージすることを提案します。 +- [`excessive-slicing`][excessive-slicing] — レイヤーにスライスが多すぎる場合、通常は分解が細かすぎるサインです。このルールは、プロジェクトのナビゲーションを助けるためにいくつかのスライスをマージ、またはグループ化することを提案します。 + +```bash +npx steiger src +``` + +これにより、1回だけ使用されるスライスを特定できるため、それらが本当に必要か再考することができます。そのような考慮において、レイヤーはその内部のすべてのスライスのための何らかのグローバル名前空間を形成することを念頭に置いてください。1回だけ使用される変数でグローバル名前空間を汚染しないようにするのと同様に、レイヤーの名前空間内の場所を貴重なものとして扱い、慎重に使用するべきです。 + +### クロスインポートの標準化 + +以前にプロジェクト内でクロスインポートがあった場合、Feature-Sliced Designでのクロスインポートのための新しい表記法`@x`を活用できます。これは次のようになります。 + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +詳細については、リファレンスの[クロスインポートの公開API][public-api-for-cross-imports]セクションを参照してください。 + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/_category_.yaml b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/_category_.yaml new file mode 100644 index 0000000000..b4c34b26eb --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/_category_.yaml @@ -0,0 +1,2 @@ +label: 技術 +position: 3 diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..67de70dec4 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx @@ -0,0 +1,109 @@ +--- +sidebar_position: 10 +--- +# NextJSとの併用 + +NextJSプロジェクトでFSDを実装することは可能ですが、プロジェクトの構造に関するNextJSの要件とFSDの原則の間に2つの点で対立が生じます。 + +- `pages`のファイルルーティング +- NextJSにおける`app`の対立、または欠如 + +## `pages`におけるFSDとNextJSの対立 {#pages-conflict} + +NextJSは、アプリケーションのルートを定義するために`pages`フォルダーを使用することを提案しています。`pages`フォルダー内のファイルがURLに対応することを期待しています。このルーティングメカニズムは、FSDの概念に**適合しません**。なぜなら、このようなルーティングメカニズムでは、スライスの平坦な構造を維持することができないからです。 + +### NextJSの`pages`フォルダーをプロジェクトのルートフォルダーに移動する(推奨) + +このアプローチは、NextJSの`pages`フォルダーをプロジェクトのルートフォルダーに移動し、FSDのページをNextJSの`pages`フォルダーにインポートすることにあります。これにより、`src`フォルダー内でFSDのプロジェクト構造を維持できます。 + +```sh +├── pages # NextJSのpagesフォルダー +├── src +│ ├── app +│ ├── entities +│ ├── features +│ ├── pages # FSDのpagesフォルダー +│ ├── shared +│ ├── widgets +``` + +### FSD構造における`pages`フォルダーの名前変更 + +もう一つの解決策は、FSD構造内の`pages`層の名前を変更して、NextJSの`pages`フォルダーとの名前衝突を避けることです。 +FSDの`pages`層を`views`層に変更することができます。 +このようにすることで、`src`フォルダー内のプロジェクト構造は、NextJSの要件と矛盾することなく保持されます。 + + +```sh +├── app +├── entities +├── features +├── pages # NextJSのpagesフォルダー +├── views # 名前が変更されたFSDのページフォルダー +├── shared +├── widgets +``` + +この場合、プロジェクトのREADMEや内部ドキュメントなど、目立つ場所にこの名前変更を文書化することをお勧めします。この名前変更は、[「プロジェクト知識」][project-knowledge]の一部です。 + +## NextJSにおける`app`フォルダーの欠如 {#app-absence} + +NextJSのバージョン13未満では、明示的な`app`フォルダーは存在せず、代わりにNextJSは`_app.tsx`ファイルを提供しています。このファイルは、プロジェクトのすべてのページのラッピングコンポーネントとして機能しています。 + +### `pages/_app.tsx`ファイルへの機能のインポート + +NextJSの構造における`app`フォルダーの欠如の問題を解決するために、`app`層内に`App`コンポーネントを作成し、NextJSがそれを使用できるように`pages/_app.tsx`に`App`コンポーネントをインポートすることができます。例えば + + +```tsx +// app/providers/index.tsx + +const App = ({ Component, pageProps }: AppProps) => { + return ( + + + + + + + + ); +}; + +export default App; +``` +その後、`App`コンポーネントとプロジェクトのグローバルスタイルを`pages/_app.tsx`に次のようにインポートできます。 + +```tsx +// pages/_app.tsx + +import 'app/styles/index.scss' + +export { default } from 'app/providers'; +``` + + +## App Routerの使用 {#app-router} + +App Routerは、Next.jsのバージョン13.4で安定版として登場しました。App Routerを使用すると、`pages`フォルダーの代わりに`app`フォルダーをルーティングに使用できます。 +FSDの原則に従うために、NextJSの`app`フォルダーを`pages`フォルダーとの名前衝突を解消するために推奨される方法で扱うべきです。 + +このアプローチは、NextJSの`app`フォルダーをプロジェクトのルートフォルダーに移動し、FSDのページをNextJSの`app`フォルダーにインポートすることに基づいています。これにより、`src`フォルダー内のFSDプロジェクト構造が保持されます。また、プロジェクトのルートフォルダーに`pages`フォルダーを追加することもお勧めします。なぜなら、App RouterはPages Routerと互換性があるからです。 + +``` +├── app # NextJSのappフォルダー +├── pages # 空のNextJSのpagesフォルダー +│ ├── README.md # このフォルダーの目的に関する説明 +├── src +│ ├── app # FSDのappフォルダー +│ ├── entities +│ ├── features +│ ├── pages # FSDのpagesフォルダー +│ ├── shared +│ ├── widgets +``` + +[![StackBlitzで開く](https://developer.stackblitz.com/img/open_in_stackblitz.svg)][ext-app-router-stackblitz] + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md \ No newline at end of file diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..f3ec6eacb1 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,178 @@ +--- +sidebar_position: 10 +--- +# NuxtJSとの併用 + +NuxtJSプロジェクトでFSDを実装することは可能ですが、NuxtJSのプロジェクト構造要件とFSDの原則の違いにより、以下の2点でコンフリクトが発生してしまいます。 + +- NuxtJSは`src`フォルダーなしでプロジェクトのファイル構造を提供している。つまり、ファイル構造がプロジェクトのルートに配置される。 +- ファイルルーティングは`pages`フォルダーにあるが、FSDではこのフォルダーはフラットなスライス構造に割り当てられている。 + +## `src`ディレクトリのエイリアスを追加する + +設定ファイルに`alias`オブジェクトを追加します。 +```ts +export default defineNuxtConfig({ + devtools: { enabled: true }, // FSDには関係なく、プロジェクト起動時に有効 + alias: { + "@": '../src' + }, +}) +``` +## ルーター設定方法の選択 + +NuxtJSには、コンフィグを使用する方法とファイル構造を使用する方法の2つのルーティング設定方法があります。 +ファイルベースのルーティングの場合、`app/routes`ディレクトリ内に`index.vue`ファイルを作成します。一方、コンフィグを使用する場合は、`router.options.ts`ファイルでルートを設定します。 + +### コンフィグによるルーティング + +`app`層に`router.options.ts`ファイルを作成し、設定オブジェクトをエクスポートします。 +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; + +``` + +プロジェクトにホームページを追加するには、次の手順を行います。 +- `pages`層内にページスライスを追加する +- `app/router.config.ts`のコンフィグに適切なルートを追加する + +ページスライスを作成するには、[CLI](https://github.com/feature-sliced/cli)を使用します。 + +```shell +fsd pages home +``` + +`home-page.vue`ファイルを`ui`セグメント内に作成し、公開APIを介してアクセスできるようにします。 + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +このように、ファイル構造は次のようになります。 +```sh +|── src +│ ├── app +│ │ ├── router.config.ts +│ ├── pages +│ │ ├── home +│ │ │ ├── ui +│ │ │ │ ├── home-page.vue +│ │ │ ├── index.ts +``` +最後に、ルートをコンフィグに追加します。 + +```ts title="app/router.config.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### ファイルルーティング + +まず、プロジェクトのルートに`src`ディレクトリを作成し、その中に`app`層と`pages`層のレイヤー、`app`層内に`routes`フォルダーを作成します。 +このように、ファイル構造は次のようになります。 + +```sh +├── src +│ ├── app +│ │ ├── routes +│ ├── pages # FSDに割り当てられたpagesフォルダー +``` + + +NuxtJSが`app`層内の`routes`フォルダーをファイルルーティングに使用するには、`nuxt.config.ts`を次のように変更します。 +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // FSDには関係なく、プロジェクト起動時に有効 + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + + +これで、`app`層内のページに対してルートを作成し、`pages`層からページを接続できます。 + +例えば、プロジェクトに`Home`ページを追加するには、次の手順を行います。 +- `pages`層内にページスライスを追加する +- `app`層内に適切なルートを追加する +- スライスのページをルートに接続する + +ページスライスを作成するには、[CLI](https://github.com/feature-sliced/cli)を使用します。 +```shell +fsd pages home +``` + + +`home-page.vue`ファイルを`ui`セグメント内に作成し、公開APIを介してアクセスできるようにします。  + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +このページのルートを`app`層内に作成します。 + +```sh + +├── src +│ ├── app +│ │ ├── routes +│ │ │ ├── index.vue +│ ├── pages +│ │ ├── home +│ │ │ ├── ui +│ │ │ │ ├── home-page.vue +│ │ │ ├── index.ts +``` + +`index.vue`ファイル内にページコンポーネントを追加します。 + +```html title="src/app/routes/index.vue" + + + +``` + +## `layouts`について + +`layouts`は`app`層内に配置できます。そのためには、コンフィグを次のように変更します。 + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // FSDには関係なく、プロジェクト起動時に有効 + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## 参照 + +- [NuxtJSのディレクトリ設定変更に関するドキュメント](https://nuxt.com/docs/api/nuxt-config#dir) +- [NuxtJSのルーター設定変更に関するドキュメント](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [NuxtJSのエイリアス設定変更に関するドキュメント](https://nuxt.com/docs/api/nuxt-config#alias) + diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..991666764a --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx @@ -0,0 +1,435 @@ +--- +sidebar_position: 10 +--- + +# React Queryとの併用 + +## キーをどこに置くか問題 + +### 解決策 - エンティティごとに分割する + +プロジェクトにすでにエンティティの分割があり、各クエリが1つのエンティティに対応している場合、エンティティごとに分割するのが最良です。この場合、次の構造を使用することをお勧めします。 + +```sh +└── src/ # + ├── app/ # + | ... # + ├── pages/ # + | ... # + ├── entities/ # + | ├── {entity}/ # + | ... └── api/ # + | ├── `{entity}.query` # クエリファクトリー、キーと関数が定義されている + | ├── `get-{entity}` # エンティティを取得する関数 + | ├── `create-{entity}` # エンティティを作成する関数 + | ├── `update-{entity}` # オブジェクトを更新する関数 + | ├── `delete-{entity}` # オブジェクトを削除する関数 + | ... # + | # + ├── features/ # + | ... # + ├── widgets/ # + | ... # + └── shared/ # + ... # +``` + +もしエンティティ間に関係がある場合(例えば、「国」のエンティティに「都市」のエンティティ一覧フィールドがある場合)、`@x` アノテーションを使用した組織的なクロスインポートの[クロスインポート用のパブリックAPI][public-api-for-cross-imports]を利用するか、以下の代替案を検討できます。 + +### 代替案 — クエリを公開で保存する + +エンティティごとの分割が適さない場合、次の構造を考慮できます。 + +```sh +└── src/ # + ... # + └── shared/ # + ├── api/ # + ... ├── `queries` # クエリファクトリー + | ├── `document.ts` # + | ├── `background-jobs.ts` # + | ... # + └── index.ts # +``` + +次に、`@/shared/api/index.ts`に + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## 問題「ミューテーションはどこに?」 + +ミューテーションをクエリと混合することは推奨されません。2つの選択肢が考えられます。 + +### 1. 使用場所の近くにAPIセグメントにカスタムフックを定義する + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. 別の場所(Shared層やEntities層)にミューテーション関数を定義し、コンポーネント内で`useMutation`を直接使用する + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## クエリの組織化 + +### クエリファクトリー + +このガイドでは、クエリファクトリーの使い方について説明します。 + +:::note +クエリファクトリーとは、JSオブジェクトのことで、そのオブジェクトキーの値がクエリキー一覧を返す関数である。 +::: + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + +:::info +`queryOptions` - react-query@v5に組み込まれたユーティリティ(オプション) + +```ts +queryOptions({ + queryKey, + ...options, +}); +``` + +より高い型安全性と将来のreact-queryのバージョンとの互換性を確保し、クエリの関数やキーへのアクセスを簡素化するために、`@tanstack/react-query`の`queryOptions`関数を使用することができる[(詳細はこちら)](https://tkdodo.eu/blog/the-query-options-api#queryoptions)。 + +::: + + +### 1. クエリファクトリーの作成 + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. アプリケーションコードでのクエリファクトリーの適用 + +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### クエリファクトリーを使用する利点 +- **クエリの構造化:** ファクトリーはすべてのAPIクエリを1か所に整理し、コードをより読みやすく、保守しやすくしている +- **クエリとキーへの便利なアクセス:** ファクトリーはさまざまなタイプのクエリとそのキーへの便利なメソッドを提供している +- **クエリの再フェッチ機能:** ファクトリーは、アプリケーションのさまざまな部分でクエリキーを変更することなく、簡単に再フェッチを行うことを可能にしている + +## ページネーション + +このセクションでは、ページネーションを使用して投稿エンティティを取得するためのAPIクエリを行う`getPosts`関数の例を挙げます。 + +### 1. `getPosts`関数の作成 + +`getPosts`関数は、APIセグメント内の`get-posts.ts`ファイルにあります。 + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. ページネーション用のクエリファクトリー + +`postQueries`クエリファクトリーは、投稿に関するさまざまなクエリオプションを定義し、事前に定義されたページとリミットを使用して投稿一覧を取得するクエリを含みます。 + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. アプリケーションコードでの使用 + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` +:::note +例は簡略化されている。 +::: + +## クエリ管理用の`QueryProvider` + +このガイドでは、`QueryProvider`をどのように構成するべきかを説明します。 + +### 1. `QueryProvider`の作成 + +`query-provider.tsx`ファイルは`@/app/providers/query-provider.tsx`にあります。 + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. `QueryClient`の作成 + +`QueryClient`はAPIクエリを管理するために使用されるインスタンスです。`query-client.ts`ファイルは`@/shared/api/query-client.ts`にあります。`QueryClient`はクエリキャッシング用の特定の設定で作成されます。 + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## コード生成 + +自動コード生成のためのツールが存在しますが、これらは上記のように設定可能なものと比較して柔軟性が低いです。Swaggerファイルが適切に構造化されている場合、これらのツールの1つを使用して`@/shared/api`ディレクトリ内のすべてのコードを生成することができます。 + +## RQの整理に関する追加のアドバイス + +### APIクライアント + +共有層であるshared層でカスタムのAPIクライアントクラスを使用することで、プロジェクト内でのAPI設定やAPI操作を標準化できます。これにより、ログ記録、ヘッダー、およびデータ交換形式(例: JSONやXML)を一元管理することができます。このアプローチにより、APIとの連携の変更や更新が簡単になり、プロジェクトのメンテナンスや開発が容易になります。 + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## 参照 {#see-also} + +- [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..17460fac88 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx @@ -0,0 +1,96 @@ +--- +sidebar_position: 10 +--- +# SvelteKitとの併用 + +SvelteKitプロジェクトでFSDを実装することは可能ですが、SvelteKitのプロジェクト構造要件とFSDの原則の違いにより、以下の2点でコンフリクトが発生してしまいます。 + +- SvelteKitは`src/routes`フォルダー内でファイル構造を作成することを提案しているが、FSDではルーティングは`app`層の一部である必要がある +- SvelteKitは、ルーティングに関係のないすべてのものを`src/lib`フォルダーに入れることを提案している + +## コンフィグファイルの設定 + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // ルーティングをapp層内に移動 + lib: 'src', + appTemplate: 'src/app/index.html', // アプリケーションのエントリーポイントをapp層内に移動 + assets: 'public' + }, + alias: { + '@/*': 'src/*' // srcディレクトリのエイリアスを作成 + } + } +}; +export default config; +``` + +## `src/app`内へのファイルルーティングの移動 + +`app`層を作成し、アプリケーションのエントリーポイントである`index.html`を移動し、`routes`フォルダーを作成します。 +最終的にファイル構造は次のようになります。 + +```sh +├── src +│ ├── app +│ │ ├── index.html +│ │ ├── routes +│ ├── pages # FSDに割り当てられたpagesフォルダー +``` + +これで、`app`内にページのルートを作成したり、`pages`からのページをルートに接続したりできます。 + +例えば、プロジェクトにホームページを追加するには、次の手順を実行します。 +- `pages`層内にホームページスライスを追加する +- `app`層の`routes`フォルダーに対応するルートを追加する +- スライスのページとルートを統合する + +ホームページスライスを作成するには、[CLI](https://github.com/feature-sliced/cli)を使用します。 + +```shell +fsd pages home +``` + +`ui`セグメント内に`home-page.svelte`ファイルを作成し、公開APIを介してアクセスできるようにします。  + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page.svelte'; +``` + +このページのルートを`app`層内に作成します。 + +```sh + +├── src +│ ├── app +│ │ ├── routes +│ │ │ ├── +page.svelte +│ │ ├── index.html +│ ├── pages +│ │ ├── home +│ │ │ ├── ui +│ │ │ │ ├── home-page.svelte +│ │ │ ├── index.ts +``` + +最後に`+page.svelte`ファイル内にページコンポーネントを追加します。 + +```html title="src/app/routes/+page.svelte" + + + + +``` + +## 参照 +- [SvelteKitのディレクトリ設定変更に関するドキュメント](https://kit.svelte.dev/docs/configuration#files) \ No newline at end of file diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/intro.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/intro.mdx new file mode 100644 index 0000000000..738b12efaf --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/intro.mdx @@ -0,0 +1,69 @@ +--- +sidebar_position: 1 +slug: / +pagination_next: get-started/index +--- + +# ドキュメント + +![feature-sliced-banner](/img/banner.jpg) + +**Feature-Sliced Design** (FSD) とは、フロントエンドアプリケーションの設計方法論です。簡単に言えば、コードを整理するためのルールと規約の集大成です。FSDの主な目的は、ビジネス要件が絶えず変化する中で、プロジェクトをより理解しやすく、構造化されたものにすることです。 + +import NavCard from "@site/src/shared/ui/nav-card/tmpl.mdx" +import { RocketOutlined, ThunderboltOutlined, FundViewOutlined } from "@ant-design/icons"; +import Link from "@docusaurus/Link"; + + + + + +
+ + + + + + + diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/reference/index.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/reference/index.mdx new file mode 100644 index 0000000000..c5b2c63852 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/reference/index.mdx @@ -0,0 +1,32 @@ +--- +hide_table_of_contents: true +pagination_prev: guides/index +--- + +import NavCard from "@site/src/shared/ui/nav-card/tmpl.mdx" +import { ApiOutlined, GroupOutlined, AppstoreOutlined, NodeIndexOutlined } from "@ant-design/icons"; + +# 📚 参考書 + +

+FSDの重要な概念に関するセクション +

+ + + + diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/reference/layers.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/reference/layers.mdx new file mode 100644 index 0000000000..e2746fbe25 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/reference/layers.mdx @@ -0,0 +1,153 @@ +--- +sidebar_position: 1 +pagination_next: reference/slices-segments +--- + +# レイヤー + +レイヤーは、Feature-Sliced Designにおける組織階層の最初のレベルです。その目的は、コードを責任の程度やアプリ内の他のモジュールへの依存度に基づいて分離することです。各レイヤーは、コードにどれだけの責任を割り当てるべきかを判断するための特別な意味を持っています。 + +合計で**7つのレイヤー**があり、責任と依存度が最も高いものから最も低いものへと配置されています。 + +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. + + +1. App (アップ) +2. Processes (プロセス、非推奨) +3. Pages (ページ) +4. Widgets (ウィジェット) +5. Features (フィーチャー) +6. Entities (エンティティ) +7. Shared (シェアード) + +プロジェクトにすべてのレイヤーを使用する必要はありません。プロジェクトに価値をもたらすと思う場合のみ追加してください。通常、ほとんどのフロントエンドプロジェクトには、少なくともShared、Page、Appのレイヤーがあります。 + +実際には、レイヤーは小文字の名前のフォルダーです(例えば、`📁 shared`、`📁 pages`、`📁 app`)。新しいレイヤーを追加することは推奨されていません。なぜなら、その意味は標準化されているからです。 + +## レイヤーに関するインポートルール + +レイヤーは _スライス_ で構成されており、これは非常に凝集性の高いモジュールのグループです。スライス間の依存関係は、**レイヤーに関するインポートルール**によって規制されています。 + +> _スライス内のモジュール(ファイル)は、下位のレイヤーにある他のスライスのみをインポートできます。_ + +例えば、フォルダー `📁 ~/features/aaa` は「aaa」という名前のスライスです。その中のファイル `~/features/aaa/api/request.ts` は、`📁 ~/features/bbb` 内のファイルからコードをインポートすることはできませんが、`📁 ~/entities` や `📁 ~/shared` からコードをインポートすることができ、例えば `~/features/aaa/lib/cache.ts` などの同じスライス内の隣接コードもインポートできます。 + +AppとSharedのレイヤーは、このルールの**例外**です。これらは同時にレイヤーとスライスの両方です。スライスはビジネスドメインによってコードを分割しますが、これらの2つのレイヤーは例外です。なぜなら、Sharedはビジネスドメインを持たず、Appはすべてのビジネスドメインを統合しているからです。 + +実際には、AppとSharedのレイヤーはセグメントで構成されており、セグメントは自由に互いにインポートできます。 + +## レイヤーの定義 + +このセクションでは、各レイヤーの意味を説明し、どのようなコードがそこに属するかの直感を得るためのものです。 + +### Shared + +このレイヤーは、アプリの残りの部分の基盤を形成します。外部世界との接続を作成する場所であり、例えばバックエンド、サードパーティライブラリ、環境などです。また、特定のタスクに集中した自分自身のライブラリを作成する場所でもあります。 + +このレイヤーは、Appレイヤーと同様に、_スライスを含みません_。スライスはビジネスドメインによってレイヤーを分割することを目的としていますが、Sharedにはビジネスドメインが存在しないため、Shared内のすべてのファイルは互いに参照し、インポートすることができます。 + +このレイヤーで通常見られるセグメントは次のとおりです。 + +- `📁 api` — APIクライアントおよび特定のバックエンドエンドポイントへのリクエストを行う関数 +- `📁 ui` — アプリケーションのUIキット + このレイヤーのコンポーネントはビジネスロジックを含むべきではありませんが、ビジネスに関連することは許可されています。例えば、会社のロゴやページレイアウトをここに置くことができます。UIロジックを持つコンポーネントも許可されています(例えば、オートコンプリートや検索バー) +- `📁 lib` — 内部ライブラリのコレクション + このフォルダーはヘルパーやユーティリティとして扱うべきではありません([なぜこれらのフォルダーがしばしばダンプに変わるか][ext-sova-utility-dump])。このフォルダー内の各ライブラリは、日付、色、テキスト操作など、1つの焦点を持つべきです。その焦点はREADMEファイルに文書化されるべきです。チームの開発者は、これらのライブラリに何を追加でき、何を追加できないかを知っているべきです +- `📁 config` — 環境変数、グローバルフィーチャーフラグ、アプリの他のグローバル設定 +- `📁 routes` — ルート定数、またはルートをマッチさせるためのパターン +- `📁 i18n` — 翻訳のセットアップコード、グローバル翻訳文字列 + +さらにセグメントを追加することは自由ですが、これらのセグメントの名前は内容の目的を説明するものでなければなりません。例えば、`components`、`hooks`、`types` は、コードを探しているときにあまり役立たないため、悪いセグメント名です。 + +### Entities + +このレイヤーのスライスは、プロジェクトが扱う現実世界の概念を表します。一般的には、ビジネスがプロダクトを説明するために使用する用語です。例えば、SNSは、ユーザー、投稿、グループなどのビジネスエンティティを扱うかもしれません。 + +エンティティスライスには、データストレージ(`📁 model`)、データ検証スキーマ(`📁 model`)、エンティティ関連のAPIリクエスト関数(`📁 api`)、およびインターフェース内のこのエンティティの視覚的表現(`📁 ui`)が含まれる場合があります。視覚的表現は、完全なUIブロックを生成する必要はなく、アプリ内の複数のページで同じ外観を再利用することを主に目的としています。異なるビジネスロジックは、プロップスやスロットを通じてそれに付加されることがあります。 + +#### エンティティの関係 + +FSDにおけるエンティティはスライスであり、デフォルトではスライスは互いに知ることができません。しかし、現実の世界では、エンティティはしばしば互いに相互作用し、一方のエンティティが他のエンティティを所有、または含むことがあります。そのため、これらの相互作用のビジネスロジックは、フィーチャーやページのような上位のレイヤーに保持されるのが望ましいです。 + +一つのエンティティのデータオブジェクトが他のデータオブジェクトを含む場合、通常はエンティティ間の接続を明示的にし、`@x`表記を使用してスライスの隔離を回避するのが良いアイデアです。理由は、接続されたエンティティは一緒にリファクタリングする必要があるため、その接続を見逃すことができないようにするのが最善です。 + +例: + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +`@x`表記の詳細については、[クロスインポートの公開API][public-api-for-cross-imports]セクションを参照してください。 + +### Features + +このレイヤーは、アプリ内の主要なインタラクション、つまりユーザーが行いたいことを対象としています。これらのインタラクションは、ビジネスエンティティを含むことが多いです。 + +アプリのフィーチャーレイヤーを効果的に使用するための重要な原則は、**すべてのものがフィーチャーである必要はない**ということです。何かがフィーチャーである必要がある良い指標は、それが複数のページで再利用されるという事実です。 + +例えば、アプリに複数のエディターがあり、すべてにコメントがある場合、コメントは再利用されるフィーチャーです。スライスはコードを迅速に見つけるためのメカニズムであり、フィーチャーが多すぎると重要なものが埋もれてしまいます。 + +理想的には、新しいプロジェクトに入ったとき、既存のページやフィーチャーを見ると、アプリの機能性が分かります。何がフィーチャーであるべきかを決定する際には、プロジェクトの新参者が重要なコードの大きな領域を迅速に発見できるように最適化してください。 + +フィーチャーのスライスには、インタラクションを実行するためのUI(例えばフォーム、`📁 ui`)、アクションを実行するために必要なAPI呼び出し(`📁 api`)、検証および内部状態(`📁 model`)、フィーチャーフラグ(`📁 config`)が含まれる場合があります。 + +### Widgets + +ウィジェットレイヤーは、大きな自己完結型のUIブロックを対象としています。ウィジェットは、複数のページで再利用される場合や、所属するページにある複数の大きな独立したブロックの一つである場合に最も便利です。 + +UIのブロックがページの大部分を構成し、再利用されない場合、それは**ウィジェットであるべきではなく**、代わりにそのページ内に直接配置するべきです。 + +:::tip + +ネストされたルーティングシステム([Remix][ext-remix]のルーターのような)を使用している場合、ウィジェットレイヤーを、フラットなルーティングシステムがページレイヤーを使用するのと同じように使用することが役立つかもしれません。それは関連データの取得、ローディング状態、エラーバウンダリを含む完全なルーターブロックを作成するためです。 + +同様に、このレイヤーにページレイアウトを保存することもできます。 + +::: + +### Pages + +ページは、ウェブサイトやアプリケーションを構成するものです(スクリーンやアクティビティとも呼ばれます)。通常、1ページは1つのスライスに対応しますが、非常に似たページが複数ある場合、それらを1つのスライスにまとめることができます。例えば、登録フォームとログインフォームです。 + +チームがナビゲートしやすい限り、ページスライスに配置できるコードの量に制限はありません。ページ上のUIブロックが再利用されない場合、それをページスライス内に保持することは完全に問題ありません。 + +ページスライスには、通常、ページのUIやローディング状態、エラーバウンダリ(`📁 ui`)、データの取得や変更リクエスト(`📁 api`)が含まれます。ページが専用のデータモデルを持つことは一般的ではなく、状態の小さな部分はコンポーネント自体に保持されることがあります。 + +### Processes + +:::caution + +このレイヤーは非推奨です。現在の仕様では、これを避け、その内容をFeaturesやAppに移動することを推奨しています。 + +::: + +プロセスは、マルチページインタラクションのための逃げ道です。 + +このレイヤーは意図的に未定義のままにされています。ほとんどのアプリケーションはこのレイヤーを使用せず、ルーターやサーバーレベルのロジックをAppレイヤーに保持するべきです。このレイヤーは、Appレイヤーが大きくなりすぎてメンテナンスが困難になった場合にのみ使用することを検討してください。 + +### App + +アプリ全体に関するあらゆるもの、技術的な意味(例えば、コンテキストプロバイダー)やビジネス的な意味(例えば、分析)を含みます。 + +このレイヤーには通常、スライスは含まれず、Sharedと同様に、セグメントが直接存在します。 + +このレイヤーで通常見られるセグメントは次のとおりです。 + +- `📁 routes` — ルーターの設定 +- `📁 store` — グローバルストアの設定 +- `📁 styles` — グローバルスタイル +- `📁 entrypoint` — アプリケーションコードへのエントリポイント、フレームワーク固有 + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/reference/public-api.md b/i18n/ja/docusaurus-plugin-content-docs/current/reference/public-api.md new file mode 100644 index 0000000000..3b6ee63517 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/reference/public-api.md @@ -0,0 +1,159 @@ +--- +sidebar_position: 3 +--- + +# 公開API + +公開APIは、モジュールのグループ(スライスなど)とそれを使用するコードとの間の**契約**です。また、特定のオブジェクトへのアクセスを制限し、その公開APIを通じてのみアクセスを許可します。 + +実際には、通常、再エクスポートを伴うインデックスファイルとして実装されます。 + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## 良い公開APIとは? + +良い公開APIは、他のコードとの統合を便利で信頼性の高いものにします。これを達成するためには、以下の3つの目標を設定することが重要です。 + +1. アプリケーションの残りの部分は、スライスの構造的変更(リファクタリングなど)から保護されるべきです。 +2. スライスの動作における重要な変更が以前の期待を破る場合、公開APIに変更が必要です。 +3. スライスの必要な部分のみを公開するべきです。 + +最後の目標には重要な実践的な意味があります。特にスライスの初期開発段階では、すべてをワイルドカードで再エクスポートしたくなるかもしれません。なぜなら、ファイルからエクスポートする新しいオブジェクトは、スライスからも自動的にエクスポートされるからです。 + + +```js title="バッドプラクティス, features/comments/index.js" +// ❌ これは悪いコードの例です。このようにしないでください。 +export * from "./ui/Comment"; // 👎 +export * from "./model/comments"; // 💩 +``` + +これは、スライスの理解可能性を損ないます。インターフェースが理解できないと、スライスのコードを深く掘り下げて統合方法を調べなければいけなくなってしまいます。もう一つの問題は、モジュールの内部を誤って公開してしまう可能性があり、誰かがそれに依存し始めるとリファクタリングが難しくなることです。 + +## クロスインポートのための公開API {#public-api-for-cross-imports} + +クロスインポートは、同じレイヤーの別のスライスからインポートする状況です。通常、これは[レイヤーに関するインポートルール][import-rule-on-layers]によって禁止されていますが、しばしば正当な理由でクロスインポートが必要です。たとえば、ビジネスエンティティは現実世界で互いに参照し合うことが多く、これらの関係をコードに反映させるのが最善です。 + +この目的のために、`@x`表記として知られる特別な種類の公開APIがあります。エンティティAとBがあり、エンティティBがエンティティAからインポートする必要がある場合、エンティティAはエンティティB専用の別の公開APIを宣言できます。 + +- `📂 entities` + - `📂 A` + - `📂 @x` + - `📄 B.ts` — エンティティB内のコード専用の特別な公開API + - `📄 index.ts` — 通常の公開API + +その後、`entities/B/`内のコードは`entities/A/@x/B`からインポートできます。 + +```ts +import type { EntityA } from "entities/A/@x/B"; +``` + + +`A/@x/B`という表記は「AとBが交差している」と読むことを意図しています。 + +:::note + +クロスインポートは最小限に抑え、**この表記はエンティティレイヤーでのみ使用してください**。クロスインポートを排除することがしばしば非現実的だからです。 + +::: + +## インデックスファイルの問題 + +`index.js`のようなインデックスファイル(Barrelファイルとも呼ばれる)は、公開APIを定義する最も一般的な方法です。作成は簡単ですが、特定のバンドラーやフレームワークで問題を引き起こすことがあります。 + +### 循環インポート + +循環インポートとは、2つ以上のファイルが互いに循環的にインポートすることです。 + + + +
+ 三つのファイルが循環的にインポートしている + 三つのファイルが循環的にインポートしている +
+ 上の図には、`fileA.js`、`fileB.js`、`fileC.js`の三つのファイルが循環的にインポートしている様子が示されています。 +
+
+ +これらの状況は、バンドラーにとって扱いが難しく、場合によってはデバッグが難しいランタイムエラーを引き起こすことさえあります。 + +循環インポートはインデックスファイルなしでも発生する可能性がありますが、インデックスファイルがあると、循環インポートを誤って作成する明確な機会が生まれます。これは、公開APIのスライスに2つのオブジェクト(例えば、`HomePage`と`loadUserStatistics`)が存在し、`HomePage`が`loadUserStatistics`に以下のようにアクセスすると循環インポートが発生してしまいます。 + + +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // pages/home/index.jsからインポート + +export function HomePage() { /* … */ } +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + + +この状況は循環インポートを作成します。なぜなら、`index.js`が`ui/HomePage.jsx`をインポートしますが、`ui/HomePage.jsx`が`index.js`をインポートするからです。 + +この問題を防ぐために、次の2つの原則を考慮してください。 +- ファイルが同じスライス内にある場合は、常に**相対インポート**を使用し、完全なインポートパスを記述すること +- ファイルが異なるスライスにある場合は、常に**絶対インポート**を使用すること(エイリアスなどで) + +### Sharedにおける大きなバンドルサイズと壊れたツリーシェイキング {#large-bundles} + +インデックスファイルがすべてを再エクスポートする場合、いくつかのバンドラーはツリーシェイキング(インポートされていないコードを削除すること)に苦労するかもしれません。 + +通常、これは公開APIにとって問題ではありません。なぜなら、モジュールの内容は通常非常に密接に関連しているため、1つのものをインポートし、他のものをツリーシェイキングする必要がほとんどないからです。しかし、公開APIのルールが問題を引き起こす非常に一般的なケースが2つあります。それは`shared/ui`と`shared/lib`です。 + +これらの2つのフォルダーは、しばしば一度にすべてが必要ではない無関係なもののコレクションです。たとえば、`shared/ui`にはUIライブラリのすべてのコンポーネントのモジュールが含まれているかもしれません。 + +- `📂 shared/ui/` + - `📁 button` + - `📁 text-field` + - `📁 carousel` + - `📁 accordion` + +この問題は、これらのモジュールの1つが重い依存関係(シンタックスハイライトやドラッグ&ドロップライブラリ)を持っている場合、悪化します。ボタンなど、`shared/ui`から何かを使用するすべてのページにそれらを引き込むことは望ましくありません。 + +`shared/ui`や`shared/lib`の単一の公開APIによってバンドルサイズが不適切に増加する場合は、各コンポーネントやライブラリのために別々のインデックスファイルを持つことをお勧めします。 + +- `📂 shared/ui/` + - `📂 button` + - `📄 index.js` + - `📂 text-field` + - `📄 index.js` + +その後、これらのコンポーネントの消費者は、次のように直接インポートできます。 + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from '@/shared/ui/button'; +import { TextField } from '@/shared/ui/text-field'; +``` + + +### 公開APIを回避することに対する実質的な保護がない + +スライスのためにインデックスファイルを作成しても、誰もそれを使用せず、直接インポートを使用することができます。これは特に自動インポートにおいて問題です。なぜなら、オブジェクトをインポートできる場所がいくつかあるため、IDEがあなたのために決定しなければならないからです。時には、直接インポートを選択し、スライスの公開APIルールを破ることがあります。 + +これらの問題を自動的にキャッチするために、[Steiger][ext-steiger]を使用することをお勧めします。これは、Feature-Sliced Designのルールセットを持つアーキテクチャリンターです。 + +### 大規模プロジェクトにおけるバンドラーのパフォーマンスの低下 + +プロジェクト内に大量のインデックスファイルがあると、開発サーバーが遅くなる可能性があります。これは、TkDodoが[「Barrelファイルの使用をやめてください」][ext-please-stop-using-barrel-files]という記事で指摘しています。 + +この問題に対処するためにできることはいくつかあります。 + +1. [「Sharedにおける大きなバンドルサイズと壊れたツリーシェイキング」](#large-bundles)のセクションと同じアドバイス — `shared/ui`や`shared/lib`の各コンポーネントやライブラリのために別々のインデックスファイルを持つこと。 +2. スライスを持つレイヤーのセグメントにインデックスファイルを持たないようにすること。 + たとえば、「コメント」フィーチャーのインデックス(`📄 features/comments/index.js`)がある場合、そのフィーチャーの`ui`セグメントのために別のインデックスを持つ理由はありません(`📄 features/comments/ui/index.js`)。 +3. 非常に大きなプロジェクトがある場合、アプリケーションをいくつかの大きなチャンクに分割できる可能性が高いです。 + たとえば、Google Docsは、ドキュメントエディターとファイルブラウザに非常に異なる責任を持っています。各パッケージが独自のレイヤーセットを持つ別々のFSDルートとしてモノレポを作成できます。いくつかのパッケージは、SharedとEntitiesレイヤーのみを持つことができ、他のパッケージはPagesとAppのみを持つことができ、他のパッケージは独自の小さなSharedを含むことができますが、他のパッケージからの大きなSharedも使用できます。 + + + + + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files \ No newline at end of file diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/reference/slices-segments.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/reference/slices-segments.mdx new file mode 100644 index 0000000000..3250e537cf --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-docs/current/reference/slices-segments.mdx @@ -0,0 +1,71 @@ +--- +title: スライスとセグメント +sidebar_position: 2 +pagination_next: reference/public-api +--- + +# スライスとセグメント + +## スライス + +スライスは、Feature-Sliced Designの組織階層の第2レベルです。主な目的は、プロダクト、ビジネス、または単にアプリケーションにとっての意味に基づいてコードをグループ化することです。 + +スライスの名前は標準化されていません。なぜなら、それらはアプリケーションのビジネス領域によって直接決定されるからです。たとえば、フォトギャラリーには `photo`、`effects`、`gallery-page` というスライスがあるかもしれません。SNSには、`post`、`comments`、`news-feed` などの別のスライスが必要です。 + +Shared層とApp層にはスライスが含まれていません。これは、Sharedがビジネスロジックを含むべきではないため、プロダクト的な意味を持たないからです。また、Appはアプリケーション全体に関わるコードのみを含むべきであり、したがって分割は必要ありません。 + +### 低結合と高凝集 {#zero-coupling-high-cohesion} + +スライスは、独立した強く凝集しているコードファイルのグループとして設計されています。以下の図は、凝集(cohesion)と結合(coupling)といった複雑な概念を視覚化するのに役立ちます。 + +
+ + +
+ この図は、https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ に触発されています。 +
+
+ +理想的なスライスは、同じレベルの他のスライスから独立しており(低結合)、その主な目的に関連するコードの大部分を含んでいます(高凝集)。 + +スライスの独立性は、[層のインポートルール][layers--import-rule]によって保証されます。 + +> _スライス内のモジュール(ファイル)は、厳密に下の層にあるスライスのみをインポートできます。_ + +### スライスの公開APIルール + +スライス内では、コードは自由に整理できますが、スライスが質の高い公開APIを持っている限り、問題はありません。これがスライスの公開APIのルールの本質です。 + +> _各スライス(およびスライスを持たない層のセグメント)は、公開APIの定義を含む必要があります。_ +> +> _あるスライス/セグメントの外部モジュールは、そのスライス/セグメントの内部ファイル構造ではなく、公開APIのみを参照できます。_ + +公開APIの要求の理由や、作成のベストプラクティスについては、[公開APIのガイド][ref-public-api]を参照してください。 + +### スライスのグループ + +密接に関連するスライスは、フォルダに構造的にグループ化できますが、他のスライスと同じ隔離ルールを遵守する必要があります。グループ化用のフォルダ内でのコードの共有は許可されていません。 + +![「compose」、「like」、「delete」機能が「post」フォルダにグループ化されています。このフォルダには、禁止を示すために取り消し線が引かれた「some-shared-code.ts」ファイルもあります。](/img/graphic-nested-slices.svg) + +## セグメント + +セグメントは、組織階層の第3および最後のレベルであり、その目的は、技術的な目的に基づいてコードをグループ化することです。 + +いくつかの標準化されたセグメント名があります。 + +- `ui` — UIに関連するすべてのもの:UIコンポーネント、日付フォーマッタ、スタイルなど。 +- `api` — バックエンドとのインタラクション:リクエスト関数、データ型、マッパーなど。 +- `model` — データモデル:スキーマ、インターフェース、ストレージ、ビジネスロジック。 +- `lib` — スライス内のモジュールに必要なライブラリコード。 +- `config` — 設定ファイルとフィーチャーフラグ。 + +[レイヤーに関するページ][layers--layer-definitions]には、これらのセグメントが異なる層でどのように使用されるかの例があります。 + +独自のセグメントを作成することもできます。カスタムセグメントの最も一般的な場所は、スライスが意味を持たないApp層とShared層です。 + +これらのセグメントの名前が、その内容が何のために必要かを説明するものであることを確認してください。たとえば、`components`、`hooks`、`types` は、コードを探すときにあまり役に立たないため、悪いセグメント名です。 + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/i18n/ja/docusaurus-theme-classic/footer.json b/i18n/ja/docusaurus-theme-classic/footer.json new file mode 100644 index 0000000000..3fe117791d --- /dev/null +++ b/i18n/ja/docusaurus-theme-classic/footer.json @@ -0,0 +1,66 @@ +{ + "link.title.Specs": { + "message": "仕様", + "description": "The title of the footer links column with title=Specs in the footer" + }, + "link.title.Community": { + "message": "コミュニティ", + "description": "The title of the footer links column with title=Community in the footer" + }, + "link.title.More": { + "message": "その他", + "description": "The title of the footer links column with title=More in the footer" + }, + "link.item.label.Documentation": { + "message": "ドキュメント", + "description": "The label of footer link with label=Документация linking to /docs" + }, + "link.item.label.Community": { + "message": "コミュニティ", + "description": "The label of the footer link with label=Community linking to /community" + }, + "link.item.label.Help": { + "message": "ヘルプ", + "description": "The label of the footer link with label=Help linking to /nav" + }, + "link.item.label.Discussions": { + "message": "ディスカッション", + "description": "The label of footer link with label=Обсуждения linking to https://github.com/feature-sliced/documentation/discussions" + }, + "link.item.label.License": { + "message": "ライセンス", + "description": "The label of footer link with label=License linking to LICENSE" + }, + "link.item.label.Contribution Guide (RU)": { + "message": "コントリビューションガイド (RU)", + "description": "The label of footer link with label=Contribution Guide (RU) linking to CONTRIBUTING.md" + }, + "link.item.label.Discord": { + "message": "Discord", + "description": "The label of footer link with label=Discord linking to https://discord.com/invite/S8MzWTUsmp" + }, + "link.item.label.Telegram": { + "message": "Telegram", + "description": "The label of footer link with label=Telegram linking to https://t.me/feature_sliced" + }, + "link.item.label.Twitter": { + "message": "X", + "description": "The label of footer link with label=X linking to https://x.com/feature_sliced" + }, + "link.item.label.Open Collective": { + "message": "Open Collective", + "description": "The label of footer link with label=Open Collective linking to https://opencollective.com/feature-sliced" + }, + "link.item.label.YouTube": { + "message": "YouTube", + "description": "The label of footer link with label=YouTube linking to https://www.youtube.com/c/FeatureSlicedDesign" + }, + "link.item.label.GitHub": { + "message": "GitHub", + "description": "The label of footer link with label=GitHub linking to https://github.com/feature-sliced" + }, + "copyright": { + "message": "Copyright © 2018-2024 Feature-Sliced Design", + "description": "The footer copyright" + } +} diff --git a/i18n/ja/docusaurus-theme-classic/navbar.json b/i18n/ja/docusaurus-theme-classic/navbar.json new file mode 100644 index 0000000000..344f9415ab --- /dev/null +++ b/i18n/ja/docusaurus-theme-classic/navbar.json @@ -0,0 +1,50 @@ +{ + "title": { + "message": "", + "description": "The title in the navbar" + }, + "item.label.🛠 Examples": { + "message": "🛠 実装例", + "description": "Navbar item with label Examples" + }, + "item.label.📖 Docs": { + "message": "📖 ドキュメント", + "description": "Navbar item with label Docs" + }, + "item.label.🔎 Intro": { + "message": "🔎 紹介", + "description": "Navbar item with label Intro" + }, + "item.label.🚀 Get Started": { + "message": "🚀 はじめに", + "description": "Navbar item with label Get Started" + }, + "item.label.🧩 Concepts": { + "message": "🧩 コンセプト", + "description": "Navbar item with label Concepts" + }, + "item.label.🎯 Guides": { + "message": "🎯 ガイド", + "description": "Navbar item with label Guides" + }, + "item.label.📚 Reference": { + "message": "📚 参考書", + "description": "Navbar item with label Reference" + }, + "item.label.🍰 About": { + "message": "🍰 FSD設計方法論について", + "description": "Navbar item with label About" + }, + "item.label.💫 Community": { + "message": "💫 コミュニティ", + "description": "Navbar item with label Community" + }, + "item.label.❔ Help": { + "message": "❔ ヘルプ", + "description": "Navbar item with label Help" + }, + "item.label.📝 Blog": { + "message": "📝 ブログ", + "description": "Navbar item with label Blog" + } +} diff --git a/i18n/kr/code.json b/i18n/kr/code.json index 64379880c6..1cbbc5d536 100644 --- a/i18n/kr/code.json +++ b/i18n/kr/code.json @@ -1,82 +1,82 @@ { "pages.home.features.title": { - "message": "Features", + "message": "특징", "description": "Features" }, "pages.home.features.logic.title": { - "message": "Explicit business logic", + "message": "명시적인 비즈니스 로직", "description": "Feature title" }, "pages.home.features.logic.description": { - "message": "Easily discoverable architecture thanks to domain scopes", + "message": "도메인 스코프 덕분에 찾고자 하는 로직을 쉽게 발견할 수 있는 아키텍처입니다.", "description": "Feature description" }, "pages.home.features.adaptability.title": { - "message": "Adaptability", + "message": "유연성", "description": "Feature title" }, "pages.home.features.adaptability.description": { - "message": "Architecture components can be flexibly replaced and added for new requirements", + "message": "아키텍처 구성 요소를 새로운 요구사항에 맞춰 유연하게 교체하고 추가할 수 있습니다.", "description": "Feature description" }, "pages.home.features.debt.title": { - "message": "Tech debt & Refactoring", + "message": "기술 부채 및 리팩토링", "description": "Feature title" }, "pages.home.features.debt.description": { - "message": "Each module can be independently modified / rewritten without side effects", + "message": "각 모듈을 부작용 없이 독립적으로 수정, 재작성할 수 있습니다.", "description": "Feature description" }, "pages.home.features.shared.title": { - "message": "Explicit code reuse", + "message": "명시적 코드 재사용", "description": "Feature title" }, "pages.home.features.shared.description": { - "message": "A balance is maintained between DRY and local customization", + "message": "DRY 원칙과 로컬 커스터마이징 사이에 균형을 유지합니다.", "description": "Feature description" }, "pages.home.concepts.title": { - "message": "Concepts", + "message": "개념", "description": "Concepts" }, "pages.home.concepts.public.title": { - "message": "Public API", + "message": "공용 API", "description": "Concept title" }, "pages.home.concepts.public.description": { - "message": "Each module must have a declaration of its public API at the top level", + "message": "각 모듈에는 최상위 레벨에 공용 API 선언이 있어야 합니다.", "description": "Concept description" }, "pages.home.concepts.isolation.title": { - "message": "Isolation", + "message": "격리", "description": "Concept title" }, "pages.home.concepts.isolation.description": { - "message": "The module should not depend directly on other modules of the same layer or overlying layers", + "message": "같은 레이어 또는 상위 레이어의 모듈에 직접 의존하지 않아야 합니다.", "description": "Concept description" }, "pages.home.concepts.needs.title": { - "message": "Needs Driven", + "message": "요구사항 중심", "description": "Concept title" }, "pages.home.concepts.needs.description": { - "message": "Orientation to business and user needs", + "message": "비즈니스 및 사용자 요구사항을 중심으로 합니다.", "description": "Concept description" }, "pages.home.scheme.title": { - "message": "Scheme", + "message": "구조", "description": "Scheme" }, "pages.home.companies.using": { - "message": "Companies using FSD", + "message": "FSD를 사용하는 기업", "description": "Companies using FSD" }, "pages.home.companies.add_me": { - "message": "FSD is used in your company?", + "message": "FSD를 사용하는 기업이신가요?", "description": "FSD is used in your company?" }, "pages.home.companies.tell_us": { - "message": "Tell us", + "message": "알려주세요", "description": "Tell us" }, "pages.examples.title": { @@ -192,19 +192,19 @@ "description": "The placeholder for rating stars input" }, "features.hero.tagline": { - "message": "Architectural methodology for frontend projects", + "message": "프론트엔드 프로젝트를 위한 아키텍처 방법론", "description": "Architectural methodology for frontend projects" }, "features.hero.get_started": { - "message": "Get Started", + "message": "시작하기", "description": "Get Started" }, "features.hero.examples": { - "message": "Examples", + "message": "예제", "description": "Examples" }, "features.hero.previous": { - "message": "Previous version", + "message": "이전 버전", "description": "Previous version" }, "shared.wip.title": { diff --git a/i18n/kr/docusaurus-plugin-content-docs/community/index.mdx b/i18n/kr/docusaurus-plugin-content-docs/community/index.mdx new file mode 100644 index 0000000000..bb8a09a1a9 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/community/index.mdx @@ -0,0 +1,41 @@ +--- +hide_table_of_contents: true +--- + +# 💫 커뮤니티 + +

+커뮤니티 리소스, 추가 자료 +

+ +## Main + +import NavCard from "@site/src/shared/ui/nav-card/tmpl.mdx" +import { StarOutlined, SearchOutlined, TeamOutlined, VerifiedOutlined } from "@ant-design/icons"; + + + + + + diff --git a/i18n/kr/docusaurus-plugin-content-docs/community/team.mdx b/i18n/kr/docusaurus-plugin-content-docs/community/team.mdx new file mode 100644 index 0000000000..24ebc84ba9 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/community/team.mdx @@ -0,0 +1,18 @@ +--- +sidebar_class_name: sidebar-item--wip +sidebar_position: 2 +--- + +import WIP from '@site/src/shared/ui/wip/tmpl.mdx' + +# 팀 소개 + + + +## 코어 팀 + +### 챔피언 + +## 기여자 + +## 협력 기업 diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/about/mission.md b/i18n/kr/docusaurus-plugin-content-docs/current/about/mission.md new file mode 100644 index 0000000000..a13527ccc6 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/about/mission.md @@ -0,0 +1,51 @@ +--- +sidebar_position: 1 +--- + +# 미션 + +이 문서에서는 방법론을 개발할 때 우리가 추구하는 목표와 적용 가능성의 한계를 설명합니다. + +- 방법론 개발의 목표는 이념과 단순성 간의 균형을 맞추는 것입니다. +- 모든 사람에게 완벽하게 맞는 만능 해결책을 만들 수는 없습니다. + +**그럼에도, 방법론은 다양한 개발자들에게 접근하기 쉽고 실용적이어야 합니다.** + +## 목표 + +### 다양한 개발자에게 직관적이고 명확하게 + +방법론은 프로젝트에 참여하는 대부분의 팀원들이 쉽게 접근하고 이해할 수 있도록 설계되어야 합니다.
+ +*향후 어떤 도구가 추가되더라도, 시니어나 리더 개발자들만 이해할 수 있는 방법론이라면 충분하지 않습니다.* + +### 일상적인 문제 해결 + +방법론은 개발 프로젝트에서 일상적으로 발생하는 문제에 대해 명확한 이유와 해결책을 제시해야 합니다. + +**이를 위해 CLI와 린터(linter) 같은 도구들도 함께 제공해야 합니다.** + +이를 통해 개발자들은 아키텍처와 개발상의 오랜 문제를 우회할 수 있는 검증된 접근 방식을 활용할 수 있습니다. + +> *@sergeysova: 방법론을 기반으로 코드를 작성하는 개발자는 이미 많은 문제에 대한 해결책이 마련되어 있기 때문에, 문제 발생 빈도가 10배 정도 줄어들 것이라고 상상해보세요.* + +## 한계 + +우리는 *특정 관점을 강요하고* 싶지 않으며, 개발자로서의 *여러 습관이 문제 해결을 방해할 수 있다는 점도 이해합니다.* + +모든 개발자의 시스템을 설계하거나 개발하는 데 경험 수준이 다르기 떄문에, **다음 사항을 이해하는 것이 중요합니다:** + +- **모두에게 동일하게 적용되지 않을 수 있음:**: 너무 간단하거나 명확한 접근법이 모든 상황에서 항상 효과적이지는 않습니다. + > *@sergeysova: 어떤 개념들은 문제를 직접 겪고, 오랜 시간을 들여 해결하는 과정을 통해서만 직관적으로 이해할 수 있는 경우가 많습니다. + > + > - *수학: 그래프 이론.* + > - *물리학: 양자 역학.* + > - *프로그래밍: 애플리케이션 아키텍처.* + +- **가능하고 바람직한 방향**: 단순함과 확장 가능성의 조화 + +## 참고 자료 + +- [아키텍쳐 문제들][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/get-started/faq.md b/i18n/kr/docusaurus-plugin-content-docs/current/get-started/faq.md new file mode 100644 index 0000000000..4b1d8468f4 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/get-started/faq.md @@ -0,0 +1,69 @@ +--- +sidebar_position: 20 +pagination_next: guides/index +--- + +# FAQ + +:::info + +여러분은 [Telegram chat][telegram], [Discord community][discord] 그리고 [GitHub Discussions][github-discussions]에서 질문을 할 수 있습니다. + +::: + +### toolkit이나 linter가 있나요? + +네! 우리는 CLI 또는 IDE를 통해 프로젝트의 아키텍처와 [폴더 생성기][ext-tools]를 확인하기 위한 [Steiger][ext-steiger]라는 linter를 가지고 있습니다. + +### Where to store the layout/template of pages? + +순수한 마크업 레이아웃이 필요하다면 `shared/ui`에 보관할 수 있습니다. 상위 계층을 사용해야 한다면 몇 가지 옵션이 있습니다. + +- 레이아웃이 필요 없을 수도 있습니다. 레이아웃이 몇 줄밖에 안 된다면, 추상화하려고 하기보다는 각 페이지에서 코드를 중복하는 것이 합리적일 수 있습니다. +- 레이아웃이 필요하다면, 별도의 위젯이나 페이지로 만들고 App의 라우터 설정에서 조합할 수 있습니다. 중첩 라우팅도 다른 옵션입니다. + +### feature와 entity의 차이점이 무엇인가요? + +*entity*는 앱이 다루는 실제 개념입니다. *feature*는 앱 사용자에게 실제 가치를 제공하는 상호작용, 즉 사람들이 entity로 하고 싶어하는 것입니다. + +더 자세한 정보와 예시는 [slices][reference-entities] 참조 페이지를 확인하세요. + +### pages/features/entities를 서로 포함시킬 수 있나요? + +네, 하지만 이런 포함은 상위 계층에서 이루어져야 합니다. 예를 들어, 위젯 내부에서 여러 기능을 가져와서 하나의 기능을 다른 기능의 props/children으로 삽입할 수 있습니다. + +한 기능을 다른 기능에서 가져올 수는 없습니다. 이는 [**계층에 대한 가져오기 규칙**][import-rule-layers]에 의해 금지됩니다. + +### 아토믹 디자인은 어떤가요? + +현재 버전의 방법론은 Feature-Sliced Design과 함께 아토믹 디자인을 사용하는 것을 요구하지도, 금지하지도 않습니다. + +예를 들어, 아토믹 디자인은 모듈의 `ui` 세그먼트에 [잘 적용될 수 있습니다](https://t.me/feature_sliced/1653). + +### FSD에 대한 유용한 리소스/기사 등이 있나요? + +네! https://github.com/feature-sliced/awesome 를 참조하세요. + +### Feature-Sliced Design이 왜 필요한가요? + +프로젝트를 주요 가치 창출 구성 요소 측면에서 빠르게 개요를 파악하는 데 도움이 됩니다. 표준화된 아키텍처는 온보딩 속도를 높이고 코드 구조에 대한 논쟁을 해결합니다. FSD가 만들어진 이유에 대해 더 자세히 알아보려면 [동기][motivation] 페이지를 참조하세요. + +### 초보 개발자에게 아키텍처/방법론이 필요한가요? + +그렇다고 볼 수 있습니다. + +*보통 한 사람이 프로젝트를 설계하고 개발할 때는 모든 것이 순조롭게 진행됩니다. 하지만 개발에 중단이 있거나 새로운 개발자가 팀에 합류하면 문제가 발생합니다* + + +### 인증 컨텍스트는 어떻게 다루나요? + +[여기](/docs/guides/examples/auth)에서 답변했습니다. + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/get-started/overview.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/get-started/overview.mdx index 75d7f69a32..23967ce248 100644 --- a/i18n/kr/docusaurus-plugin-content-docs/current/get-started/overview.mdx +++ b/i18n/kr/docusaurus-plugin-content-docs/current/get-started/overview.mdx @@ -65,15 +65,15 @@ FSD는 규모에 관계 없이 모든 팀과 프로젝트에서 사용할 수 레이어는 모든 FSD 프로젝트에서 표준화되어 있습니다. 모든 레이어를 사용할 필요는 없지만, 이름은 중요합니다. 현재(위에서 아래로) 7개가 있습니다: -1. App\* - 앱을 실행하는 모든 것 - 라우팅, 진입점, 전역 스타일, 프로바이더. -2. Processes(더 이상 사용되지 않음) - 페이지 간 복잡한 시나리오. -3. Pages - 전체 페이지 또는 중첩 라우팅에서 페이지의 주요 부분. -4. Widgets - 독립적으로 작동하는 대규모 기능 또는 UI 컴포넌트, 보통 하나의 완전한 기능. -5. Features - 제품 전반에 걸쳐 재사용되는 기능 구현체로, 사용자에게 실질적인 비즈니스 가치를 제공하는 동작. -6. Entities - 프로젝트가 다루는 비즈니스 엔티티, 예를 들어 user 또는 product. -7. Shared* - 재사용 가능한 기능, 특히 프로젝트/비즈니스의 특성과 분리되어 있을 때 (반드시 그럴 필요는 없음). +1. **App\*** - 앱을 실행하는 모든 것 - 라우팅, 진입점, 전역 스타일, 프로바이더. +2. **Processes**(더 이상 사용되지 않음) - 페이지 간 복잡한 시나리오. +3. **Pages** - 전체 페이지 또는 중첩 라우팅에서 페이지의 주요 부분. +4. **Widgets** - 독립적으로 작동하는 대규모 기능 또는 UI 컴포넌트, 보통 하나의 완전한 기능. +5. **Features** - 제품 전반에 걸쳐 재사용되는 기능 구현체로, 사용자에게 실질적인 비즈니스 가치를 제공하는 동작. +6. **Entities** - 프로젝트가 다루는 비즈니스 엔티티, 예를 들어 user 또는 product. +7. **Shared*** - 재사용 가능한 기능, 특히 프로젝트/비즈니스의 특성과 분리되어 있을 때 (반드시 그럴 필요는 없음). -_\* - App과 Shared는 다른 레이어들과 달리 슬라이스를 가지지 않으며, 직접 세그먼트로 구성됩니다._ +_\* - **App**과 **Shared**는 다른 레이어들과 달리 슬라이스를 가지지 않으며, 직접 세그먼트로 구성됩니다._ 레이어를 다룰 때의 중요한 점은 한 레이어의 구성 요소는 반드시 아래에 있는 레이어의 구성 요소만 알수있고 임포트할 수 있다는 것입니다. @@ -130,7 +130,7 @@ FSD로 마이그레이션하고자 하는 기존 코드베이스가 있다면, [tutorial]: /docs/get-started/tutorial [examples]: /examples -[migration]: /docs/guides/migration/from-legacy +[migration]: /docs/guides/migration/from-custom [ext-steiger]: https://github.com/feature-sliced/steiger [ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools [ext-telegram]: https://t.me/feature_sliced diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/get-started/tutorial.md b/i18n/kr/docusaurus-plugin-content-docs/current/get-started/tutorial.md new file mode 100644 index 0000000000..ba8c07b495 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/get-started/tutorial.md @@ -0,0 +1,2270 @@ +--- +sidebar_position: 2 +--- +# 튜토리얼 + +## Part 1. 설계 + +이 튜토리얼에서는 Real World App이라고도 알려진 Conduit를 살펴보겠습니다. Conduit는 기본적인 [Medium](https://medium.com/) 클론입니다 - 글을 읽고 쓸 수 있으며 다른 사람의 글에 댓글을 달 수 있습니다. + +![Conduit home page](/img/tutorial/realworld-feed-anonymous.jpg) + +이 애플리케이션은 매우 작은 애플리케이션이므로 과도한 분해를 피하고 간단하게 유지할 것입니다. 전체 애플리케이션이 세 개의 레이어인 **App**, **Pages**, 그리고 **Shared**에 맞춰 들어갈 것입니다. 그렇지 않다면 우리는 계속해서 추가적인 레이어를 도입할 것입니다. 준비되셨나요? + +### 먼저 페이지를 나열해 봅시다. + +위의 스크린샷을 보면 최소한 다음과 같은 페이지들이 있다고 가정할 수 있습니다: + +- 홈 (글 피드) +- 로그인 및 회원가입 +- 글 읽기 +- 글 편집기 +- 사용자 프로필 보기 +- 사용자 프로필 편집 (사용자 설정) + +이 페이지들 각각은 Pages *레이어*의 독립된 *슬라이스*가 될 것입니다. 개요에서 언급했듯이 슬라이스는 단순히 레이어 내의 폴더이고, 레이어는 `pages`와 같은 미리 정의된 이름을 가진 폴더일 뿐입니다. + +따라서 우리의 Pages 폴더는 다음과 같이 보일 것입니다. + +``` +📂 pages/ + 📁 feed/ + 📁 sign-in/ + 📁 article-read/ + 📁 article-edit/ + 📁 profile/ + 📁 settings/ +``` + +Feature-Sliced Design이 규제되지 않은 코드 구조와 다른 주요 차이점은 페이지들이 서로를 참조할 수 없다는 것입니다. 즉, 한 페이지가 다른 페이지의 코드를 가져올 수 없습니다. 이는 **레이어의 import 규칙** 때문입니다. + +*슬라이스의 모듈은 엄격히 아래에 있는 레이어에 위치한 다른 슬라이스만 가져올 수 있습니다.* + +이 경우 페이지는 슬라이스이므로, 이 페이지 내의 모듈(파일)은 같은 레이어인 Pages가 아닌 아래 레이어의 코드만 참조할 수 있습니다. + +### 피드 자세히 보기 + +
+ ![Anonymous user’s perspective](/img/tutorial/realworld-feed-anonymous.jpg) +
+ _익명 사용자의 관점_ +
+
+ +
+ ![Authenticated user’s perspective](/img/tutorial/realworld-feed-authenticated.jpg) +
+ _인증된 사용자의 관점_ +
+
+ +피드 페이지에는 세 가지 동적 영역이 있습니다. + +1. 로그인 여부를 나타내는 로그인 링크 +2. 피드에서 필터링을 트리거하는 태그 목록 +3. 좋아요 버튼이 있는 하나/두 개의 글 피드 + +로그인 링크는 모든 페이지에 공통적인 헤더의 일부이므로 나중에 따로 다루겠습니다. + +#### 태그 목록 + +태그 목록을 만들기 위해서는 사용 가능한 태그를 가져오고, 각 태그를 칩으로 렌더링하고, 선택된 태그를 클라이언트 측 저장소에 저장해야 합니다. 이러한 작업들은 각각 "API 상호작용", "사용자 인터페이스", "저장소" 카테고리에 속합니다. Feature-Sliced Design에서는 코드를 *세그먼트*를 사용하여 목적별로 분리합니다. 세그먼트는 슬라이스 내의 폴더이며, 목적을 설명하는 임의의 이름을 가질 수 있지만, 일부 목적은 너무 일반적이어서 특정 세그먼트 이름에 대한 규칙이 있습니다. + + +- 📂 `api/` 백엔드 상호작용 +- 📂 `ui/` 렌더링과 외관을 다루는 코드 +- 📂 `model/` 저장소와 비즈니스 로직 +- 📂 `config/` 기능 플래그, 환경 변수 및 기타 구성 형식 + +태그를 가져오는 코드는 `api`에, 태그 컴포넌트는 `ui`에, 저장소 상호작용은 `model`에 배치할 것입니다. + +#### 글 + +같은 그룹화 원칙을 사용하여 글 피드를 같은 세 개의 세그먼트로 분해할 수 있습니다. + +- 📂 `api/`: 좋아요 수가 포함된 페이지네이션된 글 가져오기 +- 📂 `ui/`: + - 태그가 선택된 경우 추가 탭을 렌더링할 수 있는 탭 목록 + - 개별 글 + - 기능적 페이지네이션 +- 📂 `model/`: 현재 로드된 글과 현재 페이지의 클라이언트 측 저장소 (필요한 경우) + +### 일반적인 코드 재사용 + +대부분의 페이지는 의도가 매우 다르지만, 앱 전체에 걸쳐 일부 요소는 동일하게 유지됩니다. 예를 들어, 디자인 언어를 준수하는 UI 키트나 모든 것이 동일한 인증 방식으로 REST API를 통해 수행되는 백엔드의 규칙 등이 있습니다. 슬라이스는 격리되도록 설계되었기 때문에, 코드 재사용은 더 낮은 계층인 **Shared**에 의해 촉진됩니다. + + +Shared는 슬라이스가 아닌 세그먼트를 포함한다는 점에서 다른 계층과 다릅니다. 이런 면에서 Shared 계층은 계층과 슬라이스의 하이브리드로 생각할 수 있습니다. + +일반적으로 Shared의 코드는 미리 계획되지 않고 개발 중에 추출됩니다. 실제로 어떤 코드 부분이 공유되는지는 개발 중에만 명확해지기 때문입니다. 그러나 어떤 종류의 코드가 자연스럽게 Shared에 속하는지 머릿속에 메모해 두는 것은 여전히 도움이 됩니다. + + +- 📂 `ui/` — UI 키트, 비즈니스 로직이 없는 순수한 UI. 예: 버튼, 모달 대화 상자, 폼 입력. +- 📂 `api/` — 요청 생성 기본 요소(예: 웹의 `fetch()`)에 대한 편의 래퍼 및 선택적으로 백엔드 사양에 따라 특정 요청을 트리거하는 함수. +- 📂 `config/` — 환경 변수 파싱 +- 📂 `i18n/` — 언어 지원에 대한 구성 +- 📂 `router/` — 라우팅 기본 요소 및 라우트 상수 + +이는 Shared의 세그먼트 이름의 몇 가지 예시일 뿐이며, 이 중 일부를 생략하거나 자신만의 세그먼트를 만들 수 있습니다. 새로운 세그먼트를 만들 때 기억해야 할 유일한 중요한 점은 세그먼트 이름이 **본질(무엇인지)이 아닌 목적(왜)을 설명해야 한다**는 것입니다. "components", "hooks", "modals"과 같은 이름은 이 파일들이 무엇인지는 설명하지만 내부 코드를 탐색하는 데 도움이 되지 않기 때문에 사용해서는 안 됩니다. 이는 팀원들이 이러한 폴더의 모든 파일을 파헤쳐야 하며, 관련 없는 코드를 가까이 유지하게 되어 리팩토링의 영향을 받는 코드 영역이 넓어지고 결과적으로 코드 리뷰와 테스트를 더 어렵게 만듭니다. + +### 엄격한 공개 API 정의 + +Feature-Sliced Design의 맥락에서 *공개 API*라는 용어는 슬라이스나 세그먼트가 프로젝트의 다른 모듈에서 가져올 수 있는 것을 선언하는 것을 의미합니다. 예를 들어, JavaScript에서는 슬라이스의 다른 파일에서 객체를 다시 내보내는 `index.js` 파일일 수 있습니다. 이를 통해 외부 세계와의 계약(즉, 공개 API)이 동일하게 유지되는 한 슬라이스 내부의 코드를 자유롭게 리팩토링할 수 있습니다. + +슬라이스가 없는 Shared 계층의 경우, Shared의 모든 것에 대한 단일 인덱스를 정의하는 것과 반대로 각 세그먼트에 대해 별도의 공개 API를 정의하는 것이 일반적으로 더 편리합니다. 이렇게 하면 Shared에서의 가져오기가 자연스럽게 의도별로 구성됩니다. 슬라이스가 있는 다른 계층의 경우 반대가 사실입니다 — 일반적으로 슬라이스당 하나의 인덱스를 정의하고 슬라이스가 외부 세계에 알려지지 않은 자체 세그먼트 세트를 결정하도록 하는 것이 더 실용적입니다. 다른 계층은 일반적으로 내보내기가 훨씬 적기 때문입니다. + +우리의 슬라이스/세그먼트는 서로에게 다음과 같이 나타날 것입니다. + +``` +📂 pages/ + 📂 feed/ + 📄 index + 📂 sign-in/ + 📄 index + 📂 article-read/ + 📄 index + 📁 … +📂 shared/ + 📂 ui/ + 📄 index + 📂 api/ + 📄 index + 📁 … +``` + +`pages/feed`나 `shared/ui`와 같은 폴더 내부의 내용은 해당 폴더에만 알려져 있으며, 다른 파일은 이러한 폴더의 내부 구조에 의존해서는 안 됩니다. + + +### UI의 큰 재사용 블록 + +앞서 모든 페이지에 나타나는 헤더를 다시 살펴보기로 했습니다. 모든 페이지에서 처음부터 다시 만드는 것은 비실용적이므로 재사용하고 싶을 것입니다. 우리는 이미 코드 재사용을 용이하게 하는 Shared를 가지고 있지만, Shared에 큰 UI 블록을 넣는 데는 주의할 점이 있습니다 — Shared 계층은 위의 계층에 대해 알지 못해야 합니다. + +Shared와 Pages 사이에는 Entities, Features, Widgets의 세 가지 다른 계층이 있습니다. 일부 프로젝트는 이러한 계층에 큰 재사용 가능한 블록에 필요한 것이 있을 수 있으며, 이는 해당 재사용 가능한 블록을 Shared에 넣을 수 없다는 것을 의미합니다. 그렇지 않으면 상위 계층에서 가져오게 되어 금지됩니다. 이것이 Widgets 계층이 필요한 이유입니다. Widgets는 Shared, Entities, Features 위에 위치하므로 이들 모두를 사용할 수 있습니다. + +우리의 경우, 헤더는 매우 간단합니다 — 정적 로고와 최상위 탐색입니다. 탐색은 사용자가 현재 로그인했는지 여부를 확인하기 위해 API에 요청을 해야 하지만, 이는 `api` 세그먼트에서 간단한 가져오기로 처리할 수 있습니다. 따라서 우리는 헤더를 Shared에 유지할 것입니다. + +### 폼이 있는 페이지 자세히 보기 + +읽기가 아닌 편집을 위한 페이지도 살펴보겠습니다. + +![Conduit post editor](/img/tutorial/realworld-editor-authenticated.jpg) + +간단해 보이지만, 폼 유효성 검사, 오류 상태, 데이터 지속성 등 아직 탐구하지 않은 애플리케이션 개발의 여러 측면을 포함하고 있습니다. + +이 페이지를 만들려면 Shared에서 일부 입력과 버튼을 가져와 이 페이지의 `ui` 세그먼트에서 폼을 구성할 것입니다. 그런 다음 `api` 세그먼트에서 백엔드에 글을 생성하는 변경 요청을 정의할 것입니다. + +요청을 보내기 전에 유효성을 검사하려면 유효성 검사 스키마가 필요하며, 이를 위한 좋은 위치는 데이터 모델이기 때문에 `model` 세그먼트입니다. 여기서 오류 메시지를 생성하고 `ui` 세그먼트의 다른 컴포넌트를 사용하여 표시할 것입니다. + +사용자 경험을 개선하기 위해 우발적인 데이터 손실을 방지하기 위해 입력을 지속시킬 수도 있습니다. 이것도 `model` 세그먼트의 작업입니다. + +### 요약 + +우리는 여러 페이지를 검토하고 애플리케이션의 예비 구조를 개략적으로 설명했습니다. + +1. Shared layer + 1. `ui`는 재사용 가능한 UI 키트를 포함할 것입니다. + 2. `api`는 백엔드와의 기본적인 상호작용을 포함할 것입니다. + 3. 나머지는 필요에 따라 정리될 것입니다. +2. Pages layer — 각 페이지는 별도의 슬라이스입니다. + 1. `ui`는 페이지 자체와 모든 부분을 포함할 것입니다. + 2. `api`는 `shared/api`를 사용하여 더 특화된 데이터 가져오기를 포함할 것입니다. + 3. `model`은 표시할 데이터의 클라이언트 측 저장소를 포함할 수 있습니다. + +이제 코드 작성을 시작해 봅시다! + +## Part 2. 코드 작성 + +이제 설계를 완료했으니 실제로 코드를 작성해 봅시다. React와 [Remix](https://remix.run)를 사용할 것입니다. + +이 프로젝트를 위한 템플릿이 준비되어 있습니다. GitHub에서 클론하여 시작하세요. [https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean). + +`npm install`로 의존성을 설치하고 `npm run dev`로 개발 서버를 시작하세요. [http://localhost:3000](http://localhost:3000)을 열면 빈 앱이 보일 것입니다. + + +### 페이지 레이아웃 + +모든 페이지에 대한 빈 컴포넌트를 만드는 것부터 시작하겠습니다. 프로젝트에서 다음 명령을 실행하세요. + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +이렇게 하면 `pages/feed/ui/`와 같은 폴더와 모든 페이지에 대한 인덱스 파일인 `pages/feed/index.ts`가 생성됩니다. + +### 피드 페이지 연결 + +애플리케이션의 루트 경로를 피드 페이지에 연결해 봅시다. `pages/feed/ui`에 `FeedPage.tsx` 컴포넌트를 만들고 다음 내용을 넣으세요: + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+
+ ); +} +``` + +그런 다음 피드 페이지의 공개 API인 `pages/feed/index.ts` 파일에서 이 컴포넌트를 다시 내보내세요. + +```ts title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +이제 루트 경로에 연결합니다. Remix에서 라우팅은 파일 기반이며, 라우트 파일은 `app/routes` 폴더에 있어 Feature-Sliced Design과 잘 맞습니다. + +`app/routes/_index.tsx`에서 `FeedPage` 컴포넌트를 사용하세요. + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +그런 다음 개발 서버를 실행하고 애플리케이션을 열면 Conduit 배너가 보일 것입니다! + +![The banner of Conduit](/img/tutorial/conduit-banner.jpg) + +### API 클라이언트 + +RealWorld 백엔드와 통신하기 위해 Shared에 편리한 API 클라이언트를 만들어 봅시다. 클라이언트를 위한 `api`와 백엔드 기본 URL과 같은 변수를 위한 `config`, 두 개의 세그먼트를 만드세요. + + +```bash +npx fsd shared --segments api config +``` + +그런 다음 `shared/config/backend.ts`를 만드세요. + +```tsx title="shared/config/backend.ts" +export const backendBaseUrl = "https://api.realworld.io/api"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +RealWorld 프로젝트는 편리하게 [OpenAPI 사양](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml)을 제공하므로, 클라이언트를 위한 자동 생성 타입을 활용할 수 있습니다. 추가 타입 생성기가 포함된 [`openapi-fetch` 패키지](https://openapi-ts.pages.dev/openapi-fetch/)를 사용할 것입니다. + +다음 명령을 실행하여 최신 API 타입을 생성하세요. + +```bash +npm run generate-api-types +``` + +이렇게 하면 `shared/api/v1.d.ts` 파일이 생성됩니다. 이 파일을 사용하여 `shared/api/client.ts`에 타입이 지정된 API 클라이언트를 만들 것입니다. + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### 피드의 실제 데이터 + +이제 백엔드에서 가져온 글을 피드에 추가할 수 있습니다. 글 미리보기 컴포넌트를 구현하는 것부터 시작하겠습니다. + +다음 내용으로 `pages/feed/ui/ArticlePreview.tsx`를 만드세요. + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +TypeScript를 사용하고 있으므로 글 객체에 타입을 지정하면 좋을 것 같습니다. 생성된 `v1.d.ts`를 살펴보면 글 객체가 `components["schemas"]["Article"]`을 통해 사용 가능한 것을 볼 수 있습니다. 그럼 Shared에 데이터 모델이 있는 파일을 만들고 모델을 내보내겠습니다. + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +이제 글 미리보기 컴포넌트로 돌아가 데이터로 마크업을 채울 수 있습니다. 컴포넌트를 다음 내용으로 업데이트하세요. + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +좋아요 버튼은 지금은 아무 작업도 하지 않습니다. 글 읽기 페이지를 만들고 좋아요 기능을 구현할 때 수정하겠습니다. + +이제 글을 가져와서 이러한 카드를 여러 개 렌더링할 수 있습니다. Remix에서 데이터 가져오기는 *로더* — 페이지가 필요로 하는 것을 정확히 가져오는 서버 측 함수 — 를 통해 수행됩니다. 로더는 페이지를 대신하여 API와 상호 작용하므로 페이지의 `api` 세그먼트에 넣을 것입니다: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +페이지에 연결하려면 라우트 파일에서 `loader`라는 이름으로 내보내야 합니다. + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +마지막 단계는 피드에 이러한 카드를 렌더링하는 것입니다. `FeedPage`를 다음 코드로 업데이트하세요. + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### 태그로 필터링 + +태그와 관련해서는 백엔드에서 태그를 가져오고 현재 선택된 태그를 저장해야 합니다. 가져오기 방법은 이미 알고 있습니다 — 로더에서 또 다른 요청을 하면 됩니다. `remix-utils` 패키지에서 `promiseHash`라는 편리한 함수를 사용할 것입니다. 이 패키지는 이미 설치되어 있습니다. + +로더 파일인 `pages/feed/api/loader.ts`를 다음 코드로 업데이트하세요. + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + + +오류 처리를 일반 함수 `throwAnyErrors`로 추출했다는 점에 주목하세요. 꽤 유용해 보이므로 나중에 재사용할 수 있을 것 같습니다. 지금은 그냥 주목해 두겠습니다. + +이제 태그 목록으로 넘어갑시다. 이는 상호작용이 가능해야 합니다 — 태그를 클릭하면 해당 태그가 선택되어야 합니다. Remix 규칙에 따라 URL 검색 매개변수를 선택된 태그의 저장소로 사용할 것입니다. 브라우저가 저장을 처리하게 하고 우리는 더 중요한 일에 집중하겠습니다. + +`pages/feed/ui/FeedPage.tsx`를 다음 코드로 업데이트하세요. + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +그런 다음 로더에서 `tag` 검색 매개변수를 사용해야 합니다. `pages/feed/api/loader.ts`의 `loader` 함수를 다음과 같이 변경하세요. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +이게 전부입니다. `model` 세그먼트가 필요하지 않습니다. Remix는 꽤 깔끔하죠. + +### 페이지네이션 + +비슷한 방식으로 페이지네이션을 구현할 수 있습니다. 직접 시도해 보거나 아래 코드를 복사하세요. 어차피 당신을 판단할 사람은 없습니다. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +이것으로 완료되었습니다. 탭 목록도 비슷하게 구현할 수 있지만, 인증을 구현할 때까지 잠시 보류하겠습니다. 그런데 말이 나왔으니! + +### 인증 + +인증에는 두 개의 페이지가 관련됩니다 - 로그인과 회원가입입니다. 이들은 대부분 동일하므로 필요한 경우 코드를 재사용할 수 있도록 `sign-in`이라는 동일한 슬라이스에 유지하는 것이 합리적입니다. + +`pages/sign-in`의 `ui` 세그먼트에 다음 내용으로 `RegisterPage.tsx`를 만드세요. + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

Sign up

+

+ Have an account? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+ ); +} +``` + +이제 고쳐야 할 깨진 import가 있습니다. 새로운 세그먼트가 필요하므로 다음과 같이 만드세요. + +```bash +npx fsd pages sign-in -s api +``` + +그러나 등록의 백엔드 부분을 구현하기 전에 Remix가 세션을 처리할 수 있도록 일부 인프라 코드가 필요합니다. 다른 페이지에서도 필요할 수 있으므로 이는 Shared로 갑니다. + +다음 코드를 `shared/api/auth.server.ts`에 넣으세요. 이는 Remix에 매우 특화된 것이므로 너무 걱정하지 마세요. 그냥 복사-붙여넣기 하세요. + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7 days + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +그리고 바로 옆에 있는 `models.ts` 파일에서 `User` 모델도 내보내세요. + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +이 코드가 작동하려면 `SESSION_SECRET` 환경 변수를 설정해야 합니다. 프로젝트 루트에 `.env` 파일을 만들고 `SESSION_SECRET=`을 작성한 다음 키보드에서 무작위로 키를 눌러 긴 무작위 문자열을 만드세요. 다음과 같은 결과가 나와야 합니다. + + +```bash title=".env" +SESSION_SECRET=dontyoudarecopypastethis +``` + +마지막으로 이 코드를 사용하기 위해 공개 API에 일부 내보내기를 추가하세요. + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +이제 RealWorld 백엔드와 실제로 통신하여 등록을 수행하는 코드를 작성할 수 있습니다. 그것을 `pages/sign-in/api`에 유지할 것입니다. `register.ts`라는 파일을 만들고 다음 코드를 넣으세요. + + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +거의 다 왔습니다! 페이지와 액션을 `/register` 라우트에 연결하기만 하면 됩니다. `app/routes`에 `register.tsx`를 만드세요. + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +이제 [http://localhost:3000/register](http://localhost:3000/register)로 가면 사용자를 생성할 수 있어야 합니다! 애플리케이션의 나머지 부분은 아직 이에 반응하지 않을 것입니다. 곧 그 문제를 해결하겠습니다. + +매우 유사한 방식으로 로그인 페이지를 구현할 수 있습니다. 직접 시도해 보거나 그냥 코드를 가져와서 계속 진행하세요. + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

Sign in

+

+ Need an account? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ + +
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +이제 사용자가 이 페이지에 실제로 접근할 수 있는 방법을 제공해 봅시다. + +### 헤더 + +1부에서 논의했듯이, 앱 헤더는 일반적으로 Widgets나 Shared에 배치됩니다. 매우 간단하고 모든 비즈니스 로직을 외부에 유지할 수 있기 때문에 Shared에 넣을 것입니다. 이를 위한 장소를 만들어 봅시다. + +```bash +npx fsd shared ui +``` + +이제 다음 내용으로 `shared/ui/Header.tsx`를 만드세요. + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +이 컴포넌트를 `shared/ui`에서 내보내세요. + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +헤더에서는 `shared/api`에 유지되는 컨텍스트에 의존합니다. 그것도 만드세요. + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +이제 페이지에 헤더를 추가해 봅시다. 모든 페이지에 있어야 하므로 루트 라우트에 추가하고 outlet(페이지가 렌더링될 위치)을 `CurrentUser` 컨텍스트 제공자로 감싸는 것이 합리적입니다. 이렇게 하면 전체 앱과 헤더가 현재 사용자 객체에 접근할 수 있습니다. 또한 쿠키에서 실제로 현재 사용자 객체를 가져오는 로더를 추가할 것입니다. `app/root.tsx`에 다음 내용을 넣으세요. + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +이 시점에서 홈 페이지에 다음과 같은 내용이 표시되어야 합니다. + +
+ ![The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.](/img/tutorial/realworld-feed-without-tabs.jpg) + +
헤더, 피드, 태그를 포함한 Conduit의 피드 페이지. 탭은 아직 없습니다.
+
+ +### 탭 + +이제 인증 상태를 감지할 수 있으므로 탭과 글 좋아요를 빠르게 구현하여 피드 페이지를 완성해 봅시다. 또 다른 폼이 필요하지만 이 페이지 파일이 꽤 커지고 있으므로 이러한 폼을 인접한 파일로 옮기겠습니다. `Tabs.tsx`, `PopularTags.tsx`, `Pagination.tsx`를 다음 내용으로 만들 것입니다. + + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+ + ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +이제 `FeedPage`를 다음과 같이 업데이트하세요. + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +마지막으로 로더를 업데이트하여 새로운 필터를 처리하세요. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* unchanged */ +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +피드 페이지를 떠나기 전에, 글에 대한 좋아요를 처리하는 코드를 추가해 봅시다. `ArticlePreview.tsx`를 다음과 같이 변경하세요. + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ + +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +이 코드는 글에 좋아요를 표시하기 위해 `/article/:slug`로 `_action=favorite`과 함께 POST 요청을 보냅니다. 아직 작동하지 않겠지만, 글 읽기 페이지 작업을 시작하면서 이것도 구현할 것입니다. + +이것으로 피드가 공식적으로 완성되었습니다! 야호! + +### 글 읽기 페이지 + +먼저 데이터가 필요합니다. 로더를 만들어 봅시다. + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "Expected a slug parameter"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + + +이제 `/article/:slug` 라우트에 연결할 수 있습니다. `article.$slug.tsx`라는 라우트 파일을 만드세요. + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +페이지 자체는 세 가지 주요 블록으로 구성됩니다 - 글 헤더와 액션(두 번 반복), 글 본문, 댓글 섹션입니다. 다음은 페이지의 마크업입니다. 특별히 흥미로운 내용은 없습니다: + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +더 흥미로운 것은 `ArticleMeta`와 `Comments`입니다. 이들은 글 좋아요, 댓글 작성 등과 같은 쓰기 작업을 포함합니다. 이들을 작동시키려면 먼저 백엔드 부분을 구현해야 합니다. 페이지의 `api` 세그먼트에 `action.ts`를 만드세요: + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "Expected a slug parameter"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "Expected a slug parameter"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "Expected a comment parameter"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "Expected a slug parameter"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "Expected an id parameter"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "Expected a numeric id parameter", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +그 슬라이스에서 이를 내보내고 라우트에서도 내보내세요. 그리고 페이지 자체도 연결하겠습니다. + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +이제 독자 페이지에서 좋아요 버튼을 아직 구현하지 않았지만, 피드의 좋아요 버튼이 작동하기 시작할 것입니다! 이 라우트로 "좋아요" 요청을 보내고 있었기 때문입니다. 한번 시도해 보세요. + +`ArticleMeta`와 `Comments`는 다시 한번 폼들의 모음입니다. 이전에 이미 해봤으니, 코드를 가져와서 넘어가겠습니다. + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + Edit Article + +    + + + ) : ( + <> + + +    + + + )} +
+ + ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+ + ) : ( +
+
+

+ Sign in +   or   + Sign up +   to add comments on this article. +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + + +
+ )} +
+
+ ))} +
+ ); +} +``` + +이것으로 우리의 글 읽기 페이지도 완성되었습니다! 이제 작성자를 팔로우하고, 글에 좋아요를 누르고, 댓글을 남기는 버튼들이 예상대로 작동해야 합니다. + +
+ ![Article reader with functioning buttons to like and follow](/img/tutorial/realworld-article-reader.jpg) + +
기능하는 좋아요와 팔로우 버튼이 있는 글 읽기 페이지
+
+ +### 글 작성 페이지 + +이것은 이 튜토리얼에서 다룰 마지막 페이지이며, 여기서 가장 흥미로운 부분은 폼 데이터를 어떻게 검증할 것인가 입니다. + +페이지 자체인 `article-edit/ui/ArticleEditPage.tsx`는 꽤 간단할 것이며, 추가적인 복잡성은 다른 두 개의 컴포넌트로 숨겨질 것입니다. + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ +
+
+
+
+ ); +} +``` + +이 페이지는 현재 글(새로 작성하는 경우가 아니라면)을 가져와서 해당하는 폼 필드를 채웁니다. 이전에 본 적이 있습니다. 흥미로운 부분은 `FormErrors`인데, 이는 검증 결과를 받아 사용자에게 표시할 것입니다. 한번 살펴보겠습니다. + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +여기서는 우리의 액션이 `errors` 필드, 즉 사람이 읽을 수 있는 오류 메시지 배열을 반환할 것이라고 가정하고 있습니다. 곧 액션에 대해 다루겠습니다. + +또 다른 컴포넌트는 태그 입력입니다. 이는 단순한 입력 필드에 선택된 태그의 추가적인 미리보기가 있는 것입니다. 여기에는 특별한 것이 없습니다: + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +이제 API 부분입니다. 로더는 URL을 살펴보고, 글 슬러그가 포함되어 있다면 기존 글을 수정하는 것이므로 해당 데이터를 로드해야 합니다. 그렇지 않으면 아무것도 반환하지 않습니다. 그 로더를 만들어 봅시다. + +```ts title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +액션은 새로운 필드 값들을 받아 우리의 데이터 스키마를 통해 실행하고, 모든 것이 올바르다면 이러한 변경사항을 백엔드에 커밋합니다. 이는 기존 글을 업데이트하거나 새 글을 생성하는 방식으로 이루어집니다. + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +스키마는 `FormData`를 위한 파싱 함수로도 작동하여, 깨끗한 필드를 편리하게 얻거나 마지막에 처리할 오류를 던질 수 있게 해줍니다. 그 파싱 함수는 다음과 같이 보일 수 있습니다. + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("Give this article a title"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("Describe what this article is about"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("Write the article itself"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("The tags must be a string"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +물론 이는 다소 길고 반복적이지만, 사람이 읽을 수 있는 오류 메시지를 위해 우리가 지불해야 하는 대가입니다. 이것은 Zod 스키마일 수도 있지만, 그렇게 하면 프론트엔드에서 오류 메시지를 렌더링해야 하고, 이 폼은 그런 복잡성을 감당할 만한 가치가 없습니다. + +마지막 단계로 - 페이지, 로더, 그리고 액션을 라우트에 연결합니다. 우리는 생성과 편집을 모두 깔끔하게 지원하므로 `editor._index.tsx`와 `editor.$slug.tsx` 모두에서 동일한 것을 내보낼 수 있습니다. + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (same content)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +이제 완료되었습니다! 로그인하고 새 글을 작성해보세요. 또는 글을 "잊어버리고" 검증이 작동하는 것을 확인해보세요. + +
+ ![The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: “**Describe what this article is about” and “Write the article itself”.**](/img/tutorial/realworld-article-editor.jpg) + +
제목 필드에 "새 글"이라고 쓰여 있고 나머지 필드는 비어 있는 Conduit 글 편집기. 폼 위에 두 개의 오류가 있습니다. **"이 글이 무엇에 관한 것인지 설명해주세요"**, **"글 본문을 작성해주세요"**.
+
+ +프로필과 설정 페이지는 글 읽기와 편집기 페이지와 매우 유사하므로, 독자인 여러분의 연습 과제로 남겨두겠습니다 :) diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/auth.md b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/auth.md new file mode 100644 index 0000000000..60fe0d2016 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/auth.md @@ -0,0 +1,225 @@ +--- +sidebar_position: 1 +--- + +# Authentication + +보통 인증은 세 가지 주요 단계로 이루어집니다: + +1. 사용자로부터 로그인 정보(아이디, 비밀번호 등)을 수집합니다. +2. 백엔드 서버로 해당 로그인 정보을 전송합니다. +3. 인증 후 발급받은 토큰을 저장하여 이후 요청에 사용합니다. + +## 사용자 로그인 정보 수집 방법 + +앱에서 사용자로부터 로그인 정보를 수집하는 방법을 알아보겠습니다. 만약에 OAuth를 사용하는 경우, OAuth 제공자의 로그인 페이지를 사용하여 [3단계](#how-to-store-the-token-for-authenticated-requests)로 바로 넘어갈 수 있습니다. + +### 전용 로그인 페이지 만들기 + +웹사이트에서 사용자 이름과 비밀번호를 입력하는 로그인 페이지를 제공하는 것이 일반적입니다. 이러한 페이지들은 구조가 단순하여 별도의 복잡한 분해 작업이 필요하지 않습니다. 다만, 로그인과 회원가입 양식은 외형이 비슷하기 때문에, 경우에 따라 두 양식을 하나의 페이지에서 통합하여 제공하기도 합니다. + +- 📂 pages + - 📂 login + - 📂 ui + - 📄 LoginPage.tsx (or your framework's component file format) + - 📄 RegisterPage.tsx + - 📄 index.ts + - other pages… + +로그인과 회원가입 컴포넌트를 별도로 만들고, 필요에 따라 index 파일에서 export 할 수 있습니다. 이 컴포넌트들은 사용자로부터 로그인 정보을 입력받는 폼을 포함합니다. + +### 로그인 다이얼로그 만들기 + +앱의 어디서나 사용할 수 있는 로그인 다이얼로그가 필요하다면, 이 다이얼로그를 재사용 가능한 위젯으로 만드는 것이 좋습니다. 이렇게 하면 불필요한 세분화를 피하면서도 어떤 페이지에서나 쉽게 로그인 다이얼로그를 띄울 수 있습니다. + +- 📂 widgets + - 📂 login-dialog + - 📂 ui + - 📄 LoginDialog.tsx + - 📄 index.ts + - other widgets… + +가이드 나머지 부분은 전용 페이지 방식에 대해 설명하고 있지만, 동일한 원칙을 로그인 다이얼로그에도 적용할 수 있습니다. + +### 클라이언트 측 검증 + +특히 회원가입의 경우, 사용자가 입력한 내용에 문제가 있을 때 빠르게 피드백을 제공하기 위해 클라이언트 측 검증을 수행하는 것이 좋습니다. 이를 위해 로그인 페이지의 `model` 세그먼트에서 검증 로직을 구현할 수 있습니다. 예를 들어 JS/TS에서는 [Zod][ext-zod]와 같은 스키마 검증 라이브러리를 사용할 수 있습니다: + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "비밀번호가 일치하지 않습니다", + path: ["confirmPassword"], +}); +``` + +그런 다음, ui 세그먼트에서 이 스키마를 사용하여 사용자 입력을 검증할 수 있습니다: + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: Show error message to the user + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + + + ) +} +``` + +## 로그인 정보 전송 방법 + +로그인 정보를 백엔드 서버로 전송하기 위한 요청 함수를 작성하세요. 이 함수는 상태 관리 라이브러리나 뮤테이션 라이브러리(예: TanStack Query)를 사용하여 호출할 수 있습니다. + +### 요청 함수 저장 위치 + +이 요청 함수를 저장할 수 있는 위치는 크게 두 가지입니다: `shared/api` 또는 페이지의 `api` 세그먼트입니다. + +#### `shared/api`에 저장하기 + +모든 API 요청을 `shared/api`에 모아서 관리하고, 엔드포인트별로 그룹화하는 접근 방식입니다. 파일 구조는 다음과 같습니다: + +- 📂 shared + - 📂 api + - 📂 endpoints + - 📄 login.ts + - other endpoint functions… + - 📄 client.ts + - 📄 index.ts + +`📄 client.ts` 파일은 요청을 수행하는 원시 함수(예: `fetch()`)에 대한 래퍼를 포함합니다. 이 래퍼는 백엔드의 기본 URL 설정, 헤더 설정, 데이터 직렬화 등을 처리합니다. + +```ts title="shared/api/endpoints/login.ts" +import { POST } from "../client"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +```ts title="shared/api/index.ts" +export { login } from "./endpoints/login"; +``` + +#### 페이지의 `api` 세그먼트에 저장하기 + +로그인 요청이 특정 페이지에만 필요한 경우, 로그인 페이지의 `api` 세그먼트에 함수를 저장할 수 있습니다: + +- 📂 pages + - 📂 login + - 📂 api + - 📄 login.ts + - 📂 ui + - 📄 LoginPage.tsx + - 📄 index.ts + - other pages… + +```ts title="pages/login/api/login.ts" +import { POST } from "shared/api"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +이 함수는 페이지의 공개 API에서 내보낼 필요가 없습니다. 로그인 요청이 다른 곳에서 필요할 가능성이 낮기 때문입니다. + +### 이중 인증(2FA) + +앱이 이중 인증(2FA)을 지원하는 경우, 사용자가 일회용 비밀번호(OTP)를 입력할 수 있는 별도의 페이지로 이동해야 할 수 있습니다. 일반적으로 `POST /login` 요청은 사용자가 2FA를 활성화했음을 나타내는 플래그가 포함된 사용자 객체를 반환합니다. 이 플래그가 설정되면 사용자를 2FA 페이지로 리디렉션해야 합니다. + +2FA 페이지는 로그인과 밀접하게 연관되어 있으므로 Pages 레이어의 `login` 슬라이스에 함께 저장하는 것이 좋습니다.
+ +이중 인증을 처리하기 위해서는 `login()` 함수와 유사한 또 다른 요청 함수가 필요할 것입니다. 이러한 함수들은 `Shared`나 로그인 페이지의 `api` 세그먼트에 함께 배치할 수 있습니다. + +## 인증된 요청의 토큰 저장 방법 {#how-to-store-the-token-for-authenticated-requests} + +인증 방식이 로그인/비밀번호, OAuth, 2단계 인증 등 어떤 것이든, 결국 토큰이 발급됩니다. 이 토큰은 이후 요청에서 사용자 식별을 위해 저장되어야 합니다. + +웹 애플리케이션에서는 **쿠키**를 사용해 토큰을 저장하는 것이 가장 일반적이고 이상적인 방법입니다. 쿠키를 사용하면 토큰을 수동으로 관리할 필요가 없으며, 복잡한 처리를 줄일 수 있습니다. 만약 서버 사이드 렌더링을 지원하는 프레임워크(예: [Remix][ext-remix])를 사용 중이라면, 서버 사이드 쿠키 인프라를 `shared/api`에 저장하는 것이 좋습니다. Remix를 사용하는 예시는 튜토리얼의 [인증 섹션][tutorial-authentication]에서 확인할 수 있습니다. + +그러나 쿠키를 사용할 수 없는 상황에서는, 토큰을 직접 관리해야 합니다. 이 경우, 토큰 만료 시 갱신 로직을 함께 구현해야 할 수도 있습니다. 이 경우, 토큰 만료 시 갱신 로직을 함께 구현해야 합니다. FSD에서는 토큰을 저장할 수 있는 다양한 방법이 있습니다. + +### Shared에 저장하기 + +`shared/api`에 저장하는 접근 방식은 API 클라이언트와 잘 맞아떨어집니다. 인증이 필요한 다른 요청 함수에서 이 토큰을 쉽게 사용할 수 있기 때문입니다. API 클라이언트에서 반응형 스토어나 모듈 수준 변수를 사용해 토큰을 저장하고, `login()/logout()` 함수에서 해당 상태를 업데이트할 수 있습니다. + +토큰 자동 갱신은 API 클라이언트에서 미들웨어 형태로 구현할 수 있습니다. 모든 요청마다 실행되며, 아래와 같은 방식으로 동작합니다: + +- 사용자가 로그인하면 액세스 토큰과 갱신 토큰을 저장합니다. +- 인증이 필요한 요청을 수행합니다. +- 토큰이 만료되어 요청이 실패하면, 갱신 토큰을 사용해 새로운 토큰을 요청하고 저장한 후, 원래 요청을 다시 시도합니다. + +이 방법의 단점 중 하나는 토큰 관리 로직이 요청 로직과 같은 위치에 있어, 복잡해질 수 있다는 점입니다. 간단한 경우에는 문제가 없겠지만, 토큰 관리 로직이 복잡한 경우에는 요청과 관리 로직을 분리하는 것이 좋습니다. 요청 및 API 클라이언트는 `shared/api`에 두고, 토큰 관리 로직은 `shared/auth`에 두는 방식으로 나눌 수 있습니다. + +또 다른 단점은 백엔드가 토큰과 함께 현재 사용자 정보를 반환하는 경우, 이 정보를 별도로 저장하거나 `/me` 또는 `/users/current`와 같은 엔드포인트에서 다시 요청해야 한다는 점입니다. + +### Entities에 저장하기 + +FSD 프로젝트에서는 사용자 엔티티 또는 현재 사용자 엔티티를 사용하는 것이 일반적입니다. 두 엔티티는 같은 것을 가리킬 수도 있습니다. + +:::note + +**현재 사용자**는 "viewer" 또는 "me"라고도 합니다. 이는 권한과 개인 정보를 가진 단일 인증 사용자와 공개적으로 접근 가능한 정보로 구성된 모든 사용자 목록을 구별하기 위해 사용됩니다. + +::: + +User 엔티티에 토큰을 저장하려면 `model` 세그먼트에 반응형 스토어를 생성해야 합니다. 이 스토어는 토큰과 사용자 객체를 모두 포함할 수 있습니다. + +API 클라이언트는 일반적으로 `shared/api` 정의되거나 엔티티 전체에 분산되어 있습니다. 따라서 주요 과제는 레이어의 임포트 규칙([import rule on layers][import-rule-on-layers])을 위반하지 않으면서 다른 요청에서도 토큰을 사용할 수 있도록 하는 것입니다. + +> 레이어 규칙: 슬라이스의 모듈은 자기보다 낮은 레이어에 위치한 다른 슬라이스만 임포트할 수 있습니다. + +이 문제를 해결하기 위한 몇 가지 방법은 다음과 같습니다: + +1. **요청 시마다 토큰 수동 전달** + 이 방법은 가장 간단하지만, 번거롭고 타입 안전성이 보장되지 않으면 실수가 발생할 가능성이 큽니다. 또한 Shared의 API 클라이언트에 미들웨어 패턴을 적용하기 어렵습니다. +2. **앱 전역에서 글로벌 스토어로 토큰 관리** + 토큰을 context나 `localStorage`에 저장하고, `shared/api`에 토큰 접근 키를 보관합니다. 토큰의 반응형 저장소는 User 엔터티에서 내보내며, 필요한 경우 context Provider는 App 레이어에서 설정합니다. 이 방법은 API 클라이언트 설계를 유연하게 만들지만, 상위 레이어에 context 제공이 필요하다는 암묵적인 의존성을 발생시킵니다. 따라서 context나 `localStorage`가 제대로 설정되지 않았을 경우, 유용한 오류 메시지를 제공하는 것이 좋습니다. +3. **토큰 변경 시 API 클라이언트 업데이트** + 반응형 스토어를 활용해 엔티티의 스토어가 변경될 때마다 API 클라이언트의 토큰 스토어를 업데이트하는 구독(subscribe)을 생성할 수 있습니다. 이 방법은 상위 계층에 암묵적인 의존성을 만든다는 점에서는 이전 해결책과 비슷하지만, 이 방법은 더 "명령형(push)" 접근이고, 이전 방법은 더 "선언형(pull)" 접근입니다. + +엔티티의 `model`에 토큰을 저장하여 문제를 해결하면, 토큰 관리와 관련된 더 많은 비즈니스 로직을 추가할 수 있습니다. 예를 들어, `model` 세그먼트에 토큰 만료 시 갱신하는 로직을 추가하거나, 일정 시간이 지나면 토큰을 무효화하는 로직을 포함할 수 있습니다. +백엔드에 요청을 보내야 하는 경우에는 User 엔티티의 api 세그먼트나 `shared/api`를 사용할 수 있습니다. + +### Pages/Widgets에 저장하기 (권장하지 않음) + +애플리케이션 전역에 적용되는 상태(예: 액세스 토큰)를 페이지나 위젯에 저장하는 것은 권장되지 않습니다. 예를 들어, 로그인 페이지의 `model` 세그먼트에 토큰 스토어를 배치하는 대신, 이 아티클에서 제시한 처음 두 해결책인 Shared나 Entities를 사용하는 것이 권장됩니다. + +## 로그아웃 및 토큰 무효화 + +로그아웃 기능은 애플리케이션에서 중요한 기능이지만, 이를 위한 별도의 페이지는 없는 경우가 많습니다. 이 기능은 백엔드에 인증된 요청을 보내고, 토큰 스토어를 업데이트하는 작업으로 구성됩니다. + +모든 요청을 `shared/api`에 보관했다면, 로그인 함수 근처에 로그아웃 요청 함수를 두는 것이 좋습니다. 그렇지 않은 경우, 로그아웃 버튼이 있는 위치 근처에 로그아웃 요청 함수를 배치할 수 있습니다. 예를 들어, 모든 페이지에 나타나는 헤더 위젯에 로그아웃 링크가 있다면, 해당 요청을 그 위젯의 `api` 세그먼트에 배치하는 것이 좋습니다. + +토큰 스토어에 대한 업데이트는 로그아웃 버튼이 위치한 곳(예: 헤더 위젯)에서 트리거되어야 합니다. 이 요청과 스토어 업데이트를 해당 위젯의 `model` 세그먼트에서 결합할 수 있습니다. + +### 자동 로그아웃 + +로그아웃 요청 실패나 로그인 토큰 갱신 실패 시를 대비해 안전장치를 마련하는 것도 중요합니다. 이 두 경우 모두 토큰 스토어를 비워야 합니다. 토큰을 Entities에 저장하는 경우, 이 로직은 `model` 세그먼트에 배치할 수 있습니다. 토큰을 Shared에 저장하는 경우, 이 로직을 `shared/api`에 포함하면 세그먼트가 너무 복잡해질 수 있습니다. 따라서 토큰 관리 로직을 별도의 세그먼트(예: `shared/auth`)로 분리하는 것도 고려해볼 만합니다. + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/index.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/index.mdx new file mode 100644 index 0000000000..78b29b29c0 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/index.mdx @@ -0,0 +1,36 @@ +--- +hide_table_of_contents: true +--- + +# Examples + +

+방법론 적용에 대한 예시들 +

+ +## Main + +import NavCard from "@site/src/shared/ui/nav-card/tmpl.mdx" +import { UserSwitchOutlined, LayoutOutlined, FontSizeOutlined } from "@ant-design/icons"; + + + + diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/page-layout.md b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/page-layout.md new file mode 100644 index 0000000000..df57fa83f7 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/page-layout.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +--- + +# Page layouts + +이 가이드는 여러 페이지가 같은 기본 구조를 공유하고, 주요 내용만 다른 경우 사용할 수 있는 _페이지 레이아웃_ 에 대해 설명합니다. + +:::info + +이 가이드에서 다루지 않는 질문이 있으신가요? 오른쪽 파란색 버튼을 눌러 피드백을 남겨주세요. 여러분의 의견을 반영해 가이드를 확장해 나가겠습니다! + +::: + +## 간단한 레이아웃 + +간단한 레이아웃 예시로 설명 해 보겠습니다. 이 페이지는 사이트 내비게이션이 포함된 헤더, 두 개의 사이드바, 외부 링크가 포함된 푸터로 구성되어 있습니다. 복잡한 비즈니스 로직은 없으며, 동적인 부분은 사이드바와 헤더 오른쪽에 있는 테마 전환 버튼뿐입니다. 이러한 레이아웃은 shared/ui 또는 app/layouts에 포함시킬 수 있으며, props를 통해 전달받은 사이드바 콘텐츠를 표시합니다. + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* 여기에 주요 콘텐츠가 들어갑니다 */} + +
+
+
    +
  • GitHub
  • +
  • Twitter
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + +사이드바의 구체적인 코드는 여러분 상상에 맡기겠습니다 😉. + +## layout에 widget 사용하기 + +상황에 따라 layout에 특정 비즈니스 로직을 추가하고 싶을 때가 있습니다. 특히 [React Router][ext-react-router]와 같은 라우터를 사용해 깊이 중첩된 경로를 다룰 때 이러한 요구가 발생합니다. 이러한 경우 layout을 shared나 widgets 폴더에 두는 것이 어려울 수 있습니다. 이는 [layer에 대한 import 규칙][import-rule-on-layers] 때문입니다: + +> slice의 module은 자신보다 하위 layers에 위치한 다른 slice만 import할 수 있습니다. + +이 문제가 정말 중요한지 먼저 고려해 봐야 합니다. 레이아웃이 _정말로 필요한가요?_ 그리고 그 레이아웃이 _정말로 widget이어야 할까요?_ 만약 해당 비즈니스 로직이 2-3개의 페이지에서만 사용되고, 레이아웃이 그 widget을 감싸는 역할이라면, 다음 두 가지 방법을 고려해 보세요: + +1. **App 레이어에서 인라인으로 레이아웃 작성하기** + App 레이어에서 직접 레이아웃을 정의하는 것이 좋습니다. 이렇게 하면 중첩된 라우터를 사용할 때 특정 경로 그룹에만 해당 레이아웃을 적용할 수 있어 유연하게 사용할 수 있습니다. + +2. **복사하여 붙여넣기** + 코드 추상화는 항상 좋은 선택은 아닙니다. 특히 레이아웃은 자주 변경되지 않기 때문에, 필요한 경우 해당 페이지만 수정하는 것이 더 효율적일 수 있습니다. 이렇게 하면 다른 페이지에 영향을 주지 않고 수정할 수 있습니다. 팀원들이 다른 페이지를 수정하는 걸 잊을까 봐 걱정된다면, 페이지 간의 관계를 주석으로 남겨보세요. 큰 프로젝트에서도 협업이 더 편해질 거예요. + +위의 내용이 적절하지 않은 경우, 레이아웃에 widget을 포함하는 두 가지 해결책이 있습니다: + +1. **render props나 slots 사용하기** + 대부분의 프레임워크에서는 컴포넌트 내부에 표시될 UI 요소를 외부에서 전달할 수 있는 기능을 제공합니다. React에서는 [render props][ext-render-props]라고 하며, Vue에서는 [slots][ext-vue-slots]이라고 부릅니다. +2. **레이아웃을 App 레이어로 이동하기** + 레이아웃을 `app/layouts` 등 App 레이어에 저장하고 원하는 widget을 구성할 수도 있습니다. + +## 추가 자료 + +React 및 Remix(React Router와 유사)의 인증 레이아웃 구축에 대한 예시는 [튜토리얼][tutorial]에서 확인하실 수 있습니다. + + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://vuejs.org/guide/components/slots + diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/types.md b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/types.md new file mode 100644 index 0000000000..5b07e91060 --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/examples/types.md @@ -0,0 +1,442 @@ +--- +sidebar_position: 2 +--- + +# Types + +이 가이드는 Typescript와 같은 정적 타입 언어의 데이터 타입을 다루는 방법과 FSD 구조 내에서 타입이 어떻게 활용되는지 설명합니다. + +:::info + +이 가이드에서 다루지 않는 질문이 있으신가요? 오른쪽 파란색 버튼을 눌러 피드백을 남겨주세요. 여러분의 의견을 반영해 가이드를 확장해 나가겠습니다! + +::: + +## 유틸리티 타입 + +유틸리티 타입은 자체로 큰 의미를 가지지는 않지만, 다른 타입과 자주 사용되는 경우가 많은 타입입니다. 예를 들어, 배열의 값을 나타내는 ArrayValues 타입을 정의할 수 있습니다. + +
+ +```ts +type ArrayValues = T[number]; +``` + +
+ Source: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts +
+ +
+ +프로젝트에서 이러한 유틸리티 타입을 활용하려면, [`type-fest`][ext-type-fest] 같은 라이브러리를 설치하거나, 직접 `shared/lib`에 유틸리티 타입을 모아 라이브러리를 구축할 수 있습니다. 새로 추가할 타입과 이 라이브러리에 속하지 않는 타입을 명확하게 구분하는 것이 중요합니다. 예를 들어, 이를 `shared/lib/utility-types`로 수정하고 유틸리티 타입들에 대한 설명을 포함한 README 파일을 추가하는 것도 좋은 방법입니다. + +하지만 유틸리티 타입을 너무 많이 재사용하려고 하지 않는 것도 중요합니다. 재사용할 수 있다고 해서 꼭 모든 곳에서 사용할 필요는 없습니다. 모든 유틸리티 타입을 공유 폴더에 넣기보다는, 상황에 따라 필요한 파일 가까에에 두는 것이 더 좋을 떄도 있습니다. + +- 📂 pages + - 📂 home + - 📂 api + - 📄 ArrayValues.ts (유틸리티 타입) + - 📄 getMemoryUsageMetrics.ts (유틸리티 타입을 사용하는 코드) + +:::warning + +`shared/types` 폴더를 생성하거나 각 슬라이스에 `types`라는 세그먼트를 추가하고 싶은 마음이 들 수 있지만, 그렇게 하지 않는 것이 좋습니다.
+`types`라는 카테고리는 `components`나 `hooks`와 마찬가지로 내용이 무엇인지를 설명할 뿐, 코드의 목적을 명확히 설명하지 않습니다. 슬라이스는 해당 코드의 목적을 정확히 설명할 수 있어야 합니다. + +::: + +## 비즈니스 엔티티 및 상호 참조 관계 + +앱에서 가장 중요한 타입 중 하나는 비즈니스 엔티티, 즉 앱에서 다루는 객체들 입니다. +예를 들어, 음악 스트리밍 앱에서는 _Song_, _Album_ 등이 비즈니스 엔티티가 될 수 있습니다. + +비즈니스 엔티티는 주로 백엔드 바탕이기 떄문에, 백엔드 응답을 타입으로 정의하는 것이 첫 번째 단계입니다. +각 엔드포인트에 대한 요청 함수와 그 응답을 타입으로 지정하는 것이 좋습니다, 추가적인 타입 안정성을 위해 [Zod][ext-zod]와 같은 스키마 검증 라이브러리를 사용해 응답을 검증할 수도 있습니다. + +예를 들어, 모든 요청을 Shared에 보관하는 경우 이렇게 작성할 수 있습니다. + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +`Song` 타입은 다른 엔티티인 `Artist`를 참조합니다. 이와 같이 요청 관련 코드들을 Shared에 관리하면, 타입들의 서로 얽혀 있을 떄 관리가 용이해집니다. 만약 이 함수를 `entities/song/api`에 보관했다면, `entities/artist`에서 간단히 가져오는 것이 어려웠을 것 입니다. FSD 구조에서는 [레이어별 import 규칙][import-rule-on-layers]을 통해 슬라이스 간의 교차 import를 제한하고 있기 떄문입니다: + +> 슬라이스 안에 있는 모듈은 계층적으로 더 낮은 레이어에 위치한 슬라이스만 가져올 수 있습니다. + +이 문제를 해결하기 위한 두 가지 방법은 다음과 같습니다: + +1. **타입 매개변수화** + 타입이 다른 엔티티와 연결될 때, 타입 매개변수를 통해 처리할 수 있습니다. 예를 들어, Song 타입에 ArtistType이라는 제약 조건을 설정할 수 있습니다. + + ```ts title="entities/song/model/song.ts" + interface Song { + id: number; + title: string; + artists: Array; + } + ``` + + 이 방법은 일부 타입에 더 적합합니다. 예를 들어, `Cart = { items: Array }`처럼 간단한 타입은 다양한 제품 타입을 지원하기 쉽게 할 수 있습니다. 하지만 `Country`와 `City`처럼 더 밀접하게 연결된 타입은 분리하기 어렵습니다. + +2. **Cross-import (공개 API를 사용해 관리하기)** + FSD에서 엔티티 간 cross-imports를 허용하기 위해서는 공개 API를 사용할 수 있습니다. 예를 들어, `song`, `artist`, `playlist`라는 엔티티가 있고, 후자의 두 엔티티가 `song`을 참조해야 한다고 가정합니다. 이 경우, `song` 엔티티 내에 `artist`와 `playlist`용 공개 API를 따로 `@x` 표기를 만들어 사용할 수 있습니다. + + - 📂 entities + - 📂 song + - 📂 @x + - 📄 artist.ts (artist entities를 가져오기 위한 public API) + - 📄 playlist.ts (playlist.ts (playlist entities를 가져오기 위한 public API)) + - 📄 index.ts (일반적인 public API) + + 파일 `📄 entities/song/@x/artist.ts`의 내용은 `📄 entities/song/index.ts`와 유사합니다: + + ```ts title="entities/song/@x/artist.ts" + export type { Song } from "../model/song.ts"; + ``` + + 따라서 `📄 entities/artist/model/artist.ts` 파일은 다음과 같이 `Song`을 가져올 수 있습니다: + + ```ts title="entities/artist/model/artist.ts" + import type { Song } from "entities/song/@x/artist"; + + export interface Artist { + name: string; + songs: Array; + } + ``` + + 이렇게 엔티티 간 명시적으로 연결을 해두면 의존 관계를 파악하고 도메인 분리 수준을 유지하기 쉬워집니다. + +## 데이터 전송 객체와 mappers {#data-transfer-objects-and-mappers} + +데이터 전송 객체(Data Transfer Object, DTO)는 백엔드에서 오는 데이터의 구조를 나타내는 용어입니다. 떄로는 DTO를 그대로 사용하는 것이 편리할 수 있지만, 경우에 따라 프론트엔드에서는 불편할 수 있습니다. 이때 매퍼를 사용해 DTO를 더 편리한 형태로 변환합니다. + +### DTO의 위치 + +백엔드 타입이 별도의 패키지에 있는 경우(예: 프론트엔드와 백엔드에서 코드를 공유하는 경우) DTO를 해당 패키지에서 가져와 사용하면 됩니다. 백엔드와 프론트엔드 간 코드 공유가 없다면, 프론트엔드 코드베이스 어딘가에 DTO를 보관해야 하는데, 이를 아래에서 다루어 보겠습니다. + +`shared/api`에 요청 함수가 있다면, DTO 역시 해당 함수 바로 옆에 두는 것이 좋습니다: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +앞에서 언급한 것처럼, 요청과 DTO를 shared에 두면 다른 DTO를 참조하기가 용이합니다. + +### Mappers의 위치 + +Mappers는 DTO를 받아 변환하는 역할을 하므로, DTO 정의와 가까운 위치에 두는 것이 좋습니다. 만약 요청과 DTO가 `shared/api`에 정의되어 있다면, mappers도 그곳에 위치하는 것이 적절합니다. + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** 노래의 전체 제목, 디스크 번호까지 포함된 제목입니다. */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +요청과 상태 관리 코드가 엔티티 슬라이스에 정의되어 있는 경우, mappers 역시 해당 슬라이스 내에 두는 것이 좋습니다. 이때 슬라이스 간 교차 참조가 발생하지 않도록 주의해야 합니다. + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** 노래의 전체 제목, 디스크 번호까지 포함된 제목입니다. */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }) + }, +}); +``` + +### 중첩된 DTO 처리 방법 + +백엔드 응답에 여러 엔티티가 포함된 경우 문제가 될 수 있습니다. 예를 들어, 곡 정보에 저자의 ID뿐만 아니라 저자 객체 전체가 포함된 경우가 있을 수 있습니다. 이런 상황에서는 엔티티 간의 상호 참조를 피하기 어렵습니다. 데이터를 지우거나 백엔드 팀과 협의하지 않는 한, 이러한 경우에는 슬라이스 간 간접적인 연결 대신 명시적인 교차 참조를 사용하는 것이 좋습니다. 이를 위해 `@x` 표기법을 활용할 수 있으며, 다음은 Redux Toolkit을 사용한 예시입니다: + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit' +import { normalize, schema } from 'normalizr' + +import { getSong } from "../api/getSong"; + +// Normalizr의 entities 스키마 정의 +export const artistEntity = new schema.Entity('artists') +export const songEntity = new schema.Entity('songs', { + artists: [artistEntity], +}) + +const songAdapter = createEntityAdapter() + +export const fetchSong = createAsyncThunk( + 'songs/fetchSong', + async (id: string) => { + const data = await getSong(id) + // 데이터를 정규화하여 리듀서가 예측 가능한 payload를 로드할 수 있도록 합니다: + // `action.payload = { songs: {}, artists: {} }` + const normalized = normalize(data, songEntity) + return normalized.entities + } +) + +export const slice = createSlice({ + name: 'songs', + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from '@reduxjs/toolkit' + +import { fetchSong } from 'entities/song/@x/artist' + +const artistAdapter = createEntityAdapter() + +export const slice = createSlice({ + name: 'users', + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // 같은 fetch 결과를 처리하며, 여기서 artists를 삽입합니다. + artistAdapter.upsertMany(state, action.payload.artists) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +이 방법은 슬라이스 분리의 이점을 다소 제한할 수 있지만, 우리가 제어할 수 없는 두 엔티티 간의 관계를 명확하게 나타냅니다. 만약 이러한 엔티티가 리팩토링되어야 한다면, 함께 리팩토링해야 할 것입니다. + +## 전역 타입과 Redux + +전역 타입은 애플리케이션 전반에서 사용되는 타입을 의미하며, 크게 두 가지로 나눌 수 있습니다:
+1. 애플리케이션 특성이 없는 제너릭 타입 +2. 애플리케이션 전체에 알고 있어야 하는 타입 + +첫 번째 경우에는 관련 타입을 Shared 폴더 안에 적절한 세그먼트로 배치하면 됩니다. 예를 들어, 분석 전역 변수를 위한 인터페이스가 있다면 `shared/analytics`에 두는 것이 좋습니다. + +:::warning + +경고: `shared/types` 폴더를 생성하지 않는 것이 좋습니다. "타입"이라는 공통된 속성으로 관련 없는 항목들을 그룹화하면, 프로젝트에서 코드를 검색할 때 효율성이 떨어질 수 있습니다. + +::: + +두 번째 경우는 Redux를 사용하지만 RTK가 없는 프로젝트에서 자주 발생합니다. 최종 스토어 타입은 모든 리듀서를 추가한 후에만 사용 가능하지만, 이 스토어 타입은 앱 전체에서 사용하는 셀렉터에 필요합니다. 예를 들어, 일반적인 스토어 정의는 다음과 같습니다: + +```ts title="app/store/index.ts" +import { combineReducers, rootReducer } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); + +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +`shared/store`에서 `useAppDispatch`와 `useAppSelector`와 같은 타입이 지정된 Redux 훅을 사용하는 것이 좋지만, [레이어에 대한 import 규칙][import-rule-on-layers] 떄문에 App 레이어에서 `RootState`와 `AppDispatch`를 import 할 수 없습니다. + +> 슬라이스의 모듈은 더 낮은 레이어에 위치한 다른 슬라이스만 import 할 수 있습니다. + +이 경우 권장되는 해결책은 Shared와 App 레이어 간에 암묵적인 의존성을 만드는 것입니다. `RootState`와 `AppDispatch` 두 타입은 유지보수 필요성이 적고 Redux를 사용하는 개발자들에게 익숙하므로 큰 문제 없이 사용할 수 있습니다. + +TypeScript에서는 다음과 같이 타입을 전역으로 선언할 수 있습니다: + +```ts title="app/store/index.ts" +/* 이전 코드 블록과 동일한 내용입니다… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## 열거형 + +**일반적으로 열거형(enum)은 사용되는 위치와 최대한 가까운 곳에 정의하는 것이 좋습니다**. 열거형이 특정 기능과 관련된 값을 나타낸다면, 해당 기능 내에 정의해야 합니다. + +세그먼트 선택도 사용 위치에 따라 달라져야 합니다. 예를 들어, 화면에서 토스트 위치를 나타내는 열거형이라면 ui 세그먼트에 두는 것이 좋고, 백엔드 응답 상태 등을 나타낸다면 api 세그먼트에 두는 것이 적합합니다. + +프로젝트 전반에서 공통으로 사용되는 열거형도 있습니다. 예를 들어, 일반적인 백엔드 응답 상태나 디자인 시스템 토큰 등이 있습니다. 이 경우 Shared에 두되, 열거형이 나타내는 것을 기준으로 세그먼트를 선택하면 됩니다 (`api`는 응답 상태, `ui`는 디자인 토큰 등). + +## 타입 검증 스키마와 Zod + +데이터가 특정 형태나 제약 조건을 충족하는지 검증하려면 검증 스키마를 정의할 수 있습니다. TypeScript에서는 [Zod][ext-zod]와 같은 라이브러리를 많이 사용합니다. 검증 스키마는 가능하면 사용하는 코드와 같은 위치에 두는 것이 좋습니다. + +검증 스키마는 데이터를 파싱하며, 파싱에 실패하면 오류를 발생시킵니다.([Data transfoer objects and mappers](#data-transfer-objects-and-mappers) 토론을 참조하세요.) 가장 일반적인 검증 사례 중 하나는 백엔드에서 오는 데이터에 대한 것입니다. 데이터가 스키마와 일치하지 않는 경우 요청을 실패시키기를 원하기 때문에, 보통 `api` 세그먼트에 스키마를 두는 것이 좋습니다. + +사용자 입력(예: 폼)으로 데이터를 받을 경우, 입력된 데이터에 대해 바로 검증이 이루어져야 합니다. 이 경우 스키마를 `ui` 세그먼트 내 폼 컴포넌트 옆에 두거나, `ui` 세그먼트가 너무 복잡하다면 `model` 세그먼트에 둘 수 있습니다. + +## 컴포넌트 props와 context의 타입 정의 + +보통 props나 context 인터페이스는 이를 사용하는 컴포넌트나 컨텍스트와 같은 파일에 두는 것이 가장 좋습니다. 만약 Vue나 Svelte처럼 단일 파일 컴포넌트를 사용하는 프레임워크에서 여러 컴포넌트 간에 해당 인터페이스를 공유해야 한다면, `ui` 세그먼트 내 동일 폴더에 별도의 파일을 만들어 정의할 수 있습니다. + +예를 들어, React의 JSX에서는 다음과 같이 정의합니다: + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +Vue에서 인터페이스를 별도 파일에 저장한 예는 다음과 같습니다: + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## Ambient 선언 파일(*.d.ts) + +[Vite][ext-vite]나 [ts-reset][ext-ts-reset] 같은 일부 패키지는 앱 전반에서 작동하기 위해 Ambient 선언 파일을 필요로 합니다. 이러한 파일들은 보통 크거나 복잡하지 않기 때문에 `src/` 폴더에 두어도 괜찮습니다. 더 정리된 구조를 위해 `app/ambient/` 폴더에 두는 것도 좋은 방법입니다. + +타이핑이 없는 패키지인 경우, 해당 패키지를 미타입으로 선언하거나 직접 타이핑을 작성할 수 있습니다. 이러한 타이핑을 위한 좋은 위치는 `shared/lib` 폴더 내의 `shared/lib/untyped-packages` 폴더입니다. 이 폴더에 `%LIBRARY_NAME%.d.ts` 파일을 생성하고 필요한 타입을 선언합니다 + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// 이 라이브러리는 타입 정의가 없으며 작성하는 것을 생략했습니다. +declare module "use-react-screenshot"; +``` + +## 타입 자동 생성 + +외부 소스로부터 타입을 생성하는 일은 흔히 발생합니다. 예를 들어, OpenAPI 스키마로부터 백엔드 타입을 생성하는 경우가 있습니다.
+이러한 타입을 위한 전용 위치를 코드베이스에 만드는 것이 좋습니다. 예를 들어 `shared/api/openapi`와 같은 위치가 적합합니다. 이상적으로는 이러한 파일이 무엇인지, 어떻게 재생성하는지 등을 설명하는 README 파일도 포함하는 것이 좋습니다. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/index.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/index.mdx new file mode 100644 index 0000000000..6d7a02ccda --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/index.mdx @@ -0,0 +1,50 @@ +--- +hide_table_of_contents: true +pagination_prev: get-started/index +--- + +# 🎯 Guides + +PRACTICE-ORIENTED + +

+Feature-Sliced Design(FSD)의 적용을 위한 종합 가이드입니다. 구체적인 예시, 마이그레이션 전략, 그리고 FSD 코드에서 발견할 수 있는 흔한 설계상의 문제들을 다룹니다. FSD를 프로젝트에 도입하거나 기존 구조를 개선하고자 할 때 참고하기 좋은 리소스입니다. +

+ +## Main + +import NavCard from "@site/src/shared/ui/nav-card/tmpl.mdx" +import { ToolOutlined, ImportOutlined, BugOutlined, FunctionOutlined } from "@ant-design/icons"; + + + + + + + + + diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..1a31af2b7e --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx @@ -0,0 +1,117 @@ +--- +sidebar_position: 10 +--- +# NextJS와 함께 사용하기 + +NextJS에서도 FSD(Feature-Sliced Design) 아키텍처를 구현할 수 있지만, 두 가지 점에서 NextJS의 프로젝트 구조 요구사항과 FSD 구조 간에 충돌이 발생합니다: + +- `pages` 폴더와의 라우팅 방식 차이 +- NextJS에서 `app` 폴더의 충돌 문제 또는 부재 + +## FSD와 NextJS의 `페이지` 레이어 간 충돌 {#pages-conflict} + +NextJS는 애플리케이션 라우트를 정의하기 위해 `pages` 폴더를 사용하며, `pages` 폴더 내의 파일이 URL과 매핑되도록 설정합니다. +하지만 이 방식은 FSD(Folder Slice Design) `개념에 맞지는 않습니다`. 특히, NextJS의 라우팅 방식으로는 FSD의 슬라이스 구조를 평평하게 유지하기 어려운 점이 있습니다. + +### NextJS의 `pages` 폴더를 프로젝트 루트 폴더로 이동하기 (권장) + +프로젝트 루트에 `pages` 폴더를 배치하고, FSD 구조에 맞춘 페이지들을 NextJS의 `pages` 폴더로 옮깁니다. +이렇게 하면 `src` 폴더 내에서 FSD 구조를 유지할 수 있습니다. + +```sh +├── pages # NextJS 페이지 폴더 +├── src +│ ├── app +│ ├── entities +│ ├── features +│ ├── pages # FSD 페이지 폴더 +│ ├── shared +│ ├── widgets +``` + +### FSD 구조 내 `pages` 폴더 이름 변경하기 + +다른 방법으로는 FSD 구조 내에서 `pages` 폴더의 이름을 변경하여 NextJS의 `pages` 폴더와 충돌을 피할 수도 있습니다. +예를 들어, `pages` 폴더를 `views`로 이름을 변경하면 `src` 폴더 내의 FSD 구조를 유지하면서도 NextJS의 요구 사항과 충돌하지 않게 됩니다. + +```sh +├── app +├── entities +├── features +├── pages # NextJS 페이지 폴더 +├── views # 이름이 변경된 FSD 페이지 폴더 +├── shared +├── widgets +``` + +이름을 변경하는 경우, 이를 프로젝트의 README나 내부 문서에 명확히 기록하여 변경 사항이 잘 전달되도록 하는 것이 좋습니다. 이러한 변경은 ["프로젝트 지식"][project-knowledge]의 일부로 문서화하는 것이 중요합니다. + +## NextJS에서 `app` 폴더 부재 문제 {#app-absence} + +NextJS 13버전 이하에서는 명시적인 `app` 폴더가 없으며, +대신 `_app.tsx` 파일이 모든 페이지를 감싸는 컴포넌트 역할을 합니다. + +### `pages/_app.tsx` 파일에 app 기능 가져오기 + +NextJS 구조에서 `app` 폴더가 없는 문제를 해결하려면, `app` 폴더 내에 `App` 컴포넌트를 생성하고, 이를 `pages/_app.tsx`에 가져와 NextJS가 사용할 수 있도록 설정하면 됩니다. 예를 들어: + +```tsx +// app/providers/index.tsx + +const App = ({ Component, pageProps }: AppProps) => { + return ( + + + + + + + + ); +}; + +export default App; +``` + +그 다음 `pages/_app.tsx` 파일에서 `App` 컴포넌트와 프로젝트 전역 스타일을 다음과 같이 가져올 수 있습니다: + +```tsx +// pages/_app.tsx + +import 'app/styles/index.scss' + +export { default } from 'app/providers'; +``` + +## App Router 사용하기 {#app-router} + +NextJS 13.4 버전에서는 App Router가 안정화되었습니다. App Router를 사용하면 `pages` 폴더 대신 `app` 폴더를 통해 라우팅을 처리할 수 있습니다. +FSD 원칙을 준수하기 위해, NextJS의 `app` 폴더도 `pages` 폴더와의 충돌 문제를 해결한 것과 동일한 방식으로 다루어야 합니다. + +이를 위해 NextJS의 `app` 폴더를 프로젝트 루트로 이동하고, FSD 페이지들을 `app` 폴더로 옮기는 방식을 사용합니다. +이렇게 하면 `src` 폴더 내에서 FSD 프로젝트 구조를 유지할 수 있습니다. +또한, App Router와 Pages Router가 호환되므로 `pages` 폴더를 프로젝트 루트에 추가하는 것이 필요합니다. + +``` +├── app # NextJS app 폴더 +├── pages # NextJS pages 폴더 +│ ├── README.md # 해당 폴더의 목적과 역할에 대한 설명 +├── src +│ ├── app # FSD app 폴더 +│ ├── entities +│ ├── features +│ ├── pages # FSD pages 폴더 +│ ├── shared +│ ├── widgets +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)][ext-app-router-stackblitz] + +## 관련 항목 {#see-also} + +- [(스레드) NextJS의 pages 디렉토리에 대한 토론](https://t.me/feature_sliced/3623) + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md + + diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..0c3e01e3cf --- /dev/null +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx @@ -0,0 +1,425 @@ +--- +sidebar_position: 10 +--- +# React Query와 함께 사용하기 + +## “키를 어디에 두어야 하는가” 문제 + +### 해결책 — 엔티티별로 분리하기 + +프로젝트가 이미 엔티티 단위로 구성되어 있으며, 각 요청이 단일 엔티티에 해당한다면, 엔티티별로 코드를 구성하는 것이 좋습니다. 예를 들어, 다음과 같은 디렉토리 구조를 사용할 수 있습니다: + +```sh +└── src/ # + ├── app/ # + | ... # + ├── pages/ # + | ... # + ├── entities/ # + | ├── {entity}/ # + | ... └── api/ # + | ├── `{entity}.query` # 쿼리 키와 함수 + | ├── `get-{entity}` # 엔티티 조회 함수 + | ├── `create-{entity}` # 엔티티 생성 함수 + | ├── `update-{entity}` # 엔티티 업데이트 함수 + | ├── `delete-{entity}` # 엔티티 삭제 함수 + | ... # + | # + ├── features/ # + | ... # + ├── widgets/ # + | ... # + └── shared/ # + ... # +``` + +만약 엔티티 간에 연결이 필요한 경우 (예: Country 엔티티에 City 엔티티 필드가 포함되는 경우), [교차 가져오기를 위한 공개 API][public-api-for-cross-imports]을 사용하거나 대안으로 아래의 구조를 고려할 수 있습니다. + +### 대안 방안 — shared에 유지하기 + +엔티티별 분리가 적절하지 않은 경우, 다음과 같은 구조를 사용할 수 있습니다: + +```sh +└── src/ # + ... # + └── shared/ # + ├── api/ # + ... ├── `queries` # 쿼리 팩토리들 + | ├── `document.ts` # + | ├── `background-jobs.ts` # + | ... # + └── index.ts # +``` + +이후 `@/shared/api/index.ts`에서 다음과 같이 사용합니다: + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## "mutation 위치 설정" 문제 + +쿼리와 mutation을 같은 위치에 두는 것은 권장되지 않습니다. 다음 두 가지 옵션이 있습니다: + +### 1. 사용 위치 근처의 `api` 디렉토리에서 커스텀 훅 정의하기 + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. 공용 또는 엔티티에서 mutation 함수를 정의하고, 컴포넌트에서 `useMutation`을 직접 사용하기 + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## 요청의 조직화 + +### 쿼리 팩토리 + +쿼리 팩토리는 쿼리 키 목록을 반환하는 함수를 포함한 객체입니다. 사용 방법은 다음과 같습니다: + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + +:::info +`queryOptions`는 react-query@v5의 내장 유틸리티입니다 (선택 사항) + +```ts +queryOptions({ + queryKey, + ...options, +}); +``` + +더 큰 타입 안정성, react-query의 향후 버전과의 호환성, 함수 및 쿼리 키에 대한 쉬운 액세스를 위해, "@tanstack/react-query"의 내장 queryOptions 함수를 사용할 수 있습니다 [(자세한 내용은 여기)](https://tkdodo.eu/blog/the-query-options-api#queryoptions). + +::: + +### 1. 쿼리 팩토리 생성 예시 + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. 애플리케이션 코드에서의 쿼리 팩토리 사용 예시 +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### 쿼리 팩토리 사용의 장점 +- **요청 구조화**: 팩토리를 통해 모든 API 요청을 한 곳에 조직화하여 코드의 가독성과 유지보수성을 높입니다. +- **쿼리 및 키에 대한 편리한 접근**: 다양한 유형의 쿼리와 해당 키에 쉽게 접근할 수 있는 메서드를 제공합니다. +- **쿼리 재호출 용이성**: 애플리케이션의 여러 부분에서 쿼리 키를 변경할 필요 없이 쉽게 재호출할 수 있습니다. + +## 페이지네이션 +이 섹션에서는 페이지네이션을 사용하여 게시물 엔티티를 가져오는 API 요청을 수행하는 `getPosts` 함수의 예를 소개합니다. + +### 1. `getPosts` 함수 생성하기 +getPosts 함수는 `api` 세그먼트의 `get-posts.ts` 파일에 있습니다. + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. 페이지네이션을 위한 쿼리 팩토리 +`postQueries` 쿼리 팩토리는 특정 페이지와 제한에 맞춰 게시물 목록을 요청하는 등 게시물 관련 다양한 쿼리 옵션을 정의합니다. + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. 애플리케이션 코드에서의 사용 + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` +:::note +예시는 단순화된 버전이며, 전체 코드는 [GitHub](https://github.com/ruslan4432013/fsd-react-query-example)에서 확인할 수 있습니다. +::: + +## 쿼리 관리를 위한 `QueryProvider` +이 가이드에서는 `QueryProvider`를 어떻게 구성하는지 살펴봅니다. + +### 1. `QueryProvider` 생성하기 +`query-provider.tsx` 파일은 `@/app/providers/query-provider.tsx` 경로에 있습니다. + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. `QueryClient` 생성하기 +`QueryClient`는 API 요청을 관리하는 인스턴스입니다. `query-client.ts` 파일은 `@/shared/api/query-client.ts`에 속해 있으며, 쿼리 캐싱을 위해 특정 설정으로 `QueryClient`를 생성합니다. + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## 코드 생성 + +API 코드를 생성해주는 도구들이 있지만, 이러한 방식은 위의 예제 처럼 직접 코드를 작성하는 방법보다 유연성이 부족할 수 있습니다. 그러나 Swagger 파일이 잘 구성되어 있고 이러한 자동 생성 도구를 사용하는 경우, 생성된 코드를 `@/shared/api` 디렉토리에 두어 관리하는 것이 효율적일 수 있습니다. + + +## React Query를 조직화하기 위한 추가 조언 +### API 클라이언트 + +공유 레이어에서 커스텀 API 클라이언트 클래스를 사용하면, 프로젝트 내 API 작업을 일관성 있게 관리할 수 있습니다. 이를 통해 로깅, 헤더 설정, 데이터 전송 형식(JSON 또는 XML 등)을 한 곳에서 관리할 수 있게 됩니다. 또한 이 접근 방식은 API와의 상호작용에 대한 변경 사항을 쉽게 반영할 수 있게 하여, 프로젝트의 유지보수성과 개발 편의성을 크게 향상시킵니다. + + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## 참고 자료 {#see-also} + +- [(GitHub) 샘플 프로젝트](https://github.com/ruslan4432013/fsd-react-query-example) +- [(CodeSandbox) 샘플 프로젝트](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) +- [쿼리 팩토리에 대하여](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/ru/docusaurus-plugin-content-docs/current.json b/i18n/ru/docusaurus-plugin-content-docs/current.json index 6487d3a7d4..0ecd0d7164 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current.json +++ b/i18n/ru/docusaurus-plugin-content-docs/current.json @@ -1,6 +1,6 @@ { "version.label": { - "message": "v2.0.0 🍰", + "message": "v2.1", "description": "The label for version current" }, "sidebar.getstartedSidebar.category.🚀 Get Started": { diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/get-started/faq.md b/i18n/ru/docusaurus-plugin-content-docs/current/get-started/faq.md index f5ea8d3154..1a51f0c1d8 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/get-started/faq.md +++ b/i18n/ru/docusaurus-plugin-content-docs/current/get-started/faq.md @@ -13,7 +13,7 @@ pagination_next: guides/index ### Существует ли тулкит или линтер? {#is-there-a-toolkit-or-a-linter} -Есть официальный конфиг для ESLint — [@feature-sliced/eslint-config][eslint-config-official], и плагин для ESLint — [@conarti/eslint-plugin-feature-sliced][eslint-plugin-conarti], созданный участником сообщества Александром Белоусом. Мы будем рады вашим вкладам в эти проекты или созданию своих! +Да! У нас есть линтер [Steiger][ext-steiger] для проверки архитектуры вашего проекта и [генераторы папок][ext-tools] через CLI или IDE. ### Где хранить layout/template страниц? {#where-to-store-the-layouttemplate-of-pages} @@ -58,10 +58,10 @@ _Entity_ — это понятие из реальной жизни, с кото Ответили [здесь](/docs/guides/examples/auth) +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools [import-rule-layers]: /docs/reference/layers#import-rule-on-layers [reference-entities]: /docs/reference/layers#entities -[eslint-config-official]: https://github.com/feature-sliced/eslint-config -[eslint-plugin-conarti]: https://github.com/conarti/eslint-plugin-feature-sliced [motivation]: /docs/about/motivation [telegram]: https://t.me/feature_sliced [discord]: https://discord.gg/S8MzWTUsmp diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/get-started/overview.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/get-started/overview.mdx index 776f75a0b1..2d92c60954 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/get-started/overview.mdx +++ b/i18n/ru/docusaurus-plugin-content-docs/current/get-started/overview.mdx @@ -65,15 +65,15 @@ FSD можно внедрять в проектах и командах любо Слои стандартизированы во всех проектах FSD. Вам не обязательно использовать все слои, но их названия важны. На данный момент их семь (сверху вниз): -1. App\* — всё, благодаря чему приложение запускается — роутинг, точки входа, глобальные стили, провайдеры и т. д. -2. Processes (процессы, устаревший) — сложные межстраничные сценарии. -3. Pages (страницы) — полные страницы или большие части страницы при вложенном роутинге. -4. Widgets (виджеты) — большие самодостаточные куски функциональности или интерфейса, обычно реализующие целый пользовательский сценарий. -5. Features (фичи) — _повторно используемые_ реализации целых фич продукта, то есть действий, приносящих бизнес-ценность пользователю. -6. Entities (сущности) — бизнес-сущности, с которыми работает проект, например `user` или `product`. -7. Shared\* — переиспользуемый код, особенно когда она отделена от специфики проекта/бизнеса, хотя это не обязательно. +1. **App\*** — всё, благодаря чему приложение запускается — роутинг, точки входа, глобальные стили, провайдеры и т. д. +2. **Processes** (процессы, устаревший) — сложные межстраничные сценарии. +3. **Pages** (страницы) — полные страницы или большие части страницы при вложенном роутинге. +4. **Widgets** (виджеты) — большие самодостаточные куски функциональности или интерфейса, обычно реализующие целый пользовательский сценарий. +5. **Features** (фичи) — _повторно используемые_ реализации целых фич продукта, то есть действий, приносящих бизнес-ценность пользователю. +6. **Entities** (сущности) — бизнес-сущности, с которыми работает проект, например `user` или `product`. +7. **Shared\*** — переиспользуемый код, особенно когда он отделён от специфики проекта/бизнеса, хотя это не обязательно. -_\* — эти слои, App и Shared, в отличие от других слоев, не имеют слайсов и состоят из сегментов напрямую._ +_\* — эти слои, **App** и **Shared**, в отличие от других слоев, не имеют слайсов и состоят из сегментов напрямую._ Фишка слоев в том, что модули на одном слое могут знать только о модулях со слоев строго ниже, и как следствие, импортировать только с них. @@ -131,7 +131,7 @@ _\* — эти слои, App и Shared, в отличие от других сл [tutorial]: /docs/get-started/tutorial [examples]: /examples -[migration]: /docs/guides/migration/from-legacy +[migration]: /docs/guides/migration/from-custom [ext-steiger]: https://github.com/feature-sliced/steiger [ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools [ext-telegram]: https://t.me/feature_sliced diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/get-started/tutorial.md b/i18n/ru/docusaurus-plugin-content-docs/current/get-started/tutorial.md index 3bd40159a6..6638f7d837 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/get-started/tutorial.md +++ b/i18n/ru/docusaurus-plugin-content-docs/current/get-started/tutorial.md @@ -39,7 +39,7 @@ sidebar_position: 2 Ключевое отличие Feature-Sliced Design от произвольной структуры кода заключается в том, что страницы не могут зависеть друг от друга. То есть одна страница не может импортировать код с другой страницы. Это связано с **правилом импорта для слоёв**: -*Модуль в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже.* +*Модуль (файл) в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже.* В этом случае страница является слайсом, поэтому модули (файлы) внутри этой страницы могут импортировать код только из слоев ниже, а не из других страниц. @@ -696,7 +696,7 @@ export function FeedPage() { ### Аутентификация {#authentication} -Аутентификация включает в себя две страницы — одну для входа в систему и другую для регистрации. Они, в основном, очень схожие, поэтому имеет смысл держать их в одном сегменте, `sign-in`, чтобы при необходимости можно было переиспользовать код. +Аутентификация включает в себя две страницы — одну для входа в систему и другую для регистрации. Они, в основном, очень схожие, поэтому имеет смысл держать их в одном слайсе, `sign-in`, чтобы при необходимости можно было переиспользовать код. Создайте `RegisterPage.tsx` в сегменте `ui` в `pages/sign-in` со следующим содержимым: diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/auth.md b/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/auth.md index 1b09d12279..1807b69aed 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/auth.md +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/auth.md @@ -189,7 +189,7 @@ export function login({ email, password }: { email: string, password: string }) Поскольку API-клиент обычно размещается в `shared/api` или распределяется между сущностями, главной проблемой этого подхода является обеспечение доступа к токену для других запросов, не нарушая при этом [правило импортов для слоёв][import-rule-on-layers]: -> Модуль в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже. +> Модуль (файл) в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже. Есть несколько решений этой проблемы: diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/types.md b/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/types.md index c00ae7fe90..5108e959e3 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/types.md +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/examples/types.md @@ -307,7 +307,7 @@ export const slice = createSlice({ extraReducers: (builder) => { builder.addCase(fetchSong.fulfilled, (state, action) => { // И здесь обрабатываем тот же ответ с бэкенда, добавляя исполнителей - usersAdapter.upsertMany(state, action.payload.users) + artistAdapter.upsertMany(state, action.payload.artists) }) }, }) diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/index.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/guides/index.mdx index 74b124da46..f720736132 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/index.mdx +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/index.mdx @@ -25,10 +25,10 @@ import { ToolOutlined, ImportOutlined, BugOutlined, FunctionOutlined } from "@an /> + 📁 src +
    +
  • +
    + 📁 actions +
      +
    • 📁 product
    • +
    • 📁 order
    • +
    +
    +
  • +
  • 📁 api
  • +
  • 📁 components
  • +
  • 📁 containers
  • +
  • 📁 constants
  • +
  • 📁 i18n
  • +
  • 📁 modules
  • +
  • 📁 helpers
  • +
  • +
    + 📁 routes +
      +
    • 📁 products.jsx
    • +
    • 📄 products.[id].jsx
    • +
    +
    +
  • +
  • 📁 utils
  • +
  • 📁 reducers
  • +
  • 📁 selectors
  • +
  • 📁 styles
  • +
  • 📄 App.jsx
  • +
  • 📄 index.js
  • +
+ + +## Перед началом {#before-you-start} + +Самый важный вопрос, который нужно задать своей команде при рассмотрении перехода на Feature-Sliced Design, — _действительно ли вам это нужно?_ Мы любим Feature-Sliced Design, но даже мы признаем, что некоторые проекты прекрасно обойдутся и без него. + +Вот несколько причин, по которым стоит рассмотреть переход: + +1. Новые члены команды жалуются, что сложно достичь продуктивного уровня +2. Внесение изменений в одну часть кода **часто** приводит к тому, что ломается другая несвязанная часть +3. Добавление новой функциональности затруднено из-за огромного количества вещей, о которых нужно думать + +**Избегайте перехода на FSD против воли ваших коллег**, даже если вы являетесь тимлидом. +Сначала убедите своих коллег в том, что преимущества перевешивают стоимость миграции и стоимость изучения новой архитектуры вместо установленной. + +Также имейте в виду, что любые изменения в архитектуре незаметны для руководства в моменте. Убедитесь, что они поддерживают переход, прежде чем начинать, и объясните им, как этот переход может быть полезен для проекта. + +:::tip + +Если вам нужна помощь в убеждении менеджера проекта в том, что FSD вам полезен, вот несколько идей: + +1. Миграция на FSD может происходить постепенно, поэтому она не остановит разработку новых функций +2. Хорошая архитектура может значительно сократить время, которое потребуется новым разработчикам для достижения производительности +3. FSD — это документированная архитектура, поэтому команде не нужно постоянно тратить время на поддержание собственной документации + +::: + +--- + +Если вы всё-таки приняли решение начать миграцию, то первое, что вам следует сделать, — настроить алиас для `📁 src`. Это будет полезно позже, чтоб ссылаться на папки верхнего уровня. Далее в тексте мы будем считать `@` псевдонимом для `./src`. + +## Шаг 1. Разделите код по страницам {#divide-code-by-pages} + +Большинство кастомных архитектур уже имеют разделение по страницам, независимо от размера логики. Если у вас уже есть `📁 pages`, вы можете пропустить этот шаг. + +Если у вас есть только `📁 routes`, создайте `📁 pages` и попробуйте переместить как можно больше кода компонентов из `📁 routes`. Идеально, если у вас будет маленький файл роута и больший файл страницы. При перемещении кода создайте папку для каждой страницы и добавьте в нее индекс-файл: + +:::note + +Пока что ваши страницы могут импортировать друг из друга, это нормально. Позже будет отдельный шаг для устранения этих зависимостей, но сейчас сосредоточьтесь на установлении явного разделения по страницам. + +::: + +Файл роута: + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +Индекс-файл страницы: + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +Файл с компонентом страницы: + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## Шаг 2. Отделите все остальное от страниц {#separate-everything-else-from-pages} + +Создайте папку `📁 src/shared` и переместите туда все, что не импортируется из `📁 pages` или `📁 routes`. Создайте папку `📁 src/app` и переместите туда все, что импортирует страницы или роуты, включая сами роуты. + +Помните, что у слоя Shared нет слайсов, поэтому сегменты могут импортировать друг из друга. + +В итоге у вас должна получиться структура файлов, похожая на эту: + +
+ 📁 src +
    +
  • +
    + 📁 app +
      +
    • +
      + 📁 routes +
        +
      • 📄 products.jsx
      • +
      • 📄 products.[id].jsx
      • +
      +
      +
    • +
    • 📄 App.jsx
    • +
    • 📄 index.js
    • +
    +
    +
  • +
  • +
    + 📁 pages +
      +
    • +
      + 📁 product +
        +
      • +
        + 📁 ui +
          +
        • 📄 ProductPage.jsx
        • +
        +
        +
      • +
      • 📄 index.js
      • +
      +
      +
    • +
    • 📁 catalog
    • +
    +
    +
  • +
  • +
    + 📁 shared +
      +
    • 📁 actions
    • +
    • 📁 api
    • +
    • 📁 components
    • +
    • 📁 containers
    • +
    • 📁 constants
    • +
    • 📁 i18n
    • +
    • 📁 modules
    • +
    • 📁 helpers
    • +
    • 📁 utils
    • +
    • 📁 reducers
    • +
    • 📁 selectors
    • +
    • 📁 styles
    • +
    +
    +
  • +
+
+ +## Шаг 3. Устраните кросс-импорты между страницами {#tackle-cross-imports-between-pages} + +Найдите все случаи, когда одна страница импортирует что-то из другой, и сделайте одно из двух: + +1. Скопируйте код, который импортируется, в зависимую страницу, чтобы убрать зависимость +2. Переместите код в соответствующий сегмент в Shared: + - если это часть UI-кита, переместите в `📁 shared/ui`; + - если это константа конфигурации, переместите в `📁 shared/config`; + - если это взаимодействие с бэкендом, переместите в `📁 shared/api`. + +:::note + +**Копирование само по себе не является архитектурной проблемой**, на самом деле иногда даже правильнее продублировать что-то, чем абстрагировать в новый переиспользуемый модуль. Дело в том, что иногда общие части страниц начинают расходиться, и в этих случаях вам не нужно, чтобы эти зависимости мешались. + +Однако существует смысл в принципе DRY ("don't repeat yourself" — "не повторяйтесь"), поэтому убедитесь, что вы не копируете бизнес-логику. В противном случае вам придется держать в голове, что баги нужно исправлять в нескольких местах одновременно. + +::: + +## Шаг 4. Разберите слой Shared {#unpack-shared-layer} + +На данном этапе у вас может быть много всего в слое Shared, и в целом, следует избегать таких ситуаций. Причина этому в том, что слой Shared может быть зависимостью для любого другого слоя в вашем коде, поэтому внесение изменений в этот код автоматически более чревато непредвиденными последствиями. + +Найдите все объекты, которые используются только на одной странице, и переместите их в слайс этой страницы. И да, _это относится и к экшнам (actions), редьюсерам (reducers) и селекторам (selectors)_. Нет никакой пользы в группировке всех экшнов вместе, но есть польза в том, чтобы поместить актуальные экшны рядом с их местом использования. + +В итоге у вас должна получиться структура файлов, похожая на эту: + +
+ 📁 src +
    +
  • 📁 app (unchanged)
  • +
  • +
    + 📁 pages +
      +
    • +
      + 📁 product +
        +
      • 📁 actions
      • +
      • 📁 reducers
      • +
      • 📁 selectors
      • +
      • +
        + 📁 ui +
          +
        • 📄 Component.jsx
        • +
        • 📄 Container.jsx
        • +
        • 📄 ProductPage.jsx
        • +
        +
        +
      • +
      • 📄 index.js
      • +
      +
      +
    • +
    • 📁 catalog
    • +
    +
    +
  • +
  • +
    + 📁 shared (only objects that are reused) +
      +
    • 📁 actions
    • +
    • 📁 api
    • +
    • 📁 components
    • +
    • 📁 containers
    • +
    • 📁 constants
    • +
    • 📁 i18n
    • +
    • 📁 modules
    • +
    • 📁 helpers
    • +
    • 📁 utils
    • +
    • 📁 reducers
    • +
    • 📁 selectors
    • +
    • 📁 styles
    • +
    +
    +
  • +
+
+ +## Шаг 5. Распределите код по техническому назначению {#organize-by-technical-purpose} + +В FSD разделение по техническому назначению происходит с помощью _сегментов_. Существует несколько часто встречающихся сегментов: + +- `ui` — всё, что связано с отображением интерфейса: компоненты UI, форматирование дат, стили и т. д. +- `api` — взаимодействие с бэкендом: функции запросов, типы данных, мапперы и т. д. +- `model` — модель данных: схемы, интерфейсы, хранилища и бизнес-логика. +- `lib` — библиотечный код, который нужен другим модулям на этом слайсе. +- `config` — файлы конфигурации и фиче-флаги. + +Вы можете создавать свои собственные сегменты, если это необходимо. Убедитесь, что не создаете сегменты, которые группируют код по тому, чем он является, например, `components`, `actions`, `types`, `utils`. Вместо этого группируйте код по тому, для чего он предназначен. + +Перераспределите код ваших страниц по сегментам. У вас уже должен быть сегмент `ui`, теперь пришло время создать другие сегменты, например, `model` для ваших экшнов, редьюсеров и селекторов, или `api` для ваших thunk-ов и мутаций. + +Также перераспределите слой Shared, чтобы удалить следующие папки: +- `📁 components`, `📁 containers` — большинство из их содержимого должно стать `📁 shared/ui`; +- `📁 helpers`, `📁 utils` — если остались какие-то повторно используемые хелперы, сгруппируйте их по назначению, например, даты или преобразования типов, и переместите эти группы в `📁 shared/lib`; +- `📁 constants` — так же сгруппируйте по назначению и переместите в `📁 shared/config`. + +## Шаги по желанию {#optional-steps} + +### Шаг 6. Создайте сущности/фичи ёмкостью из Redux-слайсов, которые используются на нескольких страницах {#form-entities-features-from-redux} + +Обычно эти переиспользуемые Redux-слайсы будут описывать что-то, что имеет отношение к бизнесу, например, продукты или пользователи, поэтому их можно переместить в слой Entities, одна сущность на одну папку. Если Redux-слайс скорее связан с действием, которое ваши пользователи хотят совершить в вашем приложении, например, комментарии, то его можно переместить в слой Features. + +Сущности и фичи должны быть независимы друг от друга. Если ваша бизнес-область содержит встроенные связи между сущностями, обратитесь к [руководству по бизнес-сущностям][business-entities-cross-relations] за советом по организации этих связей. + +API-функции, связанные с этими слайсами, могут остаться в `📁 shared/api`. + +### Шаг 7. Проведите рефакторинг modules {#refactor-your-modules} + +Папка `📁 modules` обычно используется для бизнес-логики, поэтому она уже довольно похожа по своей природе на слой Features из FSD. Некоторые модули могут также описывать большие части пользовательского интерфейса, например, шапку приложения. В этом случае их можно переместить в слой Widgets. + +### Шаг 8. Сформируйте чистый фундамент UI в `shared/ui` {#form-clean-ui-foundation} + +`📁 shared/ui`, в идеале, должен содержать набор UI-элементов, в которых нет бизнес-логики. Они также должны быть очень переиспользуемыми. + +Проведите рефакторинг UI-компонентов, которые раньше находились в `📁 components` и `📁 containers`, чтобы отделить бизнес-логику. Переместите эту бизнес-логику в верхние слои. Если она не используется в слишком многих местах, вы даже можете рассмотреть копирование как вариант. + +## See also {#see-also} + +- [(Доклад) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-legacy.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-legacy.mdx deleted file mode 100644 index 7bc5252981..0000000000 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-legacy.mdx +++ /dev/null @@ -1,121 +0,0 @@ ---- -sidebar_position: 3 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# Миграция с legacy - - - -> В статье агрегируется опыт нескольких компаний и проектов по переезду на Feature-Sliced Design с разными изначальными условиями - -## Зачем? {#why} - -> Насколько нужен переезд? "Смерть от тысячи порезов" и тех долг. Чего не хватает? Чем может помочь методология? - -> См. доклад [Илья Климова про необходимость и порядок рефакторинга](https://youtu.be/aOiJ3k2UvO4) - -![approaches-themed-bordered](/img/approaches.png) - -## Какой план? {#whats-the-plan} - -### 1. Унификация кодовой базы {#1-unification-of-the-code-base} - -```diff -- ├── products/ -- | ├── components/ -- | ├── containers/ -- | ├── store/ -- | ├── styles/ -- ├── checkout/ -- | ├── components/ -- | ├── containers/ -- | ├── helpers/ -- | ├── styles/ -+ └── src/ - ├── actions/ - ├── api/ -+ ├── components/ -+ ├── containers/ - ├── constants/ - ├── epics/ -+ ├── i18n/ - ├── modules/ -+ ├── helpers/ -+ ├── pages/ -- ├── routes/ -- ├── utils/ - ├── reducers/ -- ├── redux/ - ├── selectors/ -+ ├── store -+ ├── styles/ - ├── App.jsx - └── index.jsx -``` - - -### 2. Собираем вместе излишне раздробленное {#2-putting-together-the-destructive-decoupled} - -```diff - └── src/ -- ├── actions/ - ├── api/ -- ├── components/ -- ├── containers/ -- ├── constants/ -- ├── epics/ -+ ├── entities/{...} -+ | ├── ui -+ | ├── model/{actions, selectors, ...} -+ | ├── lib - ├── i18n/ - | # Временно можем положить сюда оставшиеся сегменты -+ ├── modules/{helpers, constants} -- ├── helpers/ - ├── pages/ -- ├── reducers/ -- ├── selectors/ -- ├── store/ - ├── styles/ - ├── App.jsx - └── index.jsx -``` - -### 3. Выделяем скоупы ответственности {#3-allocate-scopes-of-responsibility} - -```diff - └── src/ -- ├── api/ -+ ├── app/ -+ | ├── index.jsx -+ | ├── style.css - ├── pages/ -+ ├── features/ -+ | ├── add-to-cart/{ui, model, lib} -+ | ├── choose-delivery/{ui, model, lib} -+ ├── entities/{...} -+ | ├── delivery/{ui, model, lib} -+ | ├── cart/{ui, model, lib} -+ | ├── product/{ui, model, lib} -+ ├── shared/ -+ | ├── api/ -+ | ├── lib/ # helpers -+ | | ├── i18n/ -+ | ├── config/ # constants -- ├── i18n/ -- ├── modules/{helpers, constants} - └── index.jsx -``` - -### 4. Final ? - -> Про оставшиеся проблемы и насколько стоит их устранять - -## См. также {#see-also} - -- [(Доклад) Илья Климов - Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) -- [(Доклад) Илья Азин - Архитектура Frontend проектов](https://youtu.be/SnzPAr_FJ7w) - - В докладе в том числе рассмотрены подходы к архитектуре и стоимости рефакторинга \ No newline at end of file diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md b/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md index c0f4d33e02..5613ab5d27 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v1.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 2 --- # Миграция с v1 @@ -157,10 +157,10 @@ sidebar_position: 4 - [Новые идеи v2 с пояснениями (atomicdesign-chat)][ext-tg-v2-draft] - [Обсуждение абстракций и нейминга для новой версии методологии (v2)](https://github.com/feature-sliced/documentation/discussions/31) -[refs-low-coupling]: /docs/reference/isolation/coupling-cohesion +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion [refs-adaptability]: /docs/about/understanding/naming -[ext-v1]: https://featureslices.dev/v1.0.html +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html [ext-tg-spb]: https://t.me/feature_slices [ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven [ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md b/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md new file mode 100644 index 0000000000..371843d981 --- /dev/null +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/migration/from-v2-0.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 3 +--- + +# Миграция с v2.0 на v2.1 + +Основным изменением в v2.1 является новая ментальная модель разложения интерфейса — сначала страницы. + +В версии FSD 2.0 рекомендовалось найти сущности и фичи в вашем интерфейсе, рассматривая даже малейшие части представления сущностей и интерактивность как кандидаты на декомпозицию. Затем вы бы могли строить виджеты и страницы из сущностей и фич. В этой модели декомпозиции большая часть логики находилась в сущностях и фичах, а страницы были просто композиционными слоями, которые сами по себе не имели большого значения. + +В версии FSD 2.1 мы рекомендуем начинать со страниц, и возможно даже на них и остановиться. Большинство людей уже знают, как разделить приложение на страницы, и страницы также часто являются отправной точкой при попытке найти компонент в кодовой базе. В новой модели декомпозиции вы храните большую часть интерфейса и логики в каждой отдельной странице, а повторно используемый фундамент — в Shared. Если возникнет необходимость переиспользования бизнес-логики на нескольких страницах, вы можете переместить её на слой ниже. + +Другим нововведением в Feature-Sliced Design 2.1 является стандартизация кросс-импортов между сущностями с помощью `@x`-нотации. + +## Как мигрировать {#how-to-migrate} + +В версии 2.1 нет ломающих изменений, что означает, что проект, написанный с использованием FSD v2.0, также является валидным проектом в FSD v2.1. Однако мы считаем, что новая ментальная модель более полезна для команд и особенно для обучения новых разработчиков, поэтому рекомендуем внести небольшие изменения в вашу декомпозицию. + +### Соедините слайсы + +Простой способ начать — запустить на проекте наш линтер, [Steiger][steiger]. Steiger построен с новой ментальной моделью, и наиболее полезные правила будут: + +- [`insignificant-slice`][insignificant-slice] — если сущность или фича используется только на одной странице, это правило предложит целиком переместить код этой сущности или фичи прямо в эту страницу. +- [`excessive-slicing`][excessive-slicing] — если у слоя слишком много слайсов, это обычно означает, что декомпозиция слишком мелкая. Это правило предложит объединить или сгруппировать некоторые слайсы, чтобы помочь в навигации по проекту. + +```bash +npx steiger src +``` + +Это поможет вам определить, какие слайсы используются только один раз, чтобы вы могли ещё раз подумать, действительно ли они необходимы. Помните, что слой формирует своего рода глобальное пространство имен для всех слайсов внутри него. Точно так же, как вы не захотите загрязнять глобальное пространство имен переменными, которые используются только один раз, вы должны относиться к месту в пространстве имен слоя как к ценному месту, которое следует использовать сдержанно. + +### Стандартизируйте кросс-импорты + +Если у вас были кросс-импорты в вашем проекте до этого (мы не осуждаем!), вы теперь можете воспользоваться новой нотацией для кросс-импортов в Feature-Sliced Design — `@x`-нотацией. Она выглядит так: + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +Чтоб узнать больше об этом, обратитесь к разделу [Публичный API для кросс-импортов][public-api-for-cross-imports] в разделе справочника. + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx index ea8e25032d..db40bcd655 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-react-query.mdx @@ -35,7 +35,7 @@ sidebar_position: 10 ``` Если среди сущностей есть связи (например, у сущности Страна есть поле-список сущностей Город), то можно воспользоваться -[экспериментальным подходом к организованным кросс-импортам через @x-нотацию](https://github.com/feature-sliced/documentation/discussions/390#discussioncomment-5570073) или рассмотреть альтернативное решение ниже. +[публичным API для кросс-импортов][public-api-for-cross-imports] или рассмотреть альтернативное решение ниже. ### Альтернативное решение — хранить запросы в общем доступе. @@ -432,3 +432,5 @@ export const apiClient = new ApiClient(API_URL); - [(GitHub) Пример проекта](https://github.com/ruslan4432013/fsd-react-query-example) - [(CodeSandbox) Пример проекта](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) - [О фабрике запросов](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx index 185133bedb..35e05047ec 100644 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx +++ b/i18n/ru/docusaurus-plugin-content-docs/current/guides/tech/with-sveltekit.mdx @@ -60,10 +60,10 @@ export default config; fsd pages home ``` -Создайте файл `home-page.vue` внутри сегмента ui, откройте к нему доступ с помощью Public API +Создайте файл `home-page.svelte` внутри сегмента ui, откройте к нему доступ с помощью Public API ```ts title="src/pages/home/index.ts" -export { default as HomePage } from './ui/home-page'; +export { default as HomePage } from './ui/home-page.svelte'; ``` Создайте роут для этой страницы внутри слоя `app`: @@ -82,7 +82,7 @@ export { default as HomePage } from './ui/home-page'; │ │ │ ├── index.ts ``` -Добавьте внутрь `index.svelte` файла компонент вашей страницы: +Добавьте внутрь `+page.svelte` файла компонент вашей страницы: ```html title="src/app/routes/+page.svelte"
Sergey Sova
Sergey Sova

📝 📖 💡 🤔 📆 💬 🚇 🔬 📋 📢 🚧
Sergey Sova
Sergey Sova

📝 📖 💡 🤔 📆 💬 🚇 🔬 📋 📢 🚧
Ilya Azin
Ilya Azin

📖 💡 🤔 📆 💬 👀 🚇 📓 🎨 📢 🚧
Rin 🦊🪐😈 Akaia
Rin 🦊🪐😈 Akaia

📖 🖋 🤔 💬 🌍 📢 🚧 🔬
Alexander Khoroshikh
Alexander Khoroshikh

📖 🤔 💬 👀 🔧 🛡️ 📢 🚧