在React中应用SOLID原则
随着软件行业的发展和犯错,最佳实践和良好的软件设计原则出现并概念化,以避免在未来重复同样的错误。特别是面向对象编程(OOP)的世界是这种最佳实践的金矿,而SOLID无疑是其中比较有影响力的一个。
SOLID是一个缩写,其中每个字母代表五个设计原则中的一个,它们是:
- 单一责任原则(SRP)
- 开放-封闭原则(OCP)
- 利斯科夫替代原则(LSP)
- 接口隔离原则(ISP)
- 依赖性倒置原则(DIP)。
在这篇文章中,我们将讨论每个原则的重要性,并看看我们如何在React应用程序中应用SOLID的学习。
在我们开始之前,有一个重要的注意事项。SOLID原则的构思和概述是以面向对象的编程语言为基础的。这些原则和它们的解释在很大程度上依赖于类和接口的概念,而JS并没有这两个概念。我们通常认为JS中的 "类 "只是使用其原型系统模拟的类的外观,而接口根本不是语言的一部分(尽管TypeScript的加入确实有点帮助)。更有甚者,我们编写现代React代码的方式远远不是面向对象的--如果有的话,它感觉更像是功能性的。
不过好消息是,像SOLID这样的软件设计原则是与语言无关的,而且有很高的抽象水平,这意味着如果我们足够努力,对解释进行一些自由化,我们就能把它们应用到我们更多的功能性React代码中。
所以让我们自由发挥一下。
单一责任原则(SRP)
最初的定义是:"每个类都应该只有一个责任",也就是正好做一件事。这个原则是最容易解释的,因为我们可以简单地将这个定义推断为 "每个函数/模块/组件都应该正好做一件事"。
在所有五个原则中,SRP是最容易遵循的,但它也是影响最大的原则,因为它极大地提高了我们代码的质量。为了确保我们的组件只做一件事,我们可以。
- 将做得太多的大型组件分解成更小的组件
- 将与主要组件功能无关的代码提取到独立的实用函数中
- 将相关的功能封装成自定义钩子
现在让我们看看我们如何应用这一原则。我们首先考虑下面这个显示活跃用户列表的组件的例子。
虽然这个组件现在还比较短,但它已经在做相当多的事情了--它获取数据、过滤数据、渲染组件本身以及单个列表项。让我们来看看我们如何分解它。
首先,只要我们有连接的useState 和useEffect 钩子,这就是一个很好的机会,把它们提取到一个自定义的钩子中。
现在我们的useUsers 钩子只关心一件事--从API中获取用户。这也使我们的主组件更易读,不仅是因为它变得更短,而且还因为我们用一个域名钩子取代了你需要解读其目的的结构性钩子,其目的从它的名字中就可以立即看出。
接下来,让我们看看我们的组件所渲染的JSX。每当我们有一个对象数组的循环映射时,我们应该注意它为单个数组项目产生的JSK的复杂性。如果它是一个没有附加任何事件处理程序的单行代码,让它保持内联是完全没问题的,但对于一个更复杂的标记,把它提取到一个单独的组件中可能是个好主意。
就像之前的改动一样,我们通过将渲染用户项目的逻辑提取到一个单独的组件中,使我们的主组件更小,更易读。
最后,我们有一个逻辑,用于从我们从API得到的所有用户列表中过滤出不活跃的用户。这个逻辑是相对独立的,它可以在应用程序的其他部分重复使用,所以我们可以很容易地把它提取到一个实用函数中。
在这一点上,我们的主要组件足够简短和直接,我们可以停止分解它,并称之为一天。然而,如果我们再仔细观察一下,就会发现它所做的事情仍然比它应该做的多。目前,我们的组件正在获取数据,然后对其进行过滤,但在理想情况下,我们只想获取数据并进行渲染,而不需要进行任何额外的操作。因此,作为最后的改进,我们可以将这个逻辑封装到一个新的自定义钩子中。
在这里,我们创建了useActiveUsers 钩子来处理获取和过滤的逻辑(我们还对过滤后的数据进行了记忆,以确保良好的措施),而我们的主组件只需做最基本的工作--渲染它从钩子上得到的数据。
根据我们对 "一件事 "的解释,我们可以说这个组件仍然是先获取数据,然后再渲染,这不是 "一件事"。我们可以进一步拆分,在一个组件中调用一个钩子,然后将结果作为道具传递给另一个组件,但我发现在现实世界的应用中,这样做真正有益的情况很少,所以让我们对这个定义宽容一些,接受 "渲染组件得到的数据 "为 "一件事"。
总而言之,遵循单一责任原则,我们有效地采取了一个大型的单体代码,并使其更加模块化。模块化很好,因为它使我们的代码更容易推理,较小的模块更容易测试和修改,我们不太可能引入无意的代码重复,因此,我们的代码变得更可维护。
应该说,我们在这里看到的是一个特意设计的例子,在你自己的组件中,你可能会发现不同的活动部件之间的依赖关系更加错综复杂。在很多情况下,这可能是设计选择不当的表现--使用糟糕的抽象,创建通用的万能组件,不正确的数据范围,等等,因此可以通过更广泛的重构来解决。
开放-封闭原则(OCP)
OCP指出,"软件实体应该是开放的,可以扩展,但封闭的,可以修改"。由于我们的React组件和函数都是软件实体,所以我们根本不需要弯曲这个定义,相反,我们可以采取它的原始形式。
开放-封闭原则主张以一种允许它们被扩展而不改变其原始源代码的方式来构造我们的组件。为了看到它的作用,让我们考虑以下场景--我们正在开发一个在不同页面上使用共享的Header 组件的应用程序,根据我们所处的页面,Header 应该渲染一个稍微不同的用户界面。
在这里,我们根据我们所处的当前页面,渲染不同的页面组件的链接。如果我们想一想当我们开始添加更多的页面时会发生什么,就很容易意识到这种实现是糟糕的。每当一个新的页面被创建,我们就需要回到我们的Header 组件,并调整它的实现,以确保它知道要渲染哪个动作链接。这样的方法使我们的Header 组件变得脆弱,并与它的使用环境紧密耦合,而且违背了开放-封闭的原则。
为了解决这个问题,我们可以使用组件组合。我们的Header组件不需要关心它将在里面渲染什么,相反,它可以使用children prop将这个责任委托给将使用它的组件。
通过这种方法,我们完全删除了Header 里面的变量逻辑,现在可以使用组合,在不修改组件本身的情况下,把我们想要的东西放在那里。一个好的思考方式是,我们在组件中提供一个占位符,我们可以插入其中。而且我们也不限于每个组件只有一个占位符--如果我们需要有多个扩展点(或者如果children 道具已经被用于不同的目的),我们可以使用任何数量的道具来代替。如果我们需要从Header ,将一些上下文传递给使用它的组件,我们可以使用渲染道具模式。正如你所看到的,组合可以是非常强大的。
遵循开放-封闭原则,我们可以减少组件之间的耦合,并使它们更具扩展性和可重用性。
利斯科夫替换原则(LSP)
过于简化,LSP可以被定义为对象之间的一种关系,即 "子类型对象应该可以被超类型对象所替代"。这个原则严重依赖类的继承来定义超类型和子类型的关系,但它在React中并不十分适用,因为我们几乎没有处理过类,更不用说类的继承了。虽然脱离类的继承会不可避免地将这一原则弯曲成完全不同的东西,但使用继承写React代码将是故意创造坏的代码(React团队非常不鼓励这样做),所以相反,我们只是要跳过这一原则。
接口隔离原则(ISP)
根据ISP,"客户端不应该依赖他们不使用的接口"。为了React应用,我们把它翻译成 "组件不应该依赖它们不使用的prop"。
我们在这里扩展了ISP的定义,但这并不是一个很大的扩展--道具和接口都可以被定义为对象(组件)和外部世界(它的使用环境)之间的契约,所以我们可以在这两者之间找到相似之处。归根结底,这不是对定义的严格要求和不妥协,而是为了解决问题而应用通用原则。
为了更好地说明ISP所针对的问题,我们将在下一个例子中使用TypeScript。让我们考虑一下渲染视频列表的应用程序。
它为每个项目使用的我们的Thumbnail 组件可能看起来像这样。
Thumbnail 组件相当小而简单,但它有一个问题--它期望一个完整的视频对象作为道具被传递进来,而实际上只使用它的一个属性。
要知道为什么会有这样的问题,想象一下,除了视频,我们还决定显示直播流的缩略图,两种媒体资源都混在同一个列表中。
我们将引入一个新的类型,定义一个直播流对象。
而这就是我们更新的VideoList 。
正如你所看到的,这里我们有一个问题。我们可以很容易地区分视频和直播流对象,但我们不能将后者传递给Thumbnail 组件,因为Video 和LiveStream 是不兼容的。首先,它们的类型不同,所以TypeScript会立即抱怨。其次,它们在不同的属性下包含缩略图的URL--视频对象将其称为coverUrl ,实时流对象将其称为previewUrl 。这就是让组件依赖比它们实际需要的更多道具的问题的关键所在--它们变得不那么可重用了。所以让我们来解决这个问题。
我们将重构我们的Thumbnail 组件,确保它只依赖它所需要的道具。
通过这一改变,现在我们可以用它来渲染视频和直播流的缩略图。
接口隔离原则主张尽量减少系统中各组件之间的依赖关系,使它们的耦合度降低,从而提高可重用性。
依赖性倒置原则(DIP)
依赖性反转原则指出 "人们应该依赖抽象,而不是具体的东西"。换句话说,一个组件不应该直接依赖另一个组件,而是它们都应该依赖一些共同的抽象。这里,"组件 "指的是我们应用程序的任何部分,无论是React组件、实用函数、模块还是第三方库。这个原则在抽象的情况下可能很难掌握,所以让我们直接跳到一个例子。
下面我们有一个LoginForm 组件,当表单被提交时,它将用户的凭证发送到某个API。
在这段代码中,我们的LoginForm 组件直接引用了api 模块,所以它们之间存在着紧密的耦合。这是不好的,因为这样的依赖关系使得我们的代码更具有挑战性,因为一个组件的改变会影响到其他组件。依赖关系反转原则主张打破这种耦合,所以让我们看看如何实现这一目标。
首先,我们要从LoginForm 内部删除对api 模块的直接引用,而是允许通过props注入所需的功能。
有了这个改变,我们的LoginForm 组件就不再依赖于api 模块。向API提交证书的逻辑通过onSubmit 回调被抽象出来,现在由父组件负责提供这个逻辑的具体实现。
为了做到这一点,我们将创建一个连接版本的LoginForm ,将表单提交逻辑委托给api 模块。
ConnectedLoginForm 组件作为 和 之间的粘合剂,而它们本身仍然完全独立于对方。我们可以对它们进行迭代,并对它们进行隔离测试,而不必担心会破坏依赖性的移动部件,因为没有任何东西。而且,只要 和 都遵守约定的共同抽象,整个应用程序就会继续按预期工作。api LoginForm LoginForm api
在过去,这种创建 "哑巴 "呈现组件,然后将逻辑注入其中的方法也被许多第三方库所采用。最著名的例子是Redux,它将使用connect 高阶组件(HOC)将组件中的回调道具与dispatch 函数绑定。随着钩子的引入,这种方法变得不那么重要了,但通过HOC注入逻辑在React应用中仍有实用性。
总而言之,依赖反转原则旨在最小化应用程序不同组件之间的耦合。你可能已经注意到,最小化是贯穿所有SOLID原则的一个反复出现的主题--从最小化单个组件的责任范围到最小化跨组件的意识和它们之间的依赖关系。
总结
尽管诞生于OOP世界的问题,但SOLID原则的应用远远超出了它。在这篇文章中,我们已经看到了如何通过对这些原则的灵活解释,我们设法将它们应用到我们的React代码中,并使其更加可维护和健壮。
但重要的是要记住,教条式地严格遵守这些原则可能会造成损害,并导致过度工程化的代码,所以我们应该学会识别什么时候进一步分解或解耦组件会引入复杂性,而没有什么好处。