9

前言

最近两年前端圈子犹如春秋战国:群雄并起,中原未定,就连各路大神也纷纷感叹最近两年技术选型难做。

在模块化开发的问题上,一方面以AMD/CMD为代表的规范在过去几年间极大地提升了前端生产力。另一方面,随着ES6、Web Components的临近,开发者们面临着承前启后的巨大挑战。

对前端而言,模块化并不像后端语言那样简单,它涉及到很多工程问题与历史包袱,让人很难保持清晰的思路。在此记录下自己对于模块化的一些学习与认识,希望能进一步理清思路。

模块化的价值

要谈模块化,首先要知道模块化的意义,开门见山地说:

  1. 代码复用
  2. 提升维护性

无论任何语言任何项目,上述两个方面的意义都可以认为是模块化的核心价值。

在前端项目中经常使用到的如jQuery、underscore.js等库,其实就可以看作是公共模块,他们对常用的、工具性的代码提供了抽象。

我曾接触过一个老旧的项目,在N个js文件的顶部都写了一份对Function.prototype.bind的polyfill,看得出来出自不同人之手:因为实现方式不一样。毫无疑问,这样的代码完全不具备可维护性,如果你不提个公共文件出来,就只能在用这几个js之前沐浴熏香,祈祷他们不要出bug。

把公共代码封装成模块促进了代码的复用,但在很多时候,为了满足高内聚低耦合,我们也需要将并不具备复用价值的代码抽离成相互独立的模块,以此来提升可维护性。有很多关于函数最好不要超过XX行,文件不要超过XXX行这样的“经验”,其实就是在督促人们多做有意义的代码拆分。

总结一下:

  • 公共模块通常用于促进代码复用
  • 业务模块通常用于提升可维护性

深入到前端组件上,@民工精髓V 的 2015前端组件化框架之路 有非常精彩的讲解,这里就不赘述了。

模块化与工程化

前端的模块化,由于以下几个方面的难点,始终和工程化的命题紧紧耦合:

  1. 资源复杂
  2. 标准缺位
  3. 天生异步

下面一条条细说

资源复杂

与后端项目代码即是一切不同,前端由于面向展现层,在资源上呈现高度的复杂性,一个典型的前端组件可能包含html、css、js、img、swf 等资源中的一种或多种。最常见的情况就是js与css并存

这给资源的管理、聚合带来了极大的难度。

假设我们有tooltip组件,用伪代码描述理想的情况:

include tooltip

而事实上的情况大概是这个样子的

<link rel="stylesheet" href="./path/to/tooltip.css"/>
<!-- 省略 -->
<script type="text/javascript" src="./path/to/tooltip.js"></script>

简直像是洪荒时代的做法……很不幸的事实是,这在目前的前端业内其实很普遍。

当然也有难以忍受这种状况的前辈,做出了很多尝试和努力。最常见的思路是把资源依赖都交由js打理,由js作为模块的入口:

//login.js
require("./login.css");

//usage
require("./path/to/login.js");

至于怎么解析这种依赖并转化成浏览器能识别的输出,方法各种各样。但共同点不变:以js为入口,把资源的依赖关系管理并聚合起来。

其实这个办法不错,对资源的静态分析处理加上js在浏览器端的配合,理应能走出一条康庄大道来。

但是,我们遇到了要说的第二个问题:

标准缺位

无论是W3C的标准,还是事实上的标准,有标准总好过没标准。

前面说了,对资源做一遍预分析和处理,再辅以浏览器端js写的各种loader,理应能很好的应对前端资源的复杂性。

但随之而来的大坑就是标准问题,前述的例子login是一个业务模块,业务模块有个好处,就是代码限定在本项目内,怎么写,用什么标准都可以掌控。但如果我们有个公共模块tooltip,还是按前述的方式引入:

//usage
require("tooltip");

如果是nodejs,我自然知道应该去node_modules下面找/tooltip/package.json

但在前端模块化上,连一个广泛认可的包管理器都没有,已知的有components规范、有spm的sea_modules、有bower的bower_components,还有很多跑在浏览器端的框架和库在脸上写着nodejs的npm上托管。

公共模块放哪儿?怎么放?大家各玩一套。

这里又引出了另外的问题:对资源做一遍预分析和处理这在目前是属于工程化层面的问题,为了解析方便,现有的工程化方案往往只适配有限的包管理体系,例如基于F.I.S的Scrat以components作为组件规范。

spm则比较特别,它作为seajs的包管理体系,为自己量身定做了一套工程化的方案。

但是,无论是工程化适应包体系还是包体系衍生工程化,相互之间这种不优雅而又不得已的强相关总归是提高了各自的使用门槛和未来风险。

js//tooltip.js
//组件自己声明对css的依赖
require("../css/tooltip.css");

在这样背景下,上面这种更优雅的代码是不可能出现在公共模块中的,因为没有相关的规范,无法应对不同工程化方案的不同解读,比如有的可能需要下面这样写:

js//tooltip.js
require("css!../css/tooltip.css");

对于公共模块来说,为了保证通用性,最好的办法是:随便你们吧,老子不玩了

恩,换个文明点的说法,就是回归原始状态,js归js,css归css,想引用?自己伸手来拿,于是在spm、webpack中,代码是这样的

//引入模块入口
require("tooltip")

//引用模块下的其它资源
require("tooltip/css/tooltip.css")

这么做当然不好,依赖模块内部的路径明显不符合开闭原则。如果哪天模块目录结构或者文件名变更,等待你的只能是报错。但这却是目前为止不得已而为之的办法,别无其它选择。

天生异步

为什么模块的依赖不能在执行期解析,非要在工程化层面做?除了对多种资源的处理之外,浏览器环境的异步性也是非常重要的因素。

先简化问题:不考虑多种资源,仅考虑javascript。当我执行

//my_module.js
define(function(require){
    require("moduleA");
    //do something
});

时,如果是Node.js(当然Node.js不会有define这一段),只要去磁盘上读文件就好了,整个过程是同步的,但是在浏览器端,moduleA的代码可能还在服务器上,需要先去下载,再回来执行代码,整个过程变成了异步。

以require.js为例,看看发生了什么:

  1. 把传给define的函数用toString转成字符串
  2. 对函数字符串正则匹配require(xxx),发现依赖项
  3. 加载依赖项,并对加载下来的依赖从步骤1执行递归
  4. 所有依赖加载并执行完毕后,执行函数体。

seajs大同小异,主要的差别在于执行依赖的时机。

上述过程,概括就是:加载--分析--加载--分析--执行。在成功加载文件之前,它的依赖是无法确定的。但这样不仅用户会付出大量的等待时间,而且我们发现,在运行时分析依赖是一种浪费,因为依赖关系在开发期就已经确定了

生而与重复劳动为敌的程序员,是绝对不能容忍千千万万台用户电脑浪费资源干这种事的。

为了协调异步环境下模块开发与性能间的矛盾,我们必须在工程阶段就具备依赖分析的能力,把具备依赖关系的资源进行打包。就算不打包,也希望像 F.I.S 那样有个记录依赖关系的map.json,可以照单抓药,一次性地把需要的依赖项加载下来。

因此,在开发期解析模块依赖、合并代码、甚至重写资源路径都得在工程化的层面完成。工程化和模块化变成了容易耦合且不得不耦合的两个话题。

这也使得我们在制定模块化或工程方案时,必须两头兼顾,全面考量。抛开模块谈工程、抛开工程谈模块,都是耍流氓。

to be continue……


kpaxqin
2.4k 声望223 粉丝