Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Head First Grunt #2

Open
yleo77 opened this issue Dec 10, 2014 · 0 comments
Open

Head First Grunt #2

yleo77 opened this issue Dec 10, 2014 · 0 comments

Comments

@yleo77
Copy link
Owner

yleo77 commented Dec 10, 2014

介绍

Grunt 是一款基于 Nodejs 的任务工具,并不如大多数文章介绍的只局限于前端自动化工具,只是大多数情况下应用于前端的重复性任务。大多数场景下,Nodejs 可以干什么,它也就可以干什么。

这篇文章从 介绍新手入门核心源码分析进阶 - Grunt 的卡顿缘何引起? 以及 其他 这五个节组成,最后一节是填坑。

新手入门

准备

选择一个目录,新建项目目录,并进入

$ mkdir grunt-tutorial && cd grunt-tutorial

创建 src 目录,并在其中新建 grunt-tutorial.js

$ mkdir src && cd src && echo 'console.log("foo");' > grunt-tutorial.js

第一步,安装全局 grunt-cli 模块

$ npm install grunt-cli -g

第二步 安装项目所要用到的 grunt 模块

$ npm install grunt --save-dev

第三步 编写 Gruntfile.js

假设项目根目录下已经有了_package.json_, 如果没有请先运行npm init补上该文件。

Gruntfile.js 是个 js 文件,所以,按写 js 的形式,想怎么写,就怎么写。但是如果新手想知道如何上手,和上面一样,我建议,按照官方给出的例子,复制一份。

这个环节涉及到的知识点

  • 为什么要安装两个 grunt 相关的模块,挖坑,下面会专门提到。
  • 为什么安装这俩模块的时候一个的参数是 -g, 一个却是--save-dev。挖坑,下面会提到。
  • Gruntfile.js 是干什么? grunt 的入口文件。用来配置,定义 task,加载插件等等。

附送一个官方教程: Getting started

接下来看实际应用。

试用 Grunt 提供的插件

假设你现在的 Gruntfile.js 是从官方 copy 的,长下面这个样子,这是前提。

module.exports = function(grunt) {

  // 项目配置和任务配置
  // 每个任务以 该任务的名称为key 作为 initConfig 的参数对象的属性配置.
  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    uglify: {
      options: {
        banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'
      },
      build: {
        src: 'src/<%= pkg.name %>.js',
        dest: 'build/<%= pkg.name %>.min.js'
      }
    }
  });

  // 利用 *grunt.loadNpmTasks* 来加载插件
  grunt.loadNpmTasks('grunt-contrib-uglify');

  // 通过 *grunt.registerTask* 来注册任务名
  // 当第一个参数为 default 时,命令行可以直接以 *grunt* 的形式来来启动该任务
  // 第二个参数为所要执行的任务数组.
  grunt.registerTask('default', ['uglify']);
};

那么现在安装所要执行任务的依赖插件 grunt-contrib-uglify

$ npm install grunt-contrib-uglify --save-dev

执行完毕后,就是使用了

# 或者直接 grunt. 原因代码中注释说了,因为调用 registerTask 时已经将 uglify 任务作为默认任务了
$ grunt uglify

命令行应该会输出DONE, without errors字样,OK。

这个环节用到的知识点

DIY

grunt-tutorial 目录下新建目录 custom-tasks, 创建文件 frog.js,内容如下

module.exports = function(grunt) {

  grunt.registerTask('frog', 'custom task - frog', function(color) {
    color = color || 'green';
    console.log('the %s frog said guagua', color);
  });
};

打开 Gruntfile.js,在grunt.loadNpmTasks('grunt-contrib-uglify');下面添加一行

grunt.loadTasks('./custom-tasks');

现在可以在命令行中执行

$ grunt frog:white

这个时候,应该也是会输出一下信息的。

Running "frog:white" (frog) task
the white frog said guagua

Done, without errors

这个环节的知识点

  • API grunt.loadTasks 它和 loadNpmTasks的区别是,查找任务的方式不一样.常规情况下,如果你的组件是没有遵循 grunt 任务目录规范的话,就用前者吧。
  • grunt frog:white 注意其中的冒号分隔,这是 grunt 对任务进行传递参数特定的约束形式。多个参数就依次用 : 来分隔。

