熟记这几条原则,掌握模块化开发

3,486 阅读13分钟

模块化思维

从抽象到具体:模块化封装的实质是抽象,它将复杂性隐藏在我们不敢触及的“岩⽯”之下,这些岩⽯是我们通往另⼀个世界的接口,让我们可以远离复杂性,⼏乎不会去想复杂性。将抽象、接口及其底层概念应⽤到我们的⼯作中,使我们面对复杂项目时,可以化整为零,从项目的整体功能上把握全局,同时将需要关注的复杂性最⼩化。

拥抱模块化,认识复杂性,正确的抽象可以使一个看起来拥有复杂功能的庞大项目看起来更加精巧,方面使用和易于理解。对使用者暴露简洁的接口,而将复杂性隐藏在接口之下。推及到其他,任何一款好的产品都应该是这样,将复杂性留在内部,对外针对性的暴露简洁的接触点。

系统可以按粒度划分:系统 -> 项目 -> 应用 -> 层级 -> 模块 -> 函数。 一花一世界,一叶一菩提。这就像在宏观层⾯观察宇宙,逐渐深⼊,直⾄到达原⼦层⾯,然后越过这个层⾯。每⼀层都有⾃⼰的复杂性和有待被发现的精微之处。

每当我们描绘⼀个组件时,就要有⼀个与之对应的公共接口,系统的其他部分都可以使⽤这个公共接⼜来访问这个组件。接口或API由组件暴露(expose)的⼀组⽅法或属性组成。 组件暴露公共接口(属性或方法)也应该遵循这个原则

  • 接口的接触点越少,暴露出来的“表⾯积”就越⼩,接口也就越简单。
  • 表⾯积⼤的接口有⾼度的灵活性,但是这种接⼜会暴露⼤量功能,所以很可能难以理解与使⽤。

所以在面对必要的复杂性时,编写健壮的、有详细⽂档的接⼜是隔离⼀段复杂代码的最佳⽅法之⼀。这样使用者就可以在不了解任何实现细节的情况下使⽤它的功能。
同时在代码层面,由于设计出合适的接⼜是⼀件有难度的事情,所以采⽤⼀致的API形态是也是提⾼⽣产⼒的好⽅法。它可以保持组件的整齐以及层级的⼀致。从开发者的⾓度看,⼀致的层级(由模式和外观相似的组件组成)可带来熟悉感,这种熟悉感让⼈能够持久地使⽤它,并且随着时间的推移,开发⼈员会越来越熟悉API的形态,而不需要花另外的功夫。

谈及代码性能的问题:我们并不是在为计算机优化程序,以使其更快地运⾏。如果是这样的话,直接把⼆进制代码或硬编码逻辑写⼊电路板就⾏了。相反, 我们的重点是将代码有序地组织起来,使开发⼈员能够⾼效地⼯作,
快速理解甚⾄修改他们以前从未遇到过的代码。确保未来的开发与应⽤程序⼀直所采⽤的⽅式保持⼀致,使开发⼈员在各种约定和实践的软约束下⼯作,处于⼀种平稳状态,这就完成了⼀个闭环。再来看性能问题,我们应该把它当作⼀个特性,并且在⼤多数情况下,不应该把它放在⽐其他特性更重要的位置。这样看来,框架开发(不同语言之争)其实也是在性能和可语言可读性易用性之间的取舍。
需要注意到的时,在编程刚刚兴起的时候,由于内存资源稀缺,程序员需要优化内存的分配,这通常意味着需要将关注点更多的集中在性能上。然而在现代系统中,我们不需要将内存视为⼀种稀缺、珍贵且有限的资源;相反,我们可以把重点放在程序的易读性上,让⽇后的⾃⼰或者共事的开发者阅读起来更容易懂。这就是为什么编程语言发展到如今,作用拥有更高灵活性和易理解性的python,node以及javascript更受欢迎的原因。

综上:模块化设计,在对外暴露接口时,需要在接口的灵活性和简洁性之间取舍。在运行效率方面,需要在性能和代码可读性易用性之间取舍

模块设计原则

1. 提供尽可能少的接触面,

揭示模式:遵循揭⽰模式,不提供对实现细节的访问权限,除非明确将他们从模块中导出(通过作用域保护私有变量,)

2. 作为黑盒子的接口:

输入 --> 内部实现 --> 输出

