行业里有一种批评,说前端太浮躁,总是追逐新技术,感觉 grunt 还不熟悉,突然一夜之间满大街都在谈论 gulp 了。月影觉得不能怪技术发展太快,技术发展总是带来好处多于坏处,有时候我们确实需要鼓起勇气去“追求”技术潮流,当然理由是为了弄明白为什么有这些技术工具,而无关于什么浮躁之类的事儿。
也许是从业很多年有点累了,月影也对技术有些后知后觉,感觉 gulp 已经火了很久,才终于想起来写这篇文章,也许现在,很多工程师早已又去追求其他的什么类似的构建工具了。不管怎么样,如果你是一位前端工程师,你从来没有想过用构建工具优化网站这种事儿,或者你在工作中所在的团队和平台已经有成熟的工具,工作中不用自己再去琢磨 gulp 。你仍然可以暂时停下来阅读这篇文章,看看 gulp 这样的构建工具如何能帮你更简单地在构建的时候自动优化你的网站,也许你的个人博客也需要优化,也许你换了工作,要和之前熟悉不一样的构建工具,然而基本原理终归是“一招鲜吃遍天”的,不是吗?
Gulp 的官网title上对这个工具有一个比较准确的定义,叫做: 基于流的自动化构建工具 。如果你查看它的网页源代码,还会看到在 <meta>
标签里有一段更详细的描述:
Gulp.js 是一个自动化构建工具,开发者可以使用它在项目开发过程中自动执行常见任务。Gulp.js 是基于 Node.js 构建的,利用 Node.js 流的威力,你可以快速构建项目并减少频繁的 IO 操作。Gulp.js 源文件和你用来定义任务的 Gulp 文件都是通过 JavaScript(或者 CoffeeScript )源码来实现的。
所以,Gulp 是在项目开发过程中自动执行任务的一个工具,通过它可以方便地在开发时(或者发布前),对目标文件的内容进行I/O操作。
由于 Gulp 是基于流的,所以 Gulp 对于文件内容的操作就像是水槽对于水流,水流流经水槽,水槽将水流塑造成不同的形状。
既然是基于流的,在进一步理解 Gulp 前,我们最好先来理解什么是流。
在计算机系统中文件的大小是变化很大的,有的文件内容非常多非常大,而 Node.js 里文件操作是异步的,如果用一个形象的比喻来形容将一个文件的内容读取出来并写入另一个文件中,可以将它想象成文件内容像是水流,从一个文件“流”入另一个文件。
在node里面,读写文件可以用“流”来描述:
"use strict"; let fs = require("fs"); fs.createReadStream("./in.txt") .pipe(fs.createWriteStream("./out.txt"));
上面的代码除了将 in.txt 文件中的内容输出到 out.txt 之外,不做其他任何事情,相当于赋值了一份数据,从语法形式上可以看到,“数据流”从 fs.createReadStream 创建然后经过 pipe 流出,最后到 fs.createWriteStream。
在这输入流到输出流的中间,我们可以对“流”做一些事情:
"use strict"; let fs = require("fs"); let through = require("through2"); fs.createReadStream("./in.txt") .pipe(through.obj(function (contents, enc, done) { if(enc === "buffer"){ contents = contents.toString("utf-8"); enc = "utf-8"; } done(null, contents, enc); })) .pipe(through.obj(function (contents, enc, done) { done(null, contents.toUpperCase(), enc); })) .pipe(through.obj(function (contents, enc, done) { contents = contents.split("").reverse().join(""); done(null, contents, enc); })) .pipe(fs.createWriteStream("./out.txt"));
在上面的代码里,我们通过 Node.js 的 through2 库(这是一个针对“流”的包装库),将输入流一步步转换成输出流,在中间的 pipes 中我们先是将 Buffer 转成 String,然后将它变成大写,最后再 reverse 然后传给输出流。
所以如果 in.txt 的文件内容是 hello world~
,那么 out.txt 的文件内容将是: ~DLROW OLLEH
。
月影觉得 Gulp 的文档其实写得挺烂的,点中文文档页面除了让你看 入门指南 、 API文档 、 CLI文档 、 编写插件文档 之外就没什么了,但实际上真要用 Gulp 的高级功能,这些文档简直就和教人如何画马一样:
既然 Gulp 是基于流的,我们就要理解 Gulp 如何控制和操作流。
然而在这之前,我们还要先看最基础的(还没安装 Gulp 的同学可以照前面那个入门指南安装一下~)
var gulp = require("gulp"); gulp.task("sync1", function() { console.log("我是一个同步任务"); }); gulp.task("async", function(done) { setTimeout(function(){ console.log("我是一个异步任务"); done(); }, 2000); });
我们可以看到 Gulp 是基于任务的,gulp.task 可以定义一个任务,这样的话,我们在命令行下就可以通过 gulp 任务名
的方式来执行命令了:
$ gulp sync1 [18:27:12] Using gulpfile ~/Workspace/yuntu/myblog-master/item/photohome/gulpfile.js [18:27:12] Starting "sync1"... 我是一个同步任务 [18:27:12] Finished "sync1" after 122 μs $ gulp async [18:27:48] Using gulpfile ~/Workspace/yuntu/myblog-master/item/photohome/gulpfile.js [18:27:48] Starting "async"... 我是一个异步任务 [18:27:50] Finished "async" after 2 s
Gulp 的任务可以是同步和异步,在异步任务中确定任务完成,可以通过调用函数参数 done 来实现。
Gulp 也允许我们将任务组合起来执行:
var gulp = require("gulp"); var through = require("through2"); gulp.task("sync1", function() { console.log("我是一个同步任务"); }); gulp.task("sync2", function() { console.log("我是另一个同步任务"); }); gulp.task("sync3", function() { console.log("我是又一个同步任务"); }); gulp.task("async", function(done) { console.log("老大喊我去搬砖"); setTimeout(function(){ console.log("我是一个异步任务"); done(); }, 2000); }); gulp.task("syncs", ["async", "sync1", "sync2", "sync3"], function(){ console.log("砖搬完了!"); });
$ gulp syncs [18:30:30] Using gulpfile ~/Workspace/yuntu/myblog-master/item/photohome/gulpfile.js [18:30:30] Starting "async"... 老大喊我去搬砖 [18:30:30] Starting "sync1"... 我是一个同步任务 [18:30:30] Finished "sync1" after 142 μs [18:30:30] Starting "sync2"... 我是另一个同步任务 [18:30:30] Finished "sync2" after 55 μs [18:30:30] Starting "sync3"... 我是又一个同步任务 [18:30:30] Finished "sync3" after 43 μs 我是一个异步任务 [18:30:32] Finished "async" after 2 s [18:30:32] Starting "syncs"... 砖搬完了! [18:30:32] Finished "syncs" after 38 μs
我们看到说 gulp.task 可以有依赖,只要第二个参数传一个数组,中间加上依赖的任务就行了,而数组里面的这些任务是 并行处理 的,不会一个执行完才执行另一个(同步任务的输出比异步任务的结束早)。
以上是 Gulp 基本的任务模型。对于每个 task,Gulp 通常用来操作文件输入和输出流,因此 Gulp 封装了批量操作文件流的 api:
gulp.task("src-dist", function(){ gulp.src("./*.html") .pipe(gulp.dest("./dist")); });
上面的命令表示将当前目录下所有的 .html 文件匹配出来,依次输出到目标文件夹 ./dist 中去。
我们还可以用更高级的通配符:
gulp.task("src-dist", function(){ gulp.src("./**/*.html") .pipe(gulp.dest("./dist")); });
这样处理的 html 文件不仅仅匹配当前目录下的,还包括所有子目录里。关于输入这块,具体的用法还有很多,遵循的规范是glob模式,可以参考 node-glob
与上面说的 FileSystem 文件流类似,如果我们不做什么别的事情,那么我们就只是将文件从源 src,拷贝到了目的地 dest,其他的啥也没做,那么显然,我们可以做那么一些事情,在这里,我们尝试处理一下 index.html:
gulp.task("build-index", function(){ gulp.src("./index.html") .pipe(through.obj(function(file, encode, cb) { var contents = file.contents.toString(encode); var HTMLMinifier = require("html-minifier").minify; var minified = HTMLMinifier(contents, { minifyCSS: true, minifyJS: true, collapseWhitespace: true, removeAttributeQuotes: true }); //console.log(minified); file.contents = new Buffer(minified, encode); cb(null, file, encode); })) .pipe(gulp.dest("./dist")); });
gulp.src 的输入流和 fileReadStream 会有一点点不一样,它的第一个参数不是一个 Buffer,而是一个包含文件信息和文件内容的对象,第二个参数是文件的编码,因此我们可以通过
var contents = file.contents.toString(encode);
将文件内容转成字符串。之后,我们使用 html-minifier 对文件内容和内联的样式、脚本进行压缩,这样就简单完成了首页 index.html 的优化!
前面只完成了优化的第一步,我们还没考虑外链资源该怎么处理呢,外链资源包括 js、 css 和图片。在处理之前,我们来约定一些规范:
页面 js 存放在 ./static/js 下,公共的库放在 ./static/js/lib 下,公共库只压缩不合并,页面 js 压缩并合并。
页面 css 存放在 ./static/css 下,公共的css放在 ./static/css/common 下,公共 css 只压缩不合并,页面 css 压缩并合并。
图片资源中小于3kb的图片以 base64 方式内联,图片放在 ./static/img 下。
function minifyAndComboJS(name, encode, files){ var fs = require("fs"); var UglifyJS = require("uglify-js"); var content = ""; files.forEach(function(js){ var minified = UglifyJS.minify(js).code; content += minified; }); if(content){ var combo = "static/js/" + name; } fs.writeFileSync(combo, content); gulp.src(combo) .pipe(gulp.dest("./dist/static/js")); }
gulp.task("build-js-lib", function(){ gulp.src("./static/js/lib/**/*.js") .pipe(through.obj(function(file, encode, cb) { var UglifyJS = require("uglify-js"); var contents = file.contents.toString(encode); var minified = UglifyJS.minify(contents, {fromString:true}).code; file.contents = new Buffer(minified, encode); cb(null, file, encode); })) .pipe(gulp.dest("./dist/static/js/lib")); });
function minifyAndComboCSS(name, encode, files){ var fs = require("fs"); var CleanCSS = require("clean-css"); var content = ""; files.forEach(function(css){ var contents = fs.readFileSync(css, encode); var minified = new CleanCSS().minify(contents).styles; content += minified; }); if(content){ var combo = "static/css/" + name; } fs.writeFileSync(combo, content); gulp.src(combo) .pipe(gulp.dest("./dist/static/css")); }
gulp.task("build-common-css", function(){ gulp.src("./static/css/common/**/*.css") .pipe(through.obj(function(file, encode, cb) { var CleanCSS = require("clean-css"); var contents = file.contents.toString(encode); var minified = new CleanCSS().minify(contents).styles; file.contents = new Buffer(minified, encode); cb(null, file, encode); })) .pipe(gulp.dest("./dist/static/css/common")); });
//内联小图片 var imgs = $("img"); for(var i = 0; i < imgs.length; i++){ var img = $(imgs[i]); var src = img.attr("src"); if(/^static//img/.test(src)){ var stat = fs.statSync(src); var ext = require("path").parse(src).ext; if(stat.size <= 3000){ var head = ext === ".png" ? "data:image/png;base64," : "data:image/jpeg;base64,"; var datauri = fs.readFileSync(src).toString("base64"); img.attr("src", head + datauri); } } }
contents = $.html(); //压缩 HTML var HTMLMinifier = require("html-minifier").minify; var minified = HTMLMinifier(contents, { minifyCSS: true, minifyJS: true, collapseWhitespace: true, removeAttributeQuotes: true });
然后,在处理 index.html 的时候,我们可以使用 cheerio 来解析文件,将要处理的外链从文档中提取出来。
var $ = require("cheerio").load(contents, {decodeEntities: false}); //处理外链 css var links = $("link"); var cssToCombo = []; for(var i = 0; i < links.length; i++){ var link = $(links[i]); if(link.attr("rel") === "stylesheet"){ var href = link.attr("href"); if(/^static//css//(?!common)/.test(href)){ cssToCombo.push(href); if(cssToCombo.length == 1){ link.attr("href", "static/css/index.min.css"); }else{ link.remove(); } } } } minifyAndComboCSS("index.min.css", encode, cssToCombo); //处理外链 js var scripts = $("script"); var jsToCombo = []; for(var i = 0; i < scripts.length; i++){ var s = $(scripts[i]); //判断script标签确实是js if(s.attr("type") == null || s.attr("type") === "text/javascript"){ var src = s.attr("src"); if(src){ //外链的js,默认只处理以/static/开头的资源 if(/^static//js//(?!lib)/.test(src)){ jsToCombo.push(src); if(jsToCombo.length == 1){ s.attr("src", "static/js/index.min.js"); }else{ s.remove(); } } } } } minifyAndComboJS("index.min.js", encode, jsToCombo);
最后是完整的代码:
var gulp = require("gulp"); var through = require("through2"); function minifyAndComboCSS(name, encode, files){ var fs = require("fs"); var CleanCSS = require("clean-css"); var content = ""; files.forEach(function(css){ var contents = fs.readFileSync(css, encode); var minified = new CleanCSS().minify(contents).styles; content += minified; }); if(content){ var combo = "static/css/" + name; } fs.writeFileSync(combo, content); gulp.src(combo) .pipe(gulp.dest("./dist/static/css")); } function minifyAndComboJS(name, encode, files){ var fs = require("fs"); var UglifyJS = require("uglify-js"); var content = ""; files.forEach(function(js){ var minified = UglifyJS.minify(js).code; content += minified; }); if(content){ var combo = "static/js/" + name; } fs.writeFileSync(combo, content); gulp.src(combo) .pipe(gulp.dest("./dist/static/js")); } gulp.task("build-index", ["build-js-lib", "build-common-css"], function(){ gulp.src("./index.html") .pipe(through.obj(function(file, encode, cb) { var fs = require("fs"); var contents = file.contents.toString(encode); var $ = require("cheerio").load(contents, {decodeEntities: false}); //处理外链 css var links = $("link"); var cssToCombo = []; for(var i = 0; i < links.length; i++){ var link = $(links[i]); if(link.attr("rel") === "stylesheet"){ var href = link.attr("href"); if(/^static//css//(?!common)/.test(href)){ cssToCombo.push(href); if(cssToCombo.length == 1){ link.attr("href", "static/css/index.min.css"); }else{ link.remove(); } } } } minifyAndComboCSS("index.min.css", encode, cssToCombo); //处理外链 js var scripts = $("script"); var jsToCombo = []; for(var i = 0; i < scripts.length; i++){ var s = $(scripts[i]); //判断script标签确实是js if(s.attr("type") == null || s.attr("type") === "text/javascript"){ var src = s.attr("src"); if(src){ //外链的js,默认只处理以/static/开头的资源 if(/^static//js//(?!lib)/.test(src)){ jsToCombo.push(src); if(jsToCombo.length == 1){ s.attr("src", "static/js/index.min.js"); }else{ s.remove(); } } } } } minifyAndComboJS("index.min.js", encode, jsToCombo); //处理内联图片 var imgs = $("img"); for(var i = 0; i < imgs.length; i++){ var img = $(imgs[i]); var src = img.attr("src"); if(/^static//img/.test(src)){ var stat = fs.statSync(src); var ext = require("path").parse(src).ext; if(stat.size <= 3000){ var head = ext === ".png" ? "data:image/png;base64," : "data:image/jpeg;base64,"; var datauri = fs.readFileSync(src).toString("base64"); img.attr("src", head + datauri); } } } contents = $.html(); //压缩 HTML var HTMLMinifier = require("html-minifier").minify; var minified = HTMLMinifier(contents, { minifyCSS: true, minifyJS: true, collapseWhitespace: true, removeAttributeQuotes: true }); file.contents = new Buffer(minified, encode); cb(null, file, encode); })) .pipe(gulp.dest("./dist")); }); gulp.task("build-js-lib", function(){ gulp.src("./static/js/lib/**/*.js") .pipe(through.obj(function(file, encode, cb) { var UglifyJS = require("uglify-js"); var contents = file.contents.toString(encode); var minified = UglifyJS.minify(contents, {fromString:true}).code; file.contents = new Buffer(minified, encode); cb(null, file, encode); })) .pipe(gulp.dest("./dist/static/js/lib")); }); gulp.task("build-common-css", function(){ gulp.src("./static/css/common/**/*.css") .pipe(through.obj(function(file, encode, cb) { var CleanCSS = require("clean-css"); var contents = file.contents.toString(encode); var minified = new CleanCSS().minify(contents).styles; file.contents = new Buffer(minified, encode); cb(null, file, encode); })) .pipe(gulp.dest("./dist/static/css/common")); });
我们用 Gulp 创建了一个非常简单的构建脚本,它可以压缩合并我们项目的 js 和 css 并处理小图片,我们还可以给它进一步增加其他功能,例如给压缩的文件添加版本号,或者根据内容计算签名以实现更新后不被缓存,我们还可以用 CDN 服务的 sdk 将资源发布到 CDN 并替换原始链接,同时,我们可以不用每次发布所有的文件,我们可以在开发的时候用 gulp.watch 来监控文件的修改,以实现增量的编译发布。
总之,我们可以用 gulp 来做许多有用的事情,来完善我们的构建脚本,而这一切都因为 gulp 基于流的构建以及 NPM 丰富的库变得非常简单。最后的最后,由于我们从头使用 through2 来处理任务,所以我们在具体实现功能的时候还是略微繁琐,事实上 gulp 提供了不少有用的 插件 ,这些插件直接返回 stream 对象,可以让构建过程变得更简单,具体的可以多研究官方的文档。