首页 / 文章

选择React项目目录结构:七种布局及其局限性

比较React框架下的扁平式、基于类型、基于特性、原子设计、DDD、特征切片设计以及单仓库架构,并了解每种架构失效的适用规模。

发布者:REACTAPP.TOP阅读约 31 分钟7020 词

来源: React Folder Structure: 7 Ways to Organize a React App (And Exactly When Each One Breaks)

每一个不断发展的 React 项目最终都会面临同一个问题:这个文件应该放在哪里?虽然没有官方标准答案,但团队仍会花费整个迭代周期在不同的架构之间切换,面试官也会就此考察候选人。本指南通过一个参考应用,对比了七种常见的 React 应用组织方式,从扁平的 src/ 文件夹到多应用单仓库架构。对于每种架构,你都会了解到它的特点、真正使用它的人群、它的优势与劣势,以及它失效的具体临界点,从而帮助你选择最适合当前项目的架构。

没有官方会为你做决定

React的旧版文档中曾有一篇关于文件结构的页面。该页面首先指出React对文件应如何放入文件夹并无特定要求,同时建议刚开始做项目的人在选择布局时不要花费超过五分钟的时间。当文档迁移到react.dev后,该页面被移除且没有其他内容取而代之。如今React实际上对文件夹问题完全未作任何说明。那些旧的指导信息还保存在已归档的FAQ页面中。

React周围的框架和工具也同样没有明确规定。Next.js表示自己对项目文件的组织方式及放置位置并无意见。Vite的React模板只会生成少量源文件,根本不存在文件夹结构。在这个生态系统中,没有任何权威机构能指定你应该怎么做。

这种缺失恰恰解释了很多问题。像 Bulletproof React 这样的框架之所以能获得数以万计的 GitHub 星标,主要是因为它们提出了某种文件夹结构方案,而开发者们也一直在就此争论不休。对于后续内容而言,需要明确的是:这七种结构只是惯例,并非标准,React 本身并未认可任何一种。重要的是要了解每种结构的弊端,尤其是它在什么情况下会出问题,因为一旦应用真正投入使用,这些因素就会起到决定性作用。

每种布局背后的两大原则

创造者比推广者更随和

这些方法论的创造者往往比推广它们的人持有更为宽松的态度。原子设计的创立者布拉德·弗罗斯特在2019年发表了《扩展原子设计》一文。他在文中提到有人联系他要求“修正”他的术语,同时强调原子设计并非僵化的教条,并表示那些希望用芝加哥乐队的成员名字为九个阶段命名的团队大可照做。您可以在《扩展原子设计》一文中阅读这篇文章。该方法的创造者表示名称并无重要意义,而与此同时,某个代码审查却因为某个组件是否算作“分子”而陷入停滞。

React 的旧文档中也明确提到了原子设计。文中指出,有些开发者会根据组件在应用中的作用将其划分到不同的文件夹中,并以原子设计作为这种做法的示例,同时建议将此类方法视为有用的参考而非严格的规则。出自 React 官方文档的这些内容,是目前针对 React 中严格遵循原子设计理念的最有依据的批评。

喧嚣架构

在这场争论中其实只有一个重要的论点,而且它比 React 还要早出现。2011 年 9 月,Robert C. Martin 发表了文章 《尖叫的架构》。他提出的问题是:当有人看到你应用程序的顶层目录时,他们能否立刻看出这是一个医疗系统、会计系统还是库存管理系统?他的结论是,架构应当向读者展示系统的本质,而非它所使用的框架。

这一理念是所有基于组件的布局的基础。如果 src/ 目录下包含 components/、hooks/、utils/ 和 services/,那就表明这是一个 React 应用;而如果 src/ 目录下包含 checkout/、billing/ 和 inventory/,则能体现该应用的业务功能。本指南中的每种结构要么是对这一理念的变体,要么是对其的刻意摒弃。

就近放置

第二项原则在日常开发中最为重要。Kent C. Dodds 将其称为“就近放置”:让代码尽可能靠近与其相关的功能模块。他认为 Dan Abramov 对这一概念的表述更为精准,即那些会一同发生变化的元素应当合理地放置在一起。完整论述可见 《就近放置》一文。

从这个角度来看,本指南中的每种布局都在回应同一个问题:你的代码中哪些部分应该作为一个整体来共同演化?如果答案正确,文件夹结构几乎会自动形成;如果答案错误,再好的方法也无法挽救局面。

关于一句流行语的说明:“不断调整文件位置直到感觉合适”这一建议常被归功于 Dan Abramov,有时甚至被当作 React 的官方文档。实际上它并非官方文档——官方文档中给出的是五分钟速成建议,而 Abramov 的那句话源自一条似乎已被删除的推文。其核心思想是正确的,但请将其视为民间传说。

一个参考应用,七种布局方式

只有当应用程序保持不变时,比较不同布局才是公平的;否则就只是在比较示例而非结构。因此下面展示的每个布局都是使用相同的文件来构建同一个小型电子商务应用:

  • 功能:身份认证(登录和注册)、产品目录(产品列表与详情)、购物车、结账以及订单管理。
  • UI基础组件:Button、Input、Modal、Badge和Spinner。
  • 钩子函数:useAuth、useCart、useProducts、useCheckout以及useDebounce。
  • API模块:authApi、productsApi、checkoutApi、ordersApi,以及一个通用的apiClient。
  • 其他:一个cartStore以及一个用于格式化价格的辅助函数formatPrice。