守卫语句和条件翻转(短路):⼀个典型函数的完整结构应该以守卫语句开头,过滤和短路错误的输入。
分割大型函数:拿到正确的输入后,进行函数进行 输入->输出的转化。对于转换操作的每个部分都可以进⾏拆解,并将它们移到不同的函数中。
包装状态(纯函数实现)

  • 避免创建派生状态:创建派⽣状态可能导致同步问题,还会从原始数据中产⽣⼀些过时的副产物。
  • 提取中间状态包含到组件中。所有中间状态都被包含到⼀个组件中⽽不是暴露给外部时,组件或函数交互时的摩擦就会减少。
  • 在函数中尽量避免修改输入参数, 因为输出只依赖于输⼊且不存在副作⽤

数据结构:如果找到了合适的数据结构,你就会发现要使输⼊变成想要的输出,其所需的转换、映射与循环都不会很多。无论对于何种输入转化为输出,都可以通过模块化(总分结构,大函数包含小函数,通常纯函数,没有副作用)的方式达到。编程能力的体现也许不是实现了某种功能,而是架构,抽象和模块化和的能力。

3、封装:依赖⾦字塔结构

全局到局部:顶部处理⾼层的关注点,将阻碍当前流程的⼀切移到函数底部,在整体中抽出实现具体功能的代码封装为一个函数。让读者⼀开始对函数功能有⼀个宏观的印象,当他们继续阅读代码时,才会发现图表实现⽅式的细节。

抽出函数:提高程序易读性和可维护行:为程序的某个部分命名⽽且不添加任何注释的方法,可以创建⼀个函数来包含这个功能。这样做不仅为算法所做的事情取了名字,⽽且使我们能将这段代码移出来,仅保留对流程的⾼层描述。

特性分离:当我们把⼀个⼤组件的内部拆分成⼏个⼦组件时,会分解其内部的复杂性,最后得到⼏个简单的组件。但是复杂性并没有消失,⽽是隐藏在这些⼦组件与⽗组件之间的相互关联中。这就像在宏观层⾯观察宇宙,逐渐深⼊,直⾄到达原⼦层⾯,然后越过这个层⾯。每⼀层都有⾃⼰的复杂性和有待被发现的精微之处。

流程控制与业务分离:和回调地狱一样,需要解决作用域限定问题,如果函数从其⽗级作⽤域⾄少引⽤⼀个变量,我们就可以选择保持它们原地不动或者将那些引⽤以参数的形式传递,以便继续对该函数解耦。

分离数据与逻辑:数据转换应该与数据本⾝分离,以保证数据的每个中间表⽰的可重⽤性。

单一这职责原则:当组件有⼀个唯⼀的精确⽬标时,就称它们遵循SRP。
遵循SRP的模块不⼀定必须导出⼀个函数作为该模块的API。只要从组件中导出的⽅法和属性是相互关联的,我们就不会破坏SRP。在考虑SRP时,很重要的⼀点是要弄清楚其中的“R(职责)”是什么。

4、一点一点抽象:循序渐进。

避免过度设计:专注于解决当下或即将遇到的问题,⽐去预估可能到来的爆发式流量增长⽽为其设计对应的基础设施要好得多。这样我们的系统更加⾃然地发展,能适应近期的需求,并逐渐向⽀持更⼤ 的应⽤程序和更多的需求发展。

当我们不确定是否将⼀些⽤例与某个抽象捆绑在⼀起时,最好的⽅法通常是先等⼀等。我们应该先等待⽤例出现,当抽象的好处变得很明显时再重新考虑它。不要⼀开始就试图让你的接口满⾜每⼀个可能的⽤例。先考虑更简单的选择,然后再花点时间让接口根据需要来演进和成熟。

在开始思考如何对要实现的特性进⾏最妥当的抽象,使其满⾜将来可能出现的每⼀个需求之前,我们有必要先退⼀步,考虑更简单的选择。⼀个简单的实现意味着我们可以花费更少的前期成本,⽽且当新的需求来临时,也不需要对已有的实现做⼤量的修改。

我不是提倡草率开发内部结构,⽽是⿎励经过充分的思考周全地设计接口。一旦原型阶段结束,清晰地定义层对设计出⾼效且可维护的应⽤程序就⾄关重要。

5、最后灵活对待这些原则:

考虑⾃⼰的情况(上下⽂),当你改变某条规则以适应⾃⼰的情况时,并不代表你不同意这条规则(建议),你只是在不同的上下⽂解决相同的问题。

