模块化设计思想指南

1,475 阅读22分钟
原文链接: www.zcfy.cc

去年12月,我在克拉科夫就欧洲代码模块化设计进行了演讲。我也一直在开发AppleScript,事实证明,从Keynote演示文稿和JSON中提取数据并不是那么困难。我还制作了一个将幻灯片图片上传到S3的快速脚本,经过一番修改之后,我设法将这篇博客文章放在一起,其中包含演示文稿的记录。

作为一个个人笔记,能够上传这个记录感觉有点神奇。你经常花费一个多月的时间准备一次新的会议讨论,然后当天只有少数在场的人可以听到它。其他一些人在线观看幻灯片,而无法推断出很多背景。有些人可能会在几个月后在网上观看它,但这是JavaScript,到那时你的会议讨论可能已经过时了。我认为能够以文本格式整体上传谈话可以增加很多价值,特别是如果您可以使用可以自动完成大部分工作的脚本来完成这项工作。基本上所有的脚本都是从Keynote中拉下Presenter Notes和每张幻灯片的图像。然后它上传图像,并制作出一些不错的Markdown,我可以将它粘贴到Pony Foo中,正如我在下面所做的那样。

今天我将要谈论模块化设计

但首先,我自我介绍一下:我的名字是NicolásBevacqua,你可以在Twitter上找到我的@nzgb。我是Elastic公司的高级软件工程师,Elastic公司是elasticsearch和Kibana的幕后公司。我还在ponyfoo.com上运行了一个博客和一个时事通讯。

我的第三本书,《Mastering Modular JavaScript》,是O'Reilly的早期的一本书。它是探索JavaScript架构的五本丛书系列的一部分。本书探讨了软件复杂性的基本原理,以及这些概念如何在JavaScript中应用以获得有利于可维护性和可读性的模块化应用程序。这本书还附带了许多直接的建议和实例。它可以免费在线阅读,你可以在ponyfoo.com/books 找到它。

我们先从关于模块化的历史课程开始。与其他任何编程环境相比,网络对模块系统有着非常特殊的发展。网络是一个独特的执行环境。脚本通过HTML<script>标签加载,我们没有名称空间的概念。很长时间以来,模块并没有一个概念,今天我们几乎没有开始刮起真正的模块化JavaScript的浪潮。当我们查看大多数其他环境或语言时,有一个标准库被分解成可以使用的不同模块。有命名空间。在其他平台中,模块自第一天起就存在:它们只是文件。

在早期,JavaScript嵌入在HTML <script>标签中,或者嵌入属性如onclick处理程序。充其量,脚本保存到一个或多个文件,但所有这些文件仍共享一个全局范围或命名空间。任何在这些文件或内联脚本之一的顶部声明的变量都会印在全局窗口对象上,导致一个脚本中的变量无意中替换了另一个脚本所依赖的值,或者创建了意外情况JavaScript程序的流程。开始时这并不是什么大问题,JavaScript最常用作交互语言,在表单提交之前添加确认提醒,在鼠标悬停时交换图像等等。

但语言接受程度不断提高,需要一种能够缓解可变冲突的解决方案。我们发现了立即调用函数表达式(或IIFE)的解决方案。在一个函数中声明的变量绑定被限定为该函数的范围,并且由于这些闭包的自我调用性质,除了用这些表达式包装我们的文件之外,我们不必做任何事情。鉴于我们不再处理隐式全局变量,这种方法的一个好处是,打包后可以节省更多内存。

使用类似的表达式,我们可以从闭包中返回一个值并将该结果赋值给一个变量。这种模式使我们能够与私人成员建立图书馆,这意味着我们库的状态从外部看不到,除了我们提供的API所揭示的内容外。

除了全局命名空间污染之外,脚本标签的另一大问题是缺少正式的依赖关系图。换句话说,你必须仔细排序脚本标记,确保最依赖脚本的脚本(如jQuery)出现在依赖脚本的脚本之前。这特别令人讨厌,因为无论何时添加一个新文件,都必须评估它应该放置在脚本标记列表中的确切位置。串联并没有解决问题,因为您仍然必须提供带有排序列表的文件名或模式的concat工具。

