探究Gulp的Stream

2020-09-30 18:01 更新

来自Gulp的难题

描述 Gulp 的项目构建过程的代码,并不总是简单易懂的。

比如 Gulp 的这份 recipe :

var browserify = require('browserify');
var gulp = require('gulp');
var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var uglify = require('gulp-uglify');
var sourcemaps = require('gulp-sourcemaps');
var gutil = require('gulp-util');

gulp.task('javascript', function () {
  var b = browserify({
    entries: './entry.js',
    debug: true
  });

  return b.bundle()
    .pipe(source('app.js'))
    .pipe(buffer())
    .pipe(sourcemaps.init({loadMaps: true}))
    .pipe(uglify())
    .on('error', gutil.log)
    .pipe(sourcemaps.write('./'))
    .pipe(gulp.dest('./dist/js/'));
});

这是一个使用 Browserify 及 Uglify 并生成 Source Map 的例子。请想一下这样几个问题:

  • b.bundle() 生成了什么,为什么也可以 .pipe() ?
  • 为什么不是从 gulp.src() 开始?
  • 为什么还要 vinyl-source-stream 和 vinyl-buffer ?它们是什么?
  • 添加在中间的 .on('error', gutil.log) 有什么作用?

要回答这些问题,就需要对 Gulp 做更深入的了解,这可以分成几个要素。

要素之一:Stream

你可能也在最初开始使用 Gulp 的时候就听说过:Gulp 是一个有关 Stream(数据流)的构建系统。这句话的意思是,Gulp 本身使用了 Node 的 Stream 。

Stream 如其名字所示的“流”那样,就像是工厂的流水线。你要加工一个产品,不用全部在一个位置完成,而是可以拆分成多道工序。产品从第一道工序开始,第一道工序完成后,输出然后流入第二道工序,然后再第三道工序…一方面,大批量的产品需求也不用等到全部完工(这通常很久),而是可以完工一个就拿到一个。另一方面,复杂的加工过程被分割成一系列独立的工序,这些工序可以反复使用,还可以在需要的时候进行替换和重组。这就是 Stream 的理念。

Stream 在 Node 中的应用十分广泛,几乎所有 Node 程序都在某种程度上用到了Stream 。

管道

Stream 有一个很基本的操作叫做管道(pipe)。Stream 是水流,而管道可以从一个流的输出口,接到另一个流的输入口,从而控制流向。如果用前面的流水线工序来说的话,就是连接工序的传输带了。

Node 的 Stream 有一个方法 pipe() ,也就是管道操作对应的方法。它一般这样用:

src.pipe(dst)

其中 src 和 dst 都是 stream ,分别代表源和目标。也就是说,流 src 的输出,将作为输入转到流 dst 。此外,这个方法返回目标流(比如这里.pipe(dst)返回dst),因此可以链式调用:

a.pipe(b).pipe(c).pipe(d)

内存操作

Stream 的整个操作过程,都在内存中进行。因此,相比 Grunt ,使用 Stream 的 Gulp 进行多步操作并不需要创建中间文件,可以省去额外的 src 和 dest 。

事件

Node 的 Stream 都是 Node 事件对象 EventEmitter 的实例,它们可以通过 .on() 添加事件侦听。

你可以查看 EventEmitter的API文档

类型

在现在的 Node 里, Stream 被分为4类,分别是 Readable(只读)、Writable(只写)、Duplex(双向)、** Transform(转换**)。其中 Duplex 就是指可读可写,而 Transform 也是 Duplex ,只不过输出是由输入计算得到的,因此算作 Duplex 的特例。

Readable Stream 和 Writable Stream 分别有不同的 API 及事件(例如 readable.read() 和 writable.write() ), Duplex Stream 和 Transform Stream 因为是可读可写,因此拥有前两者的全部特性。

例子

虽然 Node 中可以通过 require("stream") 引用 Stream ,但比较少会需要这样直接使用。大部分情况下,我们用的是 Stream Consumers ,也就是具有 Stream 特性的各种子类。

Node 中许多核心包都用到了 Stream ,它们也是 Stream Consumers 。以下是一个使用 Stream 完成文件复制的例子:

