React的功能切片设计:层结构、导入规则及何时停止使用
了解特征切片设计的层结构、单向导入规则、精简的公共 API 以及 @x 跨导入功能如何帮助梳理 React 代码库,以及何时只需采用其中的部分功能。
来源: Feature-Sliced Design in React: How to Structure a Scalable Codebase (and When It’s Overkill)
几乎所有能存活超过两年的 React 项目都存在一个没人愿意打开的 utils/ 文件夹。其中包含日期格式化工具、重试封装函数、半成品状态机,以及一个 helpers.ts 文件——它从三个功能文件夹导入代码,而其中两个文件夹又会反向导入它的内容,因此没人能弄清楚到底什么依赖什么。功能切片设计(FSD)正是为解决这一问题而提出的方法论。读完本文后,你将了解它的六层结构、起关键作用的单一导入规则、如何设计便于安全重构的功能切片边界,以及最重要的一点:如何判断对于你的应用而言是否值得采用完整的 FSD,或者更轻量的子集才是更好的选择。
为何基于类型的文件夹会阻碍扩展
传统布局是按照文件的功能类型(组件、钩子、工具函数)来分类的,而非根据其用途。对于小型应用来说这没问题,但一旦涉及复杂的业务领域,情况就会变成这样:
src/
components/ # 200 files, no domain grouping
hooks/ # useAuth next to useCartTotals
utils/ # the grab-bag
services/ # api calls that import from utils, which imports back
在这样的结构中,会有两方面的问题。首先,“这个文件应该放在哪里?”这个问题没有明确的答案,因此所有模棱两可的文件最终都会被放入utils/目录中。其次,没有任何机制来限制依赖关系的方向,这就容易导致循环出现。想象一下,订单服务调用了utils/user.ts,而该工具函数又依赖于services/auth.ts,同时认证服务又会再次导入订单服务。这类循环往往不会立即显现问题,通常要等到模块首次加载时才会出现异常,表现为某个值意外地变为undefined,这是因为循环中的某个文件在它所依赖的文件处理完成之前就被执行了,而此时整个依赖关系图已经变得过于复杂,难以梳理。
FSD通过为整个项目规定统一的结构,而非依赖每个开发者的习惯,从而解决了这两个问题。截至本文撰写时,规范的最新版本为2.1版;如需了解后续更新,请查看官方文档。
六层结构及每层的功能
FSD将代码按固定顺序分层组织,从上到下依次为:App、Pages、Widgets、Features、Entities、Shared。虽然还存在较旧的Processes层,但它已过时,可忽略不计。这种顺序并非出于美观考虑,而是为了明确各部分之间的依赖关系。
- App:负责路由、入口点以及全局样式和提供者。
- Pages:完整的页面,或与特定路由关联的较大范围的UI组件。
- Widgets:大型、独立的UI模块,能够单独实现完整的功能场景。
中间的四层(从页面到实体)被划分为切片,每个业务领域对应一个切片,例如entities/user或features/auth。应用层和共享代码层则没有切片。每个切片再根据技术角色细分为段:ui用于组件,api用于后端请求与响应类型,model用于数据结构与存储,lib用于该切片内部使用的辅助函数,还有config。
避免循环出现的导入规则
支撑整个系统的规则很简单:模块只能从严格位于其下层的部分导入代码。页面可以使用功能,但功能绝不能使用页面。此外,同一层中的不同片段之间也完全不允许相互导入。在单个片段内部,文件可以自由互相引用;但这一限制适用于片段与层之间的边界。
将这条规则应用到之前的循环结构中。经过重新划分片段后,订单处理逻辑位于entities/order中,用户相关逻辑位于entities/user中,而认证流程则放在features/auth中。此时允许的依赖关系变为:
features/auth → can import entities, shared
entities/order → can import shared only
entities/user → can import shared only
如果 entities/order 试图从 features/auth 导入内容,那就属于架构层面的违规,而非风格问题。由于依赖图中的每条边都指向下方,因此不可能形成循环。这也改变了我们理解代码的方式:不必逐个文件追踪导入关系,而是以六层结构来思考,这种模型更便于人们记忆。
公共 API 会将切片内部结构保密
每个切片都会提供一个公共 API,通常是一个 index.ts 文件,用于重新导出外部用户可以使用的功能。FSD 的文档明确说明了其设计意图:仅暴露必要的内容,同时隐藏实现细节。
但这类文件中存在一种非常常见的错误做法,反而违背了设计初衷:
// features/auth/index.ts (the anti-pattern)
export * from "./ui";
export * from "./model";
通配符会重新导出所有内容,包括本应保持私有的model/tokenStore.ts。迟早会有页面直接导入该令牌存储模块,此后若要对其进行重构,就可能会破坏那些未被发现的依赖方。此外,通配符还会增加打包工具进行代码剪枝的难度,导致未使用的代码被一同发送给用户。
另一种方式则是明确列出每一个导出项:
// features/auth/index.ts (the boundary)
export { LoginForm } from "./ui/LoginForm";
export { useSession } from "./model/useSession";
// tokenStore stays internal. Nobody outside auth can touch it.
现在的结构仅有两个公共名称。其中的其他所有内容都可以被重命名、拆分或重写,而不会影响到任何调用方。这种可读性强的依赖关系图加上修改内部结构的自由度,正是FSD所承诺的核心优势。
FSD文档指出了一个需要注意的问题:桶式index文件可能会引发循环导入,在shared/ui和shared/lib中导致代码压缩问题,还会让大型项目中的打包工具运行变慢。实际应对方法是保持公共API的明确性和简洁性,尤其是在Shared目录中要格外小心——许多小型独立模块最好直接导入,而非通过一个大型桶式文件来传递。
交叉导入、过度拆分以及让代码检查工具发挥作用
用于表示相互引用的实体的@x标记法
某些实体确实存在相互依赖的关系;例如,订单就需要User类型。由于禁止同层导入,v2.1版本为这种情况制定了@x标记规范。user模块会提供一个专用的入口点供order模块使用,而order则从该模块导入功能:
// entities/user/@x/order.ts
export type { User } from "../model/types";
// in entities/order/model/types.ts
import type { User } from "entities/user/@x/order";
文件名清晰地体现了这种依赖关系且便于搜索:entities/user/@x/order.ts就是user模块中order模块可以访问的部分。文档将此视为一种特殊的例外情况。应尽量减少跨模块导入,且仅在Entities层使用@x标记。如果发现整个代码库都需要使用它,那就说明这些模块的划分位置有误。
Steiger同时指出这两种问题都属于过度设计
规则只有得到执行才能发挥作用。FSD的架构检查工具Steiger(其相关规则以@feature-sliced/steiger-plugin的形式提供)无需配置即可使用,且在撰写本文时仍处于测试阶段。除了检查导入规则外,它还能避免过度设计:当某个功能或实体仅由单个页面使用时,insignificant-slice规则会触发警告;而当项目被拆分得过于细碎时,excessive-slicing规则则会发出提示。一旦Steiger报告存在无关紧要的拆分,就应该留意这个信号——说明你创建了一个本可以用本地函数解决的文件夹结构。
何时使用完整版FSD属于过度设计
文件夹名称是FSD中最不重要的部分。将全部六层内容复制到一个小应用中会带来混乱:文件数量远超合理范围,且每个功能都分散在四个目录中,导致简单的修改都需要通过搜索才能完成。
规范意识到了这一点,因此v2.1建议采用“先处理页面”的部署方式。只需从应用、页面、小部件和共享内容开始,甚至可以考虑就此止步。对于许多应用而言,这样的结构已经足够完整且易于理解。
只有在有明确理由时,才应将代码移至Features和Entities中。一个实用的经验法则是:等到同一逻辑被三个或更多页面或组件需要时,再为其单独创建模块。在此之前,将结账流程完全放在pages/checkout中才是正确选择,而非捷径。一次重用可能是巧合,但三次则表明存在真正的模式,而正是为了表示这些可复用的模式,才有了Features层。
如果您还在考虑FSD与更简单的基于功能文件夹或域的布局方案,我们关于React文件夹结构模式的对比文章对各种方案进行了并列分析。
简短的采用检查清单
- 当团队规模超过少数几名工程师,业务领域持续增加,且
utils/目录已变成垃圾堆时,应全面采用FSD。 - 对于拥有唯一明确负责人的小型产品,应保持向下导入规则,精简公共API,避免增加额外层次。
- 每个
index.ts文件都应作为明确的导出列表来编写;避免使用export *。 - 将
@x保留用于实体间的引用,若频繁使用则视为边界问题处理。 - 运行如Steiger这样的代码检查工具,以便自动应用相关规则以及针对不必要的切片操作的警告。
总结
六个文件夹从来都不是目标。FSD真正提供的是一个仅能单向流动的依赖关系图,以及那些内部结构被隐藏在小型合约背后的切片机制,这些共同作用使得代码库在一年后依然易于理解。仅通过导入规则和公共API就能获得其中的大部分价值,只有在真正需要复用的情况下才添加额外层。