幕后的gulp:构建一个基于流的任务自动化工具


/**
 * 谨献给Yoyo
 *
 * 原文出处:https://www.toptal.com/nodejs/gulp-under-the-hood
 * @author dogstar.huang <chanzonghuang@gmail.com> 2016-05-14
 */

前端开发人员现在正在使用多种工具把日常操作自动化。三个最流行的解决方案是Grunt,Gulp和Webpack。每个工具都建立在不同的理念,但是它们共享同一个目标:精简前端构建过程。例如,Grunt是配置驱动的,而Gulp几乎不需要配置即可执行。事实上,Gulp依赖于开发人员编写代码来实现构建流程 - 各种构建任务。

当谈到选择这些工具之一时,我个人最喜欢的是Gulp。总而言之,这是一个简单,快速和可靠的解决方案。在这篇文章中,我们将看到幕后的Gulp是如何工作,通过花点心思在实现我们自己的像Gulp这样的工具。

Gulp API 接口

Gulp自带的只有四个简单的功能

  • gulp.task
  • gulp.src
  • gulp.dest
  • gulp.watch

这四个简单的功能,通过各种组合提供了Glup全部的强悍和灵活性。在4.0版本中,Gulp引入了两个新的功能:gulp.series和gulp.parallel。这些API允许任务在串行或并行运行。

这四个函数,前三个是任何Gulp文件绝对必要的。允许任务可以被定义以及能在命令行界面调用。第四个是通过允许在文件改变时运行任务使得Gulp真正实现自动化。

Gulpfile

这是一个基本的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的一部分,而是一个node-stream API,它连接可读流(由gulp.src('test.txt')生成)与可写流(由gulp.dest('out')生成)。Gulp和插件之间的所有通信都基于流。这让我们可以以一个如此优雅的方式来编写gulpfile代码。

初见Plug

既然我们已经了解了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  

子任务

Glup还允许在任务注册中定义子任务。在这种情况下,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.src。在Gulp中这个方法需要一个参数,可以是一个文件掩码(mask),文件名或文件掩码数组。它返回一个可读的Node流。

现在,在我们的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,一个可选参数。这是因为node流在默认情况下与二进制流工作。如果需要通过流传递/接收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对象。这样更方便的,因为它不仅包含文件名和内容,还包含额外的元信息,例如当前文件夹名称,完整的文件路径,等等。它可能没包含整个内容缓冲区,但它有一个可读的内容流。

vinyl:比文件更好

有一个很棒的类库vinyl-fs,它能让我们像表示为vinyl对象那样处理文件。本质上,它让我们基于文件掩码创建了一个可读,可写的流。

我们可以使用vinyl-fs类库重写plug函数。但首先我们需要安装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流会话,我们可以通过Plug工具来一起使用本地Gulp插件。

让我们来试一下。安装gulp-rename:

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,正如你想的那样,应该产生相同的结果。

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目录。

串行 vs 并行

既然来从Gulp API的全部基本功能已实现,让我们再进一步。即将到来的Gulp版本将包含更多的API函数。这些新的API将使Gulp更加强大:

  • gulp.parallel
  • gulp.series

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

假设在当前目录我们有test1.txttest2.txt。为了将这些文件并行复制到了out目录,让我们做一个plugfile:

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();
                }
            }

}

我们依赖于node流“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是如何工作的,以及让我们能更流畅使用它,并通过插件扩展它。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州