其中的四个文件刻意设计得较为棘手,因为它们暴露出了每种布局的缺陷所在:

  • Modal.jsx 根据分类人员的不同被标记为原子、分子或生物体,这强调了 Atomic Design 的分类体系。
  • useDebounce.js 完全是通用的,但却只有目录搜索功能使用了它。它是全局钩子还是目录专用钩子?
  • CartTotal.jsx 既在购物车抽屉中渲染,也在结账页面显示,因此其归属不明确。
  • formatPrice.js 看起来像是个简单的工具函数,除非“货币”确实是一个独立的领域概念,在这种情况下它应该属于 Money 类型的值对象,将其放在工具函数文件夹里是错误的。

请记住这四点。不同的结构会以不同的方式处理这些问题,而正是这些差异使得各种布局彼此区分开来。

扁平结构

什么是扁平结构

所有文件都直接存放在src/目录中,几乎没有或完全没有子文件夹,且按字母顺序排列,因为没有其他规则规定排序方式。

并没有人将这种方式正式定义为一种方法论。它只是每个项目的起始点,也有不少项目一直沿用这种结构。React的官方文档甚至推荐将其作为起点:如果感到无从下手,就把所有文件放在一个文件夹里,等到文件夹变得太大需要拆分时,你就已经知道哪些文件是经常一起编辑的。这确实是很好的建议。根据实际使用中的变化规律形成的结构,比一开始就强加的结构更好。

谁会使用它

原型、测试版本、沙箱演示、黑客松项目、带回家的作业,以及仅由一名维护者负责的内部工具。此外,它还隐含地描述了许多从未经过重构的生产级应用。

官方的 Vite React 模板结构相当简单。运行 npm create vite@latest -- --template react-ts 会生成 main.tsx、App.tsx、App.css、index.css 以及一个 assets/ 文件夹,没有 components/ 或 features/ 目录。由于该脚手架不带任何预设观点,相关争论便转而出现在每个使用它的团队之间。

