gulp构建进阶
发布在全端工作流2014年9月2日view:30773
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

gulp构建进阶

9月2日更新: gulp.src时exclude的细节. 见文章尾部.

9月2日更新: gulp-livereload模块更新了, 升级之后提供的接口的用法发生了变化, 详见模块文档

新兴的task-runner gulp给了开发者除grunt以外的选择,让我们可以更灵活更高效的执行构建.不得不承认gulp有很多grunt拍马不及的优势,连gulpfile都要远远比gruntfile更加node-style.我这个不喜欢配置式的人立刻转了过来, 本篇文章就深入介绍一下gulp的使用, 算是进阶文, 没有任何基础的同学还请先去看一下官网或者其他同学写的入门文.

一句话开始

还是赘述一下入门…就只说下四个API, 流式的处理过程就不说了, 一直pipe下去就行~

  • gulp.task('taskname', [ taskDep1, taskDep2 ],taskContentFunc) 定义一个任务,声明它的名称, 任务依赖, 和任务内容.
  • gulp.src( file[s] ) 读取文件,参数为文件路径字符串或数组, 支持通配符.
  • gulp.dest( destPath ) 写入文件, 作为pipe的一个流程.文件夹不存在时会被自动创建.
  • gulp.watch(files, [taskDep, taskDep2]) 监控文件,执行任务.

进阶1:dest匹配src

gulp.dest( destPath )会将流中的文件写入到destPath中, 但并不是所有的文件都写在destPath的根目录下, 有的可能是在其下又创建子目录,其中的规则是怎样的呢~?

dest是与src相匹配的. src读取文件路径获取文件,主要有三种情况:

  • 指定的文件:['/foo-1/bar-1.js', '/foo-1/bar-2.js','/foo-2/bar2.js'].
  • 模糊匹配文件名的文件: ['/foo-1/bar-*.js', 'foo-2/bar-*.js'].
  • 模糊匹配路径下的文件: [/*/bar-*.js]

以上三种情况读取的文件, 前两种会写入到destPath的根目录下, 而最后一种情况, 会在destPath下新建foo-1和foo-2文件夹然后写三个文件到相应的文件夹里. 发现规律了吗~? dest会将src”匹配”到的文件路径写出来~(这个匹配必须是纯粹的*匹配, 全匹配, 如果是foo*式的半匹配就不会写文件路径) 第一和第二种匹配到的是指定文件, 第三种匹配到的是符合规则的路径下的文件, 所以会出现上面的情况. 也就是说, 如果想将源文件的路径也dest到目标路径, 那就需要将路径也放在”匹配”中.

下面来看例子: locale目录下有en, zh-cn, zh-tw三个目录,三个文件下各有一个lang.js, 把文件夹也输出到目标路径的做法如下:

$javascript$
gulp.task('locale', function(){
    gulp.src( 'locale/*/lang.js' )
        .pipe( uglify() )
        .pipe( gulp.dest( localeDest ) );
});

了解这个规律之后,destPath下的目录结构就完全可控了.

gulp中实现src和dest的模块, 深入探查之后发现,可以看成是gulp -> vinyl-fs ->vinyl -> glob-stream -> node-glob的依赖链, 从最底层往上依次来看,node-glob实现的是:

Match files using the patterns the shell uses, like stars and stuff.

然后glob-stream把匹配到的文件流式读取, vinyl构建出gulp生态系统内很关键的File类, 其中的file.relative就是文件匹配时*全匹配出的路径, 上面的例子中就是 [lang-type]/lang.js.

src完成工作, File一直pipe下去, 直到dest时,vinyl-fs有一套写文件的规则: 传入的outFolder 加上 匹配文件时的relative的dirname, 作为目标目录. 整个流程下来,就是完整的src到dest的过程.

对整个流程感兴趣的同学, 可以按照我上面写出的依赖链来读一下相关的源码. 其中除了node-glob是npm作者issaacs写的之外,都是gulp的作者团队实现的, 他们做好好多工作啊.

不明白或者不想太深究的同学,也不必担心.gulp近期内就应该会有”动态dest”的功能出现, 具体参见这个issue: Allow function for outFolder in dest, 持续集成测试已经通过, 就等merge啦~

不懂为什么gulp的版本号跑的怎么这么快, 现在都3.6.3了, 它的前辈还在1.0以下(最新的是0.4.5…)缓步前行.

进阶2: src的玄机 — 递归和exclude:* and !

gulp.src方法接收的是源文件路径, 可以是string也可以是array.官方给出了src的具体语法和实现模块:

$javascript$
gulp.src(globs[, options])

glob refers to node-glob syntax or it can be a direct file path.

