前言

在完成一两天的通读grunt在线文档之后,原来grunt也并没有那么地复杂,首先他是一个nodejs程序,无非就是将反复重复的工作(比如有压缩、编译、单元测试、linting等操作)通过脚本来自动化,只需要进行一个命令的执行,即可完成一系列既定执行顺序的操作,可以理解为一系列固定流程的脚本集合,他的庞大主要在于他所提供的插件,在运行grunt的过程中,可以通过对插件的使用,来满足于实际的业务需要!

Gruntfile文件组成

Gruntfile的组成
Gruntfile文件组成
😕 这里有一个疑问,就是为毛这个Gruntfile.js要设计为对外暴露一函数对象?这个问题将留到 👇 来进行分析!

😕 关于Gruntfile文件的组成描述如下:

  1. 由包裹的 1⃣ 函数包裹,也就是对外暴露一函数方法,该方法接收一grunt对象;
  2. 执行grunt的initConfig方法,并传递对应的配置;
  3. 加载这个initConfig配置中所需的任务/目标所依赖的三方任务(loadNpmTasks);
  4. 注册这个配置文件对外暴露的任务API动作;

grunt程序的运行过程

grunt脚本程序的执行过程
grunt-cli的执行顺序

😕 关键在于liftup库,它主要用于执行一个CLI脚本程序,这里它写了一个gruntjs程序,然后通过lifeup库来创建一个系统任务,用来执行这个gruntjs程序,这个liftup可以简单地理解为是一个读取package.json以及配置的参数,来实现的可以以一js来作为系统的执行程序的库,最终这个grunt-cli其底层是调用gruntjs程序的,也就是说grunt-cli是脚本grunt应用程序包装器,通过全局安装的命令来执行这个grunt脚本的!
执行的入口就在grunt.tasks()函数中

🌠 也就是说,当执行程序(比如grunt命令),由grunt-cli命令来执行这个grunt.tasks()方法,针对之前已经配置好的配置以及依赖的三方任务库,完成一系列既定流程的任务的执行,从 Gruntfile文件的组成我们可以晓得grunt对象所提供的方法有:initConfigloadNpmTasksregisterTask等,但其实grunt对象所提供的方法以及属性远不止这些!!

grunt的组成

😕 在开始进入这个grunt的组成学习之前,先来了解为什么Gruntfile必须定义为一函数,而且它里面的grunt参数又是什么?
要理解这个问题,需要从grunt-cli的执行程序开始来分析这个!!
grunt执行的开始
🌠 上面这里调用了grunt.tasks()方法,进入到了任务初始化阶段
task初始化阶段
加载Gruntfile文件
调用Gruntfile方法
🌠 因此,Gruntfile文件中必须定义为一个方法,该方法中的grunt参数grunt对象,提供了一系列配置的动作,通过预先配置好的动作,在执行程序的时候好自动加载到对应的配置信息!

关于grunt的组成在运行时的打印如下图所示:
grunt打印输出
👇 对应结合源码整理一下grunt的一个架构图:
grunt架构组成

任务的加载、存储

grunt的模块中,🈶 一个比较核心的模块task,他负责给grunt提供最底层的任务支撑,那么关于这个task,他是怎样的一个组成结构?他是如何来为grunt提供服务的呢?
通过阅读源码,可以得知grunt中提供了两个task文件(其中一个task“继承于”另外一个task),一个位于grunt/lib/util/task.js(这里我们称之为父类),另一个位于grunt/lib/grunt/task.js(这里我们称之为子类),
而且,关于这个父子关系的继承实现中有一个微妙的用法如下(这里为方便解读,将两者相关的代码写到一起,并做了相应的调整为方便展示):

1
2
3
4
5
6
7
8
9
// 父类task
function Task(){}
export.Task = Task;
export.create = function(){
return new Task();
}
// 子类task
var parent = parentTask.create(); // 创建父类的一个实例对象(该对象拥有一系列的实力方法以及各自的一份拷贝)
var task = Object.create(parent); // 以父类实例对象来创建对应的子类对象,而不是原型对象,那么所有的子类对象都直接拥有了相对应的实例属性 + 实例方法

从源码的结构我们可以得知,父类task负责任务参数的获取、任务队列(_queue)的管理与执行等相关动作,而子类task则负责加载任务(不管是三方库任务还是自定义的任务),比如通过子类直接从init-->loadTask-->loadNpmTasks-->loadTasks-->loadTask-->registerTask,来实现将Gruntfile文件中函数所执行的内容,交由子类task来实现,而且完成配置的过程中,顺便将即将要执行的父类task任务队列(_queue)给丰满起来了!
🌠 这里关键分析一下关于registerTask()方法的定义与实现(因为父子都有定义,而且这个是核心任务的注册实现过程),以jshint插件为例(如下图所示):
jshint在grunt中的使用
当调用了grunt.registerTask('test', [jshint]);时,发生了什么事!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在子类中定义的一对象,用来缓存相关的注册任务
var registry = { tasks: [], untasks: [], meta: {} };
// 在Gruntfile文件中最终真实调用的注册任务方法
// 这里name = 'test',arguments = ['test', [jshint]]
task.registerTask = function(name){
// 将注册的任务名称添加到tasks数组中
registry.tasks.push(name);
// 调用父类task中的registerTask方法,并传递参数['test', [jshint]]
parent.registerTask.apply(task, arguments);
var thisTask = task._tasks[name]; //从对象中的中的_tasks对象中获取刚缓存的组装后的任务对象
//******* 以下时开始的往fn函数中切入函数逻辑操作 ********
var _fn = thisTask.fn; // 取出原本的fn函数
thisTask.fn = function(arg){
// ...此处省略一系列的追加额外属性到task对象中的相关操作,使得所有的任务都有统一的输出操作!!
}
return task;
}