本节提到的三个 API是使用 Grunt 的过程中非常重要。

到这里我想,大部分人应该说是可以达到会用 grunt 的程度了,但是还差一步,不做这一步,依然无法畅快使用 Grunt。那就是熟悉常用的 API,除了上面提到的几个,包括但不限于:

最好在使用的过程中把 API 列表都看一遍,混个眼熟,在用的时候能想起来就可以。

核心源码分析

grunt 的安装使用分为两个组成,一个是全局的 grunt-cli,为了区分暂时叫做global-grunt。一个是项目目录下的 grunt 模块,叫做 local-grunt 吧。

全局 grunt-cli

不算命令行补全的话,只做了一件事:通过 resolve 和 findup 来查找当前目录的 grunt 模块,并启动

关键几行代码,从这里可以看出,查找到项目 grunt 模块后,直接调用cli API来启动local-grunt。

// 同步版本的 findup。
var findup = require('findup-sync');
// resolve 模块和 require.resolve 类似,但前者支持的更广,比如支持同步和异步,支持指定目录
var resolve = require('resolve').sync;
//basedir 也支持自定义配置
var basedir = process.cwd(); 
// 获取 local-grunt模块
try {
  // 通过 resolve 模块 在basedir目录下查找 grunt 模块的文件路径
  gruntpath = resolve('grunt', {basedir: basedir});
} catch (ex) {
  // 从当前目录往上查找 grunt 模块文件路径.
  gruntpath = findup('lib/grunt.js');
  // 如果没找到,退出提示用户未找到 代码略
  if (!gruntpath) {  /** 代码略 **/ }
}

// 通过 cli API 来启动 local-grunt 
require(gruntpath).cli();

local-grunt

剩余所有的事情都是由 local-grunt 中处理完成的。

  • 解析命令行参数
  • 如果是 coffee,解析 coffee
  • 打印log
  • 任务执行
  • 等等等等...

最关键的是解析命令行参数,并传递给相应任务并启动任务,输出执行结果。

这里代码比较多,摘重要的一点点来看。下面是关键部分的目录结构

$ tree -L 3

├── lib
│   ├── grunt
│   │   ├── cli.js
│   │   ├── ...
│   │   ├── task.js
│   │   └── template.js
│   ├── grunt.js
│   └── util
│       └── task.js

因为 global-grunt 调用了local-grunt 中 /lib/grunt.js 的 cli 接口,就从这里开始入口。

// ...
// Expose internal grunt libs.
function gRequire(name) {
  return grunt[name] = require('./grunt/' + name);
}
// ...
gRequire('cli');
// ...

cli 是在 /lib/grunt/cli.js 定义的,负责解析命令行相关的逻辑,比如接收并格式化处理命令行传递进来的参数。

var cli = module.exports = function(options, done) {
  // ...处理 options

  // 执行 grunt.tasks, 根据配置 cli.options 启动相关任务 cli.tasks 
  grunt.tasks(cli.tasks, cli.options, done);
};

// 通过第三方 nopt 模块 解析命令行参数,赋值给相应变量: cli.tasks 和 cli.options
var parsed = nopt(known, aliases, process.argv, 2);
// 例如 grunt -v foo baz
// cli.tasks 就为 ['foo', 'baz']
// cli.options 就为 {verbose: true, ...}
cli.tasks = parsed.argv.remain;
cli.options = parsed;

接下来,我们转回/lib/grunt.js ,看看 grunt.tasks 都干了什么。

