精通模块化JavaScript
近日读了一本名为《精通模块化JavaScript》的书,并记录了其中的精髓。
一、模块化思维
精通模块化开发并不是指要遵循一套定义明确的规则,而是指能够将自己置身于使用者的角度,为可能即将到来的特性开发做好规划(但不能过于广泛),并且要像对待接口设计一样重视和关心文档的撰写。
系统按粒度划分:将系统分为几个项目,一个项目由多个应用组成,每个应用又包含几个层级,其中有数百个模块,由数千个函数组成。
编写健壮的、有详细文档的接口是隔离一段复杂代码的最佳方法之一。
将健壮的接口系统地组织在一起可以形成一个层,例如企业应用中的服务层或数据层。将逻辑隔离并限制在其中的一层,同时将表现层的程序,与严格的业务程序或者持久性相关的程序分开。
采用一致的API形态是提高生产力的好方法。
1)模块化的优势
- 有助于避免变量名中的意外冲突,减少了处理特定功能时必须注意的复杂性。
- 可维护性或对代码库进行变更的能力也得到了显著的提高,更容易构建和扩展。
- 让代码段简单易读,并遵循单一职责原则(SRP),即每段代码只实现一个目标,再将代码段组合成复杂组件,最终整合成一个完整的应用程序。
- 若接口设计的好,就可以进行非破坏性升级,既满足新需求又不好影响当前的使用,还能隐藏薄弱的实现,在日后重构成更为健壮的实现。
2)模块化的粒度
对于单个组件:将它们分成两个或更多个较小的组件,由另一个小组件连接起来,这个小组件充当组合层,唯一职责就是将几个底层组件组合在一起。
在模块层面,努力使函数简洁、有表现力、命名具有描述性,而且功能尽量少。
将函数体中不需要立即处理的复杂性推迟到这些代码被调用之时再处理。
重点是将代码有序地组织起来,使开发人员能够高效地工作,快速理解甚至修改他们以前从未遇到过的代码。
确保未来的开发与应用程序一直所采用的方法保持一致,使开发人员在各种约定和实践的软约束下工作,处于一种平稳状态,完成一个闭环。
如果过早地去做抽象,最终会发现这些抽象都是错误的抽象,而且我们将为这些错误付出代价。糟糕的抽象会迫使整个应用程序顺从它的意愿。
Web应用程序正变得越来越复杂,它们的范围、目标和要求也越来越复杂。它们周围的生态系统也将不断演进以适应那些扩张的需求,包括更好的工具、库、编码实践、架构、标准、模式以及更多的选择。
二、模块化原则
1)单一职责原则
当组件有一个唯一的精确目标时,就称它们遵循SRP。
2)API优先原则
专注于公共接口的设计是开发可维护组件系统的关键。好的接口设计可以使访问组件的最基本或最常见的用例变得简单,而且有足够的灵活性来支持出现的其他用例。
API设计的解决之道在于弄清楚使用者需要哪些属性和方法,同时使接口尽可能小。
关注的重点是可改进的空间、边缘用例、如何更改API,以及现有的API是否能够在不破坏向后兼容性的情况下具有更多的用途。
3)揭示模式
如果一个组件中的所有功能都被公开,就没有什么可以被视为实现的细节,因此很难对该组件进行更改。
将所有内容封装在一个闭包中,这样就不会暴露全局变量,并且功能的实现是私有的,返回一个公共API。
与其暴露好几个接触点让使用者选择,不如只暴露一个接触点,这个接触点可以根据使用者提供的输入执行恰当的代码路径。
4)寻找正确的抽象
深入挖掘,在该需求的特性,为路线图规划的特性,以及希望在未来调整组件以支持的特性之间找到共同点。
抽象极大地减少了代码重复的问题,同时还能一致地处理所有用例,限制了复杂性的产生。
如果合并一开始并不相关的用例,实际上是增加了复杂性,最终会创建比实际需要更紧密的耦合。
最好是等到出现一个可区分的模式之后再行动,这时才能明显地看到,引入抽象将有助于降低复杂性。
抽象会产生复杂性,因为它引入了新的中间层,削弱了跟踪程序周围不同代码流的能力。
5)状态管理
状态就是用户输入的函数:当用户与应用程序进行交互时,状态会增长并发生变化。
模块化设计的目标之一是让状态的数量尽可能少。
模块化设计通过将状态树划分成可管理的小块来解决这个问题,树的每个分支都处理状态的一个特定子集。
6)CRUST原则
1、一致(consistent)意味着幂等。代码风格一致能减少开发者之间的摩擦以及合并代码时的冲突;函数形态一致,提高可读性,符合直觉;命名和架构一致则能减少意外,保持代码的一致性。
2、弹性(resilient)意味着灵活,并且接受几种不同的方式的输入,包括可选参数和重载。
3、明确(unambiguous)即对于如何使用API的功能、如何提供输入或理解其输出,没有多种不同解释。对于相同类型的结果,应该返回相同类型的输出。
4、简单(simple)是指使用,处理一般用例几乎不需要配置,对于高级用例允许其定制。通过对常见用例进行优化,可以了解在保持接口简单性方面还有多大空间。
5、小巧(tiny)即够用但没有过度设计,包含尽可能小的表面积,同时为未来的非破坏性拓展留有空间。更少的失败测试用例、BUG、滥用接口的方式、需要的文档。
三、模块设计
1)构建模块
要实现整洁的模块设计。关键在于要实现一组小且作用唯一的函数。
1、可组合性和可扩展性,如果在设计接口时就考虑到可扩展性,可能就会根据功能对相似用例进行分组,在此过程中也许就能避免不必要的API表层的扩展。
如果新的用例与设计抽象时所设想的情况足够类似,那么这个抽象方案可能就能满足那些突然出现的需求。
2、现代化设计,一开始不要试图让接口满足每一个可能的用例。不仅要将开发工时集中在当下需要的功能上,而且还要避免产生不必要的复杂性。
要牢牢抓住这种心态,努力将功能保持在所需的绝对最低限度。
3、一点点抽象,当不确定是否将一些用例与某个抽象捆绑在一起时,最好的方法是先等一等,看是否有更多的用例能被归入到这个抽象的范围内。
用了错误的抽象会严重损毁组件的接口。保证API表层尽可能小,不让使用者知道完成相同的任务有多种方法。
4.谨慎地行动和尝试,代码不能脱离产品而存在。对于同样的产品,代码越简单越好。谨慎行动是指所做的一切都是有道理可循的。
不提倡草率开发内部结构,鼓励经过充分的思考周全地设计接口。
2)CURST原则
1、适度使用DRY原则,如果始终努力压缩重复性的东西,反而更难找到正确的抽象。
如果一段代码变得简洁后却导致程序更难读懂,DRY原则可能就是一个坏主意。
2、特性分离,将小组件移到不同的文件中仍然是值得的,因为几乎不费力气就从父组件中消解了构成子组件的复杂性。
但复杂性没有消失,而是隐藏在这些子组件与父组件之间的相互关联中。
考虑实现一个服务层,把业务逻辑处理放在该层,或者实现一个持久层,所有缓存和持久化存储操作都发生在这一层。
越是把模块化做到极致,在文档和测试上花费的时间就越多。如果模块要依赖其所属代码库中的其他代码,那么分离模块比较有挑战,因为该模块所依赖的部分也必须被分离出来。
3、设计内部结构时进行权衡。
试图预测使用者的需求往往会增加代码规模和复杂性,还会浪费时间,对于改善使用者的体验几乎没什么用。
3)修剪模块
1、对错误的处理、缓解、检测和解决。
测试的目的主要是防止回归,防止在已经修复的BUG上又一次栽跟头,也防止因为用错误的方式调整代码而出现可预料的错误。
2、文档是一门艺术,文档不仅是帮助使用者在设计时查阅接口使用示例和高级配置选项的指南,还可以看作实现者提供发一种参考资料。
其实测试和代码注释也是某种意义上的文档,甚至变量名或函数名也应该被视为一种文档。
用正式文档描述公共接口,用测试用例描述值得注意的使用示例,以及用注释解释异常情况。
3、删除代码,如果部分程序变得陈旧,不再被使用,最好将其删除,而不是推迟这种不可避免的命运。
开发人员可能会因为不确定无用代码是否在其他地方被使用而不愿意将其删除,随着时间的流逝,破窗理论开始生效。
很快代码库里就会到处都是不再被使用的代码,没有人知道其用途,没人知道代码库是如何变得混乱的。
4、考虑自己的情况(上下文),在分析某个依赖项、工具或建议是否适合你的情况,第一步是先找到相关的参考资料仔细阅读,并考虑它们所解决的问题是否就是自己要解决的问题。
永远不要执着于你不确定是否符合自己需求的东西,要多尝试。规则本来就是用来打破的。
四、内部构造
1)内部复杂性
1、包含嵌套的复杂性,在JavaScript中,深度嵌套是复杂性最明显的标志之一,复杂性存在于代码的衔接处。
如果一个程序中出现一系列嵌套回调,回调之间还有逻辑,那就表明流程控制和业务的关注点混成一团了。也就是将流程和业务逻辑分离,程序会更好。
2、功能杂糅与紧耦合,随着模块越来越大,模块中不同特性就会越来越容易因其代码交织而错误地套在一起,导致很难被独立地重用、调试和维护,或者彼此分离。
慢慢地从里到外构建,将这个过程的每个阶段保持在同一级别的函数中,而不是深层嵌套。让函数的作用域更小,以参数的形式获取他们所需之物。
3、框架:精华、糟粕与毒瘤,缺乏完善的设计指导和约定来描述应该如何构造应用程序的各个部分,混乱就会随之而来。
在构建应用时,这些约定和抽象对于限制应用程序的复杂性十分有用。通常可以将代码重构为更小的组件,然后使用一个大组件包含它们,以保证关注点分离和对复杂性的控制。
通过使用层以及使用函数参数而非作用域来传递上下文,可以将几个正交的组件放在一起来引入水平伸缩,而不会让它们碰触彼此的关注点。
2)重构复杂代码
1、多用变量,少写巧妙代码,把重点放在程序的易读性上,让日后的自己或共事的开发者阅读起来更容易懂。
将if语句中冗长的条件语句抽离到一个函数中,使得在分析代码时能更专注。为所创建的每一个函数、变量、目录或数据结构的名称进行周全的考虑至关重要。
if(hasValidToken(auth)) return
2、守卫语句与分支翻转,翻转条件语句,将所有处理失败的语句放到顶部附近,能减少嵌套,消灭 else 分支,还能更加注重错误处理。
提早退出的方法通常是指守卫语句,通过阅读函数或一段代码的前几行就得知所有的失败情况。
if(!response) return false; if(response.error) return false; //....
3、依赖金字塔,将高层流程放在函数的顶部附近,在后面给出详情,以这样一种方式设计复杂的功能,可以让读者一开始对函数功能有一个宏观的印象。
按照使用者阅读的顺序(队列)而不是按照被执行的顺序(栈)呈现代码库中的函数。将实现细节放到其他函数或子程序中。
double(6) function double(x) { return x * 2; }
4、抽出函数,将阻挡当前流程的一切移到函数底部,是增强代码易读性的一个有效方法。
将选择应用状态的那部分代码和根据所选状态执行操作的逻辑分离开来。
function getUserModels(err, users, done) { if (err) { done(err); return; } const models = users.map((user) => { const { name, email } = user; const model = { name, email }; if (user.type.includes("admin")) { model.admin = true; } return model; }); done(models); } //重构 function getUserModels(err, users, done) { if (err) { done(err); return; } const models = users.map(toUserModel); done(models); } function toUserModel(user) { const { name, email } = user; const model = { name, email }; if (user.type.includes("admin")) { model.admin = true; } return model; }
5、扁平化嵌套回调,耦合可以通过给回调函数命名并把它们放到同样的嵌套层级中来解决。
例如传递箭头函数。使用 async 语法。
6、重构相似任务,如果一个并发流程在多个函数之间或多或少有些一致性,可以考虑将该流程放在一个函数中,然后把在各种情况下皆不相同的实际处理逻辑作为回调传递给这个函数。
底层的代码还不够成熟,不适合抽象。当不确定某个抽象是否必要时,回头看看未建立抽象前的代码,对抽象前后的代码进行比较。
7、分割大型函数,按步骤或按同一个任务的不同方面来分割函数的功能。所有这些函数仍然需要依赖守卫语句检查错误,保证状态是受约束的。
保证接收的输入与预期的是一致的:检查必传的参数是否存在、数据类型是否正确、数据范围是否正常等。
减少复杂性的方法不是把几百行代码压缩成几十行,而是把每一段长长的代码放到独立函数中,让它们处理数据的某个方面。
小函数可能会接收大函数的一部分输入,或大函数产生的中间值。小函数可以使用自己的输入处理逻辑,还可以被进一步分解。
识别一个函数的三到四个部分,将它们拆分出来。
- 第一部分可能是过滤不感兴趣的输入。
- 第二部分可能是把输入映射为其他东西。
- 第三部分可能是将所有数据整合在一起。
3)像熵一样的状态
熵(entropy)可以被定义为对无序性或不可预测性的一种度量。系统中的熵越大,系统就越无序和不可预测。
1、复杂的当前状态,将单个大函数分解为一堆小函数,易于关注其各部分功能。
2、消除偶发状态,如果一份数据在应用的多个地方都被使用,并且是从其他数据派生出来的,就可能出现偶发状态。
当持久化派生状态时,原始数据与派生数据就会有失去同步的风险。
3、包装状态,当所有中间状态都被包含到一个组件中而不是暴露给外部时,组件或函数交互时的摩擦就会减少。
对外暴露的状态数压缩得越少,函数就封装的越好,而且接口也会因此变得更加易于使用。在函数体中修改输入,可能引入 bug,令人困惑,而且难以追踪修改源头。
4、利用不可变性,通过引入不可变性,可将函数维持为纯函数。函数的输出仅仅依赖函数的输入,并且没有任何诸如改变函数输入之类的副作用。
4)数据结构为王
所选的数据结构制约和决定了API能采取的形态。程序的复杂性往往是由于本来数据结构就糟糕,而新的或者未预见的需求又与这些数据结构无法充分匹配所造成的。
当选择使数据结构适应程序不断变化的需求这条路时,就会发现以数据驱动的方式编写程序比仅依赖于逻辑驱动程序的行为更好。
1、分离数据与逻辑,当数据没有与功能混杂在一起时,它就能从功能中分离,并因此更加易读、易懂以及易于序列化。
接口设计一开始应该是强约束的,随着新用例和需求出现而慢慢放开限制。从小的用例着手,才能让接口自然而然扩展为适于处理特定的各种实际用例。
function add(current, value) { return current + value; } function multiply(current, value) { return current * value; } multiply(add(5, 3), 2)
2、限制于聚合逻辑,如果数据结构或使用数据结构的代码需要改变,而相关联的逻辑遍布于代码库中,此时的涟漪效应可能是毁灭性的。
将逻辑拆分到同一个目录下的多个文件中,有助于防止功能爆炸,这些功能很大程序上只有数据结构是相同的,可以将其与功能紧密相关的代码放在一起。
如果将大量逻辑分散到不相关的组件中,在对代码进行大规模更新时,可能有遗漏功能关键部分的风险。
- 当不清楚功能是否会增加或会如何增加时,一开始将逻辑直接放到需要它的地方是可以接受的。
- 一旦初期探索阶段结束,并且知道该功能会继续存在而且还会增加之后,出于前述的原因,最好将功能分离开来。
- 之后,随着功能的规模和需要解决的关注点增加,可以将其各个部分组件化后置于不同的模块中。
- 这些模块在文件系统中逻辑上仍然聚集在一起,在需要考虑所有相关的关注点时就很方便。
五、开发的方法论与哲学
1)安全的配置管理
将所有敏感信息集中放在一个不参与版本控制的文件中,而不是将这些变量硬编码到使用它们的地方,或者将它们放在模块开头的常量中。
这种方法有助于模块之间共享私密信息,使其更容易进行更新之外,还促使隔离那些以前认为不敏感的信息,例如加盐密码的工作因子。
可以将应用程序关联到另一个私密信息仓库,取决于该应用程序是处于生产、预发、测试还是本地开发环境。
2)显式依赖管理
应用程序中的每个依赖项都应该在清单文件(package.json)中显式声明,而且尽可能少地依赖全局安装的包或全局变量。
3)作为黑盒的接口
改进接口的一种途径是撰写一份详细的文档,描述接口接触点期望的输入,以及它是如何影响在每种情况下的输出。
在写文档的过程中,会发现接口设计的局限性,可能因此而决定对接口做一些改动。
无论开发什么样的应用程序,都不能信任用户的输入,除非输入已被处理过。
设身处地为接口使用者着想,是防止写出不成熟接口的最佳方法。
4)构建、部署与运行
构建过程有多个方面,从宏观上来说,共享逻辑中安装和编译资源,以便运行时应用程序可以使用它们。
- 在开发时,关注增强的调试工具、使用库的开发环境版本、源代码映射和详细的日志记录级别。
- 在预发时,需要有一个与生产环境非常相似的环境,因此会避免使用大多数调试功能。
- 在生产时,更侧重于缩减代码,比如优化静态图像,采用基于路由的打包拆分等高级技术。
5)无状态
未加控制的状态会直接导致应用程序奔溃,而尽可能减少状态的数量可使应用程序易于调试。
全局状态越少,应用程序在当前状态下一时刻所出现的不可预测性越小,并且在调试时遇到的意外就越少。
使用像 Redux 或 Mobx 这样的状态管理解决方案,将所有状态与应用程序的其余部分隔离。
6)开发与生产的平等性
开发环境和生产环境是存在差异的,忽视了这样的差异会导致没有发现新组件中的限制。
尽可能将这些差异控制到最小,如果不这样做,生产环境可能会冒出很多 bug,而用户最终可能会报告它们。
7)抽象问题
匆忙做出来的抽象会导致灾难。反过来,如果没有识别并抽象出主要的复杂性来源,代价也会非常高。
在复杂接口的前面创建一个中间层,提供一个更简单的接口,配置项更少,并且重要用例的易用性更高。