# Feature-Sliced Design ## zh - [示例](/examples.md): 使用 Feature-Sliced Design 构建的网站列表 - [🧭 导航](/nav.md): Feature-Sliced Design Navigation help page - [Feature-Sliced Design 版本](/versions.md): Feature-Sliced Design Versions page listing all documented site versions - [💫 Community](/community.md): Community resources, additional materials - [Team](/community/team.md): Core-team - [替代方案](/docs/about/alternatives.md): 架构方法的历史 - [使命](/docs/about/mission.md): 在这里我们描述方法论适用性的目标和限制——这是我们在开发该方法论时所遵循的指导原则 - [动机](/docs/about/motivation.md): Feature-Sliced Design(特性分层设计)的主要理念是基于结合研究成果,讨论各种类型开发者的广泛经验,来促进和降低复杂项目开发的成本。 - [在公司中推广](/docs/about/promote/for-company.md): 项目和公司需要方法论吗? - [在团队中推广](/docs/about/promote/for-team.md): - 入职新成员 - [集成方面](/docs/about/promote/integration.md): 总结 - [部分应用](/docs/about/promote/partial-application.md): 如何部分应用方法论?这样做有意义吗?如果我忽略它会怎样? - [抽象](/docs/about/understanding/abstractions.md): 抽象泄漏定律 - [关于架构](/docs/about/understanding/architecture.md): 问题 - [项目中的知识类型](/docs/about/understanding/knowledge-types.md): 在任何项目中都可以区分以下"知识类型": - [命名](/docs/about/understanding/naming.md): 不同的开发者有不同的经验和上下文,当相同的实体被不同地命名时,这可能导致团队中的误解。例如: - [需求驱动](/docs/about/understanding/needs-driven.md): — 无法明确表述新功能要解决的目标?或者问题在于任务本身没有被明确表述?**重点是方法论有助于揭示任务和目标定义中的问题** - [架构信号](/docs/about/understanding/signals.md): 如果架构方面存在限制,那么这是有明显原因的,如果忽略这些限制就会产生后果 - [品牌指南](/docs/branding.md): FSD 的视觉身份基于其核心概念:分层、切片式自包含部分、部分和组合、分段。 - [分解速查表](/docs/get-started/cheatsheet.md): 在您决定如何分解 UI 时,将此作为快速参考。下面还提供了 PDF 版本,您可以打印出来放在枕头下。 - [常见问题](/docs/get-started/faq.md): 您可以在我们的 Telegram 聊天、Discord 社区 和 GitHub Discussions 中提问。 - [概览](/docs/get-started/overview.md): Feature-Sliced Design(FSD)是一种用于构建前端应用程序的架构方法论。简单来说,它是组织代码的规则和约定的汇编。该方法论的主要目的是在不断变化的业务需求面前,使项目更加易于理解和稳定。 - [教程](/docs/get-started/tutorial.md): 第一部分。理论上 - [处理 API 请求](/docs/guides/examples/api-requests.md): handling-api-requests} - [身份验证](/docs/guides/examples/auth.md): 广义上,身份验证包含以下步骤: - [自动完成](/docs/guides/examples/autocompleted.md): 关于按层分解 - [浏览器 API](/docs/guides/examples/browser-api.md): 关于使用浏览器 API:localStorage、音频 API、蓝牙 API 等。 - [CMS](/docs/guides/examples/cms.md): 功能可能不同 - [反馈](/docs/guides/examples/feedback.md): 错误、警告、通知…… - [国际化](/docs/guides/examples/i18n.md): 在哪里放置它?如何使用它? - [指标](/docs/guides/examples/metric.md): 关于在应用程序中初始化指标的方法 - [单体仓库](/docs/guides/examples/monorepo.md): 关于单体仓库的适用性,关于 bff,关于微应用 - [页面布局](/docs/guides/examples/page-layout.md): 本指南探讨了页面布局的抽象 — 当多个页面共享相同的整体结构,仅在主要内容上有所不同时。 - [桌面/触摸平台](/docs/guides/examples/platforms.md): 关于方法论在桌面/触摸平台上的应用 - [SSR](/docs/guides/examples/ssr.md): 关于使用方法论实现 SSR - [主题](/docs/guides/examples/theme.md): 我应该把主题和调色板的工作放在哪里? - [类型](/docs/guides/examples/types.md): 本指南涉及来自类型化语言(如 TypeScript)的数据类型,并描述它们在 FSD 中的适用位置。 - [白标](/docs/guides/examples/white-labels.md): Figma、品牌 uikit、模板、品牌适应性 - [交叉导入](/docs/guides/issues/cross-imports.md): 当层或抽象开始承担超出其应有责任时,就会出现交叉导入。这就是为什么方法论识别出新的层,允许您解耦这些交叉导入 - [去分段化](/docs/guides/issues/desegmented.md): 情况 - [Excessive Entities](/docs/guides/issues/excessive-entities.md): The entities layer in Feature-Sliced Design is the first layer that incorporates business logic, distinguishing it from the shared layer. Unlike the model segment, it is globally accessible (except by shared), making it reusable across the application. However, its global nature means changes can have a widespread impact, requiring careful design to avoid costly refactors. - [路由](/docs/guides/issues/routes.md): 情况 - [从自定义架构迁移](/docs/guides/migration/from-custom.md): 本指南描述了一种在从自定义自制架构迁移到 Feature-Sliced Design 时可能有用的方法。 - [从 v1 到 v2 的迁移](/docs/guides/migration/from-v1.md): 为什么是 v2? - [从v2.0到v2.1的迁移](/docs/guides/migration/from-v2-0.md): v2.1的主要变化是分解界面的新思维模型——页面优先。 - [与 Electron 一起使用](/docs/guides/tech/with-electron.md): Electron 应用程序具有特殊的架构,由具有不同职责的多个进程组成。在这种情况下应用 FSD 需要将结构适应 Electron 的特性。 - [与 Next.js 一起使用](/docs/guides/tech/with-nextjs.md): 如果您解决了主要冲突——app 和 pages 文件夹,FSD 与 Next.js 的 App Router 版本和 Pages Router 版本都兼容。 - [与 NuxtJS 一起使用](/docs/guides/tech/with-nuxtjs.md): 可以在 NuxtJS 项目中实现 FSD,但由于 NuxtJS 项目结构要求与 FSD 原则之间的差异,会产生冲突: - [与 React Query 一起使用](/docs/guides/tech/with-react-query.md): "键放在哪里"的问题 - [与 SvelteKit 一起使用](/docs/guides/tech/with-sveltekit.md): 可以在 SvelteKit 项目中实现 FSD,但由于 SvelteKit 项目的结构要求与 FSD 原则之间的差异,会产生冲突: - [大语言模型文档](/docs/llms.md): 本页面为大语言模型(LLM)爬虫提供链接和指导。 - [层](/docs/reference/layers.md): 层是 Feature-Sliced Design 中组织层次结构的第一级。它们的目的是根据代码需要的责任程度以及它依赖应用程序中其他模块的程度来分离代码。每一层都承载着特殊的语义意义,帮助您确定应该为您的代码分配多少责任。 - [Public API](/docs/reference/public-api.md): Public API 是一组模块(如 slice)与使用它的代码之间的契约。它也充当网关,只允许访问某些对象,并且只能通过该 public API 访问。 - [Slices 和 segments](/docs/reference/slices-segments.md): Slices - [Feature-Sliced Design](/index.md): Architectural methodology for frontend projects --- # Full Documentation Content v2 ![](/zh/assets/ideal-img/tiny-bunny.dd60f55.640.png) Tiny Bunny Mini Game Mini-game "21 points" in the universe of the visual novel "Tiny Bunny". reactredux-toolkittypescript [Website](https://sanua356.github.io/tiny-bunny/)[Source](https://github.com/sanua356/tiny-bunny) --- # 🧭 导航 ## 旧版路由 在文档重构后,一些路由已经更改。下面您可以找到可能正在寻找的页面。 但为了兼容性,旧链接会有重定向 ### 🚀 Get Started ⚡️ Simplified and merged [Tutorial](/zh/docs/get-started/tutorial.md) [**old**:](/zh/docs/get-started/tutorial.md) [/docs/get-started/quick-start](/zh/docs/get-started/tutorial.md) [**new**: ](/zh/docs/get-started/tutorial.md) [/docs/get-started/tutorial](/zh/docs/get-started/tutorial.md) [Basics](/zh/docs/get-started/overview.md) [**old**:](/zh/docs/get-started/overview.md) [/docs/get-started/basics](/zh/docs/get-started/overview.md) [**new**: ](/zh/docs/get-started/overview.md) [/docs/get-started/overview](/zh/docs/get-started/overview.md) [Decompose Cheatsheet](/zh/docs/get-started/cheatsheet.md) [**old**:](/zh/docs/get-started/cheatsheet.md) [/docs/get-started/tutorial/decompose; /docs/get-started/tutorial/design-mockup; /docs/get-started/onboard/cheatsheet](/zh/docs/get-started/cheatsheet.md) [**new**: ](/zh/docs/get-started/cheatsheet.md) [/docs/get-started/cheatsheet](/zh/docs/get-started/cheatsheet.md) ### 🍰 Alternatives ⚡️ Moved and merged to /about/alternatives as advanced materials [Architecture approaches alternatives](/zh/docs/about/alternatives.md) [**old**:](/zh/docs/about/alternatives.md) [/docs/about/alternatives/big-ball-of-mud; /docs/about/alternatives/design-principles; /docs/about/alternatives/ddd; /docs/about/alternatives/clean-architecture; /docs/about/alternatives/frameworks; /docs/about/alternatives/atomic-design; /docs/about/alternatives/smart-dumb-components; /docs/about/alternatives/feature-driven](/zh/docs/about/alternatives.md) [**new**: ](/zh/docs/about/alternatives.md) [/docs/about/alternatives](/zh/docs/about/alternatives.md) ### 🍰 Promote & Understanding ⚡️ Moved to /about as advanced materials [Knowledge types](/zh/docs/about/understanding/knowledge-types.md) [**old**:](/zh/docs/about/understanding/knowledge-types.md) [/docs/reference/knowledge-types](/zh/docs/about/understanding/knowledge-types.md) [**new**: ](/zh/docs/about/understanding/knowledge-types.md) [/docs/about/understanding/knowledge-types](/zh/docs/about/understanding/knowledge-types.md) [Needs driven](/zh/docs/about/understanding/needs-driven.md) [**old**:](/zh/docs/about/understanding/needs-driven.md) [/docs/concepts/needs-driven](/zh/docs/about/understanding/needs-driven.md) [**new**: ](/zh/docs/about/understanding/needs-driven.md) [/docs/about/understanding/needs-driven](/zh/docs/about/understanding/needs-driven.md) [About architecture](/zh/docs/about/understanding/architecture.md) [**old**:](/zh/docs/about/understanding/architecture.md) [/docs/concepts/architecture](/zh/docs/about/understanding/architecture.md) [**new**: ](/zh/docs/about/understanding/architecture.md) [/docs/about/understanding/architecture](/zh/docs/about/understanding/architecture.md) [Naming adaptability](/zh/docs/about/understanding/naming.md) [**old**:](/zh/docs/about/understanding/naming.md) [/docs/concepts/naming-adaptability](/zh/docs/about/understanding/naming.md) [**new**: ](/zh/docs/about/understanding/naming.md) [/docs/about/understanding/naming](/zh/docs/about/understanding/naming.md) [Signals of architecture](/zh/docs/about/understanding/signals.md) [**old**:](/zh/docs/about/understanding/signals.md) [/docs/concepts/signals](/zh/docs/about/understanding/signals.md) [**new**: ](/zh/docs/about/understanding/signals.md) [/docs/about/understanding/signals](/zh/docs/about/understanding/signals.md) [Abstractions of architecture](/zh/docs/about/understanding/abstractions.md) [**old**:](/zh/docs/about/understanding/abstractions.md) [/docs/concepts/abstractions](/zh/docs/about/understanding/abstractions.md) [**new**: ](/zh/docs/about/understanding/abstractions.md) [/docs/about/understanding/abstractions](/zh/docs/about/understanding/abstractions.md) ### 📚 Reference guidelines (isolation & units) ⚡️ Moved to /reference as theoretical materials (old concepts) [Decouple of entities](/zh/docs/reference/layers.md#import-rule-on-layers) [**old**:](/zh/docs/reference/layers.md#import-rule-on-layers) [/docs/concepts/decouple-entities](/zh/docs/reference/layers.md#import-rule-on-layers) [**new**: ](/zh/docs/reference/layers.md#import-rule-on-layers) [/docs/reference/layers#import-rule-on-layers](/zh/docs/reference/layers.md#import-rule-on-layers) [Low Coupling & High Cohesion](/zh/docs/reference/slices-segments.md#zero-coupling-high-cohesion) [**old**:](/zh/docs/reference/slices-segments.md#zero-coupling-high-cohesion) [/docs/concepts/low-coupling](/zh/docs/reference/slices-segments.md#zero-coupling-high-cohesion) [**new**: ](/zh/docs/reference/slices-segments.md#zero-coupling-high-cohesion) [/docs/reference/slices-segments#zero-coupling-high-cohesion](/zh/docs/reference/slices-segments.md#zero-coupling-high-cohesion) [Cross-communication](/zh/docs/reference/layers.md#import-rule-on-layers) [**old**:](/zh/docs/reference/layers.md#import-rule-on-layers) [/docs/concepts/cross-communication](/zh/docs/reference/layers.md#import-rule-on-layers) [**new**: ](/zh/docs/reference/layers.md#import-rule-on-layers) [/docs/reference/layers#import-rule-on-layers](/zh/docs/reference/layers.md#import-rule-on-layers) [App splitting](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/concepts/app-splitting](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Decomposition](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/decomposition](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Units](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Layers](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Layer overview](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/layers/overview](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [App layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/app](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Processes layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/processes](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Pages layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/pages](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Widgets layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/widgets](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Widgets layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/layers/widgets](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Features layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/features](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Entities layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/entities](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Shared layer](/zh/docs/reference/layers.md) [**old**:](/zh/docs/reference/layers.md) [/docs/reference/units/layers/shared](/zh/docs/reference/layers.md) [**new**: ](/zh/docs/reference/layers.md) [/docs/reference/layers](/zh/docs/reference/layers.md) [Segments](/zh/docs/reference/slices-segments.md) [**old**:](/zh/docs/reference/slices-segments.md) [/docs/reference/units/segments](/zh/docs/reference/slices-segments.md) [**new**: ](/zh/docs/reference/slices-segments.md) [/docs/reference/slices-segments](/zh/docs/reference/slices-segments.md) ### 🎯 Bad Practices handbook ⚡️ Moved to /guides as practice materials [Cross-imports](/zh/docs/guides/issues/cross-imports.md) [**old**:](/zh/docs/guides/issues/cross-imports.md) [/docs/concepts/issues/cross-imports](/zh/docs/guides/issues/cross-imports.md) [**new**: ](/zh/docs/guides/issues/cross-imports.md) [/docs/guides/issues/cross-imports](/zh/docs/guides/issues/cross-imports.md) [Desegmented](/zh/docs/guides/issues/desegmented.md) [**old**:](/zh/docs/guides/issues/desegmented.md) [/docs/concepts/issues/desegmented](/zh/docs/guides/issues/desegmented.md) [**new**: ](/zh/docs/guides/issues/desegmented.md) [/docs/guides/issues/desegmented](/zh/docs/guides/issues/desegmented.md) [Routes](/zh/docs/guides/issues/routes.md) [**old**:](/zh/docs/guides/issues/routes.md) [/docs/concepts/issues/routes](/zh/docs/guides/issues/routes.md) [**new**: ](/zh/docs/guides/issues/routes.md) [/docs/guides/issues/routes](/zh/docs/guides/issues/routes.md) ### 🎯 Examples ⚡️ Grouped and simplified into /guides/examples as practical examples [Viewer logic](/zh/docs/guides/examples/auth.md) [**old**:](/zh/docs/guides/examples/auth.md) [/docs/guides/examples/viewer](/zh/docs/guides/examples/auth.md) [**new**: ](/zh/docs/guides/examples/auth.md) [/docs/guides/examples/auth](/zh/docs/guides/examples/auth.md) [Monorepo](/zh/docs/guides/examples/monorepo.md) [**old**:](/zh/docs/guides/examples/monorepo.md) [/docs/guides/monorepo](/zh/docs/guides/examples/monorepo.md) [**new**: ](/zh/docs/guides/examples/monorepo.md) [/docs/guides/examples/monorepo](/zh/docs/guides/examples/monorepo.md) [White Labels](/zh/docs/guides/examples/white-labels.md) [**old**:](/zh/docs/guides/examples/white-labels.md) [/docs/guides/white-labels](/zh/docs/guides/examples/white-labels.md) [**new**: ](/zh/docs/guides/examples/white-labels.md) [/docs/guides/examples/white-labels](/zh/docs/guides/examples/white-labels.md) ### 🎯 Migration ⚡️ Grouped and simplified into /guides/migration as migration guidelines [Migration from V1](/zh/docs/guides/migration/from-v1.md) [**old**:](/zh/docs/guides/migration/from-v1.md) [/docs/guides/migration-from-v1](/zh/docs/guides/migration/from-v1.md) [**new**: ](/zh/docs/guides/migration/from-v1.md) [/docs/guides/migration/from-v1](/zh/docs/guides/migration/from-v1.md) [Migration from Legacy](/zh/docs/guides/migration/from-custom.md) [**old**:](/zh/docs/guides/migration/from-custom.md) [/docs/guides/migration-from-legacy](/zh/docs/guides/migration/from-custom.md) [**new**: ](/zh/docs/guides/migration/from-custom.md) [/docs/guides/migration/from-custom](/zh/docs/guides/migration/from-custom.md) ### 🎯 Tech ⚡️ Grouped into /guides/tech as tech-specific usage guidelines [Usage with NextJS](/zh/docs/guides/tech/with-nextjs.md) [**old**:](/zh/docs/guides/tech/with-nextjs.md) [/docs/guides/usage-with-nextjs](/zh/docs/guides/tech/with-nextjs.md) [**new**: ](/zh/docs/guides/tech/with-nextjs.md) [/docs/guides/tech/with-nextjs](/zh/docs/guides/tech/with-nextjs.md) ### Rename 'legacy' to 'custom' ⚡️ 'Legacy' is derogatory, we don't get to call people's projects legacy [Rename 'legacy' to custom](/zh/docs/guides/migration/from-custom.md) [**old**:](/zh/docs/guides/migration/from-custom.md) [/docs/guides/migration/from-legacy](/zh/docs/guides/migration/from-custom.md) [**new**: ](/zh/docs/guides/migration/from-custom.md) [/docs/guides/migration/from-custom](/zh/docs/guides/migration/from-custom.md) ### Deduplication of Reference ⚡️ Cleaned up the Reference section and deduplicated the material [Isolation of modules](/zh/docs/reference/layers.md#import-rule-on-layers) [**old**:](/zh/docs/reference/layers.md#import-rule-on-layers) [/docs/reference/isolation](/zh/docs/reference/layers.md#import-rule-on-layers) [**new**: ](/zh/docs/reference/layers.md#import-rule-on-layers) [/docs/reference/layers#import-rule-on-layers](/zh/docs/reference/layers.md#import-rule-on-layers) --- # Feature-Sliced Design 版本 ### Feature-Sliced Design v2.1 (Current) 当前已发布版本的文档可以在这里找到 | v2.1 | [Release Notes](https://github.com/feature-sliced/documentation/releases/tag/v2.1) | [Documentation](/zh/docs/get-started/overview.md) | [Migration from v1](/zh/docs/guides/migration/from-v1.md) | [Migration from v2.0](/zh/docs/guides/migration/from-v1.md) | | ---- | ---------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------- | ### Feature Slices v1 (Legacy) feature-slices 旧版本的文档可以在这里找到 | v1.0 | [Documentation](https://feature-sliced.github.io/featureslices.dev/v1.0.html) | | ---- | ----------------------------------------------------------------------------- | | v0.1 | [Documentation](https://feature-sliced.github.io/featureslices.dev/v0.1.html) | ### Feature Driven (Legacy) feature-driven 旧版本的文档可以在这里找到 | v0.1 | [Documentation](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) | | ------------- | --------------------------------------------------------------------------------------- | | Example (kof) | [Github](https://github.com/kof/feature-driven-architecture) | --- # 💫 Community Community resources, additional materials ## Main[​](#main "标题的直接链接") [Awesome Resources](https://github.com/feature-sliced/awesome) [A curated list of awesome FSD videos, articles, packages](https://github.com/feature-sliced/awesome) [Team](/zh/community/team.md) [Core-team, Champions, Contributors, Companies](/zh/community/team.md) [Brandbook](/zh/docs/branding.md) [Recommendations for FSD's branding usage](/zh/docs/branding.md) [Contributing](#) [HowTo, Workflow, Support](#) --- # Team WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/192) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## Core-team[​](#core-team "标题的直接链接") ### Champions[​](#champions "标题的直接链接") ## Contributors[​](#contributors "标题的直接链接") ## Companies[​](#companies "标题的直接链接") --- # 替代方案 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/62) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* 架构方法的历史 ## 大泥球(Big Ball of Mud)[​](#大泥球big-ball-of-mud "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/258) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 什么是大泥球;为什么它如此常见;何时开始带来问题;该怎么做以及FSD如何在这方面提供帮助 * [(文章) Oleg Isonen - 在AI接管之前关于UI架构的最后话语](https://oleg008.medium.com/last-words-on-ui-architecture-before-an-ai-takes-over-468c78f18f0d) * [(报告) Julia Nikolaeva, iSpring - 大泥球和单体的其他问题,我们已经处理过](http://youtu.be/gna4Ynz1YNI) * [(文章) DD - 大泥球](https://thedomaindrivendesign.io/big-ball-of-mud/) ## 智能和愚蠢组件(Smart & Dumb components)[​](#智能和愚蠢组件smart--dumb-components "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/214) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于这种方法;关于在前端的适用性;方法论立场 关于过时性,关于方法论的新观点 为什么容器组件方法是有害的? * [(文章) Dan Abramov - 展示型和容器型组件(TLDR:已弃用)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) ## 设计原则[​](#设计原则 "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/59) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 我们在谈论什么;FSD立场 SOLID、GRASP、KISS、YAGNI等 - 以及为什么它们在实践中不能很好地协同工作 以及它如何聚合这些实践 * [(演讲) Ilya Azin - Feature-Sliced Design(关于设计原则的片段)](https://youtu.be/SnzPAr_FJ7w?t=380) ## DDD[​](#ddd "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/1) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于这种方法;为什么它在实践中效果不佳 有什么不同,如何改善适用性,在哪里采用实践 * [(文章) DDD、六边形、洋葱、清洁、CQRS等...我如何将它们整合在一起](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) * [(演讲) Ilya Azin - Feature-Sliced Design(关于整洁架构、DDD的片段)](https://youtu.be/SnzPAr_FJ7w?t=528) ## 整洁架构(Clean Architecture)[​](#整洁架构clean-architecture "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/165) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于这种方法;关于在前端的适用性;FSD立场 它们如何相似(对许多人来说),它们如何不同 * [(讨论串) 关于方法论中的用例/交互器](https://t.me/feature_sliced/3897) * [(讨论串) 关于方法论中的依赖注入](https://t.me/feature_sliced/4592) * [(文章) Alex Bespoyasov - 前端的整洁架构](https://bespoyasov.me/blog/clean-architecture-on-frontend/) * [(文章) DDD、六边形、洋葱、清洁、CQRS等...我如何将它们整合在一起](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) * [(演讲) Ilya Azin - Feature-Sliced Design(关于整洁架构、DDD的片段)](https://youtu.be/SnzPAr_FJ7w?t=528) * [(文章) 整洁架构的误解](http://habr.com/ru/company/mobileup/blog/335382/) ## 框架[​](#框架 "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/58) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于在前端的适用性;为什么框架不能解决问题;为什么没有单一方法;FSD立场 框架无关,约定方法 * [(文章) 关于创建方法论的原因(关于框架的片段)](/zh/docs/about/motivation.md) * [(讨论串) 关于方法论对不同框架的适用性](https://t.me/feature_sliced/3867) ## 原子设计(Atomic Design)[​](#原子设计atomic-design "标题的直接链接") ### 什么是原子设计?[​](#什么是原子设计 "标题的直接链接") 在原子设计中,职责范围被划分为标准化的层级。 原子设计分为**5个层级**(从上到下): 1. `pages`(页面)- 功能类似于FSD中的`pages`层。 2. `templates`(模板)- 定义页面结构而不绑定到特定内容的组件。 3. `organisms`(有机体)- 由分子组成并具有业务逻辑的模块。 4. `molecules`(分子)- 通常不包含业务逻辑的更复杂组件。 5. `atoms`(原子)- 没有业务逻辑的UI组件。 一个层级的模块只与下面层级的模块交互,类似于FSD。 也就是说,分子由原子构建,有机体由分子构建,模板由有机体构建,页面由模板构建。 原子设计还意味着在模块内使用公共API来实现隔离。 ### 对前端的适用性[​](#对前端的适用性 "标题的直接链接") 原子设计在项目中相对常见。原子设计在网页设计师中比在开发中更受欢迎。 网页设计师经常使用原子设计来创建可扩展且易于维护的设计。 在开发中,原子设计经常与其他架构方法论混合使用。 然而,由于原子设计专注于UI组件及其组合,在架构内实现业务逻辑时会出现问题。 问题在于原子设计没有为业务逻辑提供明确的职责级别, 导致业务逻辑分散在各种组件和级别中,使维护和测试变得复杂。 业务逻辑变得模糊,使得难以清楚地分离职责,并使代码变得不够模块化和可重用。 ### 它与FSD的关系如何?[​](#它与fsd的关系如何 "标题的直接链接") 在FSD的上下文中,原子设计的一些元素可以应用于创建灵活且可扩展的UI组件。 `atoms`和`molecules`层可以在FSD的`shared/ui`中实现,简化基本UI元素的重用和维护。 ``` ├── shared │ ├── ui │ │ ├── atoms │ │ ├── molecules │ ... ``` FSD和原子设计的比较显示,两种方法论都追求模块化和可重用性, 但专注于不同的方面。原子设计面向视觉组件及其组合。 FSD专注于将应用程序功能划分为独立模块及其相互连接。 * [原子设计方法论](https://atomicdesign.bradfrost.com/table-of-contents/) * [(讨论串) 关于在shared/ui中的适用性](https://t.me/feature_sliced/1653) * [(视频) 原子设计简介](https://youtu.be/Yi-A20x2dcA) * [(演讲) Ilya Azin - Feature-Sliced Design(关于原子设计的片段)](https://youtu.be/SnzPAr_FJ7w?t=587) ## 功能驱动(Feature Driven)[​](#功能驱动feature-driven "标题的直接链接") WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/219) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于这种方法;关于在前端的适用性;FSD立场 关于兼容性、历史发展和比较 * [(演讲) Oleg Isonen - 功能驱动架构](https://youtu.be/BWAeYuWFHhs) * [功能驱动简短规范(从FSD的角度)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) --- # 使命 在这里我们描述方法论适用性的目标和限制——这是我们在开发该方法论时所遵循的指导原则 * 我们的目标是在理念和简单性之间取得平衡 * 我们无法制造一个适合所有人的银弹 **尽管如此,该方法论应该对相当广泛的开发者群体来说是亲近且可访问的** ## 目标[​](#目标 "标题的直接链接") ### 对广泛开发者的直观清晰度[​](#对广泛开发者的直观清晰度 "标题的直接链接") 该方法论应该是可访问的 - 对于项目中的大部分团队成员 *因为即使有了所有未来的工具,如果只有经验丰富的高级开发者/领导者才能理解该方法论,那也是不够的* ### 解决日常问题[​](#解决日常问题 "标题的直接链接") 该方法论应该阐述我们在开发项目时遇到的日常问题的原因和解决方案 **并且还要为所有这些提供工具(cli、linters)** 让开发者可以使用一种*经过实战检验*的方法,让他们能够绕过架构和开发中的长期问题 > *@sergeysova: 想象一下,一个开发者在该方法论的框架内编写代码,他遇到问题的频率减少了10倍,仅仅因为其他人已经考虑并解决了许多问题。* ## 限制[​](#限制 "标题的直接链接") 我们不想*强加我们的观点*,同时我们理解*作为开发者,我们的许多习惯每天都在干扰我们* 每个人在设计和开发系统方面都有自己的经验水平,**因此,值得理解以下几点:** * **不会起作用**:非常简单、非常清晰、适用于所有人 > *@sergeysova: 某些概念在你遇到问题并花费多年时间解决它们之前,是无法直观理解的。* > > * *在数学世界中:是图论。* > * *在物理学中:量子力学。* > * *在编程中:应用程序架构。* * **可能且期望的**:简单性、可扩展性 ## 参见[​](#参见 "标题的直接链接") * [架构问题](/zh/docs/about/understanding/architecture.md#problems) --- # 动机 **Feature-Sliced Design**(特性分层设计)的主要理念是基于[结合研究成果,讨论各种类型开发者的广泛经验](https://github.com/feature-sliced/documentation/discussions),来促进和降低复杂项目开发的成本。 显然,这不会是万能的解决方案,当然,该方法论也会有自己的[适用限制](/zh/docs/about/mission.md)。 尽管如此,关于*这种方法论整体可行性*仍然存在合理的质疑。 备注 更多详情[在讨论中进行了探讨](https://github.com/feature-sliced/documentation/discussions/27) ## 为什么现有解决方案还不够?[​](#为什么现有解决方案还不够 "标题的直接链接") > 通常会有这些论点: > > * *"为什么需要一些新的方法论,如果你已经有了长期建立的设计方法和原则,如 `SOLID`、`KISS`、`YAGNI`、`DDD`、`GRASP`、`DRY` 等。"* > * *"所有问题都可以通过良好的项目文档、测试和结构化流程来解决"* > * *"如果所有开发者都遵循上述所有原则,问题就不会发生"* > * *"一切都在你之前就被发明了,你只是不会使用它"* > * *"采用 {框架名称} - 那里已经为你决定了一切"* ### 仅有原则是不够的[​](#仅有原则是不够的 "标题的直接链接") **仅仅存在原则并不足以设计出良好的架构** 不是每个人都完全了解这些原则,更少的人能够正确理解和应用它们。 *设计原则过于宽泛,没有给出具体问题的明确答案:"如何设计可扩展和灵活应用程序的结构和架构?"* ### 流程并不总是有效[​](#流程并不总是有效 "标题的直接链接") *文档/测试/流程*当然是好的,但遗憾的是,即使在它们上面投入高成本 - **它们也不总能解决架构提出的问题和新人加入项目的问题** * 每个开发者进入项目的时间并没有大幅减少,因为文档往往会变得庞大/过时 * 持续确保每个人都以相同方式理解架构 - 这也需要大量资源 * 不要忘记总线因子(bus-factor) ### 现有框架不能在所有地方应用[​](#现有框架不能在所有地方应用 "标题的直接链接") * 现有解决方案通常有很高的入门门槛,这使得寻找新开发者变得困难 * 此外,技术选择通常在项目出现严重问题之前就已经确定,因此你需要能够"使用现有的" - **而不被技术绑定** > 问:*"在我的项目 `React/Vue/Redux/Effector/Mobx/{你的技术}` 中 - 我如何更好地构建实体结构和它们之间的关系?"* ### 结果[​](#结果 "标题的直接链接") 我们得到了\*"像雪花一样独特"\*的项目,每个项目都需要员工长时间的沉浸,而这些知识不太可能适用于另一个项目。 > @sergeysova: *"这正是我们前端开发领域目前存在的情况:每个技术负责人都会发明不同的架构和项目结构,虽然这些结构不一定能经受时间的考验,结果是除了他之外最多只有两个人可以开发项目,每个新开发者都需要重新沉浸其中。"* ## 为什么开发者需要这个方法论?[​](#为什么开发者需要这个方法论 "标题的直接链接") ### 专注于业务功能,而不是架构问题[​](#专注于业务功能而不是架构问题 "标题的直接链接") 该方法论允许你节省设计可扩展和灵活架构的资源,而是将开发者的注意力引导到主要功能的开发上。同时,架构解决方案本身在项目之间是标准化的。 *一个单独的问题是,该方法论应该赢得社区的信任,这样其他开发者可以熟悉它,并在他可用的时间内依靠它来解决他项目的问题* ### 经过经验验证的解决方案[​](#经过经验验证的解决方案 "标题的直接链接") 该方法论是为那些致力于*设计复杂业务逻辑的经过验证解决方案*的开发者而设计的。 *然而,很明显,该方法论通常是关于一套最佳实践、文章,这些文章解决开发过程中的某些问题和案例。因此,该方法论对其他开发者也会有用 - 那些在开发和设计过程中以某种方式面临问题的人* ### 项目健康[​](#项目健康 "标题的直接链接") 该方法论将允许*提前解决和跟踪项目问题,而不需要大量资源* **技术债务往往会随着时间的推移而积累,解决它的责任既在技术负责人身上,也在团队身上** 该方法论将允许你提前*警告*项目扩展和开发中的可能问题。 ## 为什么企业需要方法论?[​](#为什么企业需要方法论 "标题的直接链接") ### 快速入职[​](#快速入职 "标题的直接链接") 有了方法论,你可以雇佣一个**已经熟悉这种方法的人到项目中,而不需要重新培训** *人们开始更快地理解和为项目带来价值,并且有额外的保证为项目的下一次迭代找到人员* ### 经过经验验证的解决方案[​](#经过经验验证的解决方案-1 "标题的直接链接") 有了方法论,企业将获得*系统开发过程中出现的大多数问题的解决方案* 因为企业最常想要获得一个框架/解决方案,能够解决项目开发过程中的大部分问题。 ### 对项目不同阶段的适用性[​](#对项目不同阶段的适用性 "标题的直接链接") 该方法论可以在*项目支持和开发阶段以及MVP阶段*为项目带来好处 是的,对于MVP来说最重要的是\*"功能,而不是为未来奠定的架构"*。但即使在有限的截止日期条件下,了解方法论中的最佳实践,你也可以在设计系统的MVP版本时*"用很少的代价"\*找到合理的妥协 (而不是"随机"建模功能) *测试也是如此* ## 什么时候我们的方法论不需要?[​](#什么时候我们的方法论不需要 "标题的直接链接") * 如果项目只会存在很短时间 * 如果项目不需要支持的架构 * 如果企业不认为代码库和功能交付速度之间存在联系 * 如果对企业来说更重要的是尽快完成订单,而不需要进一步支持 ### 企业规模[​](#企业规模 "标题的直接链接") * **小企业** - 最常需要现成的和非常快速的解决方案。只有当企业增长(至少到接近平均水平)时,他才明白为了让客户继续使用,除其他外,有必要将时间投入到正在开发的解决方案的质量和稳定性上 * **中型企业** - 通常理解开发的所有问题,即使有必要\*"安排功能竞赛"\*,他仍然会花时间进行质量改进、重构和测试(当然 - 还有可扩展的架构) * **大企业** - 通常已经有广泛的受众、员工和更广泛的实践集合,甚至可能有自己的架构方法,所以采用别人的想法对他们来说并不常见 ## 计划[​](#计划 "标题的直接链接") 目标的主要部分[在这里阐述](/zh/docs/about/mission.md#goals),但此外,值得谈论我们对未来方法论的期望。 ### 结合经验[​](#结合经验 "标题的直接链接") 现在我们正在尝试结合`核心团队`的所有多样化经验,并因此获得一个经过实践锤炼的方法论。 当然,我们可能最终得到Angular 3.0,但这里更重要的是**调查设计复杂系统架构的根本问题** *是的 - 我们对当前版本的方法论有抱怨,但我们希望共同努力达成一个单一和最优的解决方案(考虑到社区的经验等)* ### 规范之外的生活[​](#规范之外的生活 "标题的直接链接") 如果一切顺利,那么方法论将不仅限于规范和工具包 * 也许会有报告、文章 * 可能会有用于将根据方法论编写的项目迁移到其他技术的`CODE_MOD` * 可能最终我们能够接触到大型技术解决方案的维护者 * *特别是对于React,与其他框架相比 - 这是主要问题,因为它没有说明如何解决某些问题* ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(讨论)不需要方法论?](https://github.com/feature-sliced/documentation/discussions/27) * [关于方法论的使命:目标和限制](/zh/docs/about/mission.md) * [项目中的知识类型](/zh/docs/about/understanding/knowledge-types.md) --- # 在公司中推广 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/206) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 项目和公司需要方法论吗?[​](#项目和公司需要方法论吗 "标题的直接链接") > 关于应用的合理性,职责所在 ## 如何向业务部门提交方法论?[​](#如何向业务部门提交方法论 "标题的直接链接") ## 如何准备和证明迁移到方法论的计划?[​](#如何准备和证明迁移到方法论的计划 "标题的直接链接") --- # 在团队中推广 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/182) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* * 入职新成员 * 开发指南("在哪里搜索 N 模块"等...) * 任务的新方法 ## 参见[​](#参见 "标题的直接链接") * [(线程) 旧方法的简单性和正念的重要性](https://t.me/feature_sliced/3360) * [(线程) 关于按 layers 搜索的便利性](https://t.me/feature_sliced/1918) --- # 集成方面 ## 总结[​](#总结 "标题的直接链接") 前 5 分钟(俄语): [YouTube video player](https://www.youtube.com/embed/TFA6zRO_Cl0?start=2110) ## 另外[​](#另外 "标题的直接链接") **优势:** * [概览](/zh/docs/get-started/overview.md) * 代码审查 * 入职 **缺点:** * 心理复杂性 * 高准入门槛 * "Layers 地狱" * 基于 feature 方法的典型问题 --- # 部分应用 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/199) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 如何部分应用方法论?这样做有意义吗?如果我忽略它会怎样? --- # 抽象 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/186) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 抽象泄漏定律[​](#抽象泄漏定律 "标题的直接链接") ## 为什么有这么多抽象[​](#为什么有这么多抽象 "标题的直接链接") > 抽象有助于应对项目的复杂性。问题是——这些抽象是否只针对这个项目,还是我们会尝试基于前端的特性推导出通用抽象 > 架构和应用程序本质上都是复杂的,唯一的问题是如何更好地分配和描述这种复杂性 ## 关于责任范围[​](#关于责任范围 "标题的直接链接") > 关于可选抽象 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [关于新层的需求](https://t.me/feature_sliced/2801) * [关于理解方法论和层的困难](https://t.me/feature_sliced/2619) --- # 关于架构 ## 问题[​](#问题 "标题的直接链接") 通常,当由于项目中的某些问题导致开发停止时,就会提出关于架构的讨论。 ### Bus-factor 和入职[​](#bus-factor-和入职 "标题的直接链接") 只有有限的人数理解项目及其架构 **示例:** * *"很难将一个人加入开发中"* * *"对于每个问题,每个人都有自己的解决方案意见"(让我们嫌妒 angular)* * *"我不理解这个大型单体块中发生了什么"* ### 隐式和不可控制的后果[​](#隐式和不可控制的后果 "标题的直接链接") 开发/重构过程中有很多隐式的副作用 *("一切都依赖于一切")* **示例:** * *"feature 导入 feature"* * *"我更新了一个页面的 store,另一个页面的功能就失效了"* * *"逻辑散布在整个应用程序中,无法追踪哪里是开始,哪里是结束"* ### 不可控制的逻辑重用[​](#不可控制的逻辑重用 "标题的直接链接") 很难重用/修改现有逻辑 同时,通常存在[两个极端](https://github.com/feature-sliced/documentation/discussions/14): * 要么为每个模块完全从头开始编写逻辑 *(在现有代码库中可能存在重复)* * 要么倾向于将所有实现的模块转移到 `shared` 文件夹,从而创建一个大型的模块转储场 *(其中大多数只在一个地方使用)* **示例:** * *"我的项目中有 **N** 个相同业务逻辑的实现,我仍然在为此付出代价"* * *"项目中有 6 个不同的按钮/弹窗/... 组件"* * *"helpers 的转储场"* ## 需求[​](#需求 "标题的直接链接") 因此,提出*理想架构的期望需求*似乎是合乎逻辑的: 备注 无论何处提到"容易",都意味着"对广泛的开发者来说相对容易",因为很明显[不可能为绝对所有人制作理想的解决方案](/zh/docs/about/mission.md#limitations) ### 明确性[​](#明确性 "标题的直接链接") * 应该**易于掌握和解释**项目及其架构给团队 * 结构应该反映项目的真实**业务价值** * 抽象之间必须有明确的**副作用和连接** * 应该**易于检测重复逻辑**而不干扰独特实现 * 项目中不应该有**逻辑分散** * 对于良好的架构,不应该有**太多异构抽象和规则** ### 控制[​](#控制 "标题的直接链接") * 良好的架构应该**加速任务解决和功能引入** * 应该能够控制项目的开发 * 应该易于**扩展、修改、删除代码** * 必须遵守功能的**分解和隔离** * 系统的每个组件都必须**易于替换和移除** * *[无需为变更优化](https://youtu.be/BWAeYuWFHhs?t=1631) - 我们无法预测未来* * *[更好地为删除优化](https://youtu.be/BWAeYuWFHhs?t=1666) - 基于已存在的上下文* ### 适应性[​](#适应性 "标题的直接链接") * 良好的架构应该适用于**大多数项目** * *具有现有基础设施解决方案* * *在开发的任何阶段* * 不应该依赖于框架和平台 * 应该能够**轻松扩展项目和团队**,具有开发并行化的可能性 * 应该易于**适应不断变化的需求和环境** ## 参见[​](#参见 "标题的直接链接") * [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture](https://youtu.be/BWAeYuWFHhs) * [(React SPB Meetup #1) Sergey Sova - Feature Slices](https://t.me/feature_slices) * [(文章) 关于项目模块化](https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1) * [(文章) 关于关注点分离和按功能构建](https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/) --- # 项目中的知识类型 在任何项目中都可以区分以下"知识类型": * **基础知识**
随时间变化不大的知识,如算法、计算机科学、编程语言机制及其 API。 * **技术栈**
对项目中使用的技术解决方案集合的了解,包括编程语言、框架和库。 * **项目知识**
特定于当前项目且在项目外没有价值的知识。这种知识对于新入职的开发人员能够有效贡献至关重要。 备注 **Feature-Sliced Design** 旨在减少对"项目知识"的依赖,承担更多责任,并让新团队成员更容易上手。 ## 另请参阅[​](#see-also "标题的直接链接") * [(视频 🇷🇺)Ilya Klimov - 关于知识类型](https://youtu.be/4xyb_tA-uw0?t=249) --- # 命名 不同的开发者有不同的经验和上下文,当相同的实体被不同地命名时,这可能导致团队中的误解。例如: * 用于显示的组件可以被称为 "ui"、"components"、"ui-kit"、"views"… * 在整个应用程序中重用的代码可以被称为 "core"、"shared"、"app"… * 业务逻辑代码可以被称为 "store"、"model"、"state"… ## Feature-Sliced Design 中的命名[​](#naming-in-fsd "标题的直接链接") 该方法论使用特定的术语,例如: * "app"、"process"、"page"、"feature"、"entity"、"shared" 作为 layer 名称, * "ui"、"model"、"lib"、"api"、"config" 作为 segment 名称。 坚持使用这些术语非常重要,以防止团队成员和加入项目的新开发者之间的混淆。使用标准名称也有助于向社区寻求帮助。 ## 命名冲突[​](#when-can-naming-interfere "标题的直接链接") 当 FSD 方法论中使用的术语与业务中使用的术语重叠时,可能发生命名冲突: * `FSD#process` vs 应用程序中的模拟进程, * `FSD#page` vs 日志页面, * `FSD#model` vs 汽车型号。 例如,开发者在代码中看到 "process" 这个词时,会花费额外的时间试图弄清楚指的是哪个进程。这样的**冲突可能会破坏开发过程**。 当项目术语表包含 FSD 特有的术语时,在与团队和技术不相关的各方讨论这些术语时要格外小心。 为了与团队有效沟通,建议使用缩写 "FSD" 作为方法论术语的前缀。例如,在谈论进程时,您可能会说:"我们可以将这个进程放在 FSD features layer 上。" 相反,在与非技术利益相关者沟通时,最好限制使用 FSD 术语,并避免提及代码库的内部结构。 ## 参见[​](#see-also "标题的直接链接") * [(讨论) 命名的适应性](https://github.com/feature-sliced/documentation/discussions/16) * [(讨论) Entity 命名调查](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894) * [(讨论) "processes" vs "flows" vs ...](https://github.com/feature-sliced/documentation/discussions/20) * [(讨论) "model" vs "store" vs ...](https://github.com/feature-sliced/documentation/discussions/68) --- # 需求驱动 TL;DR — *无法明确表述新功能要解决的目标?或者问题在于任务本身没有被明确表述?**重点是方法论有助于揭示任务和目标定义中的问题*** — *项目不是静态的 - 需求和功能在不断变化。随着时间推移,代码变成一团糟,因为在开始时项目只是为了初始的需求印象而设计的。**好架构的任务也是要为不断变化的开发条件做好准备。*** ## 为什么?[​](#为什么 "标题的直接链接") 要为实体选择一个清晰的名称并理解其组件,**你需要清楚地理解所有这些代码要解决什么任务。** > *@sergeysova: 在开发过程中,我们试图给每个实体或函数一个清楚反映代码执行意图和含义的名称。* *毕竟,如果不理解任务,就不可能编写覆盖最重要情况的正确测试,在正确的地方放置帮助用户的错误提示,甚至不能避免因为可修复的非关键错误而中断用户流程。* ## 我们在谈论什么任务?[​](#我们在谈论什么任务 "标题的直接链接") 前端开发为最终用户开发应用程序和界面,所以我们解决这些消费者的任务。 当一个人来到我们这里时,**他想要解决自己的某个痛点或满足某个需求。** *管理者和分析师的任务是明确表述这个需求,开发者在考虑Web开发特性(通信丢失、后端错误、拼写错误、鼠标或手指操作失误)的情况下实现它。* **用户带着的这个目标,就是开发者的任务。** > *一个小的已解决问题就是Feature-Sliced Design方法论中的一个功能(feature)— 你需要将项目任务的整个范围切分为小目标。* ## 这如何影响开发?[​](#这如何影响开发 "标题的直接链接") ### 任务分解[​](#任务分解 "标题的直接链接") 当开发者开始实现任务时,为了简化代码的理解和支持,他在心理上**将其切分为阶段**: * 首先\_分解为顶级实体\_并\_实现它们\_, * 然后将这些实体\_分解为更小的实体\_ * 以此类推 *在分解为实体的过程中,开发者被迫给它们起一个能清楚反映他的想法的名称,并在阅读代码清单时帮助理解代码解决什么任务* *同时,我们不要忘记我们正在努力帮助用户减少痛点或实现需求* ### 理解任务的本质[​](#理解任务的本质 "标题的直接链接") 但要给实体起一个清晰的名称,**开发者必须充分了解其目的** * 他将如何使用这个实体, * 它实现用户任务的哪一部分,这个实体还能在哪里应用, * 它还能参与哪些其他任务, * 等等 不难得出结论:**当开发者在方法论框架内思考实体名称时,他甚至能在编写代码之前就发现表述不清的任务。** > 如果你不能很好地理解一个实体能解决什么任务,如何给它起名?如果你不能很好地理解一个任务,又如何将任务分解为实体? ## 如何表述?[​](#如何表述 "标题的直接链接") **要表述功能(features)解决的任务,你需要理解任务本身**,这已经是项目经理和分析师的责任。 *方法论只能告诉开发者产品经理应该密切关注哪些任务。* > *@sergeysova: 整个前端主要是信息显示,任何组件首先是显示,然后"向用户显示某些东西"的任务没有实际价值。* > > *即使不考虑前端的特性,也可以问"为什么我必须向你显示",这样你可以继续问下去,直到找到消费者的痛点或需求。* 一旦我们能够找到基本需求或痛点,我们就可以回过头来弄清楚**你的产品或服务如何帮助用户实现他的目标** 你跟踪器中的任何新任务都旨在解决业务问题,而业务试图在解决用户任务的同时从中赚钱。这意味着每个任务都有特定的目标,即使它们没有在描述文本中明确说明。 ***开发者必须清楚地理解这个或那个任务追求什么目标**,但不是每个公司都能完美地构建流程,虽然这是另一个话题,但开发者完全可以自己"ping"合适的管理者来了解这一点,并有效地完成自己的工作部分。* ## 有什么好处?[​](#有什么好处 "标题的直接链接") 现在让我们从头到尾看整个过程。 ### 1. 理解用户任务[​](#1-理解用户任务 "标题的直接链接") 当开发者理解用户的痛点以及业务如何解决它们时,他可以提供由于Web开发特性而对业务不可见的解决方案。 > 但当然,所有这些只有在开发者对自己在做什么以及为什么做不漠不关心的情况下才能起作用,否则\_为什么还需要方法论和某些方法?\_ ### 2. 结构化和排序[​](#2-结构化和排序 "标题的直接链接") 随着对任务的理解,**在头脑中和任务以及代码中都有了清晰的结构** ### 3. 理解功能及其组件[​](#3-理解功能及其组件 "标题的直接链接") **一个功能就是为用户提供的一个有用功能** * 当在一个功能中实现多个功能时,这是**边界违反** * 功能可以是不可分割的和不断增长的 - **这并不坏** * **坏的** - 是当功能不能回答\_"对用户的业务价值是什么?"\_这个问题时 * 不能有"地图-办公室"功能 * 但`在地图上预订会议`、`搜索员工`、`更换工作场所` - **可以** > *@sergeysova: 重点是功能只包含实现功能本身的代码*,没有不必要的细节和内部解决方案(理想情况下)\* > > *打开功能代码**只看到与任务相关的内容** - 不多不少* ### 4. 收益[​](#4-收益 "标题的直接链接") 业务很少会彻底改变其方向,这意味着**在前端应用程序代码中反映业务任务是一个非常重要的收益。** *然后你不必向每个新团队成员解释这段或那段代码做什么,以及为什么添加它 - **一切都将通过已经反映在代码中的业务任务来解释。*** > 这就是[领域驱动开发中所谓的"业务语言"](https://thedomaindrivendesign.io/developing-the-ubiquitous-language) *** ## 回到现实[​](#回到现实 "标题的直接链接") 如果在设计阶段理解了业务流程并给出了好的名称 - *那么将这种理解和逻辑转移到代码中就不是特别有问题的。* **然而,在实践中**,任务和功能通常是"过度"迭代开发的,和/或没有时间思考设计。 **结果,功能在今天是有意义的,如果你在一个月后扩展这个功能,你可能需要重写项目的一半。** > *\[[来自讨论](https://t.me/sergeysova/318)]:开发者试图提前思考2-3步,考虑未来的需求,但在这里他依赖于自己的经验* > > *有经验的工程师通常立即看到10步之前,并理解在哪里分割一个功能并与另一个功能结合* > > *但有时会遇到必须面对经验的任务,而无处获得如何正确分解的理解,以便在未来产生最少的不幸后果* ## 方法论的作用[​](#方法论的作用 "标题的直接链接") **方法论帮助解决开发者的问题,以便更容易解决用户的问题。** 没有仅仅为了开发者而解决开发者问题的方案 但为了让开发者解决他的任务,**需要理解用户的任务** - 反之则不行 ### 方法论要求[​](#方法论要求 "标题的直接链接") 很明显,需要为**Feature-Sliced Design**确定至少两个要求: 1. 方法论应该说明**如何创建功能、流程和实体** * 这意味着它应该清楚地解释\_如何在它们之间分配代码\_,这意味着这些实体的命名也应该在规范中确定。 2. 方法论应该帮助架构\*\*[轻松适应项目不断变化的需求](/zh/docs/about/understanding/architecture.md#adaptability)\*\* ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(帖子) 清晰表述任务的激励(+ 讨论)](https://t.me/sergeysova/318) > ***当前文章**是这个讨论的改编,你可以在链接中阅读完整的未删减版本* * [(讨论) 如何分解功能以及它是什么](https://t.me/atomicdesign/18972) * [(文章) "如何更好地组织你的应用程序"](https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1) --- # 架构信号 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/194) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 如果架构方面存在限制,那么这是有明显原因的,如果忽略这些限制就会产生后果 > 方法论和架构会发出信号,如何处理这些信号取决于你愿意承担什么风险以及什么最适合你的团队 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(讨论串)关于架构和数据流的信号](https://t.me/feature_sliced/2070) * [(讨论串)关于架构的基本性质](https://t.me/feature_sliced/2492) * [(讨论串)关于突出薄弱环节](https://t.me/feature_sliced/3979) * [(讨论串)如何理解数据模型是否臃肿](https://t.me/feature_sliced/4228) --- # 品牌指南 FSD 的视觉身份基于其核心概念:`分层`、`切片式自包含部分`、`部分和组合`、`分段`。 但我们也倾向于设计简单、美丽的身份,它应该传达 FSD 的哲学并易于识别。 \*\*请按原样使用 FSD 的身份,不要更改,但可以使用我们的资产以方便您使用。\*\*此品牌指南将帮助您正确使用 FSD 的身份。 兼容性 FSD 以前有[另一个遗留身份](https://drive.google.com/drive/folders/11Y-3qZ_C9jOFoW2UbSp11YasOhw4yBdl?usp=sharing)。旧设计不能代表方法论的核心概念。此外,它是作为纯粹的草稿创建的,应该被实现。 为了兼容和长期使用品牌,我们在一年内(2021-2022)进行了谨慎的重新品牌化。**这样您在使用 FSD 身份时可以放心 🍰** *但请优先使用实际身份,而不是旧的!* ## 标题[​](#标题 "标题的直接链接") * ✅ **正确:** `Feature-Sliced Design`、`FSD` * ❌ **错误:** `Feature-Sliced`、`Feature Sliced`、`FeatureSliced`、`feature-sliced`、`feature sliced`、`FS` ## Emoji[​](#emoji "标题的直接链接") 蛋糕 🍰 图像很好地代表了 FSD 的核心概念,所以它被选为我们的标志性 emoji > 示例:*"🍰 前端项目的架构设计方法论"* ## Logo 和调色板[​](#logo-和调色板 "标题的直接链接") FSD 有几种适用于不同上下文的 logo 变体,但建议优先使用 **primary** | | | | | ------------------------------- | ------------------------------------------------------------------------------------------ | ------------------ | | 主题 | Logo (Ctrl/Cmd + 点击下载) | 用法 | | primary
(#29BEDC, #517AED) | [![logo-primary](/zh/img/brand/logo-primary.png)](/zh/img/brand/logo-primary.png) | 在大多数情况下首选 | | flat
(#3193FF) | [![logo-flat](/zh/img/brand/logo-flat.png)](/zh/img/brand/logo-flat.png) | 用于单色上下文 | | monochrome
(#FFF) | [![logo-monocrhome](/zh/img/brand/logo-monochrome.png)](/zh/img/brand/logo-monochrome.png) | 用于灰度上下文 | | square
(#3193FF) | [![logo-square](/zh/img/brand/logo-square.png)](/zh/img/brand/logo-square.png) | 用于方形边界 | ## 横幅和方案[​](#横幅和方案 "标题的直接链接") [![banner-primary](/zh/img/brand/banner-primary.jpg)](/zh/img/brand/banner-primary.jpg) [![banner-monochrome](/zh/img/brand/banner-monochrome.jpg)](/zh/img/brand/banner-monochrome.jpg) ## 社交预览[​](#社交预览 "标题的直接链接") 正在进行中... ## 演示模板[​](#演示模板 "标题的直接链接") 正在进行中... ## 另请参阅[​](#另请参阅 "标题的直接链接") * [Discussion (github)](https://github.com/feature-sliced/documentation/discussions/399) * [History of development with references (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1) --- # 分解速查表 在您决定如何分解 UI 时,将此作为快速参考。下面还提供了 PDF 版本,您可以打印出来放在枕头下。 ## 选择层[​](#选择层 "标题的直接链接") [下载 PDF](/zh/assets/files/choosing-a-layer-en-12fdf3265c8fc4f6b58687352b81fce7.pdf) ![所有层的定义和自检问题](/zh/assets/images/choosing-a-layer-en-5b67f20bb921ba17d78a56c0dc7654a9.jpg) ## 示例[​](#示例 "标题的直接链接") ### Tweet[​](#tweet "标题的直接链接") ![decomposed-tweet-bordered-bgLight](/zh/assets/images/decompose-twitter-7b9a50f879d763c49305b3bf0751ee35.png) ### GitHub[​](#github "标题的直接链接") ![decomposed-github-bordered](/zh/assets/images/decompose-github-a0eeb839a4b5ef5c480a73726a4451b0.jpg) ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(Thread) features 和 entities 的一般逻辑](https://t.me/feature_sliced/4262) * [(Thread) 臃肿逻辑的分解](https://t.me/feature_sliced/4210) * [(Thread) 关于在分解过程中理解责任区域](https://t.me/feature_sliced/4088) * [(Thread) Product List widget 的分解](https://t.me/feature_sliced/3828) * [(Article) 逻辑分解的不同方法](https://www.pluralsight.com/guides/how-to-organize-your-react-+-redux-codebase) * [(Thread) 关于 features 和 entities 之间的区别](https://t.me/feature_sliced/3776) * [(Thread) 关于事物和实体之间的区别 (2)](https://t.me/feature_sliced/3248) * [(Thread) 关于分解标准的应用](https://t.me/feature_sliced/3833) --- # 常见问题 信息 您可以在我们的 [Telegram 聊天](https://t.me/feature_sliced)、[Discord 社区](https://discord.gg/S8MzWTUsmp) 和 [GitHub Discussions](https://github.com/feature-sliced/documentation/discussions) 中提问。 ### 有工具包或代码检查器吗?[​](#有工具包或代码检查器吗 "标题的直接链接") 有!我们有一个名为 [Steiger](https://github.com/feature-sliced/steiger) 的代码检查器来检查您项目的架构,以及通过 CLI 或 IDE 的[文件夹生成器](https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools)。 ### 在哪里存储页面的布局/模板?[​](#在哪里存储页面的布局模板 "标题的直接链接") 如果您需要纯标记布局,您可以将它们保存在 `shared/ui` 中。如果您需要在内部使用更高的 layers,有几个选项: * 也许您根本不需要布局?如果布局只有几行,在每个页面中复制代码而不是试图抽象它可能是合理的。 * 如果您确实需要布局,您可以将它们作为单独的 widgets 或 pages,并在 App 中的路由配置中组合它们。嵌套路由是另一个选项。 ### feature 和 entity 之间有什么区别?[​](#feature-和-entity-之间有什么区别 "标题的直接链接") *entity* 是您的应用程序正在处理的现实生活概念。*feature* 是为您的应用程序用户提供现实生活价值的交互,是人们想要对您的 entities 做的事情。 有关更多信息和示例,请参阅 [slices](/zh/docs/reference/layers.md#entities) 的参考页面。 ### 我可以将 pages/features/entities 嵌入彼此吗?[​](#我可以将-pagesfeaturesentities-嵌入彼此吗 "标题的直接链接") 可以,但这种嵌入应该在更高的 layers 中发生。例如,在 widget 内部,您可以导入两个 features,然后将一个 feature 作为 props/children 插入到另一个 feature 中。 您不能从一个 feature 导入另一个 feature,这被 [**layers 上的导入规则**](/zh/docs/reference/layers.md#import-rule-on-layers) 禁止。 ### Atomic Design 怎么办?[​](#atomic-design-怎么办 "标题的直接链接") 该方法论的当前版本不要求也不禁止将 Atomic Design 与 Feature-Sliced Design 一起使用。 例如,Atomic Design [可以很好地应用](https://t.me/feature_sliced/1653)于模块的 `ui` segment。 ### 有关于 FSD 的有用资源/文章等吗?[​](#有关于-fsd-的有用资源文章等吗 "标题的直接链接") 有! ### 为什么我需要 Feature-Sliced Design?[​](#为什么我需要-feature-sliced-design "标题的直接链接") 它帮助您和您的团队在主要价值组件方面快速概览项目。标准化架构有助于加快入职速度并解决关于代码结构的争议。请参阅[动机](/zh/docs/about/motivation.md)页面了解更多关于为什么创建 FSD 的信息。 ### 新手开发者需要架构/方法论吗?[​](#新手开发者需要架构方法论吗 "标题的直接链接") 更倾向于需要 *通常,当您独自设计和开发项目时,一切都很顺利。但如果开发过程中有暂停,团队中添加了新的开发者 - 那么问题就会出现* ### 如何处理授权上下文?[​](#如何处理授权上下文 "标题的直接链接") 在[这里](/zh/docs/guides/examples/auth.md)有答案 --- # 概览 **Feature-Sliced Design**(FSD)是一种用于构建前端应用程序的架构方法论。简单来说,它是组织代码的规则和约定的汇编。该方法论的主要目的是在不断变化的业务需求面前,使项目更加易于理解和稳定。 除了一系列约定外,FSD 还是一个工具链。我们有一个 [代码检查器](https://github.com/feature-sliced/steiger) 来检查您项目的架构,通过 CLI 或 IDE 的[文件夹生成器](https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools),以及丰富的[示例](/zh/examples.md)库。 ## 它适合我吗?[​](#is-it-right-for-me "标题的直接链接") FSD 可以在任何规模的项目和团队中实施。如果您的项目符合以下条件,那么它就适合您: * 您正在做**前端**开发(网页、移动端、桌面端等 UI) * 您正在构建一个**应用程序**,而不是一个库 就是这样!对于您使用的编程语言、UI 框架或状态管理器没有任何限制。您也可以逐步采用 FSD,在 monorepos 中使用它,并通过将应用程序分解为包并在其中单独实施 FSD 来扩展到很大的长度。 如果您已经有了一个架构并正在考虑切换到 FSD,请确保当前的架构在您的团队中**造成了麻烦**。例如,如果您的项目变得过于庞大和相互连接,无法高效地实现新功能,或者如果您期望有很多新成员加入团队。如果当前的架构运作良好,也许不值得更改。但如果您确实决定迁移,请参阅[迁移](/zh/docs/guides/migration/from-custom.md)部分获取指导。 ## 基本示例[​](#basic-example "标题的直接链接") 这里是一个实现了 FSD 的简单项目: * `📁 app` * `📁 pages` * `📁 shared` 这些顶级文件夹被称为\_层\_。让我们更深入地看看: * `📂 app` * `📁 routes` * `📁 analytics` * `📂 pages` * `📁 home` * `📂 article-reader` * `📁 ui` * `📁 api` * `📁 settings` * `📂 shared` * `📁 ui` * `📁 api` `📂 pages` 内的文件夹被称为\_切片\_。它们按领域分割层(在这种情况下,按页面分割)。 `📂 app`、`📂 shared` 和 `📂 pages/article-reader` 内的文件夹被称为\_段\_,它们按技术目的分割切片(或层),即代码的用途。 ## 概念[​](#concepts "标题的直接链接") Layers、slices 和 segments 形成这样的层次结构: ![Hierarchy of FSD concepts, described below](/zh/assets/images/visual_schema-e826067f573946613dcdc76e3f585082.jpg) 上图显示:三个支柱,从左到右分别标记为 "Layers"、"Slices" 和 "Segments"。 "Layers" 支柱包含七个从上到下排列的部分,分别标记为 "app"、"processes"、"pages"、"widgets"、"features"、"entities" 和 "shared"。"processes" 部分被划掉了。"entities" 部分连接到第二个支柱 "Slices",表示第二个支柱是 "entities" 的内容。 "Slices" 支柱包含三个从上到下排列的部分,分别标记为 "user"、"post" 和 "comment"。"post" 部分以同样的方式连接到第三个支柱 "Segments",表示它是 "post" 的内容。 "Segments" 支柱包含三个从上到下排列的部分,分别标记为 "ui"、"model" 和 "api"。 ### Layers[​](#layers "标题的直接链接") Layers 在所有 FSD 项目中都是标准化的。您不必使用所有的 layers,但它们的名称很重要。目前有七个(从上到下): 1. **App** — 使应用程序运行的一切 — 路由、入口点、全局样式、providers。 2. **Processes**(已废弃)— 复杂的跨页面场景。 3. **Pages** — 完整页面或嵌套路由中页面的大部分。 4. **Widgets** — 大型自包含的功能或 UI 块,通常提供整个用例。 5. **Features** — 整个产品功能的\_可重用\_实现,即为用户带来业务价值的操作。 6. **Entities** — 项目处理的业务实体,如 `user` 或 `product`。 7. **Shared** — 可重用功能,特别是当它与项目/业务的具体细节分离时,但不一定如此。 注意 Layers **App** 和 **Shared** 与其他 layers 不同,它们没有 slices,直接分为 segments。 然而,所有其他 layers — **Entities**、**Features**、**Widgets** 和 **Pages**,保持您必须首先创建 slices 的结构,在其中创建 segments。 Layers 的技巧是一个 layer 上的模块只能了解并从严格位于下方的 layers 的模块中导入。 ### Slices[​](#slices "标题的直接链接") 接下来是 slices,它们按业务领域分割代码。您可以自由选择它们的名称,并根据需要创建任意数量。Slices 通过将逻辑相关的模块保持在一起,使您的代码库更容易导航。 Slices 不能使用同一 layer 上的其他 slices,这有助于实现高聚合性和低耦合性。 ### Segments[​](#segments "标题的直接链接") Slices 以及 layers App 和 Shared 由 segments 组成,segments 按代码的目的对代码进行分组。Segment 名称不受标准约束,但有几个最常见目的的传统名称: * `ui` — 与 UI 显示相关的一切:UI 组件、日期格式化程序、样式等。 * `api` — 后端交互:请求函数、数据类型、mappers 等。 * `model` — 数据模型:schemas、interfaces、stores 和业务逻辑。 * `lib` — 此 slice 上其他模块需要的库代码。 * `config` — 配置文件和 feature flags。 通常这些 segments 对于大多数 layers 来说已经足够,您只会在 Shared 或 App 中创建自己的 segments,但这不是一个规则。 ## 优势[​](#advantages "标题的直接链接") * **统一性**
由于结构是标准化的,项目变得更加统一,这使得团队新成员的入职更加容易。 * **面对变化和重构的稳定性**
一个 layer 上的模块不能使用同一 layer 上的其他模块,或者上层的 layers。
这允许您进行独立的修改,而不会对应用程序的其余部分产生不可预见的后果。 * **可控的逻辑重用**
根据 layer,您可以使代码非常可重用或非常本地化。
这在遵循 **DRY** 原则和实用性之间保持平衡。 * **面向业务和用户需求**
应用程序被分割为业务领域,并鼓励在命名中使用业务语言,这样您可以在不完全理解项目的所有其他不相关部分的情况下做有用的产品工作。 ## 渐进式采用[​](#incremental-adoption "标题的直接链接") 如果您有一个现有的代码库想要迁移到 FSD,我们建议以下策略。我们在自己的迁移经验中发现它很有用。 1. 首先逐模块地慢慢塑造 App 和 Shared layers 以创建基础。 2. 使用粗略的笔触将所有现有 UI 分布在 Widgets 和 Pages 中,即使它们有违反 FSD 规则的依赖。 3. 开始逐渐解决导入违规,并提取 Entities,甚至可能提取 Features。 建议在重构时避免添加大型新实体,或者只重构项目的某些部分。 ## 下一步[​](#next-steps "标题的直接链接") * \*\*想要好好掌握如何用 FSD 思维?\*\*查看[Tutorial](/zh/docs/get-started/tutorial.md)。 * \*\*喜欢从示例中学习?\*\*我们在 [Examples](/zh/examples.md) 部分有很多内容。 * \*\*有问题?\*\*访问我们的 [Telegram 聊天](https://t.me/feature_sliced) 并从社区获得帮助。 --- # 教程 ## 第一部分。理论上[​](#第一部分理论上 "标题的直接链接") 本教程将检查 Real World App,也称为 Conduit。Conduit 是一个基本的 [Medium](https://medium.com/) 克隆 — 它让您阅读和编写文章,以及对他人的文章进行评论。 ![Conduit home page](/zh/assets/images/realworld-feed-anonymous-8cbba45f488931979f6c8da8968ad685.jpg) 这是一个相当小的应用程序,所以我们将保持简单并避免过度分解。整个应用程序很可能只需要三个 layers:**App**、**Pages** 和 **Shared**。如果不是,我们将在过程中引入额外的 layers。准备好了吗? ### 从列出页面开始[​](#从列出页面开始 "标题的直接链接") 如果我们查看上面的截图,我们可以至少假设以下页面: * 主页(文章流) * 登录和注册 * 文章阅读器 * 文章编辑器 * 用户资料查看器 * 用户资料编辑器(用户设置) 这些页面中的每一个都将成为 Pages *layer* 上的自己的 *slice*。回忆一下概览中的内容,slices 简单来说就是 layers 内部的文件夹,而 layers 简单来说就是具有预定义名称的文件夹,如 `pages`。 因此,我们的 Pages 文件夹将如下所示: ``` 📂 pages/ 📁 feed/ 📁 sign-in/ 📁 article-read/ 📁 article-edit/ 📁 profile/ 📁 settings/ ``` Feature-Sliced Design 与无规则代码结构的关键区别是页面不能相互引用。也就是说,一个页面不能从另一个页面导入代码。这是由于 **layers 上的导入规则**: *slice 中的模块(文件)只能在其他 slices 位于严格低于当前的 layers 时才能导入它们。* 在这种情况下,页面是一个 slice,所以这个页面内部的模块(文件)只能引用下层 layers 的代码,而不能引用同一 layer Pages 的代码。 ### 仔细查看 feed[​](#仔细查看-feed "标题的直接链接") ![Anonymous user’s perspective](/zh/assets/images/realworld-feed-anonymous-8cbba45f488931979f6c8da8968ad685.jpg) *Anonymous user’s perspective* ![Authenticated user’s perspective](/zh/assets/images/realworld-feed-authenticated-15427d9ff7baae009b47b501bee6c059.jpg) *Authenticated user’s perspective* feed 页面上有三个动态区域: 1. 带有登录状态指示的登录链接 2. 触发 feed 中过滤的标签列表 3. 一个/两个文章 feeds,每篇文章都有一个点赞按钮 登录链接是所有页面通用的头部的一部分,我们将单独重新访问它。 #### 标签列表[​](#标签列表 "标题的直接链接") 要构建标签列表,我们需要获取可用的标签,将每个标签渲染为芯片,并将选中的标签存储在客户端存储中。这些操作分别属于“API 交互”、“用户界面”和“存储”类别。在 Feature-Sliced Design 中,代码使用 *segments* 按目的分离。Segments 是 slices 中的文件夹,它们可以有描述目的的任意名称,但某些目的非常常见,以至于某些 segment 名称有约定: * 📂 `api/` 用于后端交互 * 📂 `ui/` 用于处理渲染和外观的代码 * 📂 `model/` 用于存储和业务逻辑 * 📂 `config/` 用于 feature flags、环境变量和其他形式的配置 我们将获取标签的代码放入 `api`,标签组件放入 `ui`,存储交互放入 `model`。 #### 文章[​](#文章 "标题的直接链接") 使用相同的分组原则,我们可以将文章 feed 分解为相同的三个 segments: * 📂 `api/`: 获取带有点赞数的分页文章;点赞文章 * 📂 `ui/`: * 可以在选中标签时渲染额外选项卡的选项卡列表 * 单个文章 * 功能分页 * 📂 `model/`: 当前加载的文章和当前页面的客户端存储(如果需要) ### 重用通用代码[​](#重用通用代码 "标题的直接链接") 大多数页面在意图上非常不同,但某些东西在整个应用程序中保持不变 — 例如,符合设计语言的 UI 套件,或后端上使用相同认证方法的 REST API 来完成所有事情的约定。由于 slices 旨在被隔离,代码重用由更低的 layer **Shared** 促进。 Shared 与其他 layers 不同,它包含 segments 而不是 slices。这样,Shared layer 可以被认为是 layer 和 slice 之间的混合体。 通常,Shared 中的代码不是提前计划的,而是在开发过程中提取的,因为只有在开发过程中才能明确哪些代码部分实际上是共享的。然而,记住哪种代码自然属于 Shared 仍然是有帮助的: * 📂 `ui/` — the UI kit, pure appearance, no business logic. For example, buttons, modal dialogs, form inputs. * 📂 `api/` — convenience wrappers around request making primitives (like `fetch()` on the Web) and, optionally, functions for triggering particular requests according to the backend specification. * 📂 `config/` — parsing environment variables * 📂 `i18n/` — configuration of language support * 📂 `router/` — routing primitives and route constants 这些只是 Shared 中 segment 名称的几个示例,但您可以省略其中任何一个或创建自己的。创建新 segments 时要记住的唯一重要事情是,segment 名称应该描述**目的(为什么),而不是本质(是什么)**。像 "components"、"hooks"、"modals" 这样的名称*不应该*使用,因为它们描述了这些文件是什么,但不能帮助在内部导航代码。这要求团队中的人在这样的文件夹中挖掘每个文件,并且也保持不相关的代码接近,这导致了重构影响的代码区域广泛,从而使代码审查和测试更加困难。 ### 定义严格的 public API[​](#定义严格的-public-api "标题的直接链接") 在 Feature-Sliced Design 的上下文中,术语 *public API* 指的是 slice 或 segment 声明项目中的其他模块可以从它导入什么。例如,在 JavaScript 中,这可以是一个 `index.js` 文件,从 slice 中的其他文件重新导出对象。这使得在 slice 内部重构代码的自由度成为可能,只要与外部世界的契约(即 public API)保持不变。 对于没有 slices 的 Shared layer,通常为每个 segment 定义单独的 public API 比定义 Shared 中所有内容的一个单一索引更方便。这使得从 Shared 的导入按意图自然地组织。对于具有 slices 的其他 layers,情况相反 — 通常每个 slice 定义一个索引并让 slice 决定外部世界未知的自己的 segments 集合更实用,因为其他 layers 通常有更少的导出。 我们的 slices/segments 将以以下方式相互出现: ``` 📂 pages/ 📂 feed/ 📄 index 📂 sign-in/ 📄 index 📂 article-read/ 📄 index 📁 … 📂 shared/ 📂 ui/ 📄 index 📂 api/ 📄 index 📁 … ``` 像 `pages/feed` 或 `shared/ui` 这样的文件夹内部的任何内容只有这些文件夹知道,其他文件不应该依赖这些文件夹的内部结构。 ### UI 中的大型重用块[​](#ui-中的大型重用块 "标题的直接链接") 早些时候我们记录了要重新访问出现在每个页面上的头部。在每个页面上从头开始重建它是不切实际的,所以想要重用它是很自然的。我们已经有 Shared 来促进代码重用,然而,在 Shared 中放置大型 UI 块有一个警告 — Shared layer 不应该了解上面的任何 layers。 在 Shared 和 Pages 之间有三个其他 layers:Entities、Features 和 Widgets。某些项目可能在这些 layers 中有他们在大型可重用块中需要的东西,这意味着我们不能将该可重用块放在 Shared 中,否则它将从上层 layers 导入,这是被禁止的。这就是 Widgets layer 的用武之地。它位于 Shared、Entities 和 Features 之上,所以它可以使用它们所有。 在我们的情况下,头部非常简单 — 它是一个静态 logo 和顶级导航。导航需要向 API 发出请求以确定用户当前是否已登录,但这可以通过从 `api` segment 的简单导入来处理。因此,我们将把我们的头部保留在 Shared 中。 ### 仔细查看带有表单的页面[​](#仔细查看带有表单的页面 "标题的直接链接") 让我们也检查一个用于编辑而不是阅读的页面。例如,文章编写器: ![Conduit post editor](/zh/assets/images/realworld-editor-authenticated-10de4d01479270886859e08592045b1e.jpg) 它看起来微不足道,但包含了我们尚未探索的应用程序开发的几个方面 — 表单验证、错误状态和数据持久化。 如果我们要构建这个页面,我们会从 Shared 中获取一些输入和按钮,并在此页面的 `ui` segment 中组合一个表单。然后,在 `api` segment 中,我们将定义一个变更请求以在后端创建文章。 为了在发送之前验证请求,我们需要一个验证模式,一个好地方是 `model` segment,因为它是数据模型。在那里我们将产生错误消息并使用 `ui` segment 中的另一个组件显示它们。 为了改善用户体验,我们还可以持久化输入以防止意外数据丢失。这也是 `model` segment 的工作。 ### 总结[​](#总结 "标题的直接链接") 我们已经检查了几个页面并为我们的应用程序概述了初步结构: 1. Shared layer 1. `ui` 将包含我们可重用的 UI 套件 2. `api` 将包含我们与后端的原始交互 3. 其余将根据需要安排 2. Pages layer — 每个页面都是一个单独的 slice 1. `ui` 将包含页面本身及其所有部分 2. `api` 将包含更专门的数据获取,使用 `shared/api` 3. `model` 可能包含我们将显示的数据的客户端存储 让我们开始构建吧! ## 第二部分。在代码中[​](#第二部分在代码中 "标题的直接链接") 现在我们有了计划,让我们付诸实践。我们将使用 React 和 [Remix](https://remix.run)。 有一个为此项目准备的模板,从 GitHub 克隆它以获得先机:。 使用 `npm install` 安装依赖项并使用 `npm run dev` 启动开发服务器。打开 ,您应该看到一个空白应用程序。 ### 布局页面[​](#布局页面 "标题的直接链接") 让我们首先为所有页面创建空白组件。在您的项目中运行以下命令: ``` npx fsd pages feed sign-in article-read article-edit profile settings --segments ui ``` 这将为每个页面创建像 `pages/feed/ui/` 这样的文件夹和一个索引文件 `pages/feed/index.ts`。 ### 连接 feed 页面[​](#连接-feed-页面 "标题的直接链接") 让我们将应用程序的根路由连接到 feed 页面。在 `pages/feed/ui` 中创建一个组件 `FeedPage.tsx` 并将以下内容放入其中: pages/feed/ui/FeedPage.tsx ``` export function FeedPage() { return (

conduit

A place to share your knowledge.

); } ``` 然后在 feed 页面的 public API,`pages/feed/index.ts` 文件中重新导出此组件: pages/feed/index.ts ``` export { FeedPage } from "./ui/FeedPage"; ``` 现在将它连接到根路由。在 Remix 中,路由是基于文件的,路由文件位于 `app/routes` 文件夹中,这与 Feature-Sliced Design 很好地契合。 在 `app/routes/_index.tsx` 中使用 `FeedPage` 组件: 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](/zh/assets/images/conduit-banner-a20e38edcd109ee21a8b1426d93a66b3.jpg) ### API 客户端[​](#api-客户端 "标题的直接链接") 为了与 RealWorld 后端通信,让我们在 Shared 中创建一个方便的 API 客户端。创建两个 segments,`api` 用于客户端,`config` 用于像后端基础 URL 这样的变量: ``` npx fsd shared --segments api config ``` 然后创建 `shared/config/backend.ts`: shared/config/backend.ts ``` export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; ``` shared/config/index.ts ``` export { backendBaseUrl } from "./backend"; ``` 由于 RealWorld 项目方便地提供了 [OpenAPI 规范](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml),我们可以利用为我们的客户端自动生成的类型。我们将使用 [the `openapi-fetch` package](https://openapi-ts.pages.dev/openapi-fetch/),它附带一个额外的类型生成器。 运行以下命令生成最新的 API 类型: ``` npm run generate-api-types ``` 这将创建一个文件 `shared/api/v1.d.ts`。我们将使用此文件在 `shared/api/client.ts` 中创建一个类型化的 API 客户端: 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 }); ``` shared/api/index.ts ``` export { GET, POST, PUT, DELETE } from "./client"; ``` ### feed 中的真实数据[​](#feed-中的真实数据 "标题的直接链接") 我们现在可以继续向 feed 添加从后端获取的文章。让我们首先实现一个文章预览组件。 使用以下内容创建 `pages/feed/ui/ArticlePreview.tsx`: pages/feed/ui/ArticlePreview\.tsx ``` export function ArticlePreview({ article }) { /* TODO */ } ``` 由于我们用 TypeScript 编写,有一个类型化的 article 对象会很好。如果我们探索生成的 `v1.d.ts`,我们可以看到 article 对象可以通过 `components["schemas"]["Article"]` 获得。所以让我们在 Shared 中创建一个包含我们数据模型的文件并导出模型: shared/api/models.ts ``` import type { components } from "./v1"; export type Article = components["schemas"]["Article"]; ``` shared/api/index.ts ``` export { GET, POST, PUT, DELETE } from "./client"; export type { Article } from "./models"; ``` 现在我们可以回到文章预览组件并用数据填充标记。使用以下内容更新组件: 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 中获取数据是通过 *loaders* 完成的 — 服务器端函数,获取页面所需的确切内容。Loaders 代表页面与 API 交互,所以我们将它们放在页面的 `api` segment 中: 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` 导出它: pages/feed/index.ts ``` export { FeedPage } from "./ui/FeedPage"; export { loader } from "./api/loader"; ``` 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; ``` 最后一步是在 feed 中渲染这些卡片。使用以下代码更新您的 `FeedPage`: 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) => ( ))}
); } ``` ### 按标签过滤[​](#按标签过滤 "标题的直接链接") 关于标签,我们的工作是从后端获取它们并存储当前选中的标签。我们已经知道如何进行获取 — 这是来自 loader 的另一个请求。我们将使用来自已安装的 `remix-utils` 包的便利函数 `promiseHash`。 使用以下代码更新 loader 文件 `pages/feed/api/loader.ts`: 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`: 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) => ( ))}
); } ``` 然后我们需要在我们的 loader 中使用 `tag` 搜索参数。将 `pages/feed/api/loader.ts` 中的 `loader` 函数更改为以下内容: 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` segment。Remix 非常整洁。 ### 分页[​](#分页 "标题的直接链接") 以类似的方式,我们可以实现分页。随意自己尝试一下或直接复制下面的代码。反正没有人会判断您。 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")), }), ); }; ``` 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) => ( ))}
); } ``` 这样也完成了。还有选项卡列表可以类似地实现,但让我们等到实现身份验证时再处理。说到这个! ### 身份验证[​](#身份验证 "标题的直接链接") 身份验证涉及两个页面 — 一个用于登录,另一个用于注册。它们大部分相同,所以将它们保持在同一个 slice `sign-in` 中是有意义的,这样它们可以在需要时重用代码。 在 `pages/sign-in` 的 `ui` segment 中创建 `RegisterPage.tsx`,内容如下: 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}
  • ))}
)}
); } ``` 我们现在有一个损坏的导入要修复。它涉及一个新的 segment,所以创建它: ``` npx fsd pages sign-in -s api ``` 然而,在我们可以实现注册的后端部分之前,我们需要一些供 Remix 处理会话的基础设施代码。这放在 Shared 中,以防其他页面需要它。 将以下代码放入 `shared/api/auth.server.ts`。这高度特定于 Remix,所以不要太担心,只需复制粘贴: 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` 模型: 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=`,然后在键盘上随意敲击一些键来创建一个长的随机字符串。您应该得到类似这样的东西: .env ``` SESSION_SECRET=dontyoudarecopypastethis ``` 最后,向 public API 添加一些导出以使用此代码: 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` 的文件,并将以下代码放入其中: 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: "/", }); } }; ``` pages/sign-in/index.ts ``` export { RegisterPage } from './ui/RegisterPage'; export { register } from './api/register'; ``` 几乎完成了!只需要将页面和操作连接到 `/register` 路由。在 `app/routes` 中创建 `register.tsx`: app/routes/register.tsx ``` import { RegisterPage, register } from "pages/sign-in"; export { register as action }; export default RegisterPage; ``` 现在如果您转到 ,您应该能够创建用户!应用程序的其余部分还不会对此做出反应,我们将立即解决这个问题。 以非常类似的方式,我们可以实现登录页面。尝试一下或直接获取代码并继续: 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: "/", }); } }; ``` 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}
  • ))}
)}
); } ``` 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'; ``` app/routes/login.tsx ``` import { SignInPage, signIn } from "pages/sign-in"; export { signIn as action }; export default SignInPage; ``` 现在让我们给用户一种实际达到这些页面的方法。 ### 头部[​](#头部 "标题的直接链接") 正如我们在第一部分中讨论的,应用程序头部通常放在 Widgets 或 Shared 中。我们将其放在 Shared 中,因为它非常简单,所有业务逻辑都可以保持在它之外。让我们为它创建一个地方: ``` npx fsd shared ui ``` 现在创建 `shared/ui/Header.tsx`,内容如下: 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` 导出此组件: shared/ui/index.ts ``` export { Header } from "./Header"; ``` 在头部中,我们依赖保存在 `shared/api` 中的上下文。也创建它: shared/api/currentUser.ts ``` import { createContext } from "react"; import type { User } from "./models"; export const CurrentUser = createContext(null); ``` 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"; ``` 现在让我们将头部添加到页面。我们希望它出现在每一个页面上,所以简单地将其添加到根路由并用 `CurrentUser` 上下文提供者包装 outlet(页面将被渲染的地方)是有意义的。这样我们的整个应用程序以及头部都可以访问当前用户对象。我们还将添加一个 loader 来实际从 cookies 中获取当前用户对象。将以下内容放入 `app/root.tsx`: 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.](/zh/assets/images/realworld-feed-without-tabs-5da4c9072101ac20e82e2234bd3badbe.jpg) The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing. ### 选项卡[​](#选项卡 "标题的直接链接") 现在我们可以检测身份验证状态,让我们也快速实现选项卡和帖子点赞来完成 feed 页面。我们需要另一个表单,但这个页面文件正在变得有点大,所以让我们将这些表单移动到相邻的文件中。我们将创建 `Tabs.tsx`、`PopularTags.tsx` 和 `Pagination.tsx`,内容如下: 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")}
  • )}
); } ``` 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) => ( ))}
); } ``` 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}
  • ) : (
  • ), )}
); } ``` 现在我们可以显著简化 feed 页面本身: 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) => ( ))}
); } ``` 我们还需要在 loader 函数中考虑新选项卡: 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")), }), ); }; ``` 在我们离开 feed 页面之前,让我们添加一些处理帖子点赞的代码。将您的 `ArticlePreview.tsx` 更改为以下内容: 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 请求以将文章标记为收藏。它还不会工作,但当我们开始处理文章阅读器时,我们也会实现这个功能。 这样我们就正式完成了 feed!太好了! ### 文章阅读器[​](#文章阅读器 "标题的直接链接") 首先,我们需要数据。让我们创建一个 loader: ``` npx fsd pages article-read -s api ``` 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, }), ), }), ); }; ``` pages/article-read/index.ts ``` export { loader } from "./api/loader"; ``` 现在我们可以通过创建一个名为 `article.$slug.tsx` 的路由文件将其连接到路由 `/article/:slug`: app/routes/article.$slug.tsx ``` export { loader } from "pages/article-read"; ``` 页面本身由三个主要块组成 — 带有操作的文章头部(重复两次)、文章主体和评论部分。这是页面的标记,它并不特别有趣: 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` segment 中创建 `action.ts`: 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: "/" }); }, }); }; ``` 从 slice 中导出它,然后从路由中导出。趁着这个机会,让我们也连接页面本身: pages/article-read/index.ts ``` export { ArticleReadPage } from "./ui/ArticleReadPage"; export { loader } from "./api/loader"; export { action } from "./api/action"; ``` app/routes/article.$slug.tsx ``` import { ArticleReadPage } from "pages/article-read"; export { loader, action } from "pages/article-read"; export default ArticleReadPage; ``` 现在,尽管我们还没有在阅读器页面上实现点赞按钮,但 feed 中的点赞按钮将开始工作!这是因为它一直在向这个路由发送"点赞"请求。试试看吧。 `ArticleMeta` 和 `Comments` 又是一堆表单。我们之前已经做过这个,让我们获取它们的代码并继续: 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    ) : ( <>    )}
); } ``` 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](/zh/assets/images/realworld-article-reader-6a420e4f2afe139d2bdd54d62974f0b9.jpg) Article reader with functioning buttons to like and follow ### 文章编辑器[​](#文章编辑器 "标题的直接链接") 这是我们将在本教程中涵盖的最后一个页面,这里最有趣的部分是我们将如何验证表单数据。 页面本身,`article-edit/ui/ArticleEditPage.tsx`,将非常简单,额外的复杂性被存储到其他两个组件中: 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`,因为它将接收验证结果并向用户显示。让我们看一下: 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; } ``` 这里我们假设我们的 action 将返回 `errors` 字段,一个人类可读的错误消息数组。我们很快就会讲到 action。 另一个组件是标签输入。它只是一个普通的输入字段,附带所选标签的额外预览。这里没什么可看的: 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 部分。loader 应该查看 URL,如果它包含文章 slug,那意味着我们正在编辑现有文章,应该加载其数据。否则,返回空。让我们创建该 loader: 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}` }, }), ); }; ``` action 将获取新的字段值,通过我们的数据模式运行它们,如果一切都正确,就将这些更改提交到后端,通过更新现有文章或创建新文章: 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` 的解析函数,这使我们可以方便地获取干净的字段或只是抛出错误在末尾处理。这里是该解析函数的样子: 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 模式,例如,但然后我们必须在前端渲染错误消息,这个表单不值得复杂化。 最后一步 — 将页面、loader 和 action 连接到路由。由于我们巧妙地支持创建和编辑,我们可以从 `editor._index.tsx` 和 `editor.$slug.tsx` 两者导出相同的东西: pages/article-edit/index.ts ``` export { ArticleEditPage } from "./ui/ArticleEditPage"; export { loader } from "./api/loader"; export { action } from "./api/action"; ``` 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”.](/zh/assets/images/realworld-article-editor-bc3ee45c96ae905fdbb54d6463d12723.jpg) 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”**. 资料和设置页面与文章阅读器和编辑器非常相似,它们留作读者的练习,这就是您 :) --- # 处理 API 请求 ## 共享 API 请求[​](#shared-api-requests "标题的直接链接") 首先将通用的 API 请求逻辑放在 `shared/api` 目录中。这使得在应用程序中重用请求变得容易,并有助于更快的原型开发。对于许多项目来说,这就是 API 调用所需的全部内容。 典型的文件结构是: * 📂 shared * 📂 api * 📄 client.ts * 📄 index.ts * 📂 endpoints * 📄 login.ts `client.ts` 文件集中了您的 HTTP 请求设置。它包装您选择的方法(如 `fetch()` 或 `axios` 实例)并处理常见配置,例如: * 后端基础 URL。 * 默认头部(例如,用于身份验证)。 * 数据序列化。 以下是 `axios` 和 `fetch` 的示例: * Axios * Fetch shared/api/client.ts ``` // Example using axios import axios from 'axios'; export const client = axios.create({ baseURL: 'https://your-api-domain.com/api/', timeout: 5000, headers: { 'X-Custom-Header': 'my-custom-value' } }); ``` shared/api/client.ts ``` export const client = { async post(endpoint: string, body: any, options?: RequestInit) { const response = await fetch(`https://your-api-domain.com/api${endpoint}`, { method: 'POST', body: JSON.stringify(body), ...options, headers: { 'Content-Type': 'application/json', 'X-Custom-Header': 'my-custom-value', ...options?.headers, }, }); return response.json(); } // ... other methods like put, delete, etc. }; ``` 在 `shared/api/endpoints` 中组织您的单个 API 请求函数,按 API 端点分组。 备注 为了保持示例的重点,我们省略了表单交互和验证。有关 Zod 或 Valibot 等库的详细信息,请参阅[类型验证和 Schemas](/zh/docs/guides/examples/types.md#type-validation-schemas-and-zod) 文章。 shared/api/endpoints/login.ts ``` import { client } from '../client'; export interface LoginCredentials { email: string; password: string; } export function login(credentials: LoginCredentials) { return client.post('/login', credentials); } ``` 在 `shared/api` 中使用 `index.ts` 文件来导出您的请求函数。 shared/api/index.ts ``` export { client } from './client'; // 如果您想导出客户端本身 export { login } from './endpoints/login'; export type { LoginCredentials } from './endpoints/login'; ``` ## 特定 Slice 的 API 请求[​](#slice-specific-api-requests "标题的直接链接") 如果 API 请求仅由特定 slice(如单个页面或功能)使用且不会被重用,请将其放在该 slice 的 api segment 中。这样可以保持特定 slice 的逻辑整齐地包含在内。 * 📂 pages * 📂 login * 📄 index.ts * 📂 api * 📄 login.ts * 📂 ui * 📄 LoginPage.tsx pages/login/api/login.ts ``` import { client } from 'shared/api'; interface LoginCredentials { email: string; password: string; } export function login(credentials: LoginCredentials) { return client.post('/login', credentials); } ``` 您不需要在页面的公共 API 中导出 `login()` 函数,因为应用程序中的其他地方不太可能需要这个请求。 备注 避免过早地将 API 调用和响应类型放在 `entities` 层中。后端响应可能与您的前端实体需要的不同。`shared/api` 或 slice 的 `api` segment 中的 API 逻辑允许您适当地转换数据,保持实体专注于前端关注点。 ## 使用客户端生成器[​](#client-generators "标题的直接链接") 如果您的后端有 OpenAPI 规范,像 [orval](https://orval.dev/) 或 [openapi-typescript](https://openapi-ts.dev/) 这样的工具可以为您生成 API 类型和请求函数。将生成的代码放在,例如 `shared/api/openapi` 中。确保包含 `README.md` 来记录这些类型是什么,以及如何生成它们。 ## 与服务器状态库集成[​](#server-state-libraries "标题的直接链接") 当使用像 [TanStack Query (React Query)](https://tanstack.com/query/latest) 或 [Pinia Colada](https://pinia-colada.esm.dev/) 这样的服务器状态库时,您可能需要在 slices 之间共享类型或缓存键。将以下内容使用 `shared` 层: * API 数据类型 * 缓存键 * 通用查询/变更选项 有关如何使用服务器状态库的更多详细信息,请参阅 [React Query 文章](/zh/docs/guides/tech/with-react-query.md) --- # 身份验证 广义上,身份验证包含以下步骤: 1. 从用户获取凭据 2. 将它们发送到后端 3. 存储 token 以进行经过身份验证的请求 ## 如何从用户获取凭据[​](#如何从用户获取凭据 "标题的直接链接") 我们假设您的应用程序负责获取凭据。如果您通过 OAuth 进行身份验证,您可以简单地创建一个登录页面,其中包含指向 OAuth 提供商登录页面的链接,然后跳转到[步骤 3](#how-to-store-the-token-for-authenticated-requests)。 ### 专用登录页面[​](#专用登录页面 "标题的直接链接") 通常,网站有专用的登录页面,您在其中输入用户名和密码。这些页面相当简单,所以不需要分解。登录和注册表单在外观上相当相似,所以它们甚至可以被组合在一个页面中。在 Pages layer 上为您的登录/注册页面创建一个 slice: * 📂 pages * 📂 login * 📂 ui * 📄 LoginPage.tsx (or your framework's component file format) * 📄 RegisterPage.tsx * 📄 index.ts * other pages… 在这里我们创建了两个组件并在 slice 的 index 文件中导出它们。这些组件将包含表单,负责为用户提供可理解的控件来获取他们的凭据。 ### 登录对话框[​](#登录对话框 "标题的直接链接") 如果您的应用程序有一个可以在任何页面上使用的登录对话框,请考虑将该对话框设为 widget。这样,您仍然可以避免过多的分解,但可以自由地在任何页面上重用此对话框。 * 📂 widgets * 📂 login-dialog * 📂 ui * 📄 LoginDialog.tsx * 📄 index.ts * other widgets… 本指南的其余部分是为专用页面方法编写的,但相同的原则也适用于对话框 widget。 ### 客户端验证[​](#客户端验证 "标题的直接链接") 有时,特别是对于注册,执行客户端验证是有意义的,可以让用户快速知道他们犯了错误。验证可以在登录页面的 `model` segment 中进行。使用 schema 验证库,例如 JS/TS 的 [Zod](https://zod.dev),并将该 schema 暴露给 `ui` segment: 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: "Passwords do not match", path: ["confirmPassword"], }); ``` 然后,在 `ui` segment 中,您可以使用此 schema 来验证用户输入: 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))}>
) } ``` ## 如何将凭据发送到后端[​](#如何将凭据发送到后端 "标题的直接链接") 创建一个向后端登录端点发出请求的函数。此函数可以使用 mutation 库(例如 TanStack Query)直接在组件代码中调用,也可以作为状态管理器中的副作用调用。如 [API 请求指南](/zh/docs/guides/examples/api-requests.md) 中所述,您可以将请求放在 `shared/api` 中或登录页面的 `api` segment 中。 ### 双因素认证[​](#双因素认证 "标题的直接链接") 如果您的应用程序支持双因素认证(2FA),您可能需要重定向到另一个页面,用户可以在其中输入一次性密码。通常,您的 `POST /login` 请求会返回带有标志的用户对象,指示用户已启用 2FA。如果设置了该标志,请将用户重定向到 2FA 页面。 由于此页面与登录密切相关,您也可以将其保留在 Pages layer 上的同一个 slice `login` 中。 您还需要另一个请求函数,类似于我们上面创建的 `login()`。将它们放在一起,要么在 Shared 中,要么在 `login` 页面的 `api` segment 中。 ## 如何存储 token 以进行经过身份验证的请求[​](#how-to-store-the-token-for-authenticated-requests "标题的直接链接") 无论您使用哪种身份验证方案,无论是简单的登录和密码、OAuth 还是双因素认证,最终您都会收到一个 token。应该存储此 token,以便后续请求可以识别自己。 Web 应用程序的理想 token 存储是 **cookie** — 它不需要手动 token 存储或处理。因此,cookie 存储几乎不需要从前端架构方面考虑。如果您的前端框架有服务器端(例如 [Remix](https://remix.run)),那么您应该将服务器端 cookie 基础设施存储在 `shared/api` 中。在[教程的身份验证部分](/zh/docs/get-started/tutorial.md#authentication)中有一个如何使用 Remix 做到这一点的示例。 但是,有时 cookie 存储不是一个选项。在这种情况下,您将必须手动存储 token。除了存储 token 之外,您可能还需要设置在 token 过期时刷新 token 的逻辑。使用 FSD,有几个地方可以存储 token,以及几种方法可以使其对应用程序的其余部分可用。 ### 在 Shared 中[​](#在-shared-中 "标题的直接链接") 这种方法与在 `shared/api` 中定义的 API 客户端配合得很好,因为 token 可以自由地用于其他需要身份验证才能成功的请求函数。您可以让 API 客户端保持状态,无论是使用响应式存储还是简单的模块级变量,并在您的 `login()`/`logout()` 函数中更新该状态。 自动 token 刷新可以作为 API 客户端中的中间件实现 — 每次您发出任何请求时都可以执行的东西。它可以这样工作: * 认证并存储访问 token 以及刷新 token * 发出任何需要身份验证的请求 * 如果请求失败并返回指示 token 过期的状态码,并且存储中有 token,则发出刷新请求,存储新的 token,并重试原始请求 这种方法的缺点之一是管理和刷新token的逻辑没有专门的位置。对于某些应用程序或团队来说,这可能是可以接受的,但如果token管理逻辑更复杂,最好将发出请求和管理token的职责分开。你可以通过将请求和API客户端保留在`shared/api`中,但将token存储和管理逻辑放在`shared/auth`中来实现这一点。 这种方法的另一个缺点是,如果你的后端返回当前用户信息的对象以及token,你必须将其存储在某处或丢弃该信息,并从诸如`/me`或`/users/current`之类的端点再次请求它。 ### 在 Entities 中[​](#在-entities-中 "标题的直接链接") FSD 项目通常有一个用户实体和/或当前用户实体。甚至可以是同一个实体。 备注 **当前用户**有时也被称为"viewer"或"me"。这是为了区分具有权限和私人信息的单个经过身份验证的用户与具有公开可访问信息的所有用户列表。 要在用户实体中存储token,请在`model`段中创建一个响应式存储。该存储可以同时包含token和用户对象。 由于API客户端通常在`shared/api`中定义或分布在各个实体中,这种方法的主要挑战是在不违反[层级导入规则](/zh/docs/reference/layers.md#import-rule-on-layers)的情况下使token对需要它的其他请求可用: > 切片中的模块(文件)只能在其他切片位于严格较低的层级时导入它们。 有几种解决这个挑战的方案: 1. **每次发出请求时手动传递token**
这是最简单的解决方案,但很快就会变得繁琐,如果你没有类型安全,很容易忘记。它也与Shared中API客户端的中间件模式不兼容。 2. **通过上下文或像`localStorage`这样的全局存储将token暴露给整个应用程序**
检索token的键将保存在`shared/api`中,以便API客户端可以访问它。token的响应式存储将从用户实体导出,上下文提供者(如果需要)将在App层设置。这为设计API客户端提供了更多自由,但是,这会对更高层级提供上下文创建隐式依赖。遵循这种方法时,如果上下文或`localStorage`没有正确设置,请考虑提供有用的错误消息。 3. **每次token更改时将其注入API客户端**
如果你的存储是响应式的,你可以创建一个订阅,每次实体中的存储更改时都会更新API客户端的token存储。这与前一个解决方案类似,因为它们都对更高层级创建隐式依赖,但这个更具命令性("推送"),而前一个更具声明性("拉取")。 一旦你克服了暴露存储在实体模型中的token的挑战,你就可以编码更多与token管理相关的业务逻辑。例如,`model`段可以包含在一定时间后使token失效的逻辑,或在token过期时刷新token的逻辑。要实际向后端发出请求,请使用用户实体的`api`段或`shared/api`。 ### 在页面/小部件中(不推荐)[​](#在页面小部件中不推荐 "标题的直接链接") 不建议在页面或小部件中存储像访问token这样的应用程序范围状态。避免将token存储放在登录页面的`model`段中,而是从前两个解决方案中选择:Shared或Entities。 ## 登出和 token 失效[​](#登出和-token-失效 "标题的直接链接") 通常,应用程序没有专门的登出页面,但登出功能仍然非常重要。它包括对后端的经过身份验证的请求和对 token 存储的更新。 如果您将所有请求存储在 `shared/api` 中,请将登出请求函数保留在那里,靠近登录函数。否则,请考虑将登出请求函数保留在触发它的按钮旁边。例如,如果您有一个出现在每个页面上并包含登出链接的头部 widget,请将该请求放在该 widget 的 `api` segment 中。 token 存储的更新必须从登出按钮的位置触发,比如头部 widget。您可以在该 widget 的 `model` segment 中组合请求和存储更新。 ### 自动登出[​](#自动登出 "标题的直接链接") 不要忘记为登出请求失败或刷新登录 token 请求失败时构建故障保护。在这两种情况下,您都应该清除 token 存储。如果您将 token 保存在 Entities 中,此代码可以放在 `model` segment 中,因为它是纯业务逻辑。如果您将 token 保存在 Shared 中,将此逻辑放在 `shared/api` 中可能会使 segment 膨胀并稀释其目的。如果您注意到您的 API segment 包含几个不相关的东西,请考虑将 token 管理逻辑拆分到另一个 segment 中,例如 `shared/auth`。 --- # 自动完成 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/170) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于按层分解 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(讨论) 关于将方法论应用于加载字典的选择](https://github.com/feature-sliced/documentation/discussions/65#discussioncomment-480807) --- # 浏览器 API WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/197) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于使用浏览器 API:localStorage、音频 API、蓝牙 API 等。 > > 您可以向 [@alex\_novi](https://t.me/alex_novich) 询问更多详细信息 --- # CMS WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/172) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 功能可能不同[​](#功能可能不同 "标题的直接链接") 在一些项目中,所有功能都集中在来自服务器的数据中 > ## 如何更正确地使用 CMS 标记[​](#如何更正确地使用-cms-标记 "标题的直接链接") > > --- # 反馈 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/187) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 错误、警告、通知…… --- # 国际化 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/171) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 在哪里放置它?如何使用它?[​](#在哪里放置它如何使用它 "标题的直接链接") * * * --- # 指标 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/181) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于在应用程序中初始化指标的方法 --- # 单体仓库 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/221) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于单体仓库的适用性,关于 bff,关于微应用 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(讨论) 关于单体仓库和插件包](https://github.com/feature-sliced/documentation/discussions/50) * [(Thread) 关于单体仓库的应用](https://t.me/feature_sliced/2412) --- # 页面布局 本指南探讨了\_页面布局\_的抽象 — 当多个页面共享相同的整体结构,仅在主要内容上有所不同时。 信息 本指南没有涵盖您的问题?请通过在本文上留下反馈(右侧的蓝色按钮)来发布您的问题,我们将考虑扩展本指南! ## 简单布局[​](#简单布局 "标题的直接链接") 最简单的布局可以在此页面上看到。它有一个带有站点导航的头部、两个侧边栏和一个带有外部链接的页脚。没有复杂的业务逻辑,唯一的动态部分是侧边栏和头部右侧的切换器。这样的布局可以完全放置在 `shared/ui` 或 `app/layouts` 中,通过 props 填充侧边栏的内容: 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
); } ``` 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; } ``` 侧边栏的代码留给读者作为练习 😉。 ## 在布局中使用 widgets[​](#在布局中使用-widgets "标题的直接链接") 有时您希望在布局中包含某些业务逻辑,特别是如果您使用像 [React Router](https://reactrouter.com/) 这样的路由器的深度嵌套路由。然后由于[层上的导入规则](/zh/docs/reference/layers.md#import-rule-on-layers),您无法将布局存储在 Shared 或 Widgets 中: > slice 中的模块只能在其他 slices 位于严格较低的层时导入它们。 在我们讨论解决方案之前,我们需要讨论这是否首先是一个问题。您\_真的需要\_那个布局吗?如果需要,它\_真的需要\_成为一个 Widget 吗?如果所讨论的业务逻辑块在 2-3 个页面上重用,而布局只是该 widget 的一个小包装器,请考虑以下两个选项之一: 1. **在 App 层内联编写布局,在那里配置路由**
这对于支持嵌套的路由器来说很棒,因为您可以将某些路由分组并仅对它们应用布局。 2. **直接复制粘贴**
抽象代码的冲动往往被过度高估。对于很少更改的布局来说尤其如此。在某个时候,如果其中一个页面需要更改,您可以简单地进行更改,而不会不必要地影响其他页面。如果您担心有人可能忘记更新其他页面,您总是可以留下描述页面之间关系的注释。 如果上述都不适用,有两种解决方案可以在布局中包含 widget: 1. **使用 render props 或 slots**
大多数框架允许您从外部传递一段 UI。在 React 中,这被称为 [render props](https://www.patterns.dev/react/render-props-pattern/),在 Vue 中被称为 [slots](https://vuejs.org/guide/components/slots)。 2. **将布局移动到 App 层**
您也可以将布局存储在 App 层,例如在 `app/layouts` 中,并组合您想要的任何 widgets。 ## 延伸阅读[​](#延伸阅读 "标题的直接链接") * 在[教程](/zh/docs/get-started/tutorial.md)中有一个如何使用 React 和 Remix(相当于 React Router)构建带有身份验证的布局的示例。 --- # 桌面/触摸平台 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/198) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于方法论在桌面/触摸平台上的应用 --- # SSR WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/173) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 关于使用方法论实现 SSR --- # 主题 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/207) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 我应该把主题和调色板的工作放在哪里?[​](#我应该把主题和调色板的工作放在哪里 "标题的直接链接") > ## 关于主题、i18n 逻辑位置的讨论[​](#关于主题i18n-逻辑位置的讨论 "标题的直接链接") > --- # 类型 本指南涉及来自类型化语言(如 TypeScript)的数据类型,并描述它们在 FSD 中的适用位置。 信息 本指南没有涵盖您的问题?请通过在本文上留下反馈(右侧的蓝色按钮)来发布您的问题,我们将考虑扩展本指南! ## 实用类型[​](#实用类型 "标题的直接链接") 实用类型是本身没有太多意义的类型,通常与其他类型一起使用。例如: ``` type ArrayValues = T[number]; ``` Source: 要使实用类型在整个项目中可用,可以安装像 [`type-fest`](https://github.com/sindresorhus/type-fest) 这样的库,或者在 `shared/lib` 中创建您自己的库。确保清楚地指出哪些新类型\_应该\_添加到此库中,哪些类型\_不属于\_那里。例如,将其命名为 `shared/lib/utility-types` 并在其中添加一个 README,描述您团队中什么是实用类型。 不要高估实用类型的潜在可重用性。仅仅因为它可以被重用,并不意味着它会被重用,因此,并非每个实用类型都需要在 Shared 中。一些实用类型放在需要它们的地方就很好: * 📂 pages * 📂 home * 📂 api * 📄 ArrayValues.ts (utility type) * 📄 getMemoryUsageMetrics.ts (the code that uses the utility type) 注意 抵制创建 `shared/types` 文件夹或向您的 slices 添加 `types` segment 的诱惑。"types"类别类似于"components"或"hooks"类别,它描述的是内容是什么,而不是它们的用途。Segments 应该描述代码的目的,而不是本质。 ## 业务实体及其交叉引用[​](#业务实体及其交叉引用 "标题的直接链接") 应用程序中最重要的类型之一是业务实体的类型,即您的应用程序处理的现实世界的事物。例如,在音乐流媒体应用程序中,您可能有业务实体 *Song*、*Album* 等。 业务实体通常来自后端,因此第一步是为后端响应添加类型。为每个端点创建一个请求函数,并为此函数的响应添加类型是很方便的。为了额外的类型安全,您可能希望通过像 [Zod](https://zod.dev) 这样的 schema 验证库来运行响应。 例如,如果您将所有请求保存在 Shared 中,您可以这样做: 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` 导入 `Artist`,因为 FSD 通过[层上的导入规则](/zh/docs/reference/layers.md#import-rule-on-layers)限制 slices 之间的交叉导入: > slice 中的模块只能在其他 slices 位于严格较低的层时导入它们。 有两种方法来处理这个问题: 1. **参数化您的类型**
您可以让您的类型接受类型参数作为与其他实体连接的插槽,甚至可以对这些插槽施加约束。例如: entities/song/model/song.ts ``` interface Song { id: number; title: string; artists: Array; } ``` 这对某些类型比其他类型效果更好。像 `Cart = { items: Array }` 这样的简单类型可以很容易地与任何类型的产品一起工作。更连接的类型,如 `Country` 和 `City`,可能不那么容易分离。 2. **交叉导入(但要正确地做)**
要在 FSD 中的实体之间进行交叉导入,您可以为每个将要交叉导入的 slice 使用特殊的公共 API。例如,如果我们有实体 `song`、`artist` 和 `playlist`,后两者需要引用 `song`,我们可以在 `song` 实体中使用 `@x` 符号为它们创建两个特殊的公共 API: * 📂 entities * 📂 song * 📂 @x * 📄 artist.ts (供 `artist` 实体导入的公共 API) * 📄 playlist.ts (供 `playlist` 实体导入的公共 API) * 📄 index.ts (常规公共 API) 文件 `📄 entities/song/@x/artist.ts` 的内容类似于 `📄 entities/song/index.ts`: entities/song/@x/artist.ts ``` export type { Song } from "../model/song.ts"; ``` 然后 `📄 entities/artist/model/artist.ts` 可以像这样导入 `Song`: entities/artist/model/artist.ts ``` import type { Song } from "entities/song/@x/artist"; export interface Artist { name: string; songs: Array; } ``` 通过在实体之间建立显式连接,我们掌握相互依赖关系并保持良好的域分离水平。 ## 数据传输对象和映射器[​](#data-transfer-objects-and-mappers "标题的直接链接") 数据传输对象,或 DTO,是一个描述来自后端的数据形状的术语。有时,DTO 可以直接使用,但有时对前端来说不太方便。这就是映射器发挥作用的地方 — 它们将 DTO 转换为更方便的形状。 ### 在哪里放置 DTO[​](#在哪里放置-dto "标题的直接链接") 如果您在单独的包中有后端类型(例如,如果您在前端和后端之间共享代码),那么只需从那里导入您的 DTO 就完成了!如果您不在后端和前端之间共享代码,那么您需要将 DTO 保存在前端代码库的某个地方,我们将在下面探讨这种情况。 如果您的请求函数在 `shared/api` 中,那么 DTO 应该放在那里,就在使用它们的函数旁边: 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。 ### 在哪里放置映射器[​](#在哪里放置映射器 "标题的直接链接") 映射器是接受 DTO 进行转换的函数,因此,它们应该位于 DTO 定义附近。在实践中,这意味着如果您的请求和 DTO 在 `shared/api` 中定义,那么映射器也应该放在那里: 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; /** The full title of the song, including the disc number. */ 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)); } ``` 如果您的请求和存储在实体 slices 中定义,那么所有这些代码都会放在那里,请记住 slices 之间交叉导入的限制: 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; } ``` entities/song/api/mapper.ts ``` import type { SongDTO } from "./dto"; export interface Song { id: string; title: string; /** The full title of the song, including the disc number. */ 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), }; } ``` entities/song/api/listSongs.ts ``` import { adaptSongDTO } from "./mapper"; export function listSongs() { return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); } ``` 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[​](#如何处理嵌套-dto "标题的直接链接") 最有问题的部分是当来自后端的响应包含多个实体时。例如,如果歌曲不仅包含作者的 ID,还包含整个作者对象。在这种情况下,实体不可能不相互了解(除非我们想要丢弃数据或与后端团队进行坚定的对话)。与其想出 slices 之间间接连接的解决方案(例如将操作分派到其他 slices 的通用中间件),不如使用 `@x` 符号进行显式交叉导入。以下是我们如何使用 Redux Toolkit 实现它: entities/song/model/songs.ts ``` import { createSlice, createEntityAdapter, createAsyncThunk, createSelector, } from '@reduxjs/toolkit' import { normalize, schema } from 'normalizr' import { getSong } from "../api/getSong"; // 定义 normalizr 实体 schemas 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) // 规范化数据,以便 reducers 可以加载可预测的 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 ``` entities/song/@x/artist.ts ``` export { fetchSong } from "../model/songs"; ``` 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) => { // 通过在这里插入艺术家来处理相同的获取结果 artistAdapter.upsertMany(state, action.payload.artists) }) }, }) const reducer = slice.reducer export default reducer ``` 这稍微限制了 slice 隔离的好处,但它准确地表示了我们无法控制的这两个实体之间的连接。如果这些实体要被重构,它们必须一起重构。 ## 全局类型和 Redux[​](#全局类型和-redux "标题的直接链接") 全局类型是将在整个应用程序中使用的类型。根据它们需要了解的内容,有两种全局类型: 1. 没有任何应用程序特定内容的通用类型 2. 需要了解整个应用程序的类型 第一种情况很容易解决 — 将您的类型放在 Shared 中的适当 segment 中。例如,如果您有一个用于分析的全局变量接口,您可以将其放在 `shared/analytics` 中。 注意 避免创建 `shared/types` 文件夹。它仅基于"是一个类型"的属性将不相关的事物分组,而该属性在项目中搜索代码时通常没有用。 第二种情况在没有 RTK 的 Redux 项目中很常见。您的最终存储类型只有在将所有 reducer 添加在一起后才可用,但此存储类型需要对您在应用程序中使用的选择器可用。例如,这是您的典型存储定义: 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` 中拥有类型化的 Redux hooks `useAppDispatch` 和 `useAppSelector` 会很好,但由于[层上的导入规则](/zh/docs/reference/layers.md#import-rule-on-layers),它们无法从 App 层导入 `RootState` 和 `AppDispatch`: > slice 中的模块只能在其他 slices 位于严格较低的层时导入它们。 在这种情况下,推荐的解决方案是在 Shared 和 App 层之间创建隐式依赖关系。这两种类型 `RootState` 和 `AppDispatch` 不太可能改变,Redux 开发者会熟悉它们,所以我们不必太担心它们。 在 TypeScript 中,您可以通过将类型声明为全局来做到这一点: app/store/index.ts ``` /* 与之前代码块中的内容相同… */ declare type RootState = ReturnType; declare type AppDispatch = typeof store.dispatch; ``` shared/store/index.ts ``` import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; export const useAppDispatch = useDispatch.withTypes() export const useAppSelector: TypedUseSelectorHook = useSelector; ``` ## 枚举[​](#枚举 "标题的直接链接") 枚举的一般规则是它们应该**尽可能接近使用位置**定义。当枚举表示特定于单个功能的值时,它应该在同一功能中定义。 segment 的选择也应该由使用位置决定。例如,如果您的枚举包含屏幕上 toast 的位置,它应该放在 `ui` segment 中。如果它表示后端操作的加载状态,它应该放在 `api` segment 中。 一些枚举在整个项目中确实是通用的,如一般的后端响应状态或设计系统令牌。在这种情况下,您可以将它们放在 Shared 中,并根据枚举所代表的内容选择 segment(响应状态用 `api`,设计令牌用 `ui` 等)。 ## 类型验证 schemas 和 Zod[​](#类型验证-schemas-和-zod "标题的直接链接") 如果您想验证您的数据符合某种形状或约束,您可以定义一个验证 schema。在 TypeScript 中,这项工作的流行库是 [Zod](https://zod.dev)。验证 schemas 也应该尽可能与使用它们的代码放在一起。 验证 schemas 类似于映射器(如[数据传输对象和映射器](#data-transfer-objects-and-mappers)部分所讨论的),它们接受数据传输对象并解析它,如果解析失败则产生错误。 验证最常见的情况之一是来自后端的数据。通常,当数据与 schema 不匹配时,您希望请求失败,因此将 schema 放在与请求函数相同的位置是有意义的,这通常是 `api` segment。 如果您的数据通过用户输入(如表单)传入,验证应该在输入数据时进行。您可以将 schema 放在 `ui` segment 中,紧挨着表单组件,或者如果 `ui` segment 太拥挤,可以放在 `model` segment 中。 ## 组件 props 和 context 的类型定义[​](#组件-props-和-context-的类型定义 "标题的直接链接") 一般来说,最好将 props 或 context 接口保存在使用它们的组件或 context 的同一文件中。如果您有一个单文件组件的框架,如 Vue 或 Svelte,并且您无法在同一文件中定义 props 接口,或者您想在几个组件之间共享该接口,请在同一文件夹中创建一个单独的文件,通常是 `ui` segment。 以下是 JSX(React 或 Solid)的示例: pages/home/ui/RecentActions.tsx ``` interface RecentActionsProps { actions: Array<{ id: string; text: string }>; } export function RecentActions({ actions }: RecentActionsProps) { /* … */ } ``` 以下是将接口存储在 Vue 的单独文件中的示例: pages/home/ui/RecentActionsProps.ts ``` export interface RecentActionsProps { actions: Array<{ id: string; text: string }>; } ``` pages/home/ui/RecentActions.vue ``` ``` ## 环境声明文件 (`*.d.ts`)[​](#环境声明文件-dts "标题的直接链接") 一些包,例如 [Vite](https://vitejs.dev) 或 [ts-reset](https://www.totaltypescript.com/ts-reset),需要环境声明文件才能在您的应用程序中工作。通常,它们不大也不复杂,所以它们通常不需要任何架构,只需将它们放在 `src/` 文件夹中即可。为了保持 `src` 更有组织,您可以将它们保存在 App 层的 `app/ambient/` 中。 其他包根本没有类型定义,您可能希望将它们声明为无类型或甚至为它们编写自己的类型定义。这些类型定义的好地方是 `shared/lib`,在像 `shared/lib/untyped-packages` 这样的文件夹中。在那里创建一个 `%LIBRARY_NAME%.d.ts` 文件并声明您需要的类型: shared/lib/untyped-packages/use-react-screenshot.d.ts ``` // 这个库没有类型定义,我们不想费心编写自己的。 declare module "use-react-screenshot"; ``` ## 类型的自动生成[​](#类型的自动生成 "标题的直接链接") 从外部源生成类型是很常见的,例如,从 OpenAPI schema 生成后端类型。在这种情况下,为这些类型在您的代码库中创建一个专门的位置,如 `shared/api/openapi`。理想情况下,您还应该在该文件夹中包含一个 README,描述这些文件是什么、如何重新生成它们等。 --- # 白标 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/215) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > Figma、品牌 uikit、模板、品牌适应性 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(Thread) 关于白标(品牌)项目的应用](https://t.me/feature_sliced/1543) * [(演示文稿) 关于白标应用程序和设计](http://yadi.sk/i/5IdhzsWrpO3v4Q) --- # 交叉导入 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/220) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* > 当层或抽象开始承担超出其应有责任时,就会出现交叉导入。这就是为什么方法论识别出新的层,允许您解耦这些交叉导入 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(Thread) 关于交叉端口的所谓不可避免性](https://t.me/feature_sliced/4515) * [(Thread) 关于解决实体中的交叉端口](https://t.me/feature_sliced/3678) * [(Thread) 关于交叉导入和责任](https://t.me/feature_sliced/3287) * [(Thread) 关于 segments 之间的导入](https://t.me/feature_sliced/4021) * [(Thread) 关于 shared 内部的交叉导入](https://t.me/feature_sliced/3618) --- # 去分段化 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/148) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 情况[​](#情况 "标题的直接链接") 在项目中经常出现这样的情况:与主题领域中特定域相关的模块被不必要地去分段化并分散在项目周围 ``` ├── 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/ ``` ## 问题[​](#问题 "标题的直接链接") 该问题至少表现为违反了**高内聚**原则和过度拉伸**变更轴** ## 如果您忽略它[​](#如果您忽略它 "标题的直接链接") * 如果需要涉及逻辑,例如交付 - 我们必须记住它位于多个地方,并涉及代码中的多个地方 - 这不必要地拉伸了我们的**变更轴** * 如果我们需要研究用户的逻辑,我们将不得不遍历整个项目来详细研究**actions、epics、constants、entities、components** - 而不是将其放在一个地方 * 隐式连接和不断增长的主题领域的不可控性 * 使用这种方法,眼睛经常会模糊,您可能不会注意到我们如何"为了常量而创建常量",在相应的项目目录中创建垃圾场 ## 解决方案[​](#解决方案 "标题的直接链接") 将与特定域/用户案例相关的所有模块 - 直接彼此相邻放置 这样,在研究特定模块时,其所有组件都并排放置,而不是分散在项目周围 > 它还增加了代码库的可发现性和清晰度以及模块之间的关系 ``` - ├── 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[​](#see-also "标题的直接链接") * [(Article) About Low Coupling and High Cohesion clearly](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) * [(Article) Low Coupling and High Cohesion. The Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) --- # Excessive Entities The entities layer in Feature-Sliced Design is the first layer that incorporates business logic, distinguishing it from the `shared` layer. Unlike the `model` segment, it is globally accessible (except by `shared`), making it reusable across the application. However, its global nature means changes can have a widespread impact, requiring careful design to avoid costly refactors. ## How to keep `entities` layer clean[​](#how-to-keep-entities-layer-clean "标题的直接链接") To keep a maintainable `entities` layer, consider the following principles based on the application's data processing needs. Keep in mind that this classification is not strictly binary, as different parts of the same application may have “thin” or “thick” parts: * Thin Clients: These applications rely on the backend for most data processing. They often do not require an `entities` layer, as client-side business logic is minimal and involves only data retrieval. * Thick Clients: These handle significant client-side business logic, making them suitable candidates for the `entities` layer. It is acceptable for an application to lack an `entities` layer if it functions as a thin client. This simplifies the architecture and keeps the `entities` layer available for future scaling if needed. ### Avoid Unnecessary Entities[​](#avoid-unnecessary-entities "标题的直接链接") Do not create an entity for every piece of business logic. Instead, leverage types from `shared/api` and place logic in the `model` segment of a current slice. For reusable business logic, use the `model` segment within an entity slice while keeping data definitions in `shared/api`: ``` 📂 entities 📂 order 📄 index.ts 📂 model 📄 apply-discount.ts // Business logic using OrderDto from shared/api 📂 shared 📂 api 📄 index.ts 📂 endpoints 📄 order.ts ``` ### Exclude CRUD Operations from Entities[​](#exclude-crud-operations-from-entities "标题的直接链接") CRUD operations, while essential, often involve boilerplate code without significant business logic. Including them in the `entities` layer can clutter it and obscure meaningful code. Instead, place CRUD operations in `shared/api`: ``` 📂 shared 📂 api 📄 client.ts 📄 index.ts 📂 endpoints 📄 order.ts // Contains all order-related CRUD operations 📄 products.ts 📄 cart.ts ``` For complex CRUD operations (e.g., atomic updates, rollbacks, or transactions), evaluate whether the `entities` layer is appropriate, but use it with caution. ### Store Authentication Data in `shared`[​](#store-authentication-data-in-shared "标题的直接链接") Avoid creating a `user` entity for authentication data, such as tokens or user DTOs returned from the backend. These are context-specific and unlikely to be reused outside authentication: * Authentication responses (e.g., tokens or DTOs) often lack fields needed for broader reuse or vary by context (e.g., private vs. public user profiles). * Using entities for auth data can lead to cross-layer imports (e.g., `entities` into `shared`) or usage of `@x` notation, complicating the architecture. Instead, store authentication-related data in `shared/auth` or `shared/api`: ``` 📂 shared 📂 auth 📄 use-auth.ts // Hook returning authenticated user info or token 📄 index.ts 📂 api 📄 client.ts 📄 index.ts 📂 endpoints 📄 order.ts ``` ### Minimize Cross-Imports[​](#minimize-cross-imports "标题的直接链接") FSD permits cross-imports via `@x` notation, but they can introduce technical issues like circular dependencies. To avoid this, design entities within isolated business contexts to eliminate the need for cross-imports: Non-Isolated Business Context (Avoid): ``` 📂 entities 📂 order 📂 @x 📂 model 📂 order-item 📂 @x 📂 model 📂 order-customer-info 📂 @x 📂 model ``` Isolated Business Context (Preferred): ``` 📂 entities 📂 order-info 📄 index.ts 📂 model 📄 order-info.ts ``` An isolated context encapsulates all related logic (e.g., order items and customer info) within a single module, reducing complexity and preventing external modifications to tightly coupled logic. --- # 路由 WIP 文章正在编写中 为了使文章更快发布,您可以: * 📢 分享您的反馈[在文章中(评论/表情反应)](https://github.com/feature-sliced/documentation/issues/169) * 💬 收集相关的[来自聊天的主题相关资料](https://t.me/feature_sliced) * ⚒️ 贡献[以任何其他方式](https://github.com/feature-sliced/documentation/blob/master/CONTRIBUTING.md)
*🍰 Stay tuned!* ## 情况[​](#情况 "标题的直接链接") 页面的 URL 在页面下方的层中硬编码 entities/post/card ``` ... ``` ## 问题[​](#问题 "标题的直接链接") URL 没有集中在页面层中,根据责任范围,它们应该属于页面层 ## 如果您忽略它[​](#如果您忽略它 "标题的直接链接") 那么,在更改 URL 时,您必须记住这些 URL(以及 URL/重定向的逻辑)可以在除页面之外的所有层中 这也意味着现在即使是一个简单的产品卡片也承担了页面的部分责任,这模糊了项目的逻辑 ## 解决方案[​](#解决方案 "标题的直接链接") 确定如何从页面级别及以上处理 URL/重定向 通过组合/props/工厂传递到下面的层 ## 另请参阅[​](#另请参阅 "标题的直接链接") * [(Thread) 如果我在 entities/features/widgets 中"缝合"路由会怎样](https://t.me/feature_sliced/4389) * [(Thread) 为什么只在页面中模糊路由逻辑](https://t.me/feature_sliced/3756) --- # 从自定义架构迁移 本指南描述了一种在从自定义自制架构迁移到 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**,即使你是负责人。
首先,说服你的队友,让他们相信好处超过了迁移成本和学习新架构而不是既定架构的成本。 还要记住,任何类型的架构更改都不会立即被管理层观察到。在开始之前确保他们支持这种切换,并向他们解释为什么这可能对项目有益。 提示 如果你需要帮助说服项目经理FSD是有益的,请考虑以下几点: 1. 迁移到FSD可以增量进行,因此不会停止新功能的开发 2. 良好的架构可以显著减少新开发者需要变得高效的时间 3. FSD是一个有文档的架构,因此团队不必持续花时间维护自己的文档 *** 如果你决定开始迁移,那么你想要做的第一件事是为`📁 src`设置一个别名。稍后引用顶级文件夹时会很有帮助。在本指南的其余部分,我们将考虑`@`作为`./src`的别名。 ## 步骤1. 按页面划分代码[​](#divide-code-by-pages "标题的直接链接") 大多数自定义架构已经有按页面的划分,无论逻辑大小如何。如果你已经有`📁 pages`,可以跳过此步骤。 如果你只有`📁 routes`,创建`📁 pages`并尝试从`📁 routes`中移动尽可能多的组件代码。理想情况下,你会有一个小的路由和一个较大的页面。在移动代码时,为每个页面创建一个文件夹并添加一个索引文件: 备注 现在,如果你的页面相互引用是可以的。你可以稍后处理这个问题,但现在,专注于建立突出的按页面划分。 Route file: src/routes/products.\[id].js ``` export { ProductPage as default } from "@/pages/product" ``` Page index file: src/pages/product/index.js ``` export { ProductPage } from "./ProductPage.jsx" ``` Page component file: src/pages/product/ProductPage.jsx ``` export function ProductPage(props) { return
; } ``` ## 步骤2. 将其他所有内容与页面分离[​](#separate-everything-else-from-pages "标题的直接链接") 创建一个文件夹`📁 src/shared`,并将所有不从`📁 pages`或`📁 routes`导入的内容移动到那里。创建一个文件夹`📁 src/app`,并将所有导入页面或路由的内容移动到那里,包括路由本身。 记住Shared层没有切片,所以段之间相互导入是可以的。 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 ## 步骤3. 处理页面间的交叉导入[​](#tackle-cross-imports-between-pages "标题的直接链接") 找到一个页面从另一个页面导入的所有实例,并执行以下两件事之一: 1. 将导入的代码复制粘贴到依赖页面中以移除依赖关系 2. 将代码移动到Shared中的适当段: * 如果它是UI工具包的一部分,将其移动到`📁 shared/ui`; * 如果它是配置常量,将其移动到`📁 shared/config`; * 如果它是后端交互,将其移动到`📁 shared/api`。 备注 **复制粘贴在架构上并不错误**,实际上,有时复制可能比抽象为新的可重用模块更正确。原因是有时页面的共享部分开始分离,在这些情况下你不希望依赖关系阻碍你。 但是,DRY("不要重复自己")原则仍然有意义,所以确保你不是在复制粘贴业务逻辑。否则你需要记住同时在多个地方修复错误。 ## 步骤4. 拆解Shared层[​](#unpack-shared-layer "标题的直接链接") 在这一步你可能在Shared层中有很多东西,你通常想要避免这种情况。原因是Shared层可能是代码库中任何其他层的依赖项,因此对该代码进行更改自动更容易产生意外后果。 找到所有只在一个页面上使用的对象,并将其移动到该页面的切片中。是的,*这也适用于actions、reducers和selectors*。将所有actions组合在一起没有好处,但将相关actions放置在接近其使用位置是有好处的。 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 ## 步骤5. 按技术目的组织代码[​](#organize-by-technical-purpose "标题的直接链接") 在FSD中,按技术目的划分是通过\_段\_来完成的。有几个常见的段: * `ui` — 与UI显示相关的一切:UI组件、日期格式化器、样式等。 * `api` — 后端交互:请求函数、数据类型、映射器等。 * `model` — 数据模型:模式、接口、存储和业务逻辑。 * `lib` — 此切片上其他模块需要的库代码。 * `config` — 配置文件和功能标志。 如果需要,你也可以创建自己的段。确保不要创建按代码是什么分组的段,如`components`、`actions`、`types`、`utils`。相反,按代码的用途分组。 重新组织你的页面以按段分离代码。你应该已经有一个`ui`段,现在是时候创建其他段了,如用于actions、reducers和selectors的`model`,或用于thunks和mutations的`api`。 还要重新组织Shared层以移除这些文件夹: * `📁 components`、`📁 containers` — 其中大部分应该成为`📁 shared/ui`; * `📁 helpers`、`📁 utils` — 如果还有一些重用的helpers,按功能将它们组合在一起,如日期或类型转换,并将这些组移动到`📁 shared/lib`; * `📁 constants` — 再次,按功能分组并移动到`📁 shared/config`。 ## 可选步骤[​](#optional-steps "标题的直接链接") ### 步骤6. 从在多个页面使用的Redux切片形成实体/功能[​](#form-entities-features-from-redux "标题的直接链接") 通常,这些重用的Redux切片将描述与业务相关的内容,例如产品或用户,因此这些可以移动到Entities层,每个文件夹一个实体。如果Redux切片与用户想要在你的应用中执行的操作相关,如评论,那么你可以将其移动到Features层。 实体和功能意味着彼此独立。如果你的业务域包含实体之间的固有连接,请参考[业务实体指南](/zh/docs/guides/examples/types.md#business-entities-and-their-cross-references)以获取如何组织这些连接的建议。 与这些切片相关的API函数可以保留在`📁 shared/api`中。 ### 步骤7. 重构你的模块[​](#refactor-your-modules "标题的直接链接") `📁 modules`文件夹通常用于业务逻辑,因此它在本质上已经与FSD的Features层非常相似。一些模块也可能描述UI的大块,如应用头部。在这种情况下,你应该将它们迁移到Widgets层。 ### 步骤8. 在`shared/ui`中形成干净的UI基础[​](#form-clean-ui-foundation "标题的直接链接") `📁 shared/ui`理想情况下应该包含一组没有编码任何业务逻辑的UI元素。它们也应该是高度可重用的。 重构曾经在`📁 components`和`📁 containers`中的UI组件以分离业务逻辑。将该业务逻辑移动到更高的层级。如果它没有在太多地方使用,你甚至可以考虑复制粘贴。 ## 另请参阅[​](#see-also "标题的直接链接") * [(Talk in Russian) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) --- # 从 v1 到 v2 的迁移 ## 为什么是 v2?[​](#为什么是-v2 "标题的直接链接") **feature-slices** 的原始概念于 2018 年[被宣布](https://t.me/feature_slices)。 从那时起,该方法论发生了许多变化,但同时\*\*[基本原则得到了保留](https://feature-sliced.github.io/featureslices.dev/v1.0.html)\*\*: * 使用*标准化*的前端项目结构 * 首先按照*业务逻辑*分割应用程序 * 使用*隔离的 features* 来防止隐式副作用和循环依赖 * 使用 *Public API* 并禁止深入模块的"内部" 同时,在方法论的上一个版本中,仍然存在**薄弱环节**: * 有时会导致样板代码 * 有时会导致代码库的过度复杂化和抽象之间不明显的规则 * 有时会导致隐式的架构解决方案,这阻止了项目的提升和新人的入职 方法论的新版本([v2](https://github.com/feature-sliced/documentation))旨在**消除这些缺点,同时保留该方法的现有优势**。 自 2018 年以来,[还开发了](https://github.com/kof/feature-driven-architecture/issues)另一种类似的方法论 - [**feature-driven**](https://github.com/feature-sliced/documentation/tree/rc/feature-driven),最初由 [Oleg Isonen](https://github.com/kof) 宣布。 在合并两种方法后,我们**改进和完善了现有实践** - 朝着更大的灵活性、清晰度和应用效率的方向发展。 > 因此,这甚至影响了方法论的名称 - *"feature-slice**d**"* ## 为什么将项目迁移到v2是有意义的?[​](#为什么将项目迁移到v2是有意义的 "标题的直接链接") > `WIP:` 当前版本的方法论正在开发中,一些细节*可能会发生变化* #### 🔍 更透明和简单的架构[​](#-更透明和简单的架构 "标题的直接链接") 方法论(v2)提供了**更直观和更常见的抽象以及在开发者之间分离逻辑的方式。** 所有这些对吸引新人、研究项目当前状态以及分配应用程序业务逻辑都有极其积极的影响。 #### 📦 更灵活和诚实的模块化[​](#-更灵活和诚实的模块化 "标题的直接链接") 方法论(v2)允许**以更灵活的方式分配逻辑:** * 能够从头开始重构隔离的部分 * 能够依赖相同的抽象,但没有不必要的依赖交织 * 对新模块位置的更简单要求 *(层级 => 切片 => 段)* #### 🚀 更多规范、计划、社区[​](#-更多规范计划社区 "标题的直接链接") 目前,`核心团队`正在积极开发方法论的最新(v2)版本 因此对于它: * 将有更多描述的案例/问题 * 将有更多应用指南 * 将有更多真实示例 * 总的来说,将有更多文档用于新人入职和学习方法论概念 * 工具包将在未来开发以符合架构概念和约定 > 当然,第一个版本也会有用户支持 - 但最新版本仍然是我们的优先级 > > 在未来,随着下一次重大更新,你仍然可以访问方法论的当前版本(v2),**对你的团队和项目没有风险** ## Changelog[​](#changelog "标题的直接链接") ### `BREAKING` 层级[​](#breaking-层级 "标题的直接链接") 现在方法论假设在顶层明确分配层级 * `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` * *也就是说,现在不是所有东西都被视为功能/页面* * 这种方法允许你[明确设置层级规则](https://t.me/atomicdesign/18708): * 模块所在的**层级越高**,它拥有的**上下文**就越多 *(换句话说 - 层级的每个模块 - 只能导入底层的模块,而不能导入更高层的)* * 模块所在的**层级越低**,对其进行更改的**危险性和责任**就越大 *(因为通常是底层被过度使用)* ### `BREAKING` Shared[​](#breaking-shared "标题的直接链接") 基础设施抽象 `/ui`、`/lib`、`/api`,以前位于项目的src根目录中,现在由单独的目录 `/src/shared` 分离 * `shared/ui` - 仍然是应用程序的相同通用UI工具包(可选) * *同时,没有人禁止像以前一样在这里使用`原子设计`* * `shared/lib` - 用于实现逻辑的辅助库集合 * *仍然 - 没有助手的转储* * `shared/api` - 访问API的通用入口点 * *也可以在每个功能/页面中本地注册 - 但不推荐* * 和以前一样 - 在`shared`中不应该有对业务逻辑的显式绑定 * *如有必要,你需要将这种关系提升到`entities`级别或更高* ### `NEW` 实体、流程[​](#new-实体流程 "标题的直接链接") 在v2中\*\*,添加了其他新的抽象\*\*来消除逻辑复杂性和高耦合的问题。 * `/entities` - **业务实体**层,包含直接与业务模型相关的切片或仅在前端需要的合成实体 * *示例:`user`、`i18n`、`order`、`blog`* * `/processes` - **业务流程**层,贯穿应用程序 * **该层是可选的**,通常建议在*逻辑增长并开始在多个页面中模糊*时使用 * *示例:`payment`、`auth`、`quick-tour`* ### `BREAKING` 抽象和命名[​](#breaking-抽象和命名 "标题的直接链接") 现在定义了具体的抽象和[明确的命名建议](/zh/docs/about/understanding/naming.md) #### 层级[​](#层级 "标题的直接链接") * `/app` — **应用程序初始化层** * *以前的版本:`app`、`core`、`init`、`src/index`(这种情况也会发生)* * `/processes` — [**业务流程层**](https://github.com/feature-sliced/documentation/discussions/20) * *以前的版本:`processes`、`flows`、`workflows`* * `/pages` — **应用程序页面层** * *以前的版本:`pages`、`screens`、`views`、`layouts`、`components`、`containers`* * `/features` — [**功能部分层**](https://github.com/feature-sliced/documentation/discussions/23) * *以前的版本:`features`、`components`、`containers`* * `/entities` — [**业务实体层**](https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649) * *以前的版本:`entities`、`models`、`shared`* * `/shared` — [**可重用基础设施代码层**](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020) 🔥 * *以前的版本:`shared`、`common`、`lib`* #### 段[​](#段 "标题的直接链接") * `/ui` — [**UI段**](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132) 🔥 * *以前的版本:`ui`、`components`、`view`* * `/model` — [**业务逻辑段**](https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645) 🔥 * *以前的版本:`model`、`store`、`state`、`services`、`controller`* * `/lib` — **辅助代码段** * *以前的版本:`lib`、`libs`、`utils`、`helpers`* * `/api` — [**API段**](https://github.com/feature-sliced/documentation/discussions/66) * *以前的版本:`api`、`service`、`requests`、`queries`* * `/config` — **应用程序配置段** * *以前的版本:`config`、`env`、`get-env`* ### `REFINED` 低耦合[​](#refined-低耦合 "标题的直接链接") 现在由于新的层级,[遵循模块间低耦合原则](/zh/docs/reference/slices-segments.md#zero-coupling-high-cohesion)变得更加容易。 *同时,仍然建议尽可能避免极难"解耦"模块的情况* ## See also[​](#see-also "标题的直接链接") * [Notes from the report "React SPB Meetup #1"](https://t.me/feature_slices) * [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"](https://www.youtube.com/watch?v=BWAeYuWFHhs) * [Comparison with v1 (community-chat)](https://t.me/feature_sliced/493) * [New ideas v2 with explanations (atomicdesign-chat)](https://t.me/atomicdesign/18708) * [Discussion of abstractions and naming for the new version of the methodology (v2)](https://github.com/feature-sliced/documentation/discussions/31) --- # 从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中也是有效的项目。但是,我们相信新的思维模型对团队更有益,特别是对新开发者的入职,所以我们建议对你的分解进行小的调整。 ### 合并切片[​](#合并切片 "标题的直接链接") 一个简单的开始方式是在项目上运行我们的linter,[Steiger](https://github.com/feature-sliced/steiger)。Steiger是基于新的思维模型构建的,最有用的规则将是: * [`insignificant-slice`](https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice) — 如果一个实体或功能只在一个页面中使用,此规则将建议将该实体或功能完全合并到页面中。 * [`excessive-slicing`](https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing) — 如果一个层级有太多切片,这通常是分解过于细粒度的标志。此规则将建议合并或分组一些切片以帮助项目导航。 ``` npx steiger src ``` 这将帮助你识别哪些切片只使用一次,以便你可以重新考虑它们是否真的必要。在这种考虑中,请记住层级为其内部的所有切片形成某种全局命名空间。就像你不会用只使用一次的变量污染全局命名空间一样,你应该将层级命名空间中的位置视为有价值的,要谨慎使用。 ### 标准化交叉导入[​](#标准化交叉导入 "标题的直接链接") 如果你的项目之前有交叉导入(我们不评判!),你现在可以利用Feature-Sliced Design中交叉导入的新标记法——`@x`标记法。它看起来像这样: entities/B/some/file.ts ``` import type { EntityA } from "entities/A/@x/B"; ``` 更多详情,请查看参考中的[交叉导入的公共API](/zh/docs/reference/public-api.md#public-api-for-cross-imports)部分。 --- # 与 Electron 一起使用 Electron 应用程序具有特殊的架构,由具有不同职责的多个进程组成。在这种情况下应用 FSD 需要将结构适应 Electron 的特性。 ``` └── src ├── app # Common app layer │ ├── main # Main process │ │ └── index.ts # Main process entry point │ ├── preload # Preload script and Context Bridge │ │ └── index.ts # Preload entry point │ └── renderer # Renderer process │ └── index.html # Renderer process entry point ├── main │ ├── features │ │ └── user │ │ └── ipc │ │ ├── get-user.ts │ │ └── send-user.ts │ ├── entities │ └── shared ├── renderer │ ├── pages │ │ ├── settings │ │ │ ├── ipc │ │ │ │ ├── get-user.ts │ │ │ │ └── save-user.ts │ │ │ ├── ui │ │ │ │ └── user.tsx │ │ │ └── index.ts │ │ └── home │ │ ├── ui │ │ │ └── home.tsx │ │ └── index.ts │ ├── widgets │ ├── features │ ├── entities │ └── shared └── shared # Common code between main and renderer └── ipc # IPC description (event names, contracts) ``` ## 公共 API 规则[​](#公共-api-规则 "标题的直接链接") 每个进程必须有自己的公共 API。例如,您不能将模块从 `main` 导入到 `renderer`。 只有 `src/shared` 文件夹对两个进程都是公共的。 描述进程交互的契约也是必要的。 ## 额外更改标准结构[​](#额外更改标准结构 "标题的直接链接") 建议使用新的 `ipc` 段,其中进程之间的交互发生。 `pages` 和 `widgets` 层,基于其名称,不应出现在 `src/main` 中。您可以使用 `features`、`entities` 和 `shared`。 `src/app` 层中的 `app` 层包含 `main` 和 `renderer` 的入口点,以及 IPC。 不希望 `app` 层中的段有交集 ## 交互示例[​](#交互示例 "标题的直接链接") src/shared/ipc/channels.ts ``` export const CHANNELS = { GET_USER_DATA: 'GET_USER_DATA', SAVE_USER: 'SAVE_USER', } as const; export type TChannelKeys = keyof typeof CHANNELS; ``` src/shared/ipc/events.ts ``` import { CHANNELS } from './channels'; export interface IEvents { [CHANNELS.GET_USER_DATA]: { args: void, response?: { name: string; email: string; }; }; [CHANNELS.SAVE_USER]: { args: { name: string; }; response: void; }; } ``` src/shared/ipc/preload.ts ``` import { CHANNELS } from './channels'; import type { IEvents } from './events'; type TOptionalArgs = T extends void ? [] : [args: T]; export type TElectronAPI = { [K in keyof typeof CHANNELS]: (...args: TOptionalArgs) => IEvents[typeof CHANNELS[K]]['response']; }; ``` src/app/preload/index.ts ``` import { contextBridge, ipcRenderer } from 'electron'; import { CHANNELS, type TElectronAPI } from 'shared/ipc'; const API: TElectronAPI = { [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA), [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args), } as const; contextBridge.exposeInMainWorld('electron', API); ``` src/main/features/user/ipc/send-user.ts ``` import { ipcMain } from 'electron'; import { CHANNELS } from 'shared/ipc'; export const sendUser = () => { ipcMain.on(CHANNELS.GET_USER_DATA, ev => { ev.returnValue = { name: 'John Doe', email: 'john.doe@example.com', }; }); }; ``` src/renderer/pages/user-settings/ipc/get-user.ts ``` import { CHANNELS } from 'shared/ipc'; export const getUser = () => { const user = window.electron[CHANNELS.GET_USER_DATA](); return user ?? { name: 'John Donte', email: 'john.donte@example.com' }; }; ``` ## 另请参阅[​](#另请参阅 "标题的直接链接") * [Process Model Documentation](https://www.electronjs.org/docs/latest/tutorial/process-model) * [Context Isolation Documentation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) * [Inter-Process Communication Documentation](https://www.electronjs.org/docs/latest/tutorial/ipc) * [Example](https://github.com/feature-sliced/examples/tree/master/examples/electron) --- # 与 Next.js 一起使用 如果您解决了主要冲突——`app` 和 `pages` 文件夹,FSD 与 Next.js 的 App Router 版本和 Pages Router 版本都兼容。 ## App Router[​](#app-router "标题的直接链接") ### FSD 和 Next.js 在 `app` 层中的冲突[​](#conflict-between-fsd-and-nextjs-in-the-app-layer "标题的直接链接") Next.js 建议使用 `app` 文件夹来定义应用程序路由。它期望 `app` 文件夹中的文件对应于路径名。这种路由机制**与 FSD 概念不一致**,因为无法维护扁平的 slice 结构。 解决方案是将 Next.js 的 `app` 文件夹移动到项目根目录,并将 FSD 页面从 `src`(FSD 层所在的位置)导入到 Next.js 的 `app` 文件夹中。 您还需要在项目根目录中添加一个 `pages` 文件夹,否则即使您使用 App Router,Next.js 也会尝试将 `src/pages` 用作 Pages Router,这会破坏构建。在这个根 `pages` 文件夹中放置一个 `README.md` 文件来描述为什么它是必要的也是一个好主意,即使它是空的。 ``` ├── app # App folder (Next.js) │ ├── api │ │ └── get-example │ │ └── route.ts │ └── example │ └── page.tsx ├── pages # Empty pages folder (Next.js) │ └── README.md └── src ├── app │ └── api-routes # API routes ├── pages │ └── example │ ├── index.ts │ └── ui │ └── example.tsx ├── widgets ├── features ├── entities └── shared ``` 在 Next.js `app` 中从 `src/pages` 重新导出页面的示例: app/example/page.tsx ``` export { ExamplePage as default, metadata } from '@/pages/example'; ``` ### 中间件[​](#middleware "标题的直接链接") 如果您在项目中使用中间件,它必须位于项目根目录中,与 Next.js 的 `app` 和 `pages` 文件夹并列。 ### 检测[​](#instrumentation "标题的直接链接") `instrumentation.js` 文件允许您监控应用程序的性能和行为。如果您使用它,它必须位于项目根目录中,类似于 `middleware.js`。 ## Pages Router[​](#pages-router "标题的直接链接") ### FSD 和 Next.js 在 `pages` 层中的冲突[​](#conflict-between-fsd-and-nextjs-in-the-pages-layer "标题的直接链接") 路由应该放在项目根目录的 `pages` 文件夹中,类似于 App Router 的 `app` 文件夹。`src` 内部层文件夹所在的结构保持不变。 ``` ├── pages # Pages folder (Next.js) │ ├── _app.tsx │ ├── api │ │ └── example.ts # API route re-export │ └── example │ └── index.tsx └── src ├── app │ ├── custom-app │ │ └── custom-app.tsx # Custom App component │ └── api-routes │ └── get-example-data.ts # API route ├── pages │ └── example │ ├── index.ts │ └── ui │ └── example.tsx ├── widgets ├── features ├── entities └── shared ``` 在 Next.js `pages` 中从 `src/pages` 重新导出页面的示例: pages/example/index.tsx ``` export { Example as default } from '@/pages/example'; ``` ### 自定义 `_app` 组件[​](#custom-_app-component "标题的直接链接") 您可以将自定义 App 组件放在 `src/app/_app` 或 `src/app/custom-app` 中: src/app/custom-app/custom-app.tsx ``` import type { AppProps } from 'next/app'; export const MyApp = ({ Component, pageProps }: AppProps) => { return ( <>