下一代模块化JavaScript也涉及使用函数来获取自包含的范围。我们没有使用全局对象的依赖关系,而是将它们作为模块函数的参数。这些函数能够通过返回对象或任何其他JavaScript值来公开接口。通过在Angular中使用RequireJS和依赖注入机制,我们只需要指明入口点,它将有一个依赖关系列表。这些依赖关系会有自己的依赖关系,依此类推。有了这些信息,这些模块系统就可以自动组合一个依赖关系图,并且不得不在我们的应用程序中维护一个大的,仔细排序的每个模块列表的麻烦消失了。

RequireJS的部分问题是他们提供了六种竞争方式来声明依赖关系并暴露API,这只会造成混淆。他们还鼓励异步封装,这是生产中的反模式,并要求额外的配置进行优化,以便数据加载时不需要很长时间。

Angular提出了类似的问题。他们鼓励依靠自动分析函数参数名来找出模块的依赖关系。这不是最安全的选择,因为打包工具会重命名这些参数名称,通常会破坏您的生产版本。与其鼓励用户使用安全的数组格式,引入了一个复杂的构建工具,不如将您的Angular模块函数转换为数组格式,这种格式在打包时不会中断。

这种过度的工程设计给那些刚想把他们的产品拿出来的团队提出问题,因为他们正在过渡到生产。与此同时,RequireJS和Angular模块声明都非常详细。这与IIFE模块形成了对比,这些模块更为简洁。即使存在这些问题,由于他们对依赖关系图的了解,两个系统都比使用纯脚本标记有所改进。这使我们能够创建和使用新的工具,这些工具能够在没有大量前期配置的情况下就如何处理代码库做出明智的决定。

CommonJS,Node中的模块系统改变了游戏规则。 NodeJS运行在服务器上,不受Web浏览器强加的严格安全限制。我们第一次有了一个基于文件的模块系统。

这些模块有自己的本地范围。在CommonJS中,我们可以通过require调用在代码中同步声明依赖项,并且我们还可以公开API将值分配给本地导出绑定。 CommonJS不允许隐式访问全局对象,而是需要显式引用全局绑定。每个文件都被视为一个模块,并且由于它们将被系统内部包装,所以我们不再需要在函数表达式中包装我们的代码,从而将冗长度降到最低。与RequireJS或Angular模型相比,CommonJS显然更为优越,但它仅在NodeJS环境中可用。

browserify的工具在推出时带来了新的创新浪潮。这是我们第一次能够在服务器和客户端之间共享代码,而不需要付出很多努力。这很吸引人,因为我们的客户端应用程序将能够利用发布到npm注册中心的数千个小模块。在服务器和客户端之间无缝共享代码打开了一个可能的世界,例如开始在服务器上呈现页面,然后转换到客户端以进行路由。更重要的是,分歧的NodeJS和客户端生态系统能够互相提供创新,通过工具和知识共享向上提升两个生态系统。

BabelWebpackRollup这样的工具已经把我们带到了更高的高度。 Babel transpiler意味着开发人员在获得原生浏览器实现之前就开始依赖新的语言功能。这样可以缩短规格编写者,实现者和最终用户之间的反馈循环。同时,它减少了浏览器提出冲突实现的可能性,因为一旦有足够的跨浏览器支持,当用户停止转发该功能时,就会导致错误。

ES模块遵循类似于CommonJS的模型,除了出口始终是静态的,并且静态导入在动态导入方面受到高度鼓励。静态定义的模块形状的这种偏好对于使用静态分析器来了解包的依赖关系图,未使用的代码路径等的工具来说非常有用。与我们迄今为止看到的其他模块系统相比,ES模块是该语言的本地语言。 ESM方程有两个方面。一方面,有模块语法。另一方面,还有交付机制:在Node中,我们可以通过访问文件系统来同步加载模块相关性。在网络中,并不那么简单,但我们会在一分钟后回到这个话题。