var fs = require("fs");
var r = fs.createReadStream("nyanpass.txt");
var w = fs.createWriteStream("nyanpass.copy.txt");
r.pipe(w).on("finish", function(){
    console.log("Write complete.");
});

其中,fs.createReadStream() 创建了 Readable Stream 的r,fs.createWriteStream() 创建了 Writable Stream 的w,然后 r.pipe(w) 这个管道方法就可以完成数据从r到w的流动。

如前文所说,Stream 是 EventEmitter 的实例,因此这里的 on() 方法为w添加了事件侦听,事件 finish 是 Writable Stream 的一个事件,触发于写入操作完成。

更多有关 Stream 的介绍,推荐阅读 Stream Handbook 和 Stream API 。

要素之二:Vinyl文件系统

虽然 Gulp 使用的是 Stream ,但却不是普通的 Node Stream ,实际上,Gulp(以及Gulp插件)用的应该叫做 Vinyl File Object Stream 。

这里的 Vinyl ,是一种虚拟文件格式。Vinyl 主要用两个属性来描述文件,它们分别是路径(path)及内容(contents)。具体来说,Vinyl 并不神秘,它仍然是 JavaScript Object 。Vinyl 官方给了这样的示例:

var File = require('vinyl');

var coffeeFile = new File({
  cwd: "/",
  base: "/test/",
  path: "/test/file.coffee",
  contents: new Buffer("test = 123")
});

从这段代码可以看出,Vinyl 是 Object,path 和 contents 也正是这个 Object 的属性。

Vinyl的意义

Gulp 为什么不使用普通的 Node Stream 呢?请看这段代码:

gulp.task("css", function(){
    gulp.src("./stylesheets/src/**/*.css")
        .pipe(gulp.dest("./stylesheets/dest"));
});

虽然这段代码没有用到任何 Gulp 插件,但包含了我们最为熟悉的 gulp.src() 和 gulp.dest() 。这段代码是有效果的,就是将一个目录下的全部 .css 文件,都复制到了另一个目录。这其中还有一个很重要的特性,那就是所有原目录下的文件树,包含子目录、文件名等,都原封不动地保留了下来。

普通的 Node Stream 只传输 String 或 Buffer 类型,也就是只关注“内容”。但 Gulp 不只用到了文件的内容,而且还用到了这个文件的相关信息(比如路径)。因此,Gulp 的 Stream 是 Object 风格的,也就是 Vinyl File Object 了。到这里,你也知道了为什么有 contents 、 path 这样的多个属性了。

vinyl-fs

 Gulp 并没有直接使用 vinyl ,而是用了一个叫做 vinyl-fs 的模块(和vinyl一样,都是npm)。vinyl-fs 相当于 vinyl 的文件系统适配器,它提供三个方法:.src()、.dest()和 .watch(),其中 .src() 将生成 Vinyl File Object,而 .dest() 将使用 Vinyl File Object ,进行写入操作。

在 Gulp 源码 index.js 中,可以看到这样的对应关系:

var vfs = require('vinyl-fs');
// ...
Gulp.prototype.src = vfs.src;
Gulp.prototype.dest = vfs.dest;
// ...

也就是说,gulp.src() 和 gulp.dest() 直接来源于 vinyl-fs 。

类型

Vinyl File Object 的 contents 可以有三种类型:Stream、Buffer(二进制数据)、Null(就是JavaScript里的null)。需要注意的是,各类 Gulp 插件虽然操作的都是Vinyl File Object,但可能会要求不同的类型。

在使用 Gulp 过程中,可能会碰到 incompatible streams 的问题,像这样:

091835

这个问题的原因一般都是 Stream 与 Buffer 的类型差异。Stream 如前文介绍,特性是可以把数据分成小块,一段一段地传输,而Buffer则是整个文件作为一个整体传输。可以想到,不同的 Gulp 插件做的事情不同,因此可能不支持某一种类型。例如, gulp-uglify 这种需要对 JavaScript 代码做语法分析的,就必须保证代码的完整性,因此,gulp-uglify 只支持 Buffer 类型的 Vinyl File Object。

gulp.src() 方法默认会返回Buffer类型,如果想要 Stream 类型,可以这样指明:

gulp.src("*.js", {buffer: false})