👇 关于父类中的registerTask方法定义(仅展示核心相关代码):

1
2
3
4
5
Task.prototype.registerTask = function(name, fn){
var tasks = this.parseArgs(fn); // 将参数[jshint]解析成一[jshint]
fn = this.run.bind(this, fn); // 这里的this是子类对象,fn为[jshint]数组,将fn定义为run方法,并捆绑上下文为子类对象,参数为[jshint],捆绑函数动作,而且该动作只有在任务开始时触发
this._tasks[name] = { name: name, info: info, fn: fn }; // 将这个任务以及对应的函数回调缓存到_task对象中
}

👇 关于父类中的run方法定义如下:

1
2
3
4
5
6
7
Task.prototype.run = function(){
// [jshint].map(this._taskPlusArgs, this);
var things = this.parseArgs(arguments).map(this._taskPlusArgs, this);
// things的数据结构为:{task: task, nameArgs: name, args: args, flags: flags}
this._push(things);// 将things添加到_queue队列中
return this;
}

👇 是对应的registerTask的一个流程:
registerTask的过程

🌠 另外,再补充上关于Task的一个结构图如下:
grunt中的Task模块

任务的执行

一切任务的执行,从task.start()开始
该动作有两个流程:

  1. 执行task.run(name)方法将任务添加队列task._queue队列中;
  2. 执行任务的task.start()方法
registerTask与registerMulTasks的区别

首先来看下面的一个一个对比输出结果:
grunt两种注册任务的方式对比

🌠 在定义grunt的任务过程中,可以this.async()方式来返回一个done函数,然后在异步执行结束的时候,调用这个done函数,来告知grunt当前异步已执行完毕,如下图所示:
异步的grunt任务执行过程

grunt的插件

对照官方的文档,直接对应初始化了一个插件,对于插件的要求如下:
grunt插件的定义

😕 这里为什么插件的定义一定要在tasks目录中,而且导出的方法是一个接收grunt的函数呢?
👇 来分析一下关于插件的加载过程,一切也是从task.loadNpmTasks方法中入手

1
2
3
4
5
6
7
task.loadNpmTask = function(name){
// 此处省略好多代码
var tasksdir = pash.join(root, name, 'tasks'); // 找到插件中的tasks目录
if(grunt.file.exist(tasksDir)){
loadTasks(tasksdir);
}
}
1
2
3
4
5
6
function loadTasks(tasksdir){
var files = grunt.file.glob.sync('*.{js,coffee}', {cwd: tasksdir, maxDepth: 1});
files.forEach(function(filename){
loadTask(path.join(tasksdir, filename));
});
}
1
2
3
4
5
6
7
function loadTask(filepath){
// 此处省略好多代码
fn = require(path.resolve(filepath));
if(typeof fn === 'function'){
fn.call(grunt, grunt);
}
}

对上面的代码进行一个简单的描述就是:**loadNpmTasks的过程,其实就是找寻对应的已经安装的依赖,在其安装目录node_modules中找到对应的依赖包目录中的tasks目录中的js/coffee文件,然后来执行这个文件中的方法,并传递对应的grunt对象!**

grunt给我带来了什么?

  • 统一的编程范式
    • 通过统一的编码习惯结构,可快速尽性web站点或者自研库的自动化测试、打包等流程,实现一键打包的交付操作;
  • 编码技巧
    • 面向适配器编程,当需要往所有的函数追加一系列自定义的操作时(比如函数执行前后输出函数的执行时间),可以先用一中间变量nfn获取到源fn地址,然后重载源函数,加入对应的执行逻辑代码后,再执行回原本的nfn方法,通过call/apply的方式;
    • 面向函数对象”未来实现”编程,可以将自身的服务定义为扩展性很强、支持热拔插的方式(比如插件),将一个个的插件定义为一函数,同时接收库对象(比如grunt),以此来实现程序的”未来实现”编程;
    • 实现父子关系继承的手法:采用将父类的实例对象当做子类的原型对象,那么所有的子类都拥有对应的实例方法的同时,还拥有着各自自身的一份属性的拷贝,这有点像java中的面相对象编程;
    • 在调试这个grunt的时候,发现需要跟随着源码来进行的断点调试,才能够清楚地知晓关于其中的程序执行过程,因此可以根据grunt的打包结果产物进行执行分析,可以在vscode中进行以下的断点设置来跟进:
      vscode关于grunt的断点调试配置