在我们过去主要使用Google Closure Compiler和UglifyJS的类中,我们现在也可以使用像Babel,Rollup和Prepack这样的工具。如果我们退后一步,看看日益复杂的构建工具链的当前情况,那么在这一类中引入新的更有效的工具也是很自然的。像Rollup和Prepack这样的工具特别有趣,因为他们承认我们将编写不太理想的代码,并根据这一原则优化我们的构建输出。汇总分析ESM导入和导出语句,找出哪些绑定实际上正在我们的软件包中的其他地方被使用,放弃其余部分以减小软件包大小。

Prepack采用不同的方法,尝试加快捆绑包的启动时间。为了做到这一点,Prepack拥有一个完整的解释器,能够真正执行我们的输入。通过解释器和一些启发式规则,Prepack能够将产生恒定结果的代码路径转换为针对生产性JavaScript虚拟机进行优化的代码路径。

回到脚本加载,我们可以使用<script type ='module'>标记来定义到基于ESM的应用程序的入口点。这些脚本默认是延迟的,这样可以防止阻止HTML解析,同时延迟脚本执行直到文档完成解析。当然,我们不能仅仅将带有数百个依赖关系的脚本放入脚本标记中,特别是当我们记住我们介绍的每个包依赖关系可能涉及数百个文件和更多依赖关系时。这会颠覆网络乃至浏览器IPC层。这不是一个实际的事情,如果我们考虑移动设备,情况会更糟。在可预见的将来捆绑不会消失。如果有的话,构建工具将继续增长的品种和能力。好消息是,对<script type ='module'>有良好的浏览器支持。

什么使模块化架构如此重要,以至于我们花了几分钟的时间讨论不同的方法?将大型应用程序分解为小型组件的好处是什么?我们举几个例子。

没有更多的全局变量。这意味着我们不必担心会发生冲突,或者担心会提出缓解全局污染的命名方案。这是存在CSS-in-JS解决方案的主要原因:默认情况下,CSS是全局性的,就像JS早期的那样。我们最终可能会看到CSS自然演变成类似于ESM的实用和范围模块系统。 Web组件和shadow DOM可能是该解决方案的一部分。但让我们回到模块化JavaScript的好处。

复杂。这是很致命的。如果程序变得复杂,我们的应用程序越来越难以阅读和理解。难以理解或推理的应用程序也很难改变。团队突然每天都会遇到问题,比如“谁知道如果我们碰到这个问题会破坏什么”。这将生产力降低到停滞状态。模块可以在这里帮助。通过在模块化规范代码以后,我们不需要关注我们的整个代码库便能处理问题。

当我们考虑吃芝士汉堡时,我们不会考虑每一次互动。我们不需要考虑我们的手,物理学让我们移动它们来咬一口,突触,咀嚼或其他任何东西。我们只关注美味的肉类,融化的奶酪和软面包。我们不会停下来想想,究竟是什么让我们相信这个汉堡很美味。我们是模式识别机器。我们可以递归地抽象出其他抽象背后的大量复杂性。实际上,我们在生活中所做的所有事情都是创造新的抽象,将我们之前内化的一系列模式分组。

在npm上有数百个开源项目。这都是关于包含复杂性。把你的模块想象成乐高积木。你不一定关心每个块的制作细节。所有你需要知道的是如何使用乐高积木来建立你的乐高城堡。

在JavaScript世界中,模块尽可能多的隐藏了项目的复杂性。然后,我们可以将这些模块组合在一起,并将它们抽象到一个更大的模块后面,但这可能有很多依赖关系。然后我们有高度依赖于模块的一个包,并且它成为我们唯一关心的接口。我们可以根据需要继续这样做,并且在任何时候我们只需要了解最外层的界面。如果我们发现一个问题,我们可以深入挖掘,但只要界面有效,我们就不必担心这一点。