grunt.tasks = function(tasks, options, done) {

  // 版本, 帮助, 日志 相关略过.

  // 如果传了任务名 task 就执行任务 task,否则执行 default 任务
  var tasksSpecified = tasks && tasks.length > 0;
  tasks = task.parseArgs([tasksSpecified ? tasks : 'default']);

  // 初始化
  // 加载 tasks, 执行Gruntfile.js。
  task.init(tasks);

  var uncaughtHandler = function(e) {
    fail.fatal(e, fail.code.TASK_FAILURE);
  };
  process.on('uncaughtException', uncaughtHandler);

  // 设置 task error 和 done 时的回调。
  task.options({
    error: function(e) {
      fail.warn(e, fail.code.TASK_FAILURE);
    },
    done: function() {
      process.removeListener('uncaughtException', uncaughtHandler);
      // 任务执行结束相关逻辑,代码略。
    }
  });

  // run 这里在 API 语义上稍微有些歧义,它只是将各个任务详细信息(name, fn, info, args等)放入 task 实例的任务队列`_queue`中,并没有运行。
  // 详见 /lib/util/task.js Task.prototype.run 函数定义.
  // 备注: task 的实例化在 /lib/grunt/task.js 中进行
  tasks.forEach(function(name) { task.run(name); });

  // 开始执行任务
  task.start({asyncDone:true});
};

有一个部分,很关键,但上面一笔带过了,就是加载task。这一切以 Gruntfile.js 为切入点来看一看。

上面提到了 Gruntfile.js 是在 task.init(tasks); 这里加载并执行的。那么现在看看这个函数。

task.init = function(tasks, options) {

  // 如果当前需要执行的任务队列都是通过 registerInitTask 来注册的
  // 那么可以不需要加载执行 gruntfile。
  var allInit = tasks.length > 0 && tasks.every(function(name) {
    var obj = task._taskPlusArgs(name).task;
    return obj && obj.init;
  });

  // 查找 gruntfile
  var gruntfile = allInit ? null : grunt.option('gruntfile') ||
    grunt.file.findup('Gruntfile.{js,coffee}', {nocase: true});

  if (gruntfile && grunt.file.exists(gruntfile)) {
    // 设置当前工作目录,来保证所有的路径信息都是相对于 Gruntfile.j这个文件的
    process.chdir(grunt.option('base') || path.dirname(gruntfile));
    loadTask(gruntfile);
  } else if (options.help || allInit) {

  } else {
    // 其他分支。
  }

  (grunt.option('npm') || []).forEach(task.loadNpmTasks);
  (grunt.option('tasks') || []).forEach(task.loadTasks);
};

不要停,继续跟 loadTask

var loadTaskStack = [];
function loadTask(filepath) {

  // 暂存之前最后一个 registry, 并重置 registry 
  loadTaskStack.push(registry);
  registry = {tasks: [], untasks: [], meta: {info: lastInfo, filepath: filepath}};

  var filename = path.basename(filepath);
  var fn;
  try {

    // 加载 taskfile 并执行。
    fn = require(path.resolve(filepath));
    if (typeof fn === 'function') {
      fn.call(grunt, grunt);
    }
    // log 相关
  } catch(e) {}
  // 恢复 registry
  registry = loadTaskStack.pop() || {};
}

所以说,不管在命令行实际调用时执行哪个 task,在 Gruntfile.js 中,只要写了类似 loadNpmTasks 或者 loadTasks这样的代码,这个阶段它都会将这个任务加载进来。m(_ _)m。

还记得自定义任务 grunt.loadTasks 吗,也是通过 loadTask 来加载的(/lib/grunt/task.js),代码略过。

到这里了,接下来让任务 真正 执行起来吧,入口在Task.prototype.start 这里。

Task.prototype.start = function(opts) {

  var nextTask = function() {
    var thing;  // 获取接下来需要执行的任务
    do {
      thing = this._queue.shift();
    } while (thing === this._placeholder || thing === this._marker);

    // 如果没有 thing,就返回吧,通知注册好的 done 函数 任务执行结束。代码略

    // this._placeholder作为一个占位符,目的是为了动态修改当前执行的任务队列。
    // 当在 task 的 factory 中手动调用grunt.task.run(['foo'])时
    // 能将 foo 插入到一个"合适"的位置而不是被 push 到队尾。
    // 具体插入操作看 `Task.prototype._push` API.
    this._queue.unshift(this._placeholder);

    var context = {
      nameArgs: thing.nameArgs,
      name: thing.task.name,
      args: thing.args,
      flags: thing.flags
    };
    this.runTaskFn(context, function() {
      return thing.task.fn.apply(this, this.args);
    }, nextTask, !!opts.asyncDone);
  }.bind(this);

  // 开始执行
  nextTask();
};

