让我们从React应用程序的角度来讨论SOLID原则。如果你不确定SOLID原则是什么,你可能需要先阅读以下内容:
第一个原则通常是最容易理解的:你的组件应该只有一个职责,或者换句话说,这个组件存在的唯一原因是什么。
然而,如果你将思维从消费模式切换到思考模式,你可能会立即注意到一个问题:鉴于你的应用程序中的任何非叶子组件都是一个子树的根,几乎可以肯定地说,你的一些组件将具有多个职责 - 组件有五个子组件,并控制其中三个的状态。我敢打赌你的根App
组件严重违反了这个原则!
那么我们该怎么办?如何应用单一职责原则?如果你仔细观察,你可能会发现两组组件:一些很容易根据单一职责原则进行设计的组件;另一些则是一些有很多职责(或控制)的协调者。你可以称它们为智能/哑组件,或容器/展示组件,我不在乎,只要你承认这种分离即可。
我将它们称为管理者和工作者,我声称通过引入一套简单的规则,你可以使应用程序的所有组件都遵守单一职责原则!让我们定义它们:
-
管理者不应该做工作者的工作,换句话说,它们的唯一职责是管理和组合工作者(或其他管理者)。
-
工作者不应该意识到业务逻辑,也不应该将工作委托给管理者,只能委托给其他工作者。
让我们仔细思考一下这两个规则。"管理者不应该做工作者的工作"是什么意思?这意味着管理者不应该有自己的JSX内容,它们返回的内容只应该由工作者和/或其他管理者组成,或者简单地说,它们不应该有自己的HTML元素。因此,它们也不会有CSS,因为没有东西可以进行样式设置。你可以放心地说,管理者代表你的应用程序的(Java)脚本部分 - 它们承载所有的业务逻辑并控制大多数用户交互。
那么工作者"不应该意识到业务逻辑"是什么意思呢?嗯,恰恰相反!它们将承载所有的内容和样式(或应用程序的HTML和CSS部分),几乎没有JavaScript - 只在它们自己的独立存在所需时使用。
我必须指出,样式方面存在一个相当大的警告(太大了,无法在这里包含),并且根据你阅读这篇文章的时间,可能会有或可能没有另一篇我写的文章,标题为"样式如何破坏你的React应用程序"(或类似的标题)。
还要注意,工作者不能将工作委托给管理者并不意味着管理者不能是工作者在组件树中的子组件,我们将在最后一章中讨论这个问题😉
这个原则有一个听起来很简单的定义:你的组件应该对扩展开放,对修改关闭,然而理解扩展和修改之间的区别可能并不容易。从组件本身的角度来看,任何扩展都是一种修改。因此,我们可以推断出这个原则纯粹依赖于你组件的外部消费者的观点:
"我不在乎你对组件做什么,只要不需要我改变使用它的方式!"
这听起来很熟悉!那是因为这个原则是向后兼容性的基础,而且它无处不在。Web API和设计趋势将不断发展,你应该始终期望组件的演变,并且这种演变应该始终*尊重现有的消费者。让我们通过一个简单的例子来看看开闭原则在实践中是如何工作的:
你被要求创建一个Button
组件,如果提供了图标,它将显示一个图标,所以你这样做了:
过了一段时间,产品设计师告诉你:“伙计,我们需要一个图标在右边的按钮!”你会怎么做?试着停下来思考一下你会怎么做。
有多种方法可以实现这种新的行为,例如通过引入一个新的iconPlacement
属性,其默认值为left/start
,它将控制图标的位置。只要消费者满意,我不会反对这样做,但是个人而言,我实际上会引入两个新的属性并弃用一个:
我希望这能对开闭原则有一点点清晰,并且能够在你的React应用程序中实现它。请注意,这个原则不是万能的(提示:没有什么是万能的),扩展不可避免地会导致更大的文件,并且在某个时候变得适得其反。有时你可能希望通过扩展组件并创建它们的子类型来解决问题:
你能在这个组件中发现一个潜在的问题吗?如果没有,不要担心 - 我们将在下一章中讨论它。
里氏替换原则基于协变性:你应该能够用其子类型替换任何超类型。对于那些在学校没有学过计算机科学课程的人来说,这个命名可能听起来有点奇怪。如果我通过扩展类型A
创建新类型B
,将类型B
称为类型A
的子类型,将类型A
称为类型B
的超类型,这是不符合直觉的,但事实就是如此 - 记住,计算机科学中的树也是向下生长的。
那么我在上面的IconButton
组件中犯了什么错误呢?IconButton
扩展了Button
组件,换句话说,IconButton
是Button
的子组件。现在,我有两个问题要问你,第一个你可能已经猜到了,第二个是一个棘手的问题:
-
我可以用其子类型组件
IconButton
替换所有Button
组件的出现吗? -
这种替换合理吗?
对于第一个问题的答案是NO!通过省略Button
组件的icon
属性,我实际上违反了里氏替换原则。我的IconButton
组件更像是一个"孤儿子",而不是Button
的经典子类型。这两种类型是独立的,不可替代的,或者用花哨的技术术语来说,它们是彼此的不变量。
对于第二个问题的答案也是NO!因为我从一开始就没有打算创建一个子类型,换句话说,IconButton
从来没有打算替代Button
。
这是编程中组合与继承的一个很好的例子:里氏替换原则基于继承,而React基于组合(我们稍后会看到)。然而,React基于组合并不意味着继承没有用武之地。事实上,如果你看一下上面的Button
组件,我故意将它作为HTMLButtonElement
的子类型,通过启用两个关键功能:
-
extends React.PropsWithChildren
:它接受内容 -
extends React.HTMLAttributes<HTMLButtonElement>
:它接受属性
由于Button
组件遵守了里氏替换原则**,我可以放心地将所有button
HTML元素的出现都安全地替换为它在我的应用程序中,这正是计划中的。
那么为什么不让IconButton
遵守里氏替换原则呢?你可能会问。这当然是可能的,但很快你的代码将变成一个混乱的烂摊子,有着不必要复杂的组件和一长串可能给你的生活带来很多麻烦的事情。
如果你是在开发React应用程序的早期阶段,我建议你忽略这个原则。如果你对React感到满意,你应该始终从这个问题开始:这个组件是否打算替代它扩展的基本组件或元素? 如果是,确保你遵守里氏替换原则,通过正确地转发props、children、属性和引用。如果不是,不要这样做!## 第一章:接口隔离原则
如果我需要选择一个可以盲目遵循的SOLID原则,毫无疑问,那肯定是接口隔离原则:一个组件不应该依赖它不使用的属性。
起初,这个原则可能看起来奇怪地简单:为什么会有人引入组件不使用的属性呢?如果我们使用适当的代码检查配置,我们甚至无法故意这样做,对吧?有什么问题呢?
问题在于这个原则的递归性质,考虑下面的组件:
起初,这里似乎没有未使用的属性,我们确实使用了user
,这是我们唯一的属性。然而,递归地,User
类型带来了一堆未使用的属性,比如email
和id
,这些属性对于我们的组件来说完全不需要,这就是违反了接口隔离原则。这与我们上面关于Worker组件的讨论密切相关,它们不应该知道业务逻辑。事实上,可以说它们根本不应该知道任何外部信息(并非总是可能的)。
让我们来修复它!
让我们分析一下发生了什么变化。我们所做的最重要的事情是在第一个示例中的line 3移除了导入。通过这样做,我们有效地将UserAvatar
与User
接口解耦,从而极大地增强了我们组件的可重用性。事实上,我们甚至从命名中删除了对"User"的任何提及 - 现在我们的宠物也可以有头像了! 😻
现在,让我们迎来最后一章,让我们欢迎唯一无二、React中组合的教父,所有原则的基础 - 依赖倒置原则!官方定义有点难以理解,尤其是乍一看;它也是唯一一个有两部分定义的SOLID原则:
A. 高层模块不应该从低层模块导入任何东西。两者都应该依赖于抽象(例如接口)。 B. 抽象不应该依赖于细节。细节(具体实现)应该依赖于抽象。
如果听起来很沉重,那是因为确实如此。依赖倒置原则,正如你可能从我辉煌的介绍中注意到的那样,很容易值得一篇自己的文章。而且当涉及到React时,我有一个好消息和一个坏消息:
好消息:你默认情况下就在使用依赖倒置原则,甚至没有注意到!
坏消息:你默认情况下就在使用依赖倒置原则,甚至没有注意到!
所以,让我们来拆解它,开始注意到这个迄今为止最强大的SOLID原则,它是所有React事物的基础。让我们从解析名称开始:什么是依赖?它是我在组件中使用的外部代码(模块、包、函数、钩子、组件等)。这个依赖可以以两种方式出现在"我的领土"上:
-
导入:我从相应的模块中导入它
-
注入:它作为参数(或属性)传递给我
导入是直接依赖,我几乎无法控制它。如果该依赖项更改其行为,我就完蛋了。
注入是反向依赖:尽管我依赖于传递给我的内容,但我通过抽象(属性接口)控制可以传递的内容。
请注意,依赖倒置原则与单一职责原则和接口隔离原则密切相关。记住,在我们上面关于坏的UserAvatar
示例中,我们提到组件不应该知道业务逻辑(成为一个Worker);还有我们不应该传递组件不需要的属性/信息;依赖倒置原则增加了这个组件不好的第三个原因 - 我们正在创建一个直接依赖,换句话说,我们的抽象(AvatarProps
)依赖于细节(User
)。
你必须注意的第二个最重要的事情是,依赖倒置原则不仅仅停留在在组件中传递函数。从字面上看,React中的每个关于这个原则的例子都只谈论事件处理程序,没有注意到依赖倒置原则所能实现的最重要的事情。让我们再次重写我们的Button
组件,看看你能否注意到它:
所以,onClick
是显而易见的:它不能被内置,否则将违反单一职责原则,它也不能被直接导入,否则将违反依赖倒置原则。你在这里还注意到其他的依赖注入的例子吗?children
呢?它们作为属性传递了吗?检查!我控制它们的类型吗?检查!这是本章的高潮:
每当你谈论React中的组合模式时,你就在谈论依赖倒置原则。它们是一样的!
这正是使得第一章中的Workers能够在组件树中比Managers更高的原因 - 它们的依赖关系被倒置,它们实际上成为了容器***,而不知道它们确切包含的内容,从而不违反单一职责原则。
现在,让我们回到我在第十一章中的承诺,向你展示React中的一切都是组合的。在React中传递children
的方式实际上只是一种语法糖。仔细看看这些组件及其使用方式:
组件A
和B
是相同的,唯一的区别是:我能够以更自然的方式(从JSX的角度来看)通过使用保留的children
属性向组件传递数据。
现在,让我们谈谈类型区分。ReactNode | undefined
类型本身并不是一个法则,它只是React能够在不崩溃的情况下处理的最通用的类型。但我们可以自由地控制类型,记住吗?所以,让我们缩小我们的类型范围并重写上面的部分:
正如你所看到的,React中的组合不仅仅适用于children
属性,你可以使用任何属性名称来组合你的应用程序;它不仅仅适用于ReactNode
类型,你可以使用任何属性类型来组合你的应用程序。在React中,一切都是组合的。
从React的角度来看,依赖倒置原则实际上就是将责任上移给父组件。考虑到我们在第一章中讨论的Manager/Worker模式,这是完全有道理的。但是,就像单一职责原则一样,如果你最后一次将你的大脑从消费切换到思考,你会再次注意到一个很大的问题。你不能无限地将责任上移。换句话说,你的应用程序至少需要一个Manager组件;如果只有一个,就像在现实生活中一样,你可以想象这个可怜的家伙将拥有多少责任和层级。从可读性和推理的角度来看,这绝对会导致灾难。
尽管依赖倒置原则通过解耦你的组件为你提供了令人难以置信的优势和稳定性,但组合必须在一定的层次上停止,而Manager组件似乎是一个很好的停止点。通常情况下,遵循依赖倒置原则是一个好主意,或者换句话说,尽量保持应用程序中Manager组件的数量尽可能少,并且只在Manager变得过载时打破功能。例如,如果你开始一个新的Next.js项目,你自然会在每个路由中有两个管理器,layout.tsx
和page.tsx
。稍后,如果你的页面有很多功能,你可以通过为每个功能引入一个管理器组件来减轻page.tsx
的负担,依此类推。