To learn modularization, we need to understand how to break down complexity. When stripped down, modules are nothing more than knowing where to rip a piece of code and put it somewhere else. Behind an interface. An effective way of breaking down complexity is thinking in terms of the aspects of a task. When we’re looking at a large chunk of a program, is everything we’re doing dealing with the same aspect of the problem? Or is part of the solution going off and pulling off an email template, preparing a model, and sending out an email? That aspect of the solution might belong elsewhere, and we could replace all that code with a single function call that calls into the email service.

要学习模块化,我们需要了解如何打破复杂性。剥离后,模块无非是知道在哪里翻译一段代码并将其放在需要的地方。打破复杂性的一种有效方法是从任务的角度来思考。提高代码的复用率,将相同的部分提取出来,这样的代码即容易维护又简单易懂。

当我们以这种方式开始解决问题时,我们会开始注意到一段代码可能会提供两个基本目的。它可能正在解决各种用例的特定方面,例如发送电子邮件或从Markdown生成HTML。或者它可以处理特定的流量控制,例如用户注册,我们要求服务验证请求主体,要求另一项服务将用户插入我们的数据库,要求另一项服务发送验证电子邮件,最后回应请求。当然,如果流程足够简单,我们可能不需要担心将每个方面都转移到自己的可重用函数中,但即使将这些方面移动到指定的函数中,也可以帮助我们更好地编写代码。以及提高可读性。

现在,代码流通常是我们打包业务逻辑的地方。这个代码在重用性方面不太可能有价值。例如,将Markdown转换为HTML在现代JavaScript应用程序中几乎无处不在。通过在创建方面和流程之间明确的区分,我们能够最大化这些代码部分的可重用性,巧合的是,我们可以处理系统的特定方面。

与此同时,功能细分有助于我们用多种方法解决小问题,同时也有利于帮助我们解决更大的问题。

这样我们就可以专注于汉堡的美味,而不必担心我们手中究竟做了什么。

让我们来更具体一点。我们如何编写出色的模块?我们应该把时间和注意力集中在什么上?

我们的组件应该关注他们应该做得很好的一件事。我们应该努力保持尽可能专注的职能,只处理我们组件旨在承担的责任的一个方面。当一个函数主要负责我们应用程序的流程时,它不应该关心如何执行这个流程的每个分支。相反,该责任应该转移到其他功能或组件。如此清晰的关注点分离极大地提高了可读性和我们对系统各部分进行变更的能力,即使我们对应用程序的其他部分的工作方式并不了解。

在设计模块化组件时,最好从API开始。我们的流程通常从我们需要的模块开始,因为我们需要使用它。如果我们能够构建出任何东西,那么我们理想的API会是什么样子?那么,我们需要更进一步。该API如何寻找最常见的用例?最先进的用例呢?专注于使通用用例可访问会导致更清晰的API设计,从而优化消费者的可读性,但不一定会影响API自身的可用性。一旦我们确定了我们喜欢的界面,那么我们可以开始去思考如何实现。

我们的API应该只暴露开发者绝对需要的东西。

在向应用程序层,软件包或整个项目添加更多模块时,我们需要考虑同一系统中的其他模块。例如,如果三个接口预期以美分为单位的货币值,我们需要一个很好的理由来创建一个预期单位货币值而不是美分的新方法。一致性可以帮助我们避免意外。

我们要做的是避免含糊不清。以jQuery为例。您可以传入选择器,DOM元素,DOM元素数组,NodeList,jQuery对象或普通JavaScript对象以及可选的上下文元素。无论您的输入如何,输出始终是一个jQuery对象,并且该jQuery对象共享一组可用于调用的常用方法。

这是因为在输入方面具有灵活性,只要我们可以将它们映射到具有同一个接口的输出。无论接口如何接收,都必须始终以可预测的方式进行响应。

无论你把什么东西放进汉堡,你都希望能够吃到这一切。

始终追求简单。我们的界面越简单,开发越容易,获得的反馈越多,我们就可以更好地制作界面。为了简化优化,迎合最常见的用例,同时隐藏不常用功能。

