本文直接联动阮一峰blog的文章《使用 Make 构建网站》。
这篇文章在描述javascript构建工具的弱点上,是牵强附会和夸大的。grunt和gulp的问题远远没有文章所述那么严重。
而文中对make工具,仅有教程式的讲述,而缺乏对弱点和缺陷的讨论。但偏偏原文的make教程就直接暴露了make的若干设计问题。此时如果没有其他观点对make的弱点加以分析,无疑是不全面的。
我觉得这篇文章从本质上来讲,是错误和误导性的,尤其是对新手开发者而言。Make,包括标准的GNU make及其他任何仿品,都不应当作为Web项目的构建工具。
make:看似简单实则简陋
隐喻胜于明确
Makefile
使用大量的隐喻来表述实在的语法意义。
举一个最简单的例子:无参数的make
命令,执行Makefile定义的第一个任务。这就是一个非常不好的语法。这造成了我们修改Makefile时必须自行记忆:“任务是否排在首位是不一样的”。
而这一点在大多数语言中都不存在——例如类的方法写成什么顺序都可以。在grunt和gulp构建系统中,也都使用default
任务,明确规定无参时默认(default)的行为。符合语义,无需额外思考(Don't make me think)。
就连Python这样的通用语言,都把用__name__
魔术变量标明主流程,作为编写脚本的一种建议实践:
def fun1(): pass
def fun2(): pass
if __name__ == "__main__": fun1(); fun2()
实在的意义,就应当用实在的语法写清楚,这没有任何可以退让的余地。隐喻胜过明确,这是make脱不了的原罪。
丑陋的“workaround”(临时手段)
这里说的例子是那个.PHONY
伪文件。
make仅能处理实在的文件依赖文件的关系。但实际构建中,难免出现抽象、不含实体文件的任务,例如clean——人人需要,但不产出实在文件。这时就要把任务表述成文件,然后用
.PHONY
参数告知make哪些文件是假的。
用伪文件,把“任务”替代成“文件”,存在两点明显的问题:
- “任务”与“文件”的命名空间直接产生冲突。
任务占用的名称,就必须人工注意实在文件不能再用,否则必然产生麻烦。 - 增删任务时,必须人工注意维护
.PHONY
列表。
如果忘了把任务补到.PHONY
中,构建过程就会产生无谓的空文件。空文件本身还不可怕,可怕的是如果没有及时发现,就会造成一次构建之后不能再次构建,白白消耗调试时间。
而对于任何其他构建方法来说,都根本不存在这个问题。所有其他构建系统像看怪物一样,用诡异的眼神鄙视着make。
make提出了伪文件这个东西,并且还在手册中建议了“伪文件充当任务名”的用法,我相信make的开发者当初一定注意到了这个需求。但是任务(流程逻辑)和文件(内容存储)毕竟是相关却不同的两件事,分开管理才是必然的选择。
我不清楚make的开发者是没有想到这一点,还是自认为“借用过来‘文件依赖文件’的已有模型更加‘简洁’”。但结果上看,这个模型的错误是本质性且不可修正的。这个实现懒惰、简陋而不是所谓的“简洁”,最后的结果也是后患大于收益。
再举一个例子例子:UNIX声称“万物皆文件”,到头来还不是为了不同设备的逻辑,而保留了“块文件”、“socket文件”之类的区别?
要替代就替代的聪明一些,把实在、重要的本质逻辑保留住。合理、明确,不回避客观区别的替代,和一时拼凑的“workaround”(临时手段)是两回事。后者一时使用尚可,但绝不应充当作为软件基础的“万灵药”。
Makefile:介于语言和配置之间的“四不像”
考试:请仅用Makefile
语法(不依赖shell特性)写一个if/elseif/endif试试?
如果要用某种形式描述一个构建过程,其实:
- 可以表示成纯粹的配置文件。不可独立运行,不含任何实质的代码,但绝对便于编写、修改。
- 可以表述成真正的程序代码。绝对灵活,所有语言特性随便用。
- 但一般都代码和配置文件联合使用,兼取两者之长。(即使是纯代码,其实数据和执行逻辑也会有一定的分离,而不是混在一起搞成“意大利面式编程”)
审查Makefile
的本质设计,其实是一种描述依赖关系的配置文件,描述了“文件依赖文件”和“文件依赖shell代码”两种关联。但偏偏Makefile
也同时提供简单的流程控制、赋值等语句,使得Makefile
也是一种可以控制流程走向的程序代码。
所以Makefile
偏偏落在了配置和代码两者之间,既不是倒向一端,也不是两者的联合,最后形成了一个“四不像”的混合品。作为配置文件写起来太费神,作为程序代码又太简陋不够用。
我想问:就从Makefile
的设计上来看,那个被奉为圭臬(事实上也确实很优秀)的“UNIX哲学”在哪里?在哪里?
shell:躲不开的雷区
符号胜于语义
shell使用各种符号来表达语义,难读难写。也就比那个正则表达式简单点不多。
以下两段构建脚本,你愿意读、写或改哪一个?
lib_bundle := build/lib.min.js
libraries := node_modules/jquery/dist/jquery.js \
node_modules/underscore/underscore.js \
$(lib_bundle): $(libraries)
uglifyjs -cmo $@ $^
# What the heck does "c m o @ ^ $" means ???
var gulp = require('gulp');
var concat = require('gulp-concat');
var uglify = require('gulp-uglify');
gulp.task('lib:bundle', function () {
return gulp.src(['node_modules/jquery/dist/jquery.js',
'node_modules/underscore/underscore.js'])
.pipe(uglify())
.pipe(concat('lib.min.js'))
.pipe(gulp.dest('build/'));
});
运行环境的高依赖
shell是一个严重依赖系统环境的工具。一个make能够正确调用shell脚本,一般都需要:
- 系统内有GNU工具链
- 工具链的详细用法(参数意义等),不能与编写者产生严重的冲突,甚至不能进行造成deprecated(弃用)的升级
- PATH等基本的环境变量正确
- 但其他的环境变量还不能与
Makefile
中用到的变量冲突
更可怕的是以上这些要素,基本上都是隐喻性的。没有明确的版本控制手段去保证不说,甚至连确认都是不现实的。以前能用的脚本可能换个发行版、升级个系统,甚至于换个用户就可能会发生问题。
我们既然已经有了npm版本控制,更何况shell构建本质上也是调用基于node的工具,那我们为什么还要去踩shell缺乏版本控制这个坑?
shell调用js的性能问题
shell调用js,每一个命令都需要启动/停止node进程,并且各个工具是顺序执行的。
而node构建工具,只需要使用同一个node进程,并且各个工具可以异步启停、并行运行。
这个效率区别是不需要具体比较的。
总结
“shell自动化”——邪道之路
从历史上来看,shell本来就是为了方便人类执行命令的小工具。而后人类发现了自动执行命令的便利性,从而将shell扩展成为一门轻量的脚本语言,这个发展历程是可以理解的。
其实简短的shell脚本也可以大大方便人类的工作,是个好用的工具。可是一旦shell脚本庞大起来,shell不适合自动化运行和大型程序管理的各种硬伤就开始暴露:
- 提倡使用特殊符号,过于强调语法简短(首要问题)。
这一点对于人类操作shell是优势,谁都想在命令行下少打几个字。
但如果用于长时间编写、快速运行、长期维护的正式项目,shell的这个特点就会立刻表现为难读、难写、难改的短板。 - 缺少大型项目所需的版本管理、面向对象等特性
- 语言功能不足,例如缺乏最基本的数值计算
- 语言特性诡异,例如bash大多数情况下对空白字符不敏感,可偏偏变量赋值的等号两边不准加空格
- shell这一层太薄,过于依赖命令行工具链,甚至一些很基本的任务shell层都不能自行消化,例如
[
。 - 命令行工具的提示都是为人类阅读而设计的,不适合机器解读与程序间交互
shell从本质上,是方便人类手打命令的终端软件,而不是可靠的自动化工具。本质如此,将来就会一直如此。shell就是shell,也一直只会是shell,不应当赋予其过深的责任和负担。
除非①没有更好的选择②工作实在太少太简单,否则永远不要在正式项目中依赖shell自动化。
不要被“技艺高超”的假象迷惑!
必须承认:shell与make工具有不少“坑”,但一旦调试良好,它们确实能够稳定运行。并且工程师们经常会产生这种心态:解决的问题越难,填平的“坑”越多,最后成功时的成就感就越强。
这是一个思维陷阱。这个陷阱中用过程的复杂度替代了需求的复杂度,从而容易让人错误的评判和看待自己的工作。
可工程毕竟不是智力题。实际需求才是唯一的,只有需求本身的复杂度才需要尊重。代码只不过是完成任务的一种副产品。代码量越少越好,代码引入的额外复杂度越低越好,代码维护起来越容易越好。至于代码本身解决了多少难题,适配了其他工具多少的“坑”,一般都不值一提。
做黑客自有做黑客的合适场景,就如同业余时间做点智力题其实是个不错的爱好。但实际工程环境下,请老老实实做工程师,使用简单的工具解决同等的问题,不要炫技。
我的推荐
请使用npm的构建工具。我推荐目前(成文时)仍处于测试状态的Gulp 4。
Gulp 4最赞赏的地方是引入了简洁明确的语法,规定命令之间的串并行关系。从此可以把任意形状的加权森林(权值代表执行顺序)简单地表示成Gulp的代码:
gulp.task('default', gulp.series('clean', 'build', 'deploy'))
gulp.task('clean', gulp.parallel('clean:a', 'clean:b'))
gulp.task('build', gulp.parallel('less', 'uglify'))
gulp.task('deploy', gulp.series('revision', 'copy'))
Gulp 4入门请通读《Gulp 4.0 前瞻》这篇文章,以及Gulp 4源代码目录中的所有recipes
(参考代码),非常容易。
Gulp也有Gulp、Node和JavaScript的麻烦(例如并行代码的编写不良一般不会报错),但起码在Web构建这个环境下,比shell值得拥有。
如果你真的需要一些命令行的工具,那也应该舍弃shell这一层,在js、python等正常语言环境下调用它们。命令行工具是不需要shell的,启动子进程并且传递argc、argv的参数才是本质。
原创发表在 SegmentFault.com 博客,转载请遵守 SegmentFault 相关规定(见页脚),作者为沙渺 sha@miao.im。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。