编写优雅的模块化代码

多用变量,少用巧妙的学法,保持代码易读性。

尽量使⽤简单的const绑定。

可以将赋值操作放到⼀个函数中,然后将返回值⽤const绑定。这样可以消除复杂性。

const value = getInitValue()

如果需要反复地将操作结果分配给相同的绑定

  • 使⽤链式调⽤来避免重新分配绑定
  • 每次都创建新的绑定,根据之前的绑定计算它的值,享受使⽤const带来的好处

尽量使用纯函数。

函数的输出只有输入决定,而不存在其他的副作用

模板字⾯量

s=`hello ${name}`

解构、剩余参数和展开运算符。

const {low, height,ask, ...details} = ticker

解构:指明计算函数输出时会⽤到的对象字段。并且可以制定别名。
对象展开运算符:只需使⽤⼀⼩段原⽣语法就能创建⽬标对象的浅拷贝。我们还可以将对象展开符与⾃定义属性结合起来,创建⼀个对象拷贝并覆盖原始对象中的值。

巧用继承和组合实现特性行为。

Class继承
通过继承,我们可以为对象有顺序的添加添加层:从不具体的基础部分开始,构建到对象中最具体的部分。复杂性会分布在不同的类中,但主要位于基础层中,这些层会提供⼀个简洁的API,隐藏复杂性。
组合
组合的好处:对象的各部分与扩展
组合是继承的⼀种替代⽅案,组合将功能中正交的部分串起来。正交性意味着我们互补而不会改变彼此的行为
例如:编写扩展函数,⽤新功能扩展现有对象。
代码⽚段中有⼀个makeEmitter函数,它为所有的⽬标对象添加灵活的事件处理功能,其中的.on⽅法为⽬标对象添加事件监听器,⽽使⽤者通过.emit⽅法可以指⽰要传递给事件监听器的事件类型和任意数量的参数

正确的模块化写法,需要在在组合和继承之间进⾏选择

扁平化嵌套回调

具有异步流程的代码库经常陷⼊回调地狱,每个回调创建新⼀级的缩进,导致进⼊异步流程链深处后代码越来越难以阅读。嵌套回调这种结构的问题主要是作用域限定问题,在最深的回调中,继承了所有⽗回调的联合作⽤域。随着函数变得越来越⼤,越来越多的变量被绑定到这些作⽤域中,此时要想脱离⽗回调理解其中⼀个回调就变得越来越困难。在具体的编程实践中,这种耦合可以通过以下几种方式实现嵌套回调代码的的扁平化:
1、 通过给回调函数命名并把它们放到同样的嵌套层级中来解决
2、 如果确实需要使⽤⽗作⽤域中的某些变量,可以显式地在下⼀个回调中传递它们。
3、通过使⽤类似async的库来解决这个问题,简化了扁平化链接的过程
4、以串行的方式执行promise特性

重构相似任务

如果⼀个并发流程在多个函数之间或多或少有⼀些⼀致性,也会出现相似的情况。这种情况下,可以考虑将该流程放在⼀个函数中, 然后把在各种情况下皆不相同的实际处理逻辑作为回调传递给这个函数。

CRUST 原则

前面说了进行模块化设计的过程时从抽象到具体的过程,层的抽象对应到具体的行为,每个层的设计应该遵循CRUST原则,即 一致性、弹性的、明确的、简单并且小巧。

⼀致的(consistent)

在代码库的每个层级上,⼀致性都是有价值的:

  • 代码风格⼀致,能减少开发者之间的摩擦以及合并代码时的冲突;
  • 函数形态⼀致,能提⾼可读性,更符合直觉;
  • 命名和架构⼀致则能减少意外,并保持代码的⼀致性。

弹性的(resilient)

这意味着它有灵活的接口,并且接受以⼏种不同⽅式表⽰的输⼊,包括可选参数和重载。

明确的(unambiguous)

对于如何使⽤API、API的功能、如何提供输⼊或理解其输出,没有多种不同的解释

简单(simple)

使⽤起来很简单,处理⼀般⽤例时⼏乎不需要配置,对于⾼级⽤例允许为其定制

⼩巧(tiny)

即够⽤但没有过度设计,它包含了尽可能⼩的“表⾯积(surface area)”,同时为未来的⾮破坏性拓展留有空间。