通读一遍node-glob模块的文档后再加以实践,就能掌握gulp.src的玄机,将你想要的文件”吸取”到”管道”中来,然后pipe处理下去:

  • foo.js指明特定某个文件
  • *.js匹配当前目录下的所有js文件,不指名扩展名则匹配所有类型
  • */*.js匹配所有第一层子文件夹的js文件,第二层请用*/*/.js
  • **/*.js匹配所有文件夹层次下的js文件, 包括当前目录

上述就是匹配和文件夹深度匹配规则, 当上述两条或者两条以上的规则想结合的话, 匹配到的文件会有一定的读取顺序, 以这样的一个task为例: 为构建一个工具库, 需要将lib文件夹下所有的工具模块小文件合并成一个文件, 其中index.js中通过下面的代码将util暴露给全局:

$javascript$
(function( env ){ env.util = {} })(window);

其他的小模块文件也都使用IIFE来给util添加不同的功能, 如foo.js:

$javascript$
(function(){ window.util.Foo = function(){
    // Implementation of Foo 
    return this;
} })();

这样的代码结构, 我们可以在构建是保证index.js被最先写入到合并后的文件, 而不必在每个文件里都判断util变量存不存在, 我们让它在文件头,一定存在.要达到这样的目的,我们的文件匹配数组要这样写:

$javascript$
gulp.task( 'build', function(){
    gulp.src( [ 'lib/index.js', 'lib/**/*'] )
        .pipe( concat( 'chenllosUtil.js' ) )
        .pipe( gulp.dest( 'dist/lib/')  );
} )

这就是优先匹配到的文件优先读取.

下面来讲exclude.

排除的方法就是在文件匹配pattern前加!, 跟.gitignore类似. 但有些地方需要注意:如果任务需要你使用**/*递归匹配, 那么执行exclude也需要递归exclude, 即!exclude/path/**/*.

这个坑是笔者在最近写atom-shell程序时遇到的: 我将atom-shell.app放在了项目目录, 需要构建成OSX的app时就把项目目录中所有的文件都拷贝到atom-shell.app下的contents/resources/app目录下. 所有文件, 当然是要用**/*递归匹配喽~, 后面再跟!./atom-shell.app, 而OSX下的.app文件其实就是文件夹, 递归匹配加非递归exclude, 就造成了其实.app也被读取的状况, 然后就无限引用了… 不过gulp有自己的保护机制, 没有出死循环这样的傻事.

正确的作法是['**/*', '!./atom-shell.app', '!./atom-shell.app/**/*']. 这样就可以把app完全exclude掉了.

