Gulp Under the Hood:构建基于流的任务自动化工具

已发表: 2022-03-11

如今,前端开发人员正在使用多种工具来自动化日常操作。 三个最流行的解决方案是 Grunt、Gulp 和 Webpack。 这些工具中的每一个都建立在不同的理念之上,但它们有着相同的共同目标:简化前端构建过程。 例如,Grunt 是配置驱动的,而 Gulp 几乎什么都不强制。 事实上,Gulp 依赖于开发人员编写代码来实现构建流程的流程——各种构建任务。

Gulp Under the Hood:构建基于流的任务自动化工具

在选择其中一种工具时,我个人最喜欢的是 Gulp。 总而言之,这是一个简单、快速和可靠的解决方案。 在本文中,我们将通过尝试实现我们自己的类似 Gulp 的工具来了解 Gulp 是如何工作的。

吞咽API

Gulp 只有四个简单的功能:

  • 吞咽任务
  • 吞咽.src
  • gulp.dest
  • gulp.watch

这四个简单的功能,在各种组合中提供了 Gulp 的所有功能和灵活性。 在 4.0 版本中,Gulp 引入了两个新函数:gulp.series 和 gulp.parallel。 这些 API 允许任务以串行或并行方式运行。

在这四个函数中,前三个对于任何 Gulp 文件都是绝对必要的。 允许从命令行界面定义和调用任务。 第四个是让 Gulp 真正自动化的原因,它允许在文件更改时运行任务。

吞吐文件

这是一个基本的 gulpfile:

 gulp.task('test', function{ gulp.src('test.txt') .pipe(gulp.dest('out')); }); 

它描述了一个简单的测试任务。 调用时,应将当前工作目录中的文件test.txt复制到目录./out中。 运行 Gulp 试试看:

 touch test.txt # Create test.txt gulp test

请注意,方法.pipe不是 Gulp 的一部分,它是节点流 API,它将可读流(由gulp.src('test.txt')生成)与可写流(由gulp.dest('out') )。 Gulp 和插件之间的所有通信都基于流。 这让我们能够以如此优雅的方式编写 gulpfile 代码。

满足插头

现在我们对 Gulp 的工作原理有了一些了解,让我们构建自己的类似 Gulp 的工具:Plug。

我们将从 plug.task API 开始。 它应该让我们注册任务,如果在命令参数中传递任务名称,则应该执行任务。

 var plug = { task: onTask }; module.exports = plug; var tasks = {}; function onTask(name, callback){ tasks[name] = callback; }

这将允许注册任务。 现在我们需要使这个任务可执行。 为简单起见,我们不会制作单独的任务启动器。 相反,我们将把它包含在我们的插件实现中。

我们需要做的就是运行命令行参数中命名的任务。 我们还需要确保在所有任务都注册后,我们会尝试在下一个执行循环中执行此操作。 最简单的方法是在超时回调中运行任务,或者最好是 process.nextTick:

 process.nextTick(function(){ var taskName = process.argv[2]; if (taskName && tasks[taskName]) { tasks[taskName](); } else { console.log('unknown task', taskName) } });

像这样编写plugfile.js:

 var plug = require('./plug'); plug.task('test', function(){ console.log('hello plug'); })

......并运行它。

 node plugfile.js test

它将显示:

 hello plug

子任务

Gulp 还允许在任务注册时定义子任务。 在这种情况下,plug.task 应该有 3 个参数,名称、子任务数组和回调函数。 让我们实现它。

我们需要像这样更新任务 API:

 var tasks = {}; function onTask(name) { if(Array.isArray(arguments[1]) && typeof arguments[2] === "function"){ tasks[name] = { subTasks: arguments[1], callback: arguments[2] }; } else if(typeof arguments[1] === "function"){ tasks[name] = { subTasks: [], callback: arguments[1] }; } else{ console.log('invalid task registration') } } function runTask(name){ if(tasks[name].subTasks){ tasks[name].subTasks.forEach(function(subTaskName){ runTask(subTaskName); }); } if(tasks[name].callback){ tasks[name].callback(); } } process.nextTick(function(){ if (taskName && tasks[taskName]) { runTask(taskName); } });

