随着数字环境的发展,现代网站的复杂性也在发生变化。随着对更好的用户体验和高级功能的需求不断增加,前端开发人员面临着创建可扩展、可维护和高效架构的挑战。
在有关前端架构的大量文章和资源中,有相当一部分关注清洁架构及其改编。事实上,在近 70 篇接受调查的文章中,超过 50% 的文章讨论了前端开发背景下的清洁架构。
尽管信息丰富,但仍然存在一个明显的问题:许多提出的架构想法可能从未在现实世界的生产环境中实施。这让人怀疑它们在实际场景中的有效性和适用性。
在这种担忧的驱使下,我开始了为期六个月的前端实施 Clean Architecture 的旅程,让我能够面对这些想法的现实,并将小麦与谷壳分开。
在本文中,我将分享我在这段旅程中的经验和见解,提供有关如何在前端成功实施 Clean Architecture 的综合指南。
通过阐明挑战、最佳实践和实际解决方案,本文旨在为前端开发人员提供他们在不断发展的网站开发世界中导航所需的工具。
在当今快速发展的数字生态系统中,开发人员在前端框架方面的选择太多了。丰富的选项解决了许多问题并简化了开发过程。
然而,这也导致了开发人员之间无休止的争论,每个人都声称自己喜欢的框架优于其他框架。事实是,在我们这个快节奏的世界里,每天都会出现新的 JavaScript 库,几乎每个月都会推出新的框架。
为了在这样一个动态环境中保持灵活性和适应性,我们需要一个超越特定框架和技术的架构。
这对于涉及维护的产品公司或长期合同尤为重要,因为必须适应不断变化的趋势和技术进步。
独立于框架等细节,使我们能够专注于我们正在开发的产品,并为在其生命周期中可能出现的变化做好准备。
不要害怕;本文旨在为这一困境提供一个答案。
在我寻求在前端实现 Clean Architecture 的过程中,我与几位全栈和后端开发人员密切合作,以确保该架构易于理解和维护,即使对于那些前端经验最少的人也是如此。
因此,我们架构的主要要求之一是它对可能不精通前端复杂性的后端开发人员以及可能不具备丰富的前端专业知识的全栈开发人员的可访问性。
通过促进前端和后端团队之间的无缝合作,该架构旨在弥合差距并创造统一的开发体验。
不幸的是,要构建一些很棒的东西,我们需要获得一些背景知识。清楚地了解基本原则不仅会促进实施过程,还会确保架构遵循软件开发中的最佳实践。
在本节中,我们将介绍构成我们架构方法基础的三个关键概念: SOLID 原则、清洁架构(实际上来自 SOLID 原则)和原子设计。如果您对这些领域有强烈的感觉,则可以跳过此部分。
SOLID 是一个首字母缩写词,代表五个设计原则,这些原则指导开发人员创建可扩展、可维护和模块化的软件:
如果您想更深入地探讨这个主题,我强烈建议您这样做,那没问题。但是,就目前而言,我所介绍的内容足以让您走得更远。
就本文而言,SOLID 给我们带来了什么?
Robert C. Martin 基于 SOLID 原则及其在开发各种应用程序方面的丰富经验,提出了 Clean Architecture 的概念。在讨论这个概念时,通常会参考下图来直观地表示其结构:
所以,Clean Architecture 并不是一个新概念。它已被广泛用于各种编程范例,包括函数式编程和后端开发。
Lodash 等库和众多后端框架都采用了这种植根于 SOLID 原则的架构方法。
Clean Architecture 强调关注点分离和在应用程序中创建独立的、可测试的层,其主要目标是使系统易于理解、维护和修改。
该架构被组织成同心圆或层;每个都有明确的界限、依赖性和职责:
Clean Architecture 促进从外层到内层的依赖流,确保核心业务逻辑保持独立于所使用的特定技术或框架。
这会产生一个灵活、可维护和可测试的代码库,可以轻松适应不断变化的需求或技术堆栈。
原子设计是一种通过将界面分解为最基本的元素,然后将它们重新组合成更复杂的结构来组织 UI 组件的方法。 Brad Frost 于 2008 年在一篇题为“原子设计方法论”的文章中首次介绍了这个概念。
这是显示原子设计概念的图形:
它由五个不同的级别组成:
通过采用原子设计,开发人员可以获得多种好处,例如模块化、可重用性和 UI 组件的清晰结构,因为它要求我们遵循设计系统方法,但这不是本文的主题,所以继续。
为了对前端开发的清洁架构有一个全面的了解,我开始了创建应用程序的旅程。在六个月的时间里,我在从事这个项目的过程中获得了宝贵的见解和经验。
因此,本文中提供的示例均来自我对该应用程序的亲身体验。为了保持透明度,所有示例均来自可公开访问的代码。
您可以通过访问存储库来探索最终结果
如前所述,网上有许多 Clean Architecture 的实现。但是,可以在这些实现中识别出一些共同的元素:
通过了解这些共性,我们可以理解清洁架构的基本结构并使其适应我们的特定需求。
我们应用程序的核心部分包含:
用例:用例描述各种操作的业务规则,例如保存、更新和获取数据。例如,一个用例可能涉及从概念中获取单词列表或增加用户每天学习单词的次数。
本质上,用例从业务角度处理应用程序的任务和流程,确保系统按照预期目标运行。
模型:模型代表应用程序中的业务实体。这些可以使用 TypeScript 接口定义,确保它们符合需求和业务要求。
例如,如果用例涉及从 Notion 中获取单词列表,则您需要一个模型来准确描述该列表的数据结构,并遵守适当的业务规则和约束。
操作:有时,将某些任务定义为用例可能不可行,或者您可能希望创建可在域的多个部分使用的可重用功能。例如,如果您需要编写一个函数来按名称搜索概念词,那么此类操作就应该驻留在此处。
操作对于封装特定于域的逻辑很有用,这些逻辑可以在应用程序的各种上下文中共享和使用。
存储库接口:用例需要一种访问数据的方法。根据依赖倒置原则,领域层不应该依赖于任何其他层(而其他层依赖于它);因此,这一层定义了存储库的接口。
重要的是要注意它指定了接口,而不是实现细节。存储库本身使用与实际数据源无关的存储库模式,并强调从这些源获取或发送数据的逻辑。
值得一提的是,单个存储库可以实现多个 API,单个用例可以利用多个存储库。
该层负责数据访问,并可以根据需要与各种来源进行通信。考虑到我们正在开发一个前端应用程序,这一层将主要用作浏览器 API 的包装器。
这包括用于 REST、本地存储、IndexedDB、语音合成等的 API。
重要的是要注意,如果您想生成 OpenAPI 类型和 HTTP 客户端,API 层是放置它们的理想位置。在这一层中,我们有:
API 适配器:API 适配器是我们应用程序中使用的浏览器 API 的专用适配器。此组件管理 REST 调用以及与应用程序内存或您希望使用的任何其他数据源的通信。
如果需要,您甚至可以创建和实施自己的对象存储系统。通过拥有专用的 API 适配器,您可以维护与各种数据源交互的一致接口,从而更容易根据需要更新或更改它们。
存储库层通过管理多个 API 的集成、将特定于 API 的类型映射到域类型以及合并用于转换数据的操作,在应用程序架构中发挥着至关重要的作用。
例如,如果您想将语音合成 API 与本地存储相结合,这是一个完美的地方。该层包含:
适配器层负责协调这些层之间的交互并将它们连接在一起。该层仅包含负责的模块:
表示层负责呈现用户界面 (UI) 并处理用户与应用程序的交互。它利用适配器、域和共享层来创建功能性和交互式 UI。
表示层采用原子设计方法来组织其组件,从而产生可扩展和可维护的应用程序。然而,这一层不是本文的主要关注点,因为它不是 Clean Architecture 实现方面的主要主题。
所有公共元素都需要一个指定的位置,例如集中式实用程序、配置和共享逻辑。但是,我们不会在本文中深入研究这一层。
值得一提的只是为了了解如何在整个应用程序中管理和共享公共组件。
现在,在深入编码之前,有必要讨论一下测试。确保应用程序的可靠性和正确性至关重要,并且为架构的每一层实施稳健的测试策略也至关重要。
通过对架构的每一层实施全面的测试策略,您可以确保应用程序的可靠性、正确性和可维护性,同时降低开发过程中引入错误的可能性。
但是,如果您正在构建一个小型应用程序,那么在适配器层上进行集成测试就足够了。
好吧,现在您已经对 Clean Architecture 有了深入的了解,甚至可能已经形成了自己的看法,让我们更深入地研究并探索一些实际代码。
请记住,我只会在这里展示一个简单的例子;但是,如果您对更详细的示例感兴趣,请随时浏览本文开头提到的我的 GitHub 存储库。
在“现实生活”中,Clean Architecture 真正在大型企业级应用程序中大放异彩,而对于较小的项目来说可能就有些矫枉过正了。话虽如此,让我们进入正题。
以我的应用程序为例,我将演示如何执行 API 调用来获取给定单词的字典建议。这个特定的 API 端点通过网络抓取两个网站来检索含义和示例列表。
从业务角度来看,此端点对于“查找词”视图至关重要,该视图允许用户搜索特定词。一旦用户找到单词并登录,他们就可以将网络抓取的信息添加到他们的概念数据库中。
首先,我们必须建立一个能够准确反映我们之前讨论过的层的文件夹结构。该结构应类似于以下内容:
client ├── adapter ├── api ├── domain ├── presentation ├── repository └── shared
客户端目录与许多项目中的“src”文件夹有相似的用途。在这个特定的 Next.js 项目中,我采用了将前端文件夹命名为“客户端”,将后端文件夹命名为“服务器”的约定。
这种方法可以明确区分应用程序的两个主要组件。
为您的项目选择正确的文件夹结构确实是一个重要的决定,应该在开发过程的早期做出。在组织资源方面,不同的开发人员有自己的偏好和方法。
有些人可能按页面名称对资源进行分组,其他人可能遵循 OpenAPI 生成的子目录命名约定,还有一些人可能认为他们的应用程序太小而无法保证这些解决方案中的任何一种。
关键是选择最适合项目的特定需求和规模的结构,同时保持清晰且可维护的资源组织。
我在第三组,所以我的结构是这样的:
client ├── adapter │ ├── local-storage │ ├── rest │ ├── speech-synthesis │ └── supabase ├── api │ ├── local-storage │ ├── rest │ ├── speech-synthesis │ └── supabase ├── domain │ ├── local-storage │ ├── rest │ ├── speech-synthesis │ ├── supabase └── repository ├── local-storage ├── rest ├── speech-synthesis └── supabase
我决定在本文中省略共享层和表示层,因为我相信那些想要深入研究的人可以参考我的存储库以获取更多信息。现在,让我们继续使用一些代码示例来说明如何在前端应用程序中应用清洁架构。
让我们考虑一下我们的要求。作为用户,我希望收到一份建议列表,包括它们的含义和示例。因此,单个字典建议可以建模如下:
interface DictionarySuggestion { example: string; meaning: string; }
现在我们已经描述了一个字典建议,重要的是要提到有时通过网络抓取获得的词与用户键入的词不同或被更正。为了适应这一点,我们稍后将在我们的应用程序中使用更正后的版本。
因此,我们需要定义一个接口,其中包括字典建议和单词更正列表。最终界面如下所示:
export interface DictionarySuggestions { suggestions: DictionarySuggestion[]; word: string; }
我们正在导出此接口,这就是包含export
关键字的原因。
我们有了模型,现在是时候使用它了。
import { DictionarySuggestions } from './rest.models'; export interface RestRepository { getDictionarySuggestions: (word: string) => Promise<DictionarySuggestions | null>; }
在这一点上,一切都应该清楚了。重要的是要注意我们在这里根本不讨论 API!存储库本身的结构非常简单:只是一个具有一些方法的对象,其中每个方法异步返回特定类型的数据。
请记住,存储库始终以域模型格式返回数据。
现在,让我们将业务规则定义为用例。代码如下所示:
export type GetDictionarySuggestionsUseCaseUseCase = UseCaseWithSingleParamAndPromiseResult< string, DictionarySuggestions | null >; export const getDictionarySuggestionsUseCase = ( restRepository: RestRepository, ): GetDictionarySuggestionsUseCaseUseCase => ({ execute: (word) => restRepository.getDictionarySuggestions(word), });
首先要注意的是用于定义用例的常用类型列表。为此,我在域目录中创建了一个use-cases.types.ts
文件:
domain ├── local-storage ├── rest ├── speech-synthesis ├── supabase └── use-cases.types.ts
这使我可以轻松地在我的子目录之间共享用例类型。 UseCaseWithSingleParamAndPromiseResult
的定义如下所示:
export interface UseCaseWithSingleParamAndPromiseResult<TParam, TResult> { execute: (param: TParam) => Promise<TResult>; }
这种方法有助于保持跨域层用例类型的一致性和可重用性。
您可能想知道为什么我们需要execute
函数。在这里,我们有一个返回实际用例的工厂。
这种设计选择是因为我们不想在用例代码中直接引用存储库实现,也不希望存储库被导入使用。这种方法允许我们稍后轻松应用依赖注入。
通过使用工厂模式和execute
函数,我们可以将存储库的实现细节与用例代码分开,从而提高应用程序的模块化和可维护性。
这种方法遵循依赖倒置原则,其中领域层不依赖于任何其他层,并且在交换不同的存储库实现或修改应用程序的体系结构时提供更大的灵活性。
首先,让我们定义我们的接口:
export interface RestApi { getDictionarySuggestions: (word: string) => Promise<AxiosResponse<DictionarySuggestions>>; }
如您所见,接口中此函数的定义与存储库中的非常相似。由于域类型已经描述了响应,因此无需重新创建相同的类型。
请务必注意,我们的 API 返回原始数据,这就是我们返回完整AxiosResponse<DictionarySuggestions>
的原因。通过这样做,我们在 API 层和领域层之间保持清晰的分离,从而在数据处理和转换方面提供更大的灵活性。
该 API 的实现如下所示:
export const getRestApi = (axiosInstance: AxiosInstance): RestApi => ({ getDictionarySuggestions: async (word: string) => { const encodedCurrentDate = encodeURIComponent(word); const response = await axiosInstance.get( `${RestEndpoints.GET_DICTIONARY_SUGGESTIONS}?word=${encodedCurrentDate}`, ); return response; } });
在这一点上,事情变得更加有趣。要讨论的第一个重要方面是我们的axiosInstance
的注入。这使我们的代码非常灵活,并使我们能够轻松地构建可靠的测试。这也是我们处理查询参数编码或解析的地方。
但是,您也可以在此处执行其他操作,例如修剪输入字符串。通过注入axiosInstance
,我们保持清晰的关注点分离,并确保 API 实现适应不同的场景或外部服务的变化。
由于我们的接口已经由域定义,我们所要做的就是实现我们的存储库。所以,最终的实现是这样的:
export const getRestRepository = (restApi: RestApi): RestRepository => ({ getDictionarySuggestions: async (word) => { const { data } = await restApi.getDictionarySuggestions(word); if (!data?.suggestions?.length) { return null; } return formatDictionarySuggestions(data); } });
需要提及的一个重要方面与 API 相关。我们的getRestRepository
允许我们传递先前定义的restApi
。这是有利的,因为如前所述,它允许更容易的测试。我们可以简要检查formatDictionarySuggestions
:
export const formatDictionarySuggestions = ({ suggestions, word, }: DictionarySuggestions): DictionarySuggestions => { const cleanedWord = cleanUpString(word); const cleanedSuggestions = suggestions.map((_suggestion) => { const cleanedMeaning = cleanUpString(_suggestion.meaning); const cleanedExample = cleanUpString(_suggestion.example); return { meaning: cleanedMeaning, example: cleanedExample, }; }); return { word: cleanedWord, suggestions: cleanedSuggestions, }; };
此操作将我们的域DictionarySuggestions
模型作为参数并执行字符串清理,这意味着删除不必要的空格、换行符、制表符和大写字母。它非常简单,没有隐藏的复杂性。
需要注意的重要一点是,此时您无需担心 API 实现。提醒一下,存储库总是返回域模型中的数据!否则不能,因为这样做会破坏依赖倒置的原则。
现在,我们的领域层不依赖于它之外定义的任何东西。
在这一点上,一切都应该实现并准备好进行依赖注入。下面是 rest 模块的最终实现:
import { getRestRepository } from '@repository/rest/rest.repository'; import { getRestApi } from '@api/rest/rest.api'; import { getDictionarySuggestionsUseCase } from '@domain/rest/rest.use-cases'; import { axiosInstance } from '@shared/axios.instance'; const restApi = getRestApi(axiosInstance); const restRepository = getRestRepository(restApi); export const restModule = { getDictionarySuggestions: getDictionarySuggestionsUseCase(restRepository).execute, };
这是正确的!我们已经完成了实施 Clean Architecture 原则的过程,而没有绑定到特定的框架。这种方法确保我们的代码具有适应性,可以在需要时轻松切换框架或库。
谈到测试时,检查存储库是了解如何在此架构中实施和组织测试的好方法。
有了 Clean Architecture 的坚实基础,您可以编写涵盖各种场景的综合测试,使您的应用程序更加健壮和可靠。
如所示,遵循清洁架构原则和分离关注点会导致可维护、可扩展和可测试的应用程序结构。
这种方法最终使添加新功能、重构代码以及与团队合作项目变得更加容易,从而确保您的应用程序的长期成功。
在示例应用程序中,React 用于表示层。在适配器目录中,有一个名为hooks.ts
的附加文件,用于处理与 rest 模块的交互。该文件的内容如下:
import { restModule } from '@adapter/rest/rest.module'; import { useAxios } from '@shared/hooks'; export const useDictionarySuggestions = () => { const { data, error, isLoading, mutate } = useAxios(restModule.getDictionarySuggestions); return { dictionarySuggestions: data, getDictionarySuggestions: mutate, dictionarySuggestionsError: error, isDictionarySuggestionsLoading: isLoading, }; };
这种实现使得使用表示层变得异常容易。通过使用useDictionarySuggestions
挂钩,表示层不必担心管理数据映射或与其主要功能无关的其他职责。
这种关注点分离有助于维护清洁架构的原则,从而产生更易于管理和维护的代码。
首先,我鼓励您深入研究提供的 GitHub 存储库中的代码并探索其结构。
你还能做什么?天空是极限!这完全取决于您的具体设计需求。例如,您可能会考虑通过合并数据存储来实现数据层(Redux、MobX,甚至是自定义的东西——这无关紧要)。
或者,您可以尝试不同层之间的通信方法,例如使用 RxJS 处理与后端的异步通信,这可能涉及轮询、推送通知或套接字(本质上是为任何数据源准备的)。
本质上,只要您保持分层架构并坚持反向依赖原则,就可以随意探索和试验。始终确保域是您设计的核心。
通过这样做,您将创建一个灵活且可维护的应用程序结构,该结构可以适应各种场景和要求。
在本文中,我们在使用 React 构建的语言学习应用程序的上下文中深入研究了 Clean Architecture 的概念。
我们强调了维护分层架构和遵守反向依赖原则的重要性,以及分离关注点的好处。
Clean Architecture 的一个显着优势是它能够让您专注于应用程序的工程方面,而不必束缚于特定的框架。这种灵活性使您可以使您的应用程序适应各种场景和要求。
但是,这种方法有一些缺点。在某些情况下,遵循严格的架构模式可能会导致增加样板代码或增加项目结构的复杂性。
此外,减少对文档的依赖既有利也有弊——虽然它允许更多的自由和创造力,但也可能导致团队成员之间的混乱或沟通不畅。
尽管存在这些潜在的挑战,但实施 Clean Architecture 可能会非常有益,特别是在没有普遍接受的架构模式的 React 环境中。
在项目开始时就考虑您的架构,而不是经过多年的努力才解决它,这一点至关重要。
要探索实际应用中的清洁架构示例,请随时查看我的存储库
哇,这可能是我写过的最长的文章了。感觉不可思议!