by the way, 系统的”最大读取文件数”会限制gulp的执行, 比如**/*将node_module也包含了进来的时候… 具体我是怎么避免这个问题的, 大家可以看我正在写的atom-shell项目的gulpfile.

gulp项目的issue里也有讨论这个问题的, 不少同仁踩了这个坑, 虽然情景不太一样,但都是”exclude文件夹与其下的所有文件”的问题.

课间休息~

gulp的github repo里有这样一个issue:

—: μs: Is it a font issue maybe? —: http://en.wikipedia.org/wiki/Microsecond —: Wow, that was fast (pun intended). Thanks!

:-D

呃, 其实gulp没有那么那么快, 只是其中的stream处理的这么快而已, 真正的读写文件,还是需要多一点时间的, 这也就是为什么看一系列的task都执行完之后进程还没有退出的原因, 它还在写文件~! 就笔者的经验看来, 从less文件编译出十几个200~2500行的未压缩css文件还是需要个近1秒的时间的( 从敲入gulp less 命令到进程退出), 而输出显示less任务处理的时间只有10ms.文件IO的速度是固定的,task-runner能比拼的只有并发+处理速度+优化减少文件读写.

gulp之所以这么快, 是因为:

  • 使用orchestrator任务系统,最大限度的并发运行多个任务.
  • 每个plugin只做一件事,做好一件事, 提升处理速度
  • 流式处理,极少的文件IO

这里有一篇slide,Build Wars, 构建工具之战, 风趣幽默的介绍了grunt的兴盛和gulp的崛起, 其中也提到了:

Grunt 1.0 alpha uses Orchestrator! (OMG)

Task-runner将来会更加的百家争鸣哦~

进阶3: watch的高级应用 — liveReload 和 错误处理

gulp中自带的gulp.watch能帮你监控指定的文件, 随之触发执行指定的任务, 或者, 执行一个回调. 注意, 是或者, 不能两者兼具.

gulp.watch(glob [, opts], tasks) or gulp.watch(glob [, opts, cb])

有这么一个模块, 叫做gulp-livereload, 它可以在一个端口(默认为35729)上起一个server, 然后在你的页面上注入一个script标签, 引用它提供的通讯脚本, 在文件发生改变后server通知页面的脚本重新加载这个改变的文件.笔者之前试过好多种类似与livereload(以下简称LR)的插件或工具,这个算是最中意的~

注入脚本的工作可以交给这样的一个名为LiveReload的chrome浏览器扩展程序,这个扩展的图标做的略挫,辨识度较低,开启和没开启的区别就只有中间的小圆点是空心和实心… 当然扩展是死的, 它只会注入对应默认server端口的脚本, 有同时开启两个LR的需求的同学,还是自己定一个端口号然后实例化的时候传给plugin然后在页面中也好script tag吧~

LR有两种用法,作为stream的一环使用或者在watch中使用, 在它的nom页面有详细的介绍, 我就不赘述了. 其中更加灵活的用法肯定是在watch中, 比如监控less编译任务的dest目录下css文件的变化, 然后使页面重新加载某个css文件, 通过lr.changed( filePath )来指定. 不一定非得是变更的文件哦~ ). 具体的用法和方便之处, 你用了就会知道. 在开发传统页面或者SPA时, 都能提升不少的效率.

并没有特别深入的去研究reload的规则, 用它来reload样式表和整个page都是很好用的. 运行机制应该就是LR-server和页面注入的js通讯, 文件发生改变时根据规则进行不同的reload. 从使用经验来看, 如果changed的file在浏览器页面中match不到, 注入的脚本就会重新载入整个页面. 这可以当做一个feature来使用哦, 比如做nodejs开发, 使用ejs或jade或其他模板引擎时, 变更的文件为.ejs或者.jade, LR就会重新载入页面, 正是我想要的结果.

我的下一篇文会讲到jade预编译和LR配合使用的方法, stay tuned.

gulp任务添加错误处理在没有使用watch时还显得没那么重要,但使用了watch之后, 没有错误处理的task在发生错误后会立刻崩溃掉整个gulp进程, 想继续watch?重启~! 这一点程序猿肯定无法忍受, 除非你可以保证每一次的文件保存操作都是可以build通过的… 我这种保存强迫症却对做不到.

经过查阅issue和相关blog, 可以得到如下知识点:

  • gulp官方推荐的plugin书写规范中, 推荐在发生错误时抛出一个gulp-util的PluginError
  • 这个错误会出现在文件处理stream上
  • 想要捕获这个错误, 在stream上添加on('error', handler)处理函数

找到这些资料之后, 就可以写出初步的带有错误处理的task了:

$javascript$
// 编译less, 压缩
gulp.src( src )
    .pipe( less({ compress: true }) )
    .on( 'error', function(e){console.log(e)} )
    .pipe( gulp.dest(dest) );

这样的错误处理, 我用了一段时间. it works well. 后来在处理jade编译时出现了一些问题, 这里只简单说一下: 如果jade编译任务return stream(为什么return stream下面会讲), 错误发生时errhandler会避免进程crash但是会导致无法继续watch jade compile, 也就是说文件变更后也不会执行任务. 其他任务不受影响.

这些表现上的差异应该是不同插件实现原理不同导致的,因此需要有一个可以处理所有错误的通用方法, 我找到了gulp-plumber, 流式任务体系中的水管工.

使用方法:

$javascript$
var gutil = require( 'gulp-util' )
var plumber = require( 'gulp-plumber' );
function errrHandler( e ){
    // 控制台发声,错误时beep一下
    gutil.beep();
    gutil.log( e );
}
gulp.task( 'less', function(){
    gulp.src( [srcPath] )
        // 在处理前注册plumber
        .pipe( plumber( errorHandler: errrHandler ) )
        .pipe( less() )
        .pipe( gulp.dest(destPath) );
} )

现在的错误处理鲁棒性就很好了,至今没有遇见它不能胜任的情况.

进阶3:同步任务/线性任务

gulp比起grunt更加的node-style, 而且其依赖的orchestrator的特性就是最大并发的执行任务, 以上, 在提供了大并发和nodejs异步操作特性的同事,给使用者在gulp中做同步的线性的任务带来了一定的难度.

根据gulp在github上的文档的描述, 有三种方法可以通过任务依赖实现线性的同步的任务.

callback/ return stream / return promise

doc中也给出了三种的范例代码, 但就笔者看来实际可操作的就只有return stream一项, 因为其他的两中方式, 需要的是你准确的知道何时任务结束, 然后callback() 或者 进行promise的revolve. 因此, 笔者的同步任务实践, 只有return stream一种, 下面是范例:

$javascript$
gulp.task( 'coffee', function(){
    return gulp.src( coffee Src )
        .pipe( coffee() )
        .pipe( gulp.dest( coffeeDest ) )
} );
gulp.task( 'uglify', ['coffee'], function(){
        return gulp.src( jsSrc )
            .pipe( uglify() )
            .pipe( jsMinDest );
} );

gulp.task( 'dev', ['coffee'] );

gulp.task( 'default', ['uglify'] );

上述的代码实现了”开发时代码不压缩,部署时执行默认任务进行js压缩”的需求.虽然这个需求也可以通过写两个coffee编译任务来实现, 其中一个在dest前进行uglify, 但是这不该是程序猿的做法, 我们才不要做重复的劳动~!

当然单纯的return stream面对更复杂的task需求时会应付不来,我们还有更好的方式来实现DRY的同时针对不同任务执行不同的流程, 那就是下面要讲的条件判断.

进阶4:条件判断, 让task-runner更懂你

上面提到的需求: 在不增加重复代码的前提下针对不同的command gulp [taskname]执行不同的任务流程. 我们可以通过获取命令的参数然后做判断来实现.

gulp [taskname]最终还是执行的一个nodejs脚本, 所以可以通过process.argv[2]来获取taskname, 如果为undefined那么就是default任务, 有值的话就是其他的任务. 当然也可以用cli比较标准的方式用cmd file —param的格式来实行命令, 在gulpfile内部来取得参数值, 具体可以看这个讨论.

在取得任务名称后, 和gulpfile中预设好的一个tasklist来做对比, 看看是不是某些特定的任务. 我们仍然以开发和部署来举例. 预设一个这样的tasklist: 如果任务名匹配到, 那么就进行代码压缩,jade/less/coffee编译后的文件都进行压缩, 当然压缩方法不一样.所以我们可以做一个这样的list:

$javascript$
var proTaskList = [undefined, 'all-compress'];

然后用取得的taskname去匹配:

$javascript$
var taskName = process.argv[2];
var isPro = ( proTaskList.indexOf( taskName ) >=0 )? true: false;

这样就判断出了是否是需要压缩的环境.接下来就是如何做判断了.

gulp-cond模块可以在stream pipe的过程中,判断某个变量的true or false, 将stream导向不同的处理函数:

$javascript$
var cond = require('gulp-cond');
// codes
.pipe( cond( boolean, trueHanlder() [, falseHandler() ] ) )
.pipe…

上述的意思是在判断boolean的值后,true时执行trueHandler(), falseHandler()是可选的.

如此一来就可以在jade/less/coffee的编译任务中根据不同的命令行任务进行不同的流导向, 笔者常用的几句判断是这样的, 当然可以在我的github项目(如我的blog项目)中找到全部的gulpfile.

$javascript$
// jade 编译
cond( isPro, jade(), jade({ pretty: true }) )

// less编译 生产时压缩, 开发时将"哪个less文件的哪一行生成了该条css规则"
// 输出在编译好的css文件里
cond( isPro,
    less({ compressed: true }),
    less({ dumpLineNumbers: 'comments'})
)

// coffee 编译, 生产时添加uglify流程
.pipe( coffee({ bare: true }) )
.pipe( cond( isPro, uglify() ) )

额外的东西:

  • 使用gulp-changed模块可以大大减少重复编译, 注意extension参数可以告诉模块, 当前任务的源文件和产出文件的扩展名是不同的,把这点考虑进来之后changed模块就能精准的判断编译类的任务的源文件是否较上次有变更了.
  • 尽管gulpfile中是写function来定义和执行任务, 但是一点点配置加上灵活的function会让程序的复用性增强而且更加易读哦.
  • gulp现在的模块数大约有700+,远远及不上grunt的生态系统. 当然由于gulp的plugin功能性更加专一, 使得模块间相互配合更加方便, 插件的编写也更加的简单.官方给出的gulp-replace模块样例就相当之简单, 赶快给gulp社区贡献自己的一份力量吧~

结语

感谢亲爱的读者看完这篇长文,希望能给你日常的开发带来些许便利. 有任何问题都可以在下面评论中留言, 也可以到我的github上提issue.

9月2日更新: gulp.src时exclude的细节:

gulp.src匹配, 还有gulp.watch时的匹配, 本质上都是vinyl-fs做的文件系统的匹配. 在写exclude匹配表达式, 也就是那个gulp.src的参数字符串或者字符串数组时, exclude的模式要和匹配的模式一致:

var file2w = ['js/**/*.js', '!js/bundle.js'];        // 可以exclude掉bundle.js
file2w = ['js/**/*.js', '!./js/bundle.js'];            // 无法exclude掉bundle.js

尽管js./js的含义是一样的, 都是当前目录下的js文件夹, 但是匹配和排除的表达式模式不一致的时候就会无法exclude掉. 这个问题… 我已经提交了issue, 不知道gulp team会怎么回复. 大家可以关注一下这个issue. 渣英语, 勿怪.

评论
发表评论
暂无评论
WRITTEN BY
chenllos
志向远大的前端~
TA的新浪微博
PUBLISHED IN
全端工作流

前端/全端工程师的高效工作流程

友情链接 大搜车前端团队博客
我的收藏