My Custom App component

); }; ``` pages/\_app.tsx ``` export { App as default } from '@/app/custom-app'; ``` ## 路由处理程序(API 路由)[​](#route-handlers-api-routes "标题的直接链接") 使用 `app` 层中的 `api-routes` segment 来处理路由处理程序。 在 FSD 结构中编写后端代码时要谨慎——FSD 主要用于前端,这意味着人们会期望找到前端代码。 如果您需要很多端点,请考虑将它们分离到 monorepo 中的不同包中。 * App Router * Pages Router src/app/api-routes/get-example-data.ts ``` import { getExamplesList } from '@/shared/db'; export const getExampleData = () => { try { const examplesList = getExamplesList(); return Response.json({ examplesList }); } catch { return Response.json(null, { status: 500, statusText: 'Ouch, something went wrong', }); } }; ``` app/api/example/route.ts ``` export { getExampleData as GET } from '@/app/api-routes'; ``` src/app/api-routes/get-example-data.ts ``` import type { NextApiRequest, NextApiResponse } from 'next'; const config = { api: { bodyParser: { sizeLimit: '1mb', }, }, maxDuration: 5, }; const handler = (req: NextApiRequest, res: NextApiResponse) => { res.status(200).json({ message: 'Hello from FSD' }); }; export const getExampleData = { config, handler } as const; ``` src/app/api-routes/index.ts ``` export { getExampleData } from './get-example-data'; ``` app/api/example.ts ``` import { getExampleData } from '@/app/api-routes'; export const config = getExampleData.config; export default getExampleData.handler; ``` ## Additional recommendations[​](#additional-recommendations "标题的直接链接") * Use the `db` segment in the `shared` layer to describe database queries and their further use in higher layers. * Caching and revalidating queries logic is better kept in the same place as the queries themselves. ## See also[​](#see-also "标题的直接链接") * [Next.js Project Structure](https://nextjs.org/docs/app/getting-started/project-structure) * [Next.js Page Layouts](https://nextjs.org/docs/app/getting-started/layouts-and-pages) --- # 与 NuxtJS 一起使用 可以在 NuxtJS 项目中实现 FSD,但由于 NuxtJS 项目结构要求与 FSD 原则之间的差异,会产生冲突: * 最初,NuxtJS 提供的项目文件结构没有 `src` 文件夹,即在项目的根目录中。 * 文件路由在 `pages` 文件夹中,而在 FSD 中,此文件夹保留用于扁平 slice 结构。 ## 为 `src` 目录添加别名[​](#为-src-目录添加别名 "标题的直接链接") 将 `alias` 对象添加到您的配置中: nuxt.config.ts ``` export default defineNuxtConfig({ devtools: { enabled: true }, // Not FSD related, enabled at project startup alias: { "@": '../src' }, }) ``` ## 选择如何配置路由器[​](#选择如何配置路由器 "标题的直接链接") 在 NuxtJS 中,有两种自定义路由的方法 - 使用配置和使用文件结构。 在基于文件的路由情况下,您将在 app/routes 目录内的文件夹中创建 index.vue 文件,在配置情况下,您将在 `router.options.ts` 文件中配置路由器。 ### 使用配置进行路由[​](#使用配置进行路由 "标题的直接链接") 在 `app` 层中,创建一个 `router.options.ts` 文件,并从中导出配置对象: app/router.options.ts ``` import type { RouterConfig } from '@nuxt/schema'; export default { routes: (_routes) => [], }; ``` 要向项目添加 `Home` 页面,您需要执行以下步骤: * 在 `pages` 层内添加页面 slice * 将适当的路由添加到 `app/router.config.ts` 配置中 要创建页面 slice,让我们使用 [CLI](https://github.com/feature-sliced/cli): ``` fsd pages home ``` 在 ui segment 内创建一个 `home-page.vue` 文件,使用 Public API 访问它 src/pages/home/index.ts ``` export { default as HomePage } from './ui/home-page'; ``` 因此,文件结构将如下所示: ``` ├── src │ ├── app │ │ ├── router.config.ts │ ├── pages │ │ ├── home │ │ │ ├── ui │ │ │ │ ├── home-page.vue │ │ │ ├── index.ts ``` 最后,让我们向配置添加一个路由: 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文件夹。 因此,你的文件结构应该如下所示: ``` ├── src │ ├── app │ │ ├── routes │ ├── pages # Pages folder, related to FSD ``` 为了让 NuxtJS 使用 `app` 层内的 routes 文件夹进行文件路由,您需要按如下方式修改 `nuxt.config.ts`: nuxt.config.ts ``` export default defineNuxtConfig({ devtools: { enabled: true }, // Not FSD related, enabled at project startup alias: { "@": '../src' }, dir: { pages: './src/app/routes' } }) ``` 现在,你可以在`app`内为页面创建路由,并将`pages`中的页面连接到它们。 例如,要向项目添加 `Home` 页面,您需要执行以下步骤: * 在 `pages` 层内添加页面 slice * 在 `app` 层内添加相应的路由 * 将 slice 中的页面与路由连接 要创建页面 slice,让我们使用 [CLI](https://github.com/feature-sliced/cli): ``` fsd pages home ``` 在 ui segment 内创建一个 `home-page.vue` 文件,使用 Public API 访问它 src/pages/home/index.ts ``` export { default as HomePage } from './ui/home-page'; ``` 在 `app` 层内为此页面创建路由: ``` ├── src │ ├── app │ │ ├── routes │ │ │ ├── index.vue │ ├── pages │ │ ├── home │ │ │ ├── ui │ │ │ │ ├── home-page.vue │ │ │ ├── index.ts ``` 在 `index.vue` 文件内添加您的页面组件: src/app/routes/index.vue ``` ``` ## `layouts` 怎么办?[​](#layouts-怎么办 "标题的直接链接") 您可以将布局放在 `app` 层内,为此您需要按如下方式修改配置: nuxt.config.ts ``` export default defineNuxtConfig({ devtools: { enabled: true }, // Not related to FSD, enabled at project startup 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) --- # 与 React Query 一起使用 ## "键放在哪里"的问题[​](#键放在哪里的问题 "标题的直接链接") ### 解决方案——按实体分解[​](#解决方案按实体分解 "标题的直接链接") 如果项目已经有实体划分,并且每个请求对应单个实体, 最纯粹的划分将按实体进行。在这种情况下,我们建议使用以下结构: ``` └── src/ # ├── app/ # | ... # ├── pages/ # | ... # ├── entities/ # | ├── {entity}/ # | ... └── api/ # | ├── `{entity}.query` # Query-factory where are the keys and functions | ├── `get-{entity}` # Entity getter function | ├── `create-{entity}` # Entity creation function | ├── `update-{entity}` # Entity update function | ├── `delete-{entity}` # Entity delete function | ... # | # ├── features/ # | ... # ├── widgets/ # | ... # └── shared/ # ... # ``` 如果实体之间有连接(例如,Country 实体有一个 City 实体的列表字段), 则可以使用 [公共 API 跨导入](/zh/docs/reference/public-api.md#public-api-for-cross-imports) 或考虑以下替代方案。 ### 替代方案——保持共享[​](#替代方案保持共享 "标题的直接链接") 在实体分离不合适的情况下,可以考虑以下结构: ``` └── src/ # ... # └── shared/ # ├── api/ # ... ├── `queries` # Query-factories | ├── `document.ts` # | ├── `background-jobs.ts` # | ... # └── index.ts # ``` 然后在 `@/shared/api/index.ts` 中: @/shared/api/index.ts ``` export { documentQueries } from "./queries/document"; ``` ## "在哪里插入突变?"的问题[​](#在哪里插入突变的问题 "标题的直接链接") 不建议将突变与查询混合。有两种选择: ### 1. 在 `api` 段附近定义一个自定义钩子[​](#1-在-api-段附近定义一个自定义钩子 "标题的直接链接") @/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. 在其他地方(共享或实体)定义突变函数,并在组件中直接使用 `useMutation`[​](#2-在其他地方共享或实体定义突变函数并在组件中直接使用-usemutation "标题的直接链接") ``` const { mutateAsync, isPending } = useMutation({ mutationFn: postApi.createPost, }); ``` @/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 ); }; ``` ## 请求组织[​](#请求组织 "标题的直接链接") ### 查询工厂[​](#查询工厂 "标题的直接链接") 查询工厂是一个对象,其中键值是返回查询键列表的函数。以下是如何使用它: ``` const keyFactory = { all: () => ["entity"], lists: () => [...postQueries.all(), "list"], }; ``` 信息 `queryOptions` 是 react-query\@v5 的内置工具(可选) ``` queryOptions({ queryKey, ...options, }); ``` 为了更好的类型安全性,进一步兼容 react-query 的未来版本,并易于访问函数和查询键, 您可以使用 “@tanstack/react-query” 的内置 queryOptions 函数 [(More details here)](https://tkdodo.eu/blog/the-query-options-api#queryoptions). ### 1. 创建查询工厂[​](#1-创建查询工厂 "标题的直接链接") @/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. 在应用程序代码中使用查询工厂[​](#2-在应用程序代码中使用查询工厂 "标题的直接链接") ``` 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 请求,使代码更易于阅读和维护。 * **方便访问查询和键:** 工厂提供方便的方法来访问不同类型的查询及其键。 * **查询刷新能力:** 工厂允许轻松刷新,无需在应用程序的不同部分更改查询键。 ## 分页[​](#分页 "标题的直接链接") 在本节中,我们将查看 `getPosts` 函数的示例,该函数通过分页 API 请求检索帖子实体。 ### 1. 创建 `getPosts` 函数[​](#1-创建-getposts-函数 "标题的直接链接") `getPosts` 函数位于 `get-posts.ts` 文件中,位于 `api` 段 @/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. 分页查询工厂[​](#2-分页查询工厂 "标题的直接链接") `postQueries` 查询工厂定义了各种查询选项,用于处理帖子, 包括请求特定页面和限制的帖子列表。 ``` 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. 在应用程序代码中使用[​](#3-在应用程序代码中使用 "标题的直接链接") @/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" /> ); }; ``` 备注 该示例已简化,完整版本可在 [GitHub](https://github.com/ruslan4432013/fsd-react-query-example) 上找到 ## `QueryProvider` 用于管理查询[​](#queryprovider-用于管理查询 "标题的直接链接") 在本指南中,我们将查看如何组织 `QueryProvider`。 ### 1. 创建 `QueryProvider`[​](#1-创建-queryprovider "标题的直接链接") 文件 `query-provider.tsx` 位于路径 `@/app/providers/query-provider.tsx`。 @/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`[​](#2-创建-queryclient "标题的直接链接") `QueryClient` 是一个用于管理 API 请求的实例。 文件 `query-client.ts` 位于 `@/shared/api/query-client.ts`。 `QueryClient` 使用某些设置进行查询缓存。 @/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` 目录中的所有代码可能是有意义的。 ## 额外的组织建议[​](#额外的组织建议 "标题的直接链接") ### API 客户端[​](#api-客户端 "标题的直接链接") 在共享层使用自定义 API 客户端类, 您可以标准化配置并处理项目中的 API。 这使您可以管理日志, 从一处管理头和数据交换格式(如 JSON 或 XML)。 这种方法使项目更容易维护和开发,因为它简化了更改和更新与 API 的交互。 @/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) 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) --- # 与 SvelteKit 一起使用 可以在 SvelteKit 项目中实现 FSD,但由于 SvelteKit 项目的结构要求与 FSD 原则之间的差异,会产生冲突: * 最初,SvelteKit 在 `src/routes` 文件夹内提供文件结构,而在 FSD 中,路由必须是 `app` 层的一部分。 * SvelteKit 建议将与路由无关的所有内容放在 `src/lib` 文件夹中。 ## 让我们设置配置[​](#让我们设置配置 "标题的直接链接") 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', // move routing inside the app layer lib: 'src', appTemplate: 'src/app/index.html', // Move the application entry point inside the app layer assets: 'public' }, alias: { '@/*': 'src/*' // Create an alias for the src directory } } }; export default config; ``` ## 将文件路由移动到 `src/app`。[​](#将文件路由移动到-srcapp "标题的直接链接") 让我们创建一个 app 层,将应用程序的入口点 `index.html` 移动到其中,并创建一个 routes 文件夹。 因此,您的文件结构应该如下所示: ``` ├── src │ ├── app │ │ ├── index.html │ │ ├── routes │ ├── pages # FSD Pages folder ``` 现在,您可以在 `app` 内为页面创建路由,并将 `pages` 中的页面连接到它们。 例如,要向项目添加主页,您需要执行以下步骤: * 在 `pages` 层内添加页面 slice * 从 `app` 层向 `routes` 文件夹添加相应的路由 * 将 slice 中的页面与路由对齐 要创建页面 slice,让我们使用 [CLI](https://github.com/feature-sliced/cli): ``` fsd pages home ``` 在 ui segment 内创建 `home-page.svelte` 文件,使用公共 API 访问它 src/pages/home/index.ts ``` export { default as HomePage } from './ui/home-page.svelte'; ``` 在 `app` 层内为此页面创建路由: ``` ├── src │ ├── app │ │ ├── routes │ │ │ ├── +page.svelte │ │ ├── index.html │ ├── pages │ │ ├── home │ │ │ ├── ui │ │ │ │ ├── home-page.svelte │ │ │ ├── index.ts ``` 在 `+page.svelte` 文件中添加您的页面组件: src/app/routes/+page.svelte ``` ``` ## 另请参阅[​](#另请参阅 "标题的直接链接") * [SvelteKit 中更改目录配置的文档](https://kit.svelte.dev/docs/configuration#files) --- # 大语言模型文档 本页面为大语言模型(LLM)爬虫提供链接和指导。 * 规范: ### 文件[​](#文件 "标题的直接链接") * [llms.txt](/zh/llms.txt) * [llms-full.txt](/zh/llms-full.txt) ### 说明[​](#说明 "标题的直接链接") * 文件从站点根目录提供服务,与当前页面路径无关。 * 在具有非根基础 URL(例如 `/documentation/`)的部署中,上述链接会自动添加前缀。 --- # 层 层是 Feature-Sliced Design 中组织层次结构的第一级。它们的目的是根据代码需要的责任程度以及它依赖应用程序中其他模块的程度来分离代码。每一层都承载着特殊的语义意义,帮助您确定应该为您的代码分配多少责任。 总共有 **7 个 layers**,按从最高责任和 依赖到最低排列: ![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.](/zh/img/layers/folders-graphic-light.svg#light-mode-only) ![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.](/zh/img/layers/folders-graphic-dark.svg#dark-mode-only) 1. App 2. Processes (deprecated) 3. Pages 4. Widgets 5. Features 6. Entities 7. Shared 您不必在项目中使用每一层 — 只有当您认为它们为您的项目带来价值时才添加它们。通常,大多数前端项目至少会有 Shared、Pages 和 App 层。 在实践中,层是具有小写名称的文件夹(例如,`📁 shared`、`📁 pages`、`📁 app`)。\_不建议\_添加新层,因为它们的语义是标准化的。 ## 层上的导入规则[​](#层上的导入规则 "标题的直接链接") 层由 *slices* 组成 — 高度内聚的模块组。slices 之间的依赖关系由**层上的导入规则**调节: > *slice 中的模块(文件)只能在其他 slices 位于严格较低的层时导入它们。* 例如,文件夹 `📁 ~/features/aaa` 是一个名为"aaa"的 slice。其中的文件 `~/features/aaa/api/request.ts` 不能从 `📁 ~/features/bbb` 中的任何文件导入代码,但可以从 `📁 ~/entities` 和 `📁 ~/shared` 导入代码,以及从 `📁 ~/features/aaa` 导入任何同级代码,例如 `~/features/aaa/lib/cache.ts`。 App 和 Shared 层是此规则的**例外** — 它们既是层又是 slice。Slices 按业务域划分代码,这两层是例外,因为 Shared 没有业务域,而 App 结合了所有业务域。 在实践中,这意味着 App 和 Shared 层由 segments 组成,segments 可以自由地相互导入。 ## 层定义[​](#层定义 "标题的直接链接") 本节描述每一层的语义含义,以便直观地了解什么样的代码属于那里。 ### Shared[​](#shared "标题的直接链接") 这一层为应用程序的其余部分奠定了基础。这是与外部世界建立连接的地方,例如后端、第三方库、环境。这也是定义您自己的高度封装库的地方。 这一层,像 App 层一样,*不包含 slices*。Slices 旨在将层划分为业务域,但业务域在 Shared 中不存在。这意味着 Shared 中的所有文件都可以相互引用和导入。 Here are the segments that you can typically find in this layer: * `📁 api` — the API client and potentially also functions to make requests to specific backend endpoints. * `📁 ui` — the application's UI kit.
Components on this layer should not contain business logic, but it's okay for them to be business-themed. For example, you can put the company logo and page layout here. Components with UI logic are also allowed (for example, autocomplete or a search bar). * `📁 lib` — a collection of internal libraries.
This folder should not be treated as helpers or utilities ([read here why these folders often turn into a dump](https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo)). Instead, every library in this folder should have one area of focus, for example, dates, colors, text manipulation, etc. That area of focus should be documented in a README file. The developers in your team should know what can and cannot be added to these libraries. * `📁 config` — environment variables, global feature flags and other global configuration for your app. * `📁 routes` — route constants or patterns for matching routes. * `📁 i18n` — setup code for translations, global translation strings. 你可以自由添加更多段,但要确保这些段的名称描述内容的目的,而不是其本质。例如,`components`、`hooks`和`types`是不好的段名称,因为它们在你寻找代码时没有太大帮助。 ### Entities[​](#entities "标题的直接链接") 这一层的切片代表项目正在处理的现实世界概念。通常,它们是业务用来描述产品的术语。例如,社交网络可能会处理用户(User)、帖子(Post)和群组(Group)等业务实体。 实体切片可能包含数据存储(`📁 model`)、数据验证模式(`📁 model`)、与实体相关的API请求函数(`📁 api`),以及该实体在界面中的视觉表示(`📁 ui`)。视觉表示不必产生完整的UI块 — 它主要是为了在应用程序的多个页面中重用相同的外观,不同的业务逻辑可以通过props或slots附加到它上面。 #### 实体关系[​](#实体关系 "标题的直接链接") FSD中的实体是切片,默认情况下,切片不能相互了解。然而,在现实生活中,实体经常相互交互,有时一个实体拥有或包含其他实体。因此,这些交互的业务逻辑最好保存在更高的层级中,如功能(Features)或页面(Pages)。 当一个实体的数据对象包含其他数据对象时,通常最好明确实体之间的连接,并通过使用`@x`标记法创建交叉引用API来绕过切片隔离。原因是连接的实体需要一起重构,所以最好让连接不可能被忽略。 For example: entities/artist/model/artist.ts ``` import type { Song } from "entities/song/@x/artist"; export interface Artist { name: string; songs: Array; } ``` entities/song/@x/artist.ts ``` export type { Song } from "../model/song.ts"; ``` 在[交叉导入的公共API](/zh/docs/reference/public-api.md#public-api-for-cross-imports)部分了解更多关于`@x`标记法的信息。 ### Features[​](#features "标题的直接链接") 这一层用于应用程序中的主要交互,即用户关心要做的事情。这些交互通常涉及业务实体,因为这就是应用程序的核心内容。 有效使用功能层的一个关键原则是:**不是所有东西都需要成为功能**。某个东西需要成为功能的一个好指标是它在多个页面上被重用。 例如,如果应用程序有多个编辑器,并且所有编辑器都有评论功能,那么评论就是一个可重用的功能。记住,切片是快速查找代码的机制,如果功能太多,重要的功能就会被淹没。 理想情况下,当你进入一个新项目时,你会通过查看页面和功能来发现其功能性。在决定什么应该成为功能时,要为项目新人的体验进行优化,让他们能够快速发现重要的大型代码区域。 功能切片可能包含执行交互的UI(如表单)(`📁 ui`)、执行操作所需的API调用(`📁 api`)、验证和内部状态(`📁 model`)、功能标志(`📁 config`)。 ### Widgets[​](#widgets "标题的直接链接") 小部件层用于大型自给自足的UI块。小部件在跨多个页面重用时最有用,或者当它们所属的页面有多个大型独立块,而这是其中之一时。 如果一个UI块构成了页面上大部分有趣的内容,并且从不被重用,它**不应该是小部件**,而应该直接放在该页面内。 提示 如果你使用嵌套路由系统(如[Remix](https://remix.run)的路由器),使用小部件层的方式可能与扁平路由系统使用页面层的方式相同 — 创建完整的路由块,包括相关的数据获取、加载状态和错误边界。 同样,你可以在这一层存储页面布局。 ### Pages[​](#pages "标题的直接链接") 页面是构成网站和应用程序的内容(也称为屏幕或活动)。一个页面通常对应一个切片,但是,如果有几个非常相似的页面,它们可以组合成一个切片,例如注册和登录表单。 只要你的团队仍然觉得容易导航,你可以在页面切片中放置任意数量的代码。如果页面上的UI块不被重用,将其保留在页面切片内是完全可以的。 在页面切片中,你通常可以找到页面的UI以及加载状态和错误边界(`📁 ui`)和数据获取和变更请求(`📁 api`)。页面拥有专用数据模型并不常见,少量状态可以保存在组件本身中。 ### Processes[​](#processes "标题的直接链接") 警告 这一层已被弃用。当前版本的规范建议避免使用它,并将其内容移至`features`和`app`。 流程是多页面交互的逃生舱。 这一层故意保持未定义。大多数应用程序不应该使用这一层,应该将路由级和服务器级逻辑保留在App层。只有当App层变得足够大以至于无法维护并需要卸载时,才考虑使用这一层。 ### App[​](#app "标题的直接链接") 各种应用程序范围的事务,包括技术意义上的(例如,上下文提供者)和业务意义上的(例如,分析)。 这一层通常不包含切片,与Shared层一样,而是直接包含段。 以下是你通常可以在这一层找到的段: * `📁 routes` — 路由器配置 * `📁 store` — 全局存储配置 * `📁 styles` — 全局样式 * `📁 entrypoint` — 应用程序代码的入口点,特定于框架 --- # Public API Public API 是一组模块(如 slice)与使用它的代码之间的\_契约\_。它也充当网关,只允许访问某些对象,并且只能通过该 public API 访问。 在实践中,它通常作为具有重新导出的 index 文件实现: pages/auth/index.js ``` export { LoginPage } from "./ui/LoginPage"; export { RegisterPage } from "./ui/RegisterPage"; ``` ## 什么构成了好的 public API?[​](#什么构成了好的-public-api "标题的直接链接") 好的 public API 使得使用和集成到其他代码中的 slice 方便可靠。这可以通过设定这三个目标来实现: 1. 应用程序的其余部分必须受到保护,免受 slice 结构变化(如重构)的影响 2. slice 行为的重大变化(破坏了之前的期望)应该导致 public API 的变化 3. 只应该暴露 slice 的必要部分 最后一个目标有一些重要的实际含义。创建所有内容的通配符重新导出可能很诱人,特别是在 slice 的早期开发中,因为您从文件中导出的任何新对象也会自动从 slice 导出: Bad practice, features/comments/index.js ``` // ❌ BAD CODE BELOW, DON'T DO THIS export * from "./ui/Comment"; // 👎 don't try this at home export * from "./model/comments"; // 💩 this is bad practice ``` 这会损害 slice 的可发现性,因为您无法轻易地说出这个 slice 的接口是什么。不知道接口意味着您必须深入挖掘 slice 的代码才能理解如何集成它。另一个问题是您可能意外地暴露模块内部,如果有人开始依赖它们,这将使重构变得困难。 ## 用于交叉导入的 Public API[​](#public-api-for-cross-imports "标题的直接链接") 交叉导入是指同一 layer 上的一个 slice 从另一个 slice 导入的情况。通常这被 [layers 上的导入规则](/zh/docs/reference/layers.md#import-rule-on-layers) 禁止,但经常有合理的交叉导入理由。例如,业务 entities 在现实世界中经常相互引用,最好在代码中反映这些关系而不是绕过它们。 为此,有一种特殊的 public API,也称为 `@x` 记号法。如果您有 entities A 和 B,并且 entity B 需要从 entity A 导入,那么 entity A 可以为 entity B 声明一个单独的 public API。 * `📂 entities` * `📂 A` * `📂 @x` * `📄 B.ts` — 仅用于 `entities/B/` 内部代码的特殊 public API * `📄 index.ts` — 常规 public API 然后 `entities/B/` 内部的代码可以从 `entities/A/@x/B` 导入: ``` import type { EntityA } from "entities/A/@x/B"; ``` 记号法 `A/@x/B` 旨在读作 "A crossed with B"。 备注 尽量减少交叉导入,并且**仅在 Entities layer 上使用此记号法**,在该 layer 上消除交叉导入通常是不合理的。 ## index 文件的问题[​](#index-文件的问题 "标题的直接链接") 像 `index.js` 这样的 index 文件(也称为 barrel 文件)是定义 public API 的最常见方式。它们容易制作,但众所周知会在某些打包器和框架中引起问题。 ### 循环导入[​](#循环导入 "标题的直接链接") 循环导入是指两个或多个文件在一个循环中相互导入。 ![Three files importing each other in a circle](/zh/img/circular-import-light.svg#light-mode-only)![Three files importing each other in a circle](/zh/img/circular-import-dark.svg#dark-mode-only) Pictured above: three files, `fileA.js`, `fileB.js`, and `fileC.js`, importing each other in a circle. 这些情况对于打包器来说通常难以处理,在某些情况下,它们甚至可能导致难以调试的运行时错误。 循环导入可以在没有 index 文件的情况下发生,但拥有 index 文件提供了意外创建循环导入的明显机会。当您在 slice 的 public API 中有两个暴露的对象时,这经常发生,例如 `HomePage` 和 `loadUserStatistics`,并且 `HomePage` 需要访问 `loadUserStatistics`,但它像这样做: pages/home/ui/HomePage.jsx ``` import { loadUserStatistics } from "../"; // importing from pages/home/index.js export function HomePage() { /* … */ } ``` pages/home/index.js ``` export { HomePage } from "./ui/HomePage"; export { loadUserStatistics } from "./api/loadUserStatistics"; ``` 这种情况创建了循环导入,因为 `index.js` 导入 `ui/HomePage.jsx`,但 `ui/HomePage.jsx` 导入 `index.js`。 为了防止这个问题,考虑这两个原则。如果您有两个文件,其中一个从另一个导入: * 当它们在同一个 slice 中时,始终使用\_相对\_导入并编写完整的导入路径 * 当它们在不同的 slices 中时,始终使用\_绝对\_导入,例如使用别名 ### Shared 中的大型包和损坏的 tree-shaking[​](#large-bundles "标题的直接链接") 当您有一个重新导出所有内容的 index 文件时,某些打包器可能在 tree-shaking(移除未导入的代码)方面遇到困难。 通常这对于 public APIs 来说不是问题,因为模块的内容通常关系非常密切,所以您很少需要导入一个东西并 tree-shake 掉另一个。然而,当 FSD 中的正常 public API 规则可能导致问题时,有两个非常常见的情况 — `shared/ui` 和 `shared/lib`。 这两个文件夹都是不相关事物的集合,通常不是在一个地方都需要的。例如,`shared/ui` 可能为 UI 库中的每个组件都有模块: * `📂 shared/ui/` * `📁 button` * `📁 text-field` * `📁 carousel` * `📁 accordion` 当其中一个模块有重度依赖时,这个问题会变得更加严重,比如语法突出显示器或拖放库。您不希望将这些引入到使用 `shared/ui` 中某些内容的每个页面中,例如按钮。 如果您的包由于 `shared/ui` 或 `shared/lib` 中的单个 public API 而不必要地增长,建议改为为每个组件或库单独有一个 index 文件: * `📂 shared/ui/` * `📂 button` * `📄 index.js` * `📂 text-field` * `📄 index.js` 然后这些组件的使用者可以像这样直接导入它们: pages/sign-in/ui/SignInPage.jsx ``` import { Button } from '@/shared/ui/button'; import { TextField } from '@/shared/ui/text-field'; ``` ### 对绝过 public API 没有真正的保护[​](#对绝过-public-api-没有真正的保护 "标题的直接链接") 当您为 slice 创建 index 文件时,您实际上并没有禁止任何人不使用它而直接导入。这对于自动导入来说尤其是一个问题,因为有几个位置可以导入对象,所以 IDE 必须为您做决定。有时它可能选择直接导入,破坏 slices 上的 public API 规则。 为了自动捕获这些问题,我们建议使用 [Steiger](https://github.com/feature-sliced/steiger),一个具有 Feature-Sliced Design 规则集的架构 linter。 ### 大型项目中打包器的较差性能[​](#大型项目中打包器的较差性能 "标题的直接链接") 在项目中具有大量 index 文件可能会减慢开发服务器,正如 TkDodo 在[他的文章“请停止使用 Barrel 文件”](https://tkdodo.eu/blog/please-stop-using-barrel-files)中所指出的。 您可以做几件事来解决这个问题: 1. 与[“Shared 中的大型包和损坏的 tree-shaking”问题](#large-bundles)相同的建议 — 在 `shared/ui` 和 `shared/lib` 中为每个组件/库单独有 index 文件,而不是一个大的 2. 避免在有 slices 的 layers 上的 segments 中有 index 文件。
例如,如果您有一个用于 feature “comments” 的 index,`📄 features/comments/index.js`,则没有理由为该 feature 的 `ui` segment 有另一个 index,`📄 features/comments/ui/index.js`。 3. 如果您有一个非常大的项目,很可能您的应用程序可以分割成几个大块。
例如,Google Docs 在文档编辑器和文件浏览器方面有非常不同的责任。您可以创建一个 monorepo 设置,其中每个包都是一个单独的 FSD 根,具有自己的 layers 集。某些包可能只有 Shared 和 Entities layers,其他包可能只有 Pages 和 App,还有一些包可能包含它们自己的小 Shared,但仍然使用另一个包中的大 Shared。 --- # Slices 和 segments ## Slices[​](#slices "标题的直接链接") Slices 是 Feature-Sliced Design 组织层次结构中的第二级。它们的主要目的是按其对产品、业务或应用程序的意义对代码进行分组。 Slices 的名称没有标准化,因为它们直接由您应用程序的业务领域决定。例如,照片库可能有 slices `photo`、`effects`、`gallery-page`。社交网络将需要不同的 slices,例如 `post`、`comments`、`news-feed`。 Layers Shared 和 App 不包含 slices。这是因为 Shared 应该不包含任何业务逻辑,因此对产品没有意义,而 App 应该只包含涉及整个应用程序的代码,所以不需要分割。 ### 零耦合和高聚合[​](#zero-coupling-high-cohesion "标题的直接链接") Slices 旨在成为独立且高度聚合的代码文件组。下面的图形可能有助于可视化\_聚合性\_和\_耦合性\_这些复杂的概念: ![](/zh/img/coupling-cohesion-light.svg#light-mode-only)![](/zh/img/coupling-cohesion-dark.svg#dark-mode-only) Image inspired by 理想的 slice 独立于其 layer 上的其他 slices(零耦合)并包含与其主要目标相关的大部分代码(高聚合)。 切片的独立性由[层级导入规则](/zh/docs/reference/layers.md#import-rule-on-layers)强制执行: > *切片中的模块(文件)只能在其他切片位于严格较低的层级时导入它们。* ### 切片的公共API规则[​](#切片的公共api规则 "标题的直接链接") 在切片内部,代码可以按你想要的任何方式组织。只要切片为其他切片提供良好的公共API来使用它,这就不会造成任何问题。这通过**切片的公共API规则**来强制执行: > *每个切片(以及没有切片的层级上的段)都必须包含公共API定义。* > > *此切片/段之外的模块只能引用公共API,而不能引用切片/段的内部文件结构。* 在[公共API参考](/zh/docs/reference/public-api.md)中阅读更多关于公共API的基本原理和创建最佳实践的信息。 ### 切片组[​](#切片组 "标题的直接链接") 密切相关的切片可以在文件夹中进行结构化分组,但它们应该遵循与其他切片相同的隔离规则 — 该文件夹中应该**没有代码共享**。 ![Features \"compose\", \"like\" and \"delete\" grouped in a folder \"post\". In that folder there is also a file \"some-shared-code.ts\" that is crossed out to imply that it\'s not allowed.](/zh/assets/images/graphic-nested-slices-b9c44e6cc55ecdbf3e50bf40a61e5a27.svg) ## Segments[​](#segments "标题的直接链接") 段是组织层次结构中的第三级也是最后一级,其目的是按技术性质对代码进行分组。 有几个标准化的段名称: * `ui` — 与UI显示相关的一切:UI组件、日期格式化器、样式等。 * `api` — 后端交互:请求函数、数据类型、映射器等。 * `model` — 数据模型:模式、接口、存储和业务逻辑。 * `lib` — 此切片上其他模块需要的库代码。 * `config` — 配置文件和功能标志。 查看[层级页面](/zh/docs/reference/layers.md#layer-definitions)了解这些段在不同层级上可能用于什么的示例。 你也可以创建自定义段。自定义段最常见的地方是App层和Shared层,在这些层中切片没有意义。 确保这些段的名称描述内容的目的,而不是其本质。例如,`components`、`hooks`和`types`是不好的段名称,因为它们在你寻找代码时没有太大帮助。 --- ### 明确的业务逻辑 通过领域范围实现易于发现的架构 ---