引言
在经历过多个项目的研发后,我发现即使在一个项目中,因为迭代者不同或者项目迭代时长以年为单位的情况下,项目中会出现各类的“代码风格”。日常研发中大家应该常会发出这种感慨:“我不敢改这一块代码,太乱了。”“我还是复制一份出来改吧,这是一个公共组件”“这组件一千多行,里面还没注释”... ...
为了尽可能避免这些感慨,我们需要写出研发友好型业务组件,下文是一些切入点。
命名及目录
业务组件的命名一般为大驼峰格式,遵循如下规则:${业务功能}${组件特征}
,如:ExecutePrecheckAuthModal. 尽可能语义化描述该组件核心的功能,从这个命名上即使是其他参与研发的同学也大致能看出来这是一个执行前校验权限的弹窗。
个人建议不要直接创建一个 ExecutePrecheckAuthModal.tsx
文件而是创建成 ExecutePrecheckAuthModal/index.tsx
。这样根据组件的复杂度我们可以将相关逻辑都收敛到这个目录下并合理拆分。
分层设计
我们定义一个业务组件是否是简单的,一般看该组件是否只用于简单展示业务数据,无复杂数据处理动作、不涉及需自行处理的网络请求,这种只用于展示的简单组件我们定义为UI组件,也叫 dumb component。与之相反的就是令人头疼的复杂组件了。但即使是复杂组件,我们也可以拆分出两个层次,根据最终页面交互可以先将其划分为若干个简单的UI组件,根据各UI组件期望的状态流向,将状态处理的部分抽出成单独的自定义hooks。这样做的好处是我们将复杂组件的复杂度平摊到了不同的UI组件和状态处理逻辑,如此我们也在不断提升问题拆解的能力,写组件的时候会为未来预留口子。
声明式组件
当我们开始写一个组件的时候,大多数时候并不是一蹴而就的,而是一个不断迭代的过程,我非常推荐一开始先从组件的 render 部分开始编写,写JSX的部分可以当作在编写HTML,HTML是一个网页的骨架,JSX相当于一个组件的骨架。根据最终期望的页面表现,我们在编写JSX的时候就可以进行抽象组件的动作,并在此时初步决定要传入这些抽象组件的数据属性。 十分不推荐在render部分利用函数将JSX抽出,并在组件中其他地方来判断各种状态或者属性值来决定渲染哪部分JSX,这是一种非常反直觉的做法,会使得研发心智在组件不同地方不断跳跃,甚至不如直接写在render里来的直观。
属性设计
一个组件的属性设计至关重要,它决定了该组件的形状与其输入输出,输入即传入该组件的数据属性或事件属性,输出即为最终的渲染与数据交互逻辑。我们也经常会遇到数据源的结构与组件需要的数据属性不一致的情况,这多发生于接口数据没法直接用于前端渲染的情况。这时我们会进入一个阶段,该阶段主要是进行一些胶水工作,负责处理数据流转,如数据格式转换、数据交互逻辑的实现。如果我们已经写了一个声明式的组件,这部分的逻辑又应该写到哪里?为了尽可能保证组件的纯粹性,这部分逻辑应当抽离,比如抽成一个自定义hooks,其输入是元数据源,其输出即为组件期望的数据属性与一些数据交互方法。一句话,让展示的归展示,让逻辑处理的归逻辑处理。
如果该组件在某些交互中还存在外部通信的情况,我们需要考虑如何设计其事件属性:
-
命名规范遵循小驼峰,如on${Action}:onChange、onEdit、OnDelete
-
非必要,组件的数据属性决定其渲染,但事件属性一般在渲染后,其父组件也可以选择不处理事件
-
对外部调用组件无感知,如一个弹窗组件点确认之后需要刷新父组件列表
除了数据属性与事件属性外,还有一类属性会高频出现在组件库的设计中:插槽(slot)。我个人业务代码中不常用,个人理解是提升了组件灵活度,并在一定程度上提升组件使用时的声明式编程程度。
组件方法
上文我们一般都坚持着声明式编程,这也是 React 所推荐的。但如果有父组件调子组件的场景,React 也提供了useImperativeHandle来在子组件中定义需要暴露给父组件的属性与方法。非常不推荐使用这种方式,实际上大多数认为某场景只能父组件显式调子组件方法来获取内部状态的,都还是命令式编程的思维。这种写法会使得研发失去从子组件获取其状态流转与处理时机的能力,因为变成父组件在控制时机了。尽量不要用。
自定义 hooks
UI 的抽离复用即组件,逻辑的抽离复用即 hooks。假设你需要处理状态,从状态A经过一系列转化到状态B。你可以通过const B = useMemo(() => f(A),[A])描述这个流转过程,或者有状态是受其他多个状态影响得来的,我们可以写一系列的处理逻辑,这部分逻辑完全可以不放在 UI 组件内部而是抽离到自定义hooks中,这样针对同样类型的状态,逻辑就被复用了。涉及网络请求的逻辑,也可以进行同样的封装,收敛入口。
注释
如果平时以一种为他人着想,为后人铺路的心态写代码,注释还是非常有必要的,因为大部分人的代码并没有完美到“好的代码自己就是注释”。而何时需要写注释呢,请在编码过程中不断提醒自己,如果你不在了或有新人需要参与迭代,正好要改到这部分代码,没有上下文的他是否能通过代码快速掌握,若不能就请多写点注释。
研发原则
单一职责
在前端组件设计中,单一职责原则意味着每个组件应该只负责一项具体的任务。
-
核心思想:一个组件或模块应该只有一个原因引起变化,即它应该只负责一项任务。
-
应用场景:在前端开发中,这意味着一个组件应该只处理一种类型的功能或数据。例如,一个数据展示组件不应该同时负责数据处理和用户交互逻辑。
-
目的:减少组件之间的耦合,使得每个组件更容易理解和维护。
开闭原则
-
核心思想:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着在不修改现有代码的情况下,应该能够通过添加新代码来扩展系统的功能。
-
应用场景:在前端开发中,这通常意味着设计组件时,应该允许通过添加新的组件或功能来扩展系统,而不是通过修改现有组件的内部代码。例如,可以通过添加新的组件来扩展一个应用的功能,而不是修改现有组件的内部逻辑。
-
目的:使系统更容易适应新的需求,减少对现有代码的修改,从而降低引入错误的风险。
里氏替换
在组件设计中,里氏替换原则要求子组件能够无缝替换其父组件,而不破坏整体功能。这意味着子组件应该完全实现父组件的接口,并且不应该引入新的行为或依赖。
-
子组件的可替换性:当你有一个父组件,比如一个通用的列表组件,它应该能够被任何子组件替换,比如特定类型的列表项组件,而不会影响整个应用的功能和行为。
-
不改变父组件行为:子组件在扩展或修改自己的行为时,不应该改变父组件已经定义好的行为。这意味着子组件的实现不应该依赖于父组件的具体实现细节。
-
扩展而非覆盖:子组件应该通过添加新的方法或属性来扩展功能,而不是重写父组件的方法。这样可以避免在多态使用时引入错误。
-
保持接口一致性:子组件的方法应该与父组件的方法保持一致,包括方法的输入参数和返回值。这样可以确保子组件在任何期望父组件类型的地方都能被无缝替换。
-
尊重父组件的约束:子组件在实现时,应该遵循父组件设定的约束和约定,比如数据格式、事件处理方式等。
迪米特法则
又称最少知识原则,它规定一个对象应该对其他对象有尽可能少的了解,只与直接的朋友通信。这里的“朋友”包括当前对象本身、方法的输入参数、当前对象的成员变量、当前对象所创建的对象,以及当前对象的成员变量的元素(如果成员变量是集合或数组)。迪米特法则的目的是降低类之间的耦合度,使得系统的功能模块更加独立,从而提高代码的可维护性和可扩展性。
-
当你设计组件的交互时,限制组件之间的直接通信。每个组件应该只与它的直接依赖(朋友)通信,而不是跨越多层去调用其他组件的方法或属性。这样可以减少组件之间的耦合,使得组件更容易独立更新和维护。这也要求我们慎用 Context,这把双刃剑会破坏组件状态内聚性,组件会多一层自身与 Context 的耦合,导致其难以在其他地方直接复用。
接口隔离原则
接口隔离原则建议我们应该将大的接口拆分成更小的、特定的接口,这样客户端组件只需要依赖它们实际使用的接口。在前端组件设计中,这意味着我们应该避免创建包含大量方法的大组件,而是创建只包含必要功能的小型组件。
-
核心思想:客户端不应该依赖它不使用的方法;一个类对另一个类的依赖应该建立在最小的接口上。
-
应用场景:在前端开发中,这意味着组件应该只暴露它需要的方法和属性,而不是一个大而全的接口。这样可以避免组件的使用者依赖于它们不需要的功能。
-
目的:提高组件的可用性和灵活性,减少组件之间的不必要依赖。