优势

  • 导航无需额外成本。只有一个文件夹,文件选择器可以通过名称找到任何文件。由于不存在关于文件放置位置的决策,也就不会有相关争论。
  • 代码重构是免费的。没有需要遵守的结构规范,因此也无需进行任何迁移操作。你可以快速行动,弄清楚应用程序的真正结构,了解之后再加以整理。
  • 它对不确定性持坦诚态度。在第一周时你还不清楚功能的边界,而扁平化布局也不会假装你知道这些边界。此时创建的任何文件夹都只是猜测,日后可能需要花费精力去修改。
  • 缺点

    • 当文件数量达到约30个时,其扩展能力会突然下降。这种问题并非逐渐出现:某一天文件列表就不再能显示在侧边栏中,应用程序的界面也会随之变形。
    • 字母顺序没有任何实际意义。authApi.js会与App.jsx放在一起,仅仅因为两者都以A开头,而你通常会一起编辑的useAuth.js和LoginForm.jsx却会被分开存放。排序规则实际上与合理的文件排列方式背道而驰。
    • 没有边界可强制实施。任何文件都可以导入其他任何文件,循环依赖会在不知不觉中滋生,等到想要设置边界时,所有的违规情况都已经影响到系统功能。
    • 删除某个功能简直就像在搞侦探工作。要删除结账相关功能,就必须逐个读取文件以确定哪些文件属于该功能,因为根本不存在可以删除的文件夹。

    其缺陷所在

    当文件数量达到大约30个,或者有第二名开发者加入时,问题就会显现,而且第二个阈值更为关键。扁平结构之所以可行,是因为只有一个人能记住整个文件结构。但一旦有两个人参与,就会出现两种不同的理解,很快就会产生分歧。

    基于类型的结构

    其定义

    文件会按照其类型进行分组:组件归为一类,钩子归为一类,API调用归为一类,存储模块也归为一类。React的旧版文档将这种方式描述为按文件类型分组,并将其列为两种常见架构之一,且不对任何一种加以偏好。对于参考应用而言,这意味着components/目录下的Button、Modal、CartItem、CheckoutForm以及所有其他组件,hooks/目录下的所有钩子,api/目录下的所有API模块,还有store/目录下的cartStore都属于同一类。

    由于大多数教程都采用这种结构,它也是你在实际项目中最常遇到的架构。

    谁在使用它

    几乎所有的 React 教程、大多数训练营项目,以及那些因缺乏规划而无法继续使用扁平布局的量产应用都在采用这种方式。这是最简单的做法:一旦 src/ 目录变得杂乱,人们首先想到的自然就是创建一个 components/ 文件夹。

    优势

    • 无需额外解释。没人需要被告知 hooks/ 目录里应该放什么,因此新开发者可以在入职第一天就用自己已知的术语开始贡献代码。
    • 分类极其简单。组件放在 components/ 中,钩子函数放在 hooks/ 中,文件名本身就能表明其用途。相比之下,原子设计甚至会让理智的人为单个组件的归属争论一小时。
    • 在小型项目中表现十分出色。当文件数量少于50个时,这种方式效果很好,而改用其他方式带来的成本将远高于收益。

    缺点

    • 它宣传的是框架而非产品,这正是Screaming Architecture所反对的。名为components/、hooks/、api/和utils/的顶层文件夹只会让新手知道你使用了React,却无法体现应用程序的功能。从根目录来看,在线商店和医院调度系统看起来完全相同。
  • 任何更改都会影响到整个代码结构。要在购物车中添加一个字段,就需要编辑components/目录下的CartItem.jsx、hooks/目录下的useCart.js、api/目录下的cartApi.js以及store/目录下的cartStore.js。仅仅一个逻辑上的更改就需要操作四个目录并切换四次滚动位置。按照既定规则,变更可能性最高的文件会被放置得彼此最远。
  • components/目录会变成一个新的扁平文件夹。在参考应用中,该目录下大约有18个文件,且没有内部分类结构。虽然钩子函数和API模块的扁平化问题得到了解决,但在存放大部分代码的文件夹中这一问题又会出现。
  • 移除某个功能意味着需要搜索多个文件夹而非仅一个。要删除结账功能,则必须遍历四个目录,并希望没有遗漏任何内容。
  • 仍然不存在明确的边界划分。ProductCard可以随意导入useCheckout;这些文件夹纯粹只是用于视觉上的区分。
  • 出现问题的情况

    当文件数量达到约100个,或者components/目录下有20多个条目时问题就会显现。其警示信号表现为开发人员开始在文件名前加上前缀,如CartItem、CartDrawer和CartTotal,以此模拟文件夹无法提供的分类功能。这些前缀实际上就是代码库在要求建立功能文件夹。

    基于功能的结构

    其定义

    文件会根据其功能进行分类。每个功能的所有组件、钩子、API调用以及状态都集中在一处,而通用的基础元素则存放在公共区域中,各功能在应用层进行组合,彼此之间无需导入。

    React的旧版文档将这种分类方式称为按功能或路由分组,并提供了一种确定边界的具体方法。由于“功能”的含义并非普遍统一,因此由你自己决定分类的细致程度;如果想不出顶层文件夹的列表,可以让用户描述产品的主要组成部分,并以他们的认知模型作为蓝图。这可以说是整个主题中最实用的建议:你的用户已经知道你的功能边界所在。

    实际上,这就是将 Screaming Architecture 转换为 React 的结果,如今它已成为主流选择。在参考应用中,features/cart/ 目录存放着购物车相关组件,useCart 是购物车 API,cartStore 也是相关功能;features/checkout/ 目录则包含 CheckoutForm、useCheckout 以及 checkoutApi;其他组件也是如此,而 Button、Modal 以及其他基础组件则都存放在共享的 components/ 文件夹中。

    谁在使用它

    最著名的参考实现是Alan Alickovic开发的Bulletproof React,它在GitHub上拥有约35.5k个星标,在本文撰写时仍在持续维护中。这是目前最被广泛复制的React项目结构,建议直接阅读Bulletproof React项目结构文档,而非依赖他人的总结。

    该框架推荐的src/目录包含恰好十一个顶层文件夹,下文列出了每个文件夹的用途。需要注意的是,其中只有features文件夹是按功能领域组织的;其余的都是各功能模块所使用的共享层,再加上用于整合所有内容的app层。

    src
    +-- app          # application layer (routes, app.tsx, provider.tsx, router.tsx)
    +-- assets       # static files such as images, fonts, etc.
    +-- components   # shared components used across the entire application
    +-- config       # global configurations, exported env variables etc.
    +-- features     # feature based modules
    +-- hooks        # shared hooks used across the entire application
    +-- lib          # reusable libraries preconfigured for the application
    +-- stores       # global state stores
    +-- testing      # test utilities and mocks
    +-- types        # shared types used across the application
    +-- utils        # shared utility functions
    

    每个特性最多可包含七个部分:api、assets、components、hooks、stores、types以及utils。文档明确指出,特性只需包含其实际使用的那些部分即可。

    有两条规则起着关键作用:

    • 禁止在不同特性之间进行导入操作。Bulletproof React建议不要让一个特性导入另一个特性,而是应在应用层面组合各个特性,这样每个特性都能保持独立性,代码结构也会更易于理解。
    • 单向数据流。代码应沿着单一方向流动,即从共享代码到各个特性,最终再到应用层(shared -> features -> app)。

    这两条规则都可以通过 ESLint 规则 import/no-restricted-paths 来强制执行。然而许多团队都会跳过这一步,而这通常也是他们的项目边界逐渐模糊的原因。

    “桶文件”概念的转变:此前 Bulletproof React 推荐使用“桶文件”,即一个重新导出某个功能公共接口的 index.js 文件。现在它建议直接导入文件,因为“桶文件”可能会干扰 Vite 的代码压缩机制并导致性能问题。如果你是从旧指南中学到这种做法的,那么现在的建议已经发生了变化。由于过去大多数团队都是通过“桶文件”来定义功能的公共 API,放弃这种做法意味着边界现在完全由 lint 配置来决定。

    优势

    • 文件会默认被放置在一起。因为一起变更的文件会被存放在同一个文件夹中,所以只需修改一次代码就能影响相关文件。
  • 删除某个功能只需执行一次 rm -rf 命令。删掉 features/checkout/ 目录,修复编译器报错的导入问题,即可完成。这比听起来更重要:难以删除的代码往往永远都不会被删除。
  • 从根目录就能看到各个功能模块。新工程师只需查看 auth/、catalog/、cart/、checkout/ 和 orders/ 这些目录,无需打开任何文件就能了解产品结构。
  • 可以通过 CI 中的代码检查错误来强制维护功能边界,而非依赖人工审查意见。
  • 这种结构具备很强的扩展性,即便文件数量超过1,000个也没问题,因为每个新功能都是一个独立的文件夹,不会影响现有模块。
  • 缺点

    • “功能”并无固定定义,因此人们会对此产生争议。CartTotal.jsx既在购物车模块中渲染,也在结账页面中渲染。两种说法都没有错;你只需做出决定,之后可能还会重新考虑。
    • 共享代码每次都需要权衡判断。useDebounce目前仅用于产品目录搜索功能。如果将其设为全局变量,共享钩子文件夹就会充斥着仅使用一次的代码。应将其保留在features/catalog/hooks/目录中,当计费功能需要使用时,要么复制该代码,要么在各个功能模块之间导入(这是被禁止的),要么将其提升为全局变量。
    • 过早地将代码拆分为独立功能模块,其危害甚至大于过早进行抽象处理。将一个包含40个文件的应用拆分成五个功能文件夹,只会增加繁琐流程,而无法带来真正的结构优化。
  • 代码检查配置无法自动扩展。Bulletproof React的跨功能规则需要为每个功能单独设置区域,且每个区域都要自行免除检查。若有十个功能,则需手动创建十个区域,一旦忘记设置某个区域的规则,该功能就会失去保护。
  • 其缺陷所在

    通常情况下并没有问题,这也是它被设为默认选项的原因。但在两种情况下会显得不够实用:首先,当某个功能变得极为庞大时——比如一个包含80个文件的checkout/文件夹,实际上只是另一种层级的基于类型的结构;其次,当出现与UI功能不匹配的实际领域模型时。这两种情况会促使人们采用以下三种结构。

    原子设计

    其定义

    原子设计是Brad Frost提出的一种方法论,最初发表于2013年6月的一篇博客文章中,并在2016年的书籍中得到进一步阐述。该方法定义了五个层级。用他的定义来概括的话:

    • 原子是构成所有界面的基本组成部分。
    • 分子是由多个UI元素组成的相对简单的群体,它们作为一个整体协同工作。
    • 有机体则是由分子、原子甚至其他有机体构成的更为复杂的组件。
    • 模板是页面级结构,用于将组件安排在特定布局中,并描述底层的内容结构。
    • 页面则是填充了真实代表性内容的模板具体实例。

    几乎所有人都会忽略的一个细节是Frost对自己所提出方法的初衷描述。他将原子设计视为一种思维模型而非线性流程:一种能够同时将界面看作一个连贯的整体以及一组独立部分的视角。它从来就不是文件夹结构,只是后来社区才加上了文件夹概念。

    谁在使用它

    实际情况与传闻并不一致。几乎没有可验证的证据表明主流的 React 设计系统真的使用了 atoms/、molecules/、organisms/ 这样的文件夹分类结构。例如,Shopify Polaris 和 GitHub Primer 就没有采用这种命名方式,尽管搜索结果可能会给人这样的印象。唯一能明确验证的例子是 Pattern Lab,那是 Frost 自己开发的用于演示该方法的工具。

    作为设计系统团队中的术语,“原子设计”具有极大的影响力。但就实际生产环境中的 React 应用程序的文件夹布局而言,其应用情况的证据远不如其知名度所显示的那样充分。如果有人声称某家大公司是按这种方式组织 React 文件夹的,那就要求查看其代码仓库。

    优势

    • 它为设计师和工程师提供了共同的语言。这才是真正的优势,而文件夹与此并无关联。当设计师提到某个组件时,工程师就能立刻明白其大致范围,这对于设计系统团队来说非常有用。
    • 它有助于组件组合。层级结构促使人们先创建小模块再将其组装起来,无论文件存放在何处,这都是 React 开发的良好实践。
    • 它能让你清晰了解现有的基础组件。如果有三个本应合并为同一个的按钮变体,atoms/ 文件夹就能让这一点一目了然。

    缺点

    • 分类确实十分困难,创建者也承认这一点。Modal.jsx包含了背景、容器、标题以及关闭控件。这究竟是一组原子因而构成分子,还是一种复杂的独立整体因而构成有机体?Frost最终提出了一条经验法则:如果将某个组件拆解后会得到图片、标题、段落和按钮等基本元素,那么它就是分子;如果拆解后会得到卡片、分享列表或媒体对象等更小的组件,那么它就是有机体。之所以会有这样的经验法则,只是因为相关界限本身就存在模糊性,而每一种模糊的界限都会引发无休止的讨论,却无法为用户带来任何价值。
  • 它仅对用户界面进行分类,因此无法构建应用程序的结构。这正是其不可取的缺陷。Atomic Design 对 Button 和 CheckoutForm 有相关定义,但对 useCheckout、checkoutApi、cartStore 或 formatPrice 完全没有涉及。一半的文件都不属于该分类体系,因此团队不得不同时使用原子、分子和有机体组件,再加上基于类型的布局来处理其余内容,从而继承了这两类问题。
  • 它与合理分类原则背道而驰。所有与结账相关的内容最终都会永久分散在 organisms/、templates/、pages/、hooks/ 和 api/ 等目录中。
  • 组件被升级后,相关文件也会随之移动。一个分子逐渐成长为有机体,进而移到另一个文件夹中,同时打破所有的导入关联。分类体系取决于复杂度,而复杂度是会变化的。
  • React 自身的旧版文档也提醒过,不应将其视为严格规则,而应看作有参考价值的示例。
  • 其局限性

    一旦脱离设计系统,这种方法就会失效。在 packages/ui/ 或 shared/ui/ 中按复杂度对基础元素进行排序是合理的。但若应用于整个应用程序,这种方法很快就会失灵,因为任何应用的大部分内容都不是 UI 组件。

    Frost本人的观点也支持这种解读:他将它视为一种思维模型而非教条,并建议团队为各个阶段重新命名。那些在React应用中严格遵循“原子”和“分子”概念的团队,其实比该方法的创造者还要固执。

    领域驱动结构

    其含义

    领域驱动设计源自Eric Evans在2003年出版的书籍,而那时React还未出现,它是一种用代码对复杂业务领域进行建模的方法。其中的两个理念也被应用到了前端开发中。“受限上下文”指的是软件中某些术语、定义和规则始终如一地适用的区域;“通用语言”则是指代码应使用与业务相同的词汇表这一原则。

    应用到React应用中时,每个领域都会成为一个顶层文件夹,并在内部进一步划分为不同的层次:

    • domain/ 中存放实体和值对象:纯粹的业务规则。
    • application/ 中存放用例。
    • infrastructure/ 中存放 API 客户端和存储机制。
    • presentation/ 中存放 React 组件。

    依赖关系仅指向内部,因此领域层对 React 或 HTTP 一无所知。

    看看那些特殊文件的变动:formatPrice 已不再是一个辅助函数,而是变成了存放在 catalog/domain/ 中的 Money 值对象;useAuth 则简化为对 loginUser 用例的轻量级表现层封装。React 被刻意置于边缘位置。

    谁在使用它

    最实用的参考资料是 Alex Bespoyasov 撰写的 《前端领域的整洁架构》,该文的 bespoyasov/frontend-clean-architecture 仓库中提供了完整的 React 和 TypeScript 实现。他对各层结构的解释是:无论从 React 转换到 Angular 还是改变特定的使用场景,领域层都是保持不变的部分。

    另外值得了解的是,或许是最具影响力的前端 DDD 支持者 Manfred Steyer 主要使用 Angular 进行开发。Angular 基于类、以服务为导向且依赖注入机制完善的模型,比 React 的函数与钩子更适于应用 DDD 思想。在全面采用某套成熟的前端 DDD 指导原则之前,务必先确认其是为哪种框架编写的。

    这种风格常见于金融科技、保险、医疗保健、物流以及企业级B2B软件领域,这些领域的业务规则极为复杂,其存在时间远早于用户界面。

    优势

    • 业务逻辑独立于框架存在。一个带有addItem规则的Cart实体仅仅是普通的TypeScript代码;即便更换为React,业务层也不会受到影响。对于预期能使用数十年的产品而言,这无疑是一种宝贵的资产。
    • 无需React即可对规则进行测试。没有需要渲染的内容,没有hooks,也没有测试库,只有能在几毫秒内运行的函数和断言。对于复杂的规则而言,这一点本身就足以证明这种结构的合理性。
    • 代码能够直接反映业务需求。当行业专家指出订单在地址未被验证前不得发货时,这条规则就只存在于一个名为Order.ts的文件中。
  • 受限上下文适用于大型组织,因为每个上下文可归属于一个团队,并通过明确的接口与其他团队相连。
  • 缺点

    • 样板代码量很大,这也是其主要成本。每个实体通常都需要一个领域模型、一个DTO以及两者之间的映射器:对于基于功能的应用而言,原本只需一个带类型的API响应,却要生成三个文件和一个转换层。若此类实体有二十个,则成本会进一步上升。
    • 大多数 React 应用都没有真正的领域模型。这属于一种判断而非有依据的事实,但却是本节的核心观点。典型的 React 应用会渲染来自其他团队或服务所拥有的 API 的数据,其真正的复杂性体现在服务器状态管理、缓存机制、乐观更新以及竞态条件等方面。领域驱动设计无法解决这些问题;像 React Query 这样的服务器状态管理库比领域层更能有效解决实际问题。如果你的实体仅仅是缺乏不变性的带类型 API 响应,那么这样的架构只会耗费成本却无法提供任何保护。
    • 前端实际上无法摆脱其框架的束缚。虽然宣传称可实现框架独立,但前端始终与渲染模型、路由机制、状态管理方式、浏览器API以及构建工具紧密相关。整洁架构只是将这些限制移至边缘,而非彻底消除它们。这确实有其优势,只不过程度不如宣传的那么显著。
    • UI的边界语境较为模糊。在后端,数据所有权能明确划分界限;而前端则只显示屏幕所需的界面内容,且一个页面往往涉及三种不同的上下文,因此这些边界具有主观性且会不断变化。
    • 上手需要时间。那些从未接触过DDD的React开发者可能需要数周时间才能开始高效工作,而且总有人会质疑映射器的存在必要性。

    它的局限性

    当领域模型仅存在于纸面上时,DDD正是为应对真实的业务复杂性而设计的。如果你的不变量是真实存在的,比如金钱、合同、受监管的工作流程或基于规则的多步骤状态机,那么这项投资就会获得回报。当任务只是从某个端点获取产品列表时,你其实只是在为fetch操作增添大量不必要的繁琐步骤。

    一个有用的测试:你能列出三条比必填字段检查更有趣的业务规则吗?如果不能,那你所拥有的只是用户界面,而非真正的领域模型。

    功能切片设计

    它是什么

    功能切片设计(FSD)是此处唯一具有正式规范性的结构,它包含分版本的发布内容、文档,以及一个名为steiger的官方架构检查工具。其他方法都只是惯例,而FSD则是以书面形式明确规定的。

    它具有三层层次结构:

    • 层是固定的顶级文件夹:app、pages、widgets、features、entities和shared。
    • 切片是层内的分组,按业务含义划分,即代码对产品或应用程序的意义。
    • 段是切片内的分组,按技术用途划分:ui、api、model、lib和config。

    其核心规则是:切片中的模块只能导入位于其所在层正下方的其他切片。导入关系始终是自上而下的,且共享同一层的两个切片绝不能互相导入。同级组件之间不存在任何耦合关系,这是通过代码检查工具强制实现的,而非出于良好意愿。

    有两点针对其他地方流传的说法的更正值得了解:

    • processes 层已被废弃。文档中指出,当前规范建议避免使用该层,将其内容移至 features 和 app 中。那些仍列出包括 processes 在内的七层结构的指南描述的是旧版本。
    • pages 层并非被废弃,而是得到了提升。2024年11月发布的2.1版本以“页面优先!”为标题。现在的指导原则是让更多代码保留在页面中:大型UI组件、表单以及其他页面不会重复使用的数据逻辑都应保留在该页面的对应部分中。

    在FSD版本的参考应用中,CheckoutForm.tsx位于pages/checkout/ui/目录下,而非某个功能模块中。按照v2.0的设计理念,本应从原则上将其拆分为实体和功能组件,而v2.1则仍将其保留在页面内,直到有其他页面真正需要其部分内容。

    这一变更的动机相当有趣。v2.0采用的以实体为优先的分解方式导致代码内聚性较差:追踪某一用户流程需要遍历众多文件夹,而且死代码也很难被发现。维护者们实际上承认,早期版本在编写任何代码之前就不得不做出太多架构决策。因此,对FSD最严厉的批评正是由其开发者自己提出并解决的。虽然他们及时进行了修正,但采用v2.0的团队却为此付出了代价。

    谁在使用它

    关于其采用情况的现状需要客观审视。在撰写本文时,FSD官网设有一个面向使用该技术的公司的板块,但并未列出任何具体公司名称,而是邀请访客自行申报自己的公司。

    官方的示例页面列出了约21个项目,大多是演示版和业余开发的应用程序,比如现实世界模拟应用、数独游戏、模拟网店以及一款小型浏览器游戏。那些看似已投入实际使用的软件项目数量较少,包括Roke.to、Moke Smoke、Moonlogs以及一个Chrome扩展程序。

    截至本文撰写时,可量化的数据虽然有限但确实存在:约有297个GitHub仓库使用了feature-sliced-design标签,相关文档仓库的星标数约为2300,代码检查工具的星标数则为388。该技术社区主要集中在说俄语的开发者群体中,这使得英语环境下的采用情况看起来比实际更为稀少。

    根据可靠来源,尚无任何大型、知名的西方公司被确认为采用FSD的机构,也没有官方文件提及该方法的创建者。对于如此规范化的工具而言,这种情况实属罕见,应在决定是否采用时予以考虑。

    优势

    • 由于规范早已确定,因此无需再争论文件的放置位置。这个文件该放在哪里?规范已经给出了答案。在大型团队中,这能节省大量时间,而且在这方面FSD明显优于基于功能的布局方式——后者需要不断重新定义功能。
    • 边界由机器自动检查。steiger与ESLint配置会强制执行导入规则,而经过约束的架构比未经约束的架构衰减得慢得多。
    • 即便在缺乏协调的大型团队中也能良好运行。固定的层级结构以及禁止同级文件相互导入的特性,使得团队可以并行工作而不会产生冲突。
  • 当正确区分实体与特征后,很多问题都会变得清晰起来。product是实际存在的事物;add-to-cart则是用户执行的操作。它们属于不同类型的代码,而大多数框架会将二者混为一谈。
  • 缺点

    • 学习曲线极为陡峭,FSD自身的使用指南也承认了这一普遍问题,指出现有方法的存在门槛很高,增加了招聘难度。FSD也不例外:在能够处理单个文件之前,必须先掌握层级、切片和分段这三种分类方式。
    • v2.1版本的转型预示着变化。分解的基本单位发生了改变,所有遵循该规范的人都需要适应这些变更。
    • 其官方文档认为,对于小型项目而言这种做法过于复杂,指出在项目生命周期较短或速度比代码质量更重要的情况下并无必要使用它。
    • 相关采用证据较为缺乏。对于个人项目来说这倒无妨,但当利益相关者询问还有谁在实际环境中使用该技术时,就很难说服他们了。
    • 迁移传统代码库成本高昂。在现有代码上添加各种层和模块是一项规模庞大的工程,却很难看到明显的回报。

    其局限性

    它适用于小型团队和生命周期较短的应用程序,因为这类场景中形式上的流程比实际收益更重要。FSD旨在服务于那些规模庞大、稳定性高且需要多人协作、同时确实需要明确边界约束的产品。对于规模较小的项目,基于功能的架构就能以更少的概念实现大部分优势。

    模块化结构与单仓库

    其定义

    与其他类型不同,这种架构并不涉及对src/目录的安排,因此被放在最后讲解。它是一种仓库布局:将多个应用程序和共享包置于同一个仓库中,由任务调度器来协调它们。

    Turborepo的文档描述了标准的结构方式:首先将应用程序和服务放在apps/目录中,而库和工具等其他所有内容则放在packages/目录中。可参阅Turborepo指南中的仓库布局设计部分。

    apps/目录中的每个应用程序在其内部仍需要采用其他六种结构中的一种。单仓库架构与单个应用程序的组织方式是相互独立的,将两者混淆是该主题中最常见的误解。

    在撰写本文时,主要的工具为 turbo(2.10.x版本,每月下载量约7300万次)、nx(23.x版本,每月下载量约3800万次)以及 lerna(9.x版本,每月下载量约750万次)。在实际使用前请核实最新数据。与普遍看法不同,Lerna并未被废弃。2022年当Lerna面临停止维护的风险时,Nx接过了其管理职责。目前Lerna仍会持续发布新版本,同时将任务执行和缓存功能交由Nx处理,自身则专注于版本控制和发布工作。

    谁在使用它

    关于多仓库项目的讨论中经常提到谷歌、Meta和微软,但这具有误导性。那些是公司级多仓库项目,并非React相关的多仓库项目,与您的React应用毫无关联。

    这些数字依然很有意思。据报道,谷歌的代码仓库大约有10亿个文件,存储容量为86TB(Potvin和Levenberg,《ACM通讯》,2016年7月)。Meta称其由Sapling管理的代码仓库包含数千万个文件、提交记录和分支。据Brian Harry在2017年5月的介绍,微软的Windows代码仓库约有350万个文件,总存储量为300GB,由大约4000名工程师负责维护。

    这些公司都不得不自行开发工具来应对挑战:谷歌创造了Piper和Bazel,Meta开发了Sapling和EdenFS,而微软则编写了GVFS。这应被视为一种成本信号,而非认可。如果一个单仓库系统需要专门的基础设施团队才能正常运行,那本身就说明了单仓库系统的某些特点。

    相关且可验证的React案例则有所不同:

    • Vercel的next.js仓库是一个真正的React单体仓库,包含顶级的apps/和packages/目录、pnpm工作空间以及19个包,其中包括next、create-next-app和eslint-config-next。
    • Shopify将其所有React Native应用都放在一个单体仓库中,该仓库涵盖了Shop、Inbox、Point of Sale、Shopify Mobile和Local Delivery等功能。其工程团队在2021年9月表示,单一单体仓库能够降低维护成本,因为只需优化一个流程,而无需为众多独立的包和仓库分别处理。

    这就是单体仓库的真正优势,需要注意的是,这里讨论的是维护流程而非代码组织方式。

    优点

    • 跨包修改是原子性的。修改packages/ui/Button以及所有三个使用该组件的应用,都可以在一次提交、一个拉取请求、一次审查和一次CI测试中完成。而如果使用独立的仓库,就需要进行版本升级、发布操作以及三次依赖更新。这正是单仓库架构存在的理由。
    • 共享机制具有明确的边界。packages/ui拥有自己的package.json和明确的公共API,而包边界则是JavaScript所提供的最强大的隔离机制。
    • 所有内容都只有一个版本:不会出现某个应用仍使用React 18而另一个已升级到19的情况,只有一个锁定文件,一个唯一的真实数据源。
    • 大规模重构变得可行。你可以在一次提交中为所有使用该API的应用重命名它,同时由编译器检查修改后的结果。

    缺点

    • 工具链确实是一笔长期存在的负担:工作空间、任务图、远程缓存以及持续集成矩阵。Turborepo的官方文档也承认了扩展性问题,因为每个工作空间都会带来各自的测试、代码检查及构建任务,一个单一的单一仓库最终可能会拥有数千个此类任务。Turborepo的存在正是为了解决单一仓库所带来的问题。
    • 在速度提升之前,持续集成的效率会先下降且成本会上升。如果没有正确配置变更检测和缓存机制,每次拉取请求都会触发所有任务的执行。
    • 新成员的上手难度更大。在首次提交代码之前,他们就必须了解工作空间是如何解析的、任务图是如何运作的以及缓存机制的具体表现。
  • 共享组件虽然看似实现了代码复用,但实际上会引入耦合。一旦packages/ui被三个应用使用,任何改动都可能导致这三个应用出现功能退化。团队通常会通过添加更多属性和变体来应对,最终使得这个共享组件变成一种没人能理解的配置语言。
  • 问题出现的场景

    当只有一个应用时。如果单仓库结构中的apps/文件夹仅包含一个应用程序,那么就需要为完全不必要的工具开销买单,却得不到任何回报:既没有可共享的代码,也无法实现跨应用的原子化修改。

    至于具体阈值,目前并无权威数据。Turborepo和Nx并未公布何种团队规模或应用数量时采用该工具才值得。网上流传的一些具体数字,比如所谓需要使用Turborepo的工程师人数,其实源自低质量的SEO内容,而非供应商的官方文档或研究结果。任何给出确切阈值的人都是在编造数据。

    这里提供一个仅供参考的经验法则:当您拥有两个真正需要共享代码的实际应用,或者有一个应用搭配有供外部团队使用的设计系统时,可以考虑采用单仓库架构。否则,其成本将高于收益,而您的具体情况也可能会改变这一标准。

    React Server Components并不规定文件夹结构

    许多最近的文章声称,React Server Components 改变了文件夹的结构方式,通常建议分别设置 components/server/ 和 components/client/ 目录。但官方文档中并未有任何内容支持这一观点。

    use client 指令标记的是模块图中的边界,而非目录树中的边界。相关的 Next.js 渲染指南 将 use client 描述为在服务器模块图与客户端模块图之间划定边界,并指出一旦某个文件使用了该指令,其导入的依赖以及它直接渲染的子组件都会被纳入客户端代码包中。

    第二点才是关键所在。边界是沿着导入关系传播的,而非文件夹结构。位于 client/ 目录中的文件并不会因其所在位置就成为客户端组件,而位于 server/ 目录中的文件也无法因名称而获得保护。决定最终结果的是相关指令以及导入关系链。

    如果需要强制执行这些规则,可以使用 server-only 和 client-only 这类 npm 包,当代码跨越错误边界时它们会让构建过程失败。文件夹结构则无法实现这一点。

    官方指南实际上建议采取相反的做法:将 'use client' 放在更底层的组件中,以保持客户端代码包的体积较小。而将项目分为顶层服务器文件夹和客户端文件夹的做法则容易引发相反的倾向。

    App Router 的一些规范确实会影响项目结构,而且这些规范是真实存在的:

    • 路由组以(文件夹)的形式表示,可在不改变URL的情况下对路由进行组织。
    • 私有文件夹以_文件夹的形式表示,可完全避免路由功能。
    • 默认情况下共存是安全的,因为只有当添加了page.js或route.js文件后,路由才会对外公开。组件、测试代码和工具函数可以直接放在路由路径中而不会变成路由,因此无需使用私有文件夹。

    Next.js明确表示不对项目文件的组织方式及共存方式持任何意见。其文档介绍了三种组织策略,但并未对它们进行排序。

    如何选择

    以下是经过简化的决策依据,重点列出了最关键的考量因素:

    • 文件数量少于30个,或者你仍在探索问题解决方案:采用扁平结构。正如React的旧版文档所建议的,基于实际变化模式构建的结构优于第一天就凭猜测设计的结构。
    • 文件数量在50个左右,团队规模较小且项目生命周期较短:基于类型的设计即可。虽然这种做法已过时,但并无大碍;迁移带来的成本将高于其节省的成本。
    • 对于真正需要长期使用的系统:应采用基于功能的设计。这应是默认选择。此类设计符合“尖叫架构”原则,便于功能组合,能让删除功能变得简单,并支持通过lint工具设定边界。可从Bulletproof React的11个文件夹结构开始,尽早添加import/no-restricted-paths区域,同时跳过barrel文件。
  • 庞大的团队、寿命漫长的产品,以及关于何为功能功能的持续争论——以FSD为例。你支付的是早已做出决策的规范以及强制执行这些规范的检查工具,而作为代价的则是微弱的采用证据和极高的概念数量。
  • 那些比用户界面更持久的复杂业务规则:只需添加领域层,无需整套DDD架构。将这些规则放入从不导入React的普通TypeScript实体中,其余部分仍以功能为导向设计。这样只需付出少量努力就能获得大部分价值。
  • 拥有真正使用者的设计系统:仅在该包内部将Atomic Design作为术语体系使用,不要将其纳入应用程序的整体布局中。
  • 几个共享相同代码的真实应用:使用包含 apps/ 和 packages/ 的单仓库结构,由 Turborepo 或 Nx 管理,让每个应用从该列表中选择自己的内部结构。
  • 有一条规则高于所有这些原则:各种方法可以相互结合。真实的代码库中常常会混合使用这些方法,React 的旧版文档也提到过这一点,指出大型项目在实践中通常会综合运用多种方法,因此一开始就选定“最正确”的方法并非非常重要。

    一个非常合理的生产环境配置是:包含基于功能划分的应用的单仓库、按大致独立模块组织的设计系统包,以及用于存放实际业务规则的领域层。没人会因为架构的“纯粹性”来给它打分。

    关键要点

    那些最接近这些规范的人并不会严格遵循它们,而是不断在公开场合对其进行修改。Frost将Atomic Design视为一种思维模型而非教条。React的文档曾规定决策时间不超过五分钟,之后便删除了相关页面。Next.js和Vite则选择不表态。FSD在v2.1版本中改变了其主要的拆分单元,而Bulletproof React则改变了其关于单一文件结构的建议。他们之所以改变立场,是因为听取了真实的反馈,而非固守原有观点。

    这样一来,就只剩下两条历经时间考验的规则,它们的出现时间甚至早于此处讨论的所有布局方式:

    • “显性架构”原则:顶层文件夹应向新工程师说明产品的功能,而非它使用了哪种框架。
    • “邻近放置”原则:那些会一同发生变化的元素应尽可能地放在一起。

    本指南中的每种架构都试图在特定规模上满足这两条规则。请为当前的应用选择最能满足这些规则的方案,而非你期望未来拥有的方案。表明该升级的信号是明确的:用文件名前缀代替文件夹、一次修改需打开四个文件夹,或是为判断某事物是否属于分子而耗费一小时。应在这些信号出现时立即采取行动,而非提前。