寻找脉路,看runTaskFn。

// done 参数为任务执行完毕的 callback,也就是上面的 nextTask 的化身。
Task.prototype.runTaskFn = function(context, fn, done, asyncDone) {

  var async = false; // 是否为异步任务 标志位

  var complete = function(success) {
    // 处理 success 信息 ...
    // 然后传递参数,执行 done(err, success);
    process.nextTick(function () {
      done(err, success);
    });
  }.bind(this);

  // 处理异步任务的奥秘所在。
  // 这里就是 task 中调用的 async 的函数声明。调用后返回一个函数,当异步执行任务完毕后,
  // 主动调用传递任务执行结果,通过上面的 complete 来拉起队列里面的下一个任务继续执行。
  context.async = function() {
    async = true;
    return function(success) {
      setTimeout(function() { complete(success); }, 1);
    };
  };
  this.current = context; // 注册当前任务的相关信息

  try {
    var success = fn.call(context);
    // 如果标志位设置为 true 了,回调就不会通过这里来继续执行下一步了.
    if (!async) {
      complete(success);
    }
  } catch (err) {complete(err); }
};

到此,核心代码和一个完整的流程就介绍完了。

这个环节结束,现在,我想你应该知道为什么有两个 grunt 模块了吧。简单来说就是 grunt-cli 为了查找 local-grunt 模块同时启动它。

看到这里,大家对 grunt 的核心代码应该是有一个轮廓性的大致了解。使用的话,估计是没问题了。

附一张图来帮助理解 Grunt 的整体结构。

Head First Grunt

如果你还想为 grunt 提点速的话,请继续往下看。否则可以关了这篇文章干别的去。

进阶 - Grunt 的卡顿?

关于 Gruntfile.js 的执行

上面一部分其实埋了一个伏笔,提到了关于 grunt 的加载插件的时机。试想如下场景

当我们的 Gruntfile.js 是这样写:

grunt.initConfig({
  // ...
});

// 加载一堆 grunt 插件
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-cssmin');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-contrib-connect');
// 可能还有...

// 这里还有自定义插件
grunt.loadTasks('ultraman');
// 可能还有...

而我们在使用的时候,并不一定每次都会调用全部加载进来的插件,大部分情景可能是只使用其中一个,比如说这样:

$ grunt uglify

其实在内部逻辑上,不管你使用的是哪个插件, Grunt 都会为你默认加载所有写在 Gruntfile.js 中的组件。(备注,这么说不太严格,但是目前绝大多数Gruntfile.js 写法都是会被加载,譬如说下面这种情况就不会被加载,但是这种写法的使用太少。)

// foo 函数是定义在 Gruntfile.js 中,并且在 grunt 初始化执行 gruntfile.js 时,foo 函数并没有被调用到,总之遵循 js 的执行就是了。
module.exports = function(grunt) {
  // ... 其他部分
  function foo(){
    grunt.loadNpmTasks('grunt-contrib-watch');
  }
}

这个问题,也是我在使用的过程中发现,当任务数稍微多些,此时在命令行执行 grunt taskname 时都会有一定的卡顿才会由执行任务过程和结果的输出。

插件 time-grunt

为了确定这个问题是由 Grunt 引起,所以就找来了这么一个工具 time-grunt,它能显示出 Grunt 在执行任务的时候各个阶段的耗时,大概是这样样子:

optimize_before.png

明显能对比, load 阶段的时间大于执行任务的时间,当任务数越多,load 就会越耗时间(取决于 task 的数目和复杂度,task 一多这个耗时非常明显),问题确定了就开始改造 Grunt 吧。

插件 grunt-task-loader

问题提炼一下,在初始化执行 Gruntfile.js 时已经加载了全部的任务,而大多数情况都用不上。

