强迫症的模块化

549 查看

在ES2015发布后,JavaScript最终也有了一个标准的模块化方案,而同时从webpack开始,也带来了一波“一切皆模块”的潮流。整个2015-2016的前端发展中,除去在UI层不断的努力和突破外,几乎每一件事都和模块化脱不开关系。

本文也试图从几个方面简单地说一下模块化,并分析一些在模块化实施中产生的误区。

模块的本质

模块化顾名思义,指的必然是将程序拆分为多个“模块”,使用模块间通信的方式进行交互。这和其它对程序逻辑进行拆分的手段是一样的,无论是拆成类、拆成对象还是拆成啥,其目的无非是隔离内部的实现以及声明对外的接口。

事实上自远古时代起,JavaScript就在努力进行实现封装的尝试,无论是通过IIFE避免全局变量,还是基于Namespace进行管理,以至后期的AMD、CJS社区模块化方案,直到现在的ES Module标准模块化,无不以上为目的。

在模块化的思想里,一个模块应该是:

  1. 需要一系列的依赖。
  2. 涉及一个内部的实现。
  3. 提供一系列的接口。

一个模块的接口既然是它的结构表达,就如同一个类的属性和方法一样,应该是一个在同版本内稳定的签名。在实际的版本管理中,对于模块应该严格遵守semver等版本号规则,当模块对外声明的接口有所变化时,通过不同层级的版本号的变化以示区分。

从ES Module开始

在ES2015发布后,ES Module也事实上成为了JavaScript的模块标准。从语法上来看,ES Module与以往社区主导的AMD、CJS等都有不同,主要体现在:

  1. importexport是语法静态的,即通过语法的解析就可以得到,而不需要实际运行。
  2. import的对象是export,而不是模块本身,在粒度上比AMD和CJS要更细化。
  3. export的是一个引用(或者说Binding),而不是一个值。

ES Module的这些限制大大增加了语言的静态性,从而也收获了不少的优势:

  1. 可以通过语法分析确立模块间的关系,从而为构建等后续工作提供基础。
  2. 可以应用Tree Shaking等手段进行代码的消除。众所周知JavaScript因为其动态性,导致Code Elimination非常难做,而静态化的依赖声明以及依赖粒度为export则很大程度上简化了这一过程。
  3. 使用引用作为export有效解决不少情况下循环引用的问题。

从这一分析中可以看到,语言层面的静态性是对除编程以外的各个工作环节都有很大的帮助的,作为长久以来一直基于JavaScript的弱类型、动态性进行编程的工程师,也应该借这次机会重新认识和审视一下类型和静态性的优势。随着应用程序的复杂度的提升,我们的设计应该更倾向于更多使用强类型和静态性支撑程序的强壮性和可维护性。

全模块化设计

当前另一个流行的概念叫“一切皆模块”,由前端构建工具webpack所引领。在这一概念下,开发者倾向于认为模块化不仅仅应用于JavaScript,任何的资源都可以被抽象为模块,并通过统一的方式(webpack中使用require)进行管理。

然而,在我看来,当前的前端远没有达到所谓的“全模块化设计”,更多只是浮于表面跟随webpack实现的一种表现而已。

在当前使用webpack的场景中,工程师会在JavaScript中使用require('css!./style.css')声明对一个样式的依赖,会使用require('image!./logo.png')声明对一个图片的依赖。然后他们会说“我们将样式和图片都作为模块来管理了,所以我们是一切皆模块的忠实履行者”。

但是,事实上大家都忽略了几个很重要的因素。

模块与资源的区别

首先,我们并没有遵循一个统一的模块结构。如果以ES Module作为模块结构的标准,我们的JavaScript可以很容易地做到声明依赖和接口,并封装一个内部实现,但是对于其它类型的资源,我们是否有考虑过作为模块时,它是怎么样的?

这就很像我们对RESTful的实施,玩了好多年最后也就是URL长得规则一点,用了用PUTDELETE,而没有把RESTful的思想应用到系统的设计中去,把好好的一篇博士论文硬是玩成了表面上的无聊玩意,估计是Roy Thomas Fielding博士万万想不到的。

举一个例子,当我们创建一个style.css文件时,我们在心里想的依旧是“建立了一个样式文件”,而不是“创建了一个样式模块”,从而我们忽略了对一系列问题的思考:

  1. 这个模块的依赖是什么?
  2. 这个模块对外的接口(导出)是什么?
  3. 模块的依赖通过怎么样的语法进行声明?
  4. 模块的依赖和接口如何支持静态(非运行时)的分析?
  5. 当前样式的语法是否足够我进行模块的声明,而不是简单地编写样式?