自然,我们需要编写测试。界面是我们所有开发者关心的。也可以针对界面编写测试。如果我们为每个用例中提供的输入获得适当的输出,那就足够了。只要测试通过,这种思路就可以让我们在我们认为合适的情况下编写这些接口的底层实现。与此同时,测试变得不那么脆弱。我们不再担心底层例程是否被“调用过”,这意味着我们只有在对接口进行更改或发现实际错误时才需要更改测试。

Sane文档提供了模块化设计的方向。文档可以帮助开发者,它也可以帮助我们识别可用性问题。如果消费者不得不去了解接口的功能或工作原理,那么这意味着我们需要更好的文档或更简单的接口。

抽象是对模块化体系结构的有益补充。它们允许我们建立可重复使用的模式,从而形成一致和简单的界面。例如, event handling是一个很好的解决方案,可以很好地跨模块进行事件处理。

我们已经讨论过 - 从某种意义上说 - 一切都是抽象的。这包括模块。在模块中,我们有一个简单的界面,隐藏了一些复杂的实现过程。抽象是一个强大的工具。但抽象也存在缺点。很容易陷入陷阱,我们认为他们会解决我们所有的问题。

如果我们添加了太多的抽象概念,它可能会很快到达一个没有人真正理解他们在做什么的地步。如果我们添加错误的模块,我们可能需要对代码进行调整,以使其符合模块,而模块并不适用于我们试图解决的问题。

在我们权衡隐藏代码背后的优点和缺点之前,我们过早地创建抽象概念时会发生这种情况。为了解决这个问题,我们应该在安装之前给出一些考虑过的抽象概念。然后明确地决定应该将它们抽象出去,以便我们可以避免重复的逻辑。虽然我们的代码库中的一些轻量级代码重复可能不好,但要解决不考虑大量潜在用例的抽象要花费更多代价。糟糕的抽象可能最终导致歪曲简单的代码,给应用程序增加不必要的复杂性。

寻找正确的需要抽象出去的代码可能会很棘手,但重要的是给我们自己时间这样做。保存你自己和你的队友从一个痛苦的世界中保留下来,同时保持代码在流程中可读。简而言之,抽象是一个很好的工具,仔细利用时,但错误可能是昂贵的,难以回滚,所以尽量不要过早提交。

在本演示的最后部分,我想分享一些关于如何编写易于阅读的模块的提示。在编写代码时,总要试着记住需要阅读和理解你写的内容的目标人群。诸如可访问性,性能甚至安全性等事物都是协作和迭代能力的第二要素。如果您的团队无法进行协作,则无论您认为自己的可访问性,性能或安全措施如何出色,都会导致用户失败。如果你不能协作,那意味着你不能重复使你的产品变得更好。这几乎肯定会障碍,性能和安全漏洞。

在这种情况下,我的第一个提示非常明显。停止写出让人无法理解的代码。这些对于团队中只有少数工程师能够理解的代码来说是不恰当的。虽然那些的代码可能解决一个紧急问题或修复性能问题,但它可能同样容易导致无法预料的错误。不要编写聪明的代码,而要将其解压缩到每个人都能更容易理解的地方。你将来会感谢你自己。如果一切都失败了,请留下清晰的注释,以便将来的开发人员能够理解为什么代码是按照原来的方式编写的。

使用更多变量可以更轻松地逐步执行代码,而不仅仅是使用调试器,还可以使用我们的头脑,因为我们正在阅读代码并在头脑中执行代码。我们不是去寻找巧妙的代码,而是试图将所有的逻辑都塞进一行代码中,而是使用更多的变量来存储中间结果。

每当你注意到可以被分离到一个函数调用中的代码分支时,将它们提取到一个函数中。

有时我们并不担心可读性,因为不相关的逻辑阻碍了它的发展,但是由于函数的长度很长,你可以将你的大功能切成小块。尝试着重于突破的较大功能的各个方面,然后创建仅处理这些方面的较小函数。父函数最终可能只是5或6个函数调用的列表,开发人员可以通过阅读这些子例程的实现来了解更多。

Thanks!