现在,如果我们的 plugfile.js 看起来像这样:

 plug.task('subTask1', function(){ console.log('from sub task 1'); }) plug.task('subTask2', function(){ console.log('from sub task 2'); }) plug.task('test', ['subTask1', 'subTask2'], function(){ console.log('hello plug'); })

… 运行它

node plugfile.js test

… 应该显示:

 from sub task 1 from sub task 2 hello plug

请注意,Gulp 并行运行子任务。 但为了简单起见,在我们的实现中,我们按顺序运行子任务。 Gulp 4.0 允许使用它的两个新 API 函数来控制它,我们将在本文后面实现。

来源和目的地

如果我们不允许读取和写入文件,则 Plug 几乎没有用处。 所以接下来我们将实现plug.src 。 Gulp 中的这个方法需要一个参数,它可以是文件掩码、文件名或文件掩码数组。 它返回一个可读的节点流。

现在,在我们的src实现中,我们只允许文件名:

 var plug = { task: onTask, src: onSrc }; var stream = require('stream'); var fs = require('fs'); function onSrc(fileName){ var src = new stream.Readable({ read: function (chunk) { }, objectMode: true }); //read file and send it to the stream fs.readFile(path, 'utf8', (e,data)=> { src.push({ name: path, buffer: data }); src.push(null); }); return src; }

请注意,我们在这里使用objectMode: true ,这是一个可选参数。 这是因为节点流默认使用二进制流。 如果我们需要通过流传递/接收 JavaScript 对象,我们必须使用这个参数。

如您所见,我们创建了一个人造对象:

 { name: path, //file name buffer: data //file content }

…并将其传递到流中。

另一方面,plug.dest 方法应该接收一个目标文件夹名称并返回一个可写流,该流将从 .src 流中接收对象。 一旦接收到文件对象,它将被存储到目标文件夹中。

 function onDest(path){ var writer = new stream.Writable({ write: function (chunk, encoding, next) { if (!fs.existsSync(path)) fs.mkdirSync(path); fs.writeFile(path +'/'+ chunk.name, chunk.buffer, (e)=> { next() }); }, objectMode: true }); return writer; }

让我们更新我们的plugfile.js:

 var plug = require('./plug'); plug.task('test', function(){ plug.src('test.txt') .pipe(plug.dest('out')) })

… 创建test.txt

 touch test.txt

…并运行它:

 node plugfile.js test ls ./out

test.txt应该被复制到./out文件夹。

Gulp 本身的工作方式大致相同,但它不是使用我们的人工文件对象,而是使用乙烯基对象。 它更方便,因为它不仅包含文件名和内容,还包含其他元信息,例如当前文件夹名称、文件的完整路径等。 它可能不包含整个内容缓冲区,但它具有内容的可读流。

乙烯基:比文件更好

有一个出色的库vinyl-fs,它可以让我们操作表示为乙烯基对象的文件。 它本质上让我们基于文件掩码创建可读、可写的流。

我们可以使用vinyl-fs 库重写插件函数。 但首先我们需要安装vinyl-fs:

 npm i vinyl-fs

安装后,我们新的 Plug 实现将如下所示:

 var vfs = require('vinyl-fs') function onSrc(fileName){ return vfs.src(fileName); } function onDest(path){ return vfs.dest(path); } // ...

…并尝试一下:

 rm out/test.txt node plugFile.js test ls out/test.txt

结果应该还是一样的。

Gulp 插件

由于我们的 Plug 服务使用 Gulp 流约定,我们可以将原生 Gulp 插件与我们的 Plug 工具一起使用。

让我们试一试。 安装 gulp 重命名:

 npm i gulp-rename

...并更新 plugfile.js 以使用它:

 var plug = require('./app.js'); var rename = require('gulp-rename'); plug.task('test', function () { return plug.src('test.txt') .pipe(rename('renamed.txt')) .pipe(plug.dest('out')); });

你猜对了,现在运行 plugfile.js 应该仍然会产生相同的结果。

 node plugFile.js test ls out/renamed.txt

监控变化

最后但并非最不重要的方法是gulp.watch该方法允许我们注册文件侦听器并在文件更改时调用已注册的任务。 让我们实现它:

 var plug = { task: onTask, src: onSrc, dest: onDest, watch: onWatch }; function onWatch(fileName, taskName){ fs.watchFile(fileName, (event, filename) => { if (filename) { tasks[taskName](); } }); }

要尝试一下,请将此行添加到 plugfile.js:

 plug.watch('test.txt','test');

现在,在每次更改test.txt时,文件将被复制到更改名称的 out 文件夹中。

串联与并联

现在 Gulp API 的所有基本功能都已实现,让我们更进一步。 即将发布的 Gulp 版本将包含更多 API 函数。 这个新的 API 将使 Gulp 更加强大:

  • 吞咽并行
  • gulp.series

这些方法允许用户控制任务运行的顺序。 要并行注册子任务,可以使用 gulp.parallel,这是当前的 Gulp 行为。 另一方面,gulp.series 可用于以顺序方式运行子任务,一个接一个。

假设我们在当前文件夹中有test1.txttest2.txt 。 为了将这些文件并行复制到 out 文件夹,让我们制作一个插件文件:

 var plug = require('./plug'); plug.task('subTask1', function(){ return plug.src('test1.txt') .pipe(plug.dest('out')) }) plug.task('subTask2', function(){ return plug.src('test2.txt') .pipe(plug.dest('out')) }) plug.task('test-parallel', plug.parallel(['subTask1', 'subTask2']), function(){ console.log('done') }) plug.task('test-series', plug.series(['subTask1', 'subTask2']), function(){ console.log('done') })

为了简化实现,使子任务回调函数返回其流。 这将帮助我们跟踪流的生命周期。

我们将开始修改我们的 API:

 var plug = { task: onTask, src: onSrc, dest: onDest, parallel: onParallel, series: onSeries };

我们还需要更新onTask函数,因为我们需要添加额外的任务元信息来帮助我们的任务启动器正确处理子任务。

 function onTask(name, subTasks, callback){ if(arguments.length < 2){ console.error('invalid task registration',arguments); return; } if(arguments.length === 2){ if(typeof arguments[1] === 'function'){ callback = subTasks; subTasks = {series: []}; } } tasks[name] = subTasks; tasks[name].callback = function(){ if(callback) return callback(); }; } function onParallel(tasks){ return { parallel: tasks }; } function onSeries(tasks){ return { series: tasks }; }

为简单起见,我们将使用 async.js,一个用于处理异步函数以并行或串行运行任务的实用程序库:

 var async = require('async') function _processTask(taskName, callback){ var taskInfo = tasks[taskName]; console.log('task ' + taskName + ' is started'); var subTaskNames = taskInfo.series || taskInfo.parallel || []; var subTasks = subTaskNames.map(function(subTask){ return function(cb){ _processTask(subTask, cb); } }); if(subTasks.length>0){ if(taskInfo.series){ async.series(subTasks, taskInfo.callback); }else{ async.parallel(subTasks, taskInfo.callback); } }else{ var stream = taskInfo.callback(); if(stream){ stream.on('end', function(){ console.log('stream ' + taskName + ' is ended'); callback() }) }else{ console.log('task ' + taskName +' is completed'); callback(); } } }

我们依赖节点流'end',它在流处理完所有消息并关闭时发出,这表明子任务已完成。 使用 async.js,我们不必处理一大堆回调。

要尝试一下,让我们首先并行运行子任务:

 node plugFile.js test-parallel
 task test-parallel is started task subTask1 is started task subTask2 is started stream subTask2 is ended stream subTask1 is ended done

并连续运行相同的子任务:

 node plugFile.js test-series
 task test-series is started task subTask1 is started stream subTask1 is ended task subTask2 is started stream subTask2 is ended done

结论

就是这样,我们已经实现了 Gulp 的 API,现在可以使用 Gulp 插件了。 当然,不要在实际项目中使用 Plug,因为 Gulp 不仅仅是我们在这里实现的。 我希望这个小练习能帮助你了解 Gulp 的底层工作原理,让我们更流畅地使用它并使用插件对其进行扩展。

相关:使用 Gulp 进行 JavaScript 自动化简介