在 Gulp 的插件编写指南中,也可以找到 Using buffers 及 Dealing with streams 这样两种类型的参考。

Stream转换

为了让 Gulp 可以更多地利用当前 Node 生态体系的 Stream,出现了许多 Stream 转换模块。下面介绍一些比较常用的。

vinyl-source-stream

vinyl-source-stream 可以把普通的 Node Stream 转换为 Vinyl File Object Stream。这样,相当于就可以把普通 Node Stream 连接到 Gulp 体系内。具体用法是:

var fs = require("fs");
var source = require('vinyl-source-stream');
var gulp = require('gulp');

var nodeStream = fs.createReadStream("komari.txt");
nodeStream
    .pipe(source("hotaru.txt"))
    .pipe(gulp.dest("./"));

这段代码中的 Stream 管道,作为起始的并不是 gulp.src() ,而是普通的 Node Stream。但经过 vinyl-source-stream 的转换后,就可以用 gulp.dest() 进行输出。其中 source([filename]) 就是调用转换,我们知道 Vinyl 至少要有 contents 和 path ,而这里的原 Node Stream 只提供了 contents ,因此还要指定一个 filename 作为 path。

vinyl-source-stream 中的 stream,指的是生成的 Vinyl File Object ,其 contents 类型是 Stream。类似的,还有 vinyl-source-buffer,它的作用相同,只是生成的 contents 类型是 Buffer。

vinyl-buffer

vinyl-buffer 接收 Vinyl File Object 作为输入,然后判断其 contents 类型,如果是Stream 就转换为 Buffer。

很多常用的 Gulp 插件如 gulp-sourcemaps 、gulp-uglify,都只支持 Buffer 类型,因此 vinyl-buffer 可以在需要的时候派上用场。

Gulp错误处理

Gulp 有一个比较令人头疼的问题是,如果管道中有任意一个插件运行失败,整个 Gulp 进程就会挂掉。尤其在使用 gulp.watch() 做即时更新的时候,仅仅是临时更改了代码产生了语法错误,就可能使得 watch 挂掉,又需要到控制台里开启一遍。

对错误进行处理就可以改善这个问题。前面提到过,Stream 可以通过 .on() 添加事件侦听。对应的,在可能产生错误的插件的位置后面,加入 on("error"),就可以做错误处理:

gulp.task("css", function() {
    return gulp.src(["./stylesheets/src/**/*.scss"])
        .pipe(sass())
        .on("error", function(error) {
            console.log(error.toString());
            this.emit("end");
        })
        .pipe(gulp.dest("./stylesheets/dest"));
});

如果你不想这样自己定义错误处理函数,可以考虑 gulp-util 的 .log() 方法。

另外,这种方法可能会需要在多个位置加入 on("error"),此时推荐 gulp-plumber,这个插件可以很方便地处理整个管道内的错误。

据说 Gulp 下一版本,Gulp 4,将大幅改进 Gulp 的错误处理功能,敬请期待。

解答

现在,来回答本文开头的问题吧。

b.bundle() 生成了什么,为什么也可以 .pipe() ?b.bundle() 生成了 Node Stream 中的 Readable Stream ,而 Readable Stream 有管道方法 pipe() 。

为什么不是从 gulp.src() 开始?Browserify 来自 Node 体系而不是 Gulp 体系,要结合 Gulp 和 Browserify ,适当的做法是先从 Browserify 生成的普通 Node Stream 开始,然后再转换为 VInyl File Object Stream 连接到 Gulp 体系中。

为什么还要 vinyl-source-stream 和 vinyl-buffer ?它们是什么?因为 Gulp 插件的输入必须是 Buffer 或 Stream 类型的 Vinyl File Object 。它们分别是具有不同功能的 Stream 转换模块。

添加在中间的 .on('error', gutil.log) 有什么作用?错误处理,以便调试问题。

结语

再次确认,Gulp 是一个有关 Stream 的构建系统。Gulp 对其插件有非常严格的要求(看看插件指南就可以知道),认为插件必须专注于单一事务。这也许算是 Gulp 对Stream 理念的推崇。

尝试用 Gulp 完成更高级、更个性化的构建工作吧!


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号