将这些问题落地细化,就可能转为更实际的内容(css本身语法太弱,此处以less为例):

  1. JavaScript的依赖是到export级别的,因此一个.less依赖另一个.less文件并不合适,需要细化至selectormixinfunction级别。
  2. 因此一个样式模块的接口(导出)应该是一系列的selectormixinfunction,而不是一段文本甚至啥也没有。
  3. less的语法是不足以声明这么细粒度的依赖的,事实上根本不存在一个扩展css的语言支持这个,我们需要自己动手。

所以,如果是全模块化的设计的话,我们大概就会再去实现一个CSS超集语言了吧……

如果扩展开来,还会有很多有趣的问题,比如:

  1. 一个.ico类型的模块,它的导出是不是应该是不同尺寸的图像?
  2. 一个模块类型的模块,导出其实应该是多个Block而不是一个渲染函数?
  3. 作为入口模块的HTML,应该如何声明对其它模块的依赖?

其中第3个问题尤为有趣,因为在WHATWG的标准里,一个HTML引入一个JavaScript的模块是这样的:

可以看到,这里其实是引入了整个模块,而不是模块的一部分export,这是不符合ES对模块间依赖的定义的。也就是说,其实在WHATWG的标准里,根本没把HTML与JavaScript之间的关系作为模块间的关系来考虑,那么我们在实际实施全模块化设计的时候,又要做怎么样的扩展才能配合真正的全模块化设计呢?

模块类型的误区

在实际的模块化实施过程中,另一个很大的误区就是使用资源类型来决定模块类型。比如只要是.js文件,一概当作普通的ES Module处理,只要是.less文件,一概使用css插件处理。

但实际上,在全模块化设计中,根本不存在“资源”这样的一个概念,后缀只是URL和文件系统上的表达,与模块的真正含义并不相关。

以一个实际的场景为例:

在系统中存在一些变量,这些变量在开发环境、测试环境和线上环境有不同的值。

系统建立了一个变量模块,对应的文件为variables.js。该模块使用多个export提供不同的变量。

到此为止,我们就很容易犯一个错误,我们会这样去引用这个模块:

并不是说这样会让系统挂掉,但这绝对不是一个合理的设计。因为在实际的设计中,这个模块的作用是“变量”,而非“程序”,所以它在使用的时候,也不应该被当作程序来引用,正确的方法应该是:

虽然可能只是细微的变化,虽然要为此多写一个Loader Plugin,虽然可能在实际执行时根本没有变化,但这意味着我们确实是以模块为粒度进行设计,而不是单纯地作文件-模块的映射。

当然要说优势的话也不是没有,在这样的设计下,我们可以建立variables-dev.jsvariables-qa.jsvariables-online.js等多套变量,通过var这个Loader Plugin控制具体加载的变量集,而不需要使用类似TextReplacer之类基于文件的工具进行处理,让整个运行和构建过程也得以靠近模块化。

远程服务

最后一个相对极端的情况是,我们将前端的所有资源认为模块后,却忽略了其它远程服务,而在全模块化的设计中,根本不需要区分前端后端远程本地,一切均可以是一个模块。

所以我们在代码中经常出现的:

其实并不是“那么的模块化”,不如尝试变成这样:

配合模块化的静态依赖分析等特点,将整个系统全部作为模块来管理后,我们甚至可以基于此对Web API进行管理,比如移除已经不再使用的接口(其实就是Tree Shaking)等工作变得非常简便。

当然这对于整个系统的设计挑战是非常大的,即便真的理解这是一个很好的模块化方向,也大概没什么工程会照此执行吧。

总结

本文旨在简单介绍模块化的概念后,重点提出在日常模块化实施中应当被注意的几个点,包括:

  1. 从ES Module的静态性出发,重新审视强类型和静态性对构建应用程序的重要性,在实际设计和实现中更多地利用这些概念提升健壮性和可维护性。
  2. 在全模块化的设计中,将资源的概念摒弃,真正从模块的角度思考每一件事物。
  3. 用模块的类型决定模块的引入,而不是模块对应的文件或资源的类型。
  4. 除前端自有的资源外,其它外部的服务同样可用模块化的方式进行定义。