怎么解决呢,所以第一步,不要让它在初始化的时候加载插件,换句话说就是全部去掉出现在 Gruntfile.js 中 grunt.loadNpmTasks这样的语句。

第二步 在任务执行阶段的前期来加载该任务相关 js 代码。

上面在核心源码分析这一部分,提到了 /lib/grunt.js 文件中这样一行代码以及相关它的注释。

tasks.forEach(function(name) { task.run(name); });

该函数的作用:将各个任务的详细信息放入 task 实例的任务队列 _queue

所以就选择这里作为切入点,通过劫持 Task.prototype.run函数 来实现加速 Grunt 的需求。

代码如下:

// 先劫持原有的 run 函数。
var run = grunt.util.task.Task.prototype.run;

grunt.util.task.Task.prototype.run = function() {
  var args = [].slice.call(arguments, 0);
  this.parseArgs(arguments).forEach((function(item) {
    var taskname = item.split(':')[0];
    // 如果该任务还没有被注册,这个时候再加载
    if (!this._tasks[taskname]) {
      load(taskname);
    }
  }).bind(this));
  // 最后调用原有 run 函数
  run.apply(this, args);
};

function load(name) {
// 根据 name 和一定规则在本地查找相关 task 的代码了。
// 比如说 'grunt-contrib-' + name
// 比如说有可能是用户自定义的插件,那就在相关目录下查找  name + '.js' 文件是不是存在之类的。
// 代码略。
}

现在,再来通过 time-grunt 来看一下时间消耗情况。

optimize_after.png

明显降少了 load task 阶段的时间消耗,其实就是只 load 本次任务需要用到的插件。

对了,最好不要修改 Grunt 的源代码,和 time-grunt 类似,尽量以插件的形式修改,这同时就要求这样的插件的执行时机要尽量“早一些”。

这个环节介绍的其实就是 grunt-task-loader 插件的由来。

其他

最后,填一个坑,**为什么一个是 -g, 一个是--save-dev?**这里面其实涉及到两个点,一个是 npm 包的安装方式,一个是 npm 包的依赖管理方式,这俩问题又存在一些交叉,因为都是在安装 npm 包。

当安装一个npm 包时,与 -g 对应的选项是不带 -g 的形式,前者是全局安装,后者会根据情况决定是安装在当前项目目录下或者用户目录下。

另一个问题,npm 包的依赖,常见的存在三种形式:

  • dependencies,包的正常运行依赖。举例说明理解为 backbone 依赖 underscore。
  • devDependencies,开发依赖,当使用的时候,不会加载该依赖. 举例: 有的 npm 包开发时由于需要单元测试会依赖 mocha,但在使用时是不会依赖 mocha 的,反过来理解也成立。再比如 Grunt 插件,绝大多数情况下是存在于一个库的开发依赖里,而不是运行依赖。
  • peerDependencies,npm 1.2.0 新加入的,描述的是插件和宿主之间的表达关系。从级别上来说,两者是同级别,不是从属关系。以_grunt-contrib-watch_ 和 grunt-contrib-cssmin 为例,这俩个组件肯定都需要依赖_grunt_,但是是哪种依赖呢,如果是运行依赖或者开发依赖,那么他们都需要特定场景下在自己的目录下存一份 Grunt 没错,可是在这样的插件中,并不会出现require('grunt')的代码来,即使这些插件的 node_modules 目录下存在着一份 grunt 的拷贝,也不会被实际应用到。摘一段官方的描述:

What we need is a way of expressing these "dependencies" between plugins and their host package. Some way of saying, "I only work when plugged in to version 1.2.x of my host package, so if you install me, be sure that it's alongside a compatible host." We call this relationship a peer dependency

via: Peer Dependencies ,这篇文章从该依赖诞生的原因,要解决的问题说的很清楚了。

其实还有两个,optionalDependencies 和 bundledDependencies。 前者是可选,即使依赖包出错,npm 也可以继续初始化。后者是一组包名,他们会在发布的时候被打包进去,不太常用,我对这俩者的了解也只局限于此了。

EOF

@yleo77 yleo77 changed the title 深入浅出 Grunt Head First Grunt Dec 10, 2014
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant