Let’s Give Grunt Tasks the Marie Kondo Organization Treatment

Avatar of Serj Lavrin
Serj Lavrin on

We live in an era of webpack and npm scripts. Good or bad, they took the lead for bundling and task running, along with bits of Rollup, JSPM and Gulp. But let’s face it. Some of your older projects are still using good ol’ Grunt. While it no longer glimmers as brightly, it does the job well so there’s little reason to touch it.

Though, from time to time, you wonder if there’s a way to make those projects better, right? Then start from “Organizing Your Grunt Tasks” article and come back. I’ll wait. That’ll set the stage for this post and then we’ll take it further together to create a solid organization of Grunt tasks.

Automatic Speed Daemon task loading

It’s no fun writing loading declarations for each task, like this:

grunt.loadNpmTasks('grunt-contrib-clean')
grunt.loadNpmTasks('grunt-contrib-watch')
grunt.loadNpmTasks('grunt-csso')
grunt.loadNpmTasks('grunt-postcss')
grunt.loadNpmTasks('grunt-sass')
grunt.loadNpmTasks('grunt-uncss')

grunt.initConfig({})

It’s common to use load-grunt-tasks to load all tasks automatically instead. But what if I tell you there is a faster way?

Try jit-grunt! Similar to load-grunt-tasks, but even faster than native grunt.loadNpmTasks.

The difference can be striking, especially in projects with large codebases.

Without jit-grunt

loading tasks     5.7s  ▇▇▇▇▇▇▇▇ 84%
assemble:compile  1.1s  ▇▇ 16%
Total 6.8s

With jit-grunt

loading tasks     111ms  ▇ 8%
loading assemble  221ms  ▇▇ 16%
assemble:compile   1.1s  ▇▇▇▇▇▇▇▇ 77%
Total 1.4s

1.4 seconds doesn’t really make it a Speed Daemon… so I kinda lied. But still, it’s six times faster than the traditional way! If you’re curious how that’s possible, read about the original issue which led to the creation of jit-grunt.

How is jit-grunt used? First, install:

npm install jit-grunt --save

Then replace all tasks load statements with a single line:

module.exports = function (grunt) {
  // Intead of this:
  // grunt.loadNpmTasks('grunt-contrib-clean')
  // grunt.loadNpmTasks('grunt-contrib-watch')
  // grunt.loadNpmTasks('grunt-csso')
  // grunt.loadNpmTasks('grunt-postcss')
  // grunt.loadNpmTasks('grunt-sass')
  // grunt.loadNpmTasks('grunt-uncss')

  // Or instead of this, if you've used `load-grunt-tasks`
  // require('load-grunt-tasks')(grunt, {
  //   scope: ['devDependencies', 'dependencies'] 
  // })

  // Use this:
  require('jit-grunt')(grunt)

  grunt.initConfig({})
}

Done!

Better configs loading

In the last example, we told Grunt how to load tasks itself, but we didn’t quite finish the job. As “Organizing Your Grunt Tasks” suggests, one of the most useful things we’re trying to do here is split up a monolithic Gruntfile into smaller standalone files.

If you read the mentioned article, you’ll know it’s better to move all task configuration into external files. So, instead of a large single gruntfile.js file:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  grunt.initConfig({
    clean: {/* task configuration goes here */},
    watch: {/* task configuration goes here */},
    csso: {/* task configuration goes here */},
    postcss: {/* task configuration goes here */},
    sass: {/* task configuration goes here */},
    uncss: {/* task configuration goes here */}
  })
}

We want this:

tasks
  ├─ postcss.js
  ├─ concat.js
  ├─ cssmin.js
  ├─ jshint.js
  ├─ jsvalidate.js
  ├─ uglify.js
  ├─ watch.js
  └─ sass.js
gruntfile.js

But that will force us to load each external configuration into gruntfile.js manually, and that takes time! We need a way to load our configuration files automatically.

We’ll use load-grunt-configs for that purpose. It takes a path, grabs all of the configuration files there and gives us a merged config object which we use for Grunt config initialization.

Here how it works:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  const configs = require('load-grunt-configs')(grunt, {
    config: { src: 'tasks/.js' }
  })

  grunt.initConfig(configs)
  grunt.registerTask('default', ['cssmin'])
}

Grunt can do the same thing natively! Take a look at grunt.task.loadTasks (or it’s alias grunt.loadTasks).

Use it like this:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  grunt.initConfig({})

  // Load all your external configs.
  // It's important to use it _after_ Grunt config has been initialized,
  // otherwise it will have nothing to work with.
  grunt.loadTasks('tasks')

  grunt.registerTask('default', ['cssmin'])
}

Grunt will automatically load all js or coffee config files from the specified directory. Nice and clean! But, if you’ll try to use it, you’ll notice it does nothing. How is that? We still need to do one more thing.

Let’s look into our gruntfile.js code once again, this time without the comments:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  grunt.initConfig({})

  grunt.loadTasks('tasks')

  grunt.registerTask('default', ['cssmin'])
}

Notice that grunt.loadTasks loads files from tasks directory, but never assigns it to our actual Grunt config.

Compare it with a way load-grunt-configs works:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  // 1. Load configs
  const configs = require('load-grunt-configs')(grunt, {
    config: { src: 'tasks/.js' }
  })

  // 2. Assign configs
  grunt.initConfig(configs)

  grunt.registerTask('default', ['cssmin'])
}

We initialize our Grunt config before actually loadings tasks configuration. If you are getting a strong feeling that it will make us end up with empty Grunt config — you’re totally right. You see, on contrary to the load-grunt-configs, grunt.loadTasks just imports files into gruntfile.js. It does nothing more.

Woah! So, how do we make use of it? Let’s explore!

First, create a file inside directory tasks named test.js

module.exports = function () {
  console.log("Hi! I'm an external task and I'm taking precious space in your console!")
}

Let’s run Grunt now:

$ grunt

We’ll see printed to the console:

> Hi! I'm an external task and I'm taking precious space in your console!

So, upon importing grunt.loadTasks, every function is executed as it loads files. That’s nice, but what’s the use of it for us? We still can’t do a thing we actually want — to configure our tasks.

Hold my beer because there is a way to command Grunt from within external configuration files! Using grunt.loadTasks upon importing provides current Grunt instance as a function first argument and also binds it to this.

So, we can update our Gruntfile:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  grunt.initConfig({
    // Add some value to work with
    testingValue: 123
  })

  grunt.loadTasks('tasks')

  grunt.registerTask('default', ['cssmin'])
}

…and change the external config file tasks/test.js:

// Add `grunt` as first function argument
module.exports = function (grunt) {
  // Now, use Grunt methods on `grunt` instance
  grunt.log.error('I am a Grunt error!')

  // Or use them on `this` which does the same
  this.log.error('I am a Grunt error too, from the same instance, but from `this`!')

  const config = grunt.config.get()

  grunt.log.ok('And here goes current config:')
  grunt.log.ok(config)
}

Now, let’s run Grunt again:

$ grunt

And what we’ll get:

> I am Grunt error!
> I am Grunt error too, from the same instance, but from `this`!
> And here goes current config:
> {
    testingValue: 123
  }

See how we accessed native Grunt methods from an external file and were even able to retrieve the current Grunt config? Are you thinking about that too? Yeah, the full power of Grunt is already there, right at our fingertips in each file!

If you are wondering why methods inside external files can affect our main Grunt instance, it is because of a referencing. grunt.loadTasks passing this and grunt to our current Grunt instance — not a copy of it. By invoking methods on that reference, we’re able to read and mutate our main Grunt configuration file.

Now, we need to actually configure something! One last thing…

This time, let’s make configuration loading work for real

Alright, we’ve come a long way. Our tasks are loaded automatically and faster. We learned how to load external configs with native Grunt method. But our task configs are still not quite there because they do not end up in Grunt config.

But we’re almost there! We learned that we can use any Grunt instance methods in imported files using grunt.loadTasks. They are available on grunt and this instances.

Among many other methods, there is a precious grunt.config method. It allows us to set a value in an existing Grunt config. The main one, which we initialized in our Gruntfile… remember that one?

What’s important is the way we can define tasks configurations. Exactly what we need!

// tasks/test.js

module.exports = function (grunt) {
  grunt.config('csso', {
    build: {
      files: { 'style.css': 'styles.css' }
    }
  })

  // same as
  // this.config('csso', {
  //   build: {
  //     files: { 'style.css': 'styles.css' }
  //   }
  // })
}

Now let’s update Gruntfile to log the current config. We need to see what we did, after all:

module.exports = function (grunt) {
  require('jit-grunt')(grunt)

  grunt.initConfig({
    testingValue: 123
  })

  grunt.loadTasks('tasks')

  // Log our current config
  console.log(grunt.config())

  grunt.registerTask('default', ['cssmin'])
}

Run Grunt:

$ grunt

…and here’s what we see:

> {
    testingValue: 123,
    csso: {
      build: {
        files: {
          'style.css': 'styles.css'
        }
      }
    }
  }

grunt.config sets csso value when imported, so the CSSO task is now configured and ready to run when Grunt is invoked. Perfect.

Note that if you used load-grunt-configs previously, you had a code like that, where each file exports a configuration object:

// tasks/grunt-csso.js

module.exports = {
  target: {
    files: { 'style.css': 'styles.css' }
  }
}

That needs to be changed to a function, as described above:

// tasks/grunt-csso.js

module.exports = function (grunt) {
  grunt.config('csso', {
    build: {
      files: { 'style.css': 'styles.css' }
    }
  })
}

OK, one more one more last thing… this time for real!

Taking external config files to the next level

We learned a lot. Load tasks, load external configuration files, define a configuration with Grunt methods… that’s fine, but where’s the profit?

Hold my beer again!

By this time, we’ve externalized all our task configuration files. So, the our project directory looks something like this:

tasks
  ├─ grunt-browser-sync.js  
  ├─ grunt-cache-bust.js
  ├─ grunt-contrib-clean.js 
  ├─ grunt-contrib-copy.js  
  ├─ grunt-contrib-htmlmin.js   
  ├─ grunt-contrib-uglify.js
  ├─ grunt-contrib-watch.js 
  ├─ grunt-csso.js  
  ├─ grunt-nunjucks-2-html.js   
  ├─ grunt-postcss.js   
  ├─ grunt-processhtml.js
  ├─ grunt-responsive-image.js  
  ├─ grunt-sass.js  
  ├─ grunt-shell.js 
  ├─ grunt-sitemap-xml.js   
  ├─ grunt-size-report.js   
  ├─ grunt-spritesmith-map.mustache 
  ├─ grunt-spritesmith.js   
  ├─ grunt-standard.js  
  ├─ grunt-stylelint.js 
  ├─ grunt-tinypng.js   
  ├─ grunt-uncss.js 
  └─ grunt-webfont.js
gruntfile.js

That keeps Gruntfile relatively small and things seem to be well organized. But do you get a clear picture of the project just by glancing into this cold and lifeless list of tasks? What actually do they do? What’s the flow?

Can you tell that Sass files are going through grunt-sass, then grunt-postcss:autoprefixer, then grunt-uncss, and finally through grunt-csso? Is it obvious that the clean task is cleaning the CSS or that grunt-spritesmith is generating a Sass file which should be picked up too, as grunt-watch watches over changes?

Seems like things are all over the place. We may have gone too far with externalization!

So, finally… now what if tell you that there’s yet a better way would be group configs… based on features? Instead of a not-so-helpful list of tasks, we’ll get a sensible list of features. How about that?

tasks
  ├─ data.js 
  ├─ fonts.js 
  ├─ icons.js 
  ├─ images.js 
  ├─ misc.js 
  ├─ scripts.js 
  ├─ sprites.js 
  ├─ styles.js 
  └─ templates.js
gruntfile.js

That tells me a story! But how could we do that?

We already learned about grunt.config. And believe it or not, you can use it multiple times in a single external file to configure multiple tasks at once! Let’s see how it works:

// tasks/styles.js

module.exports = function (grunt) {
  // Configuring Sass task
  grunt.config('sass', {
    build: {/* options */}
  })
  
  // Configuring PostCSS task
  grunt.config('postcss', {
    autoprefix: {/* options */}
  })
}

One file, multiple configurations. Quite flexible! But there is an issue we missed.

How should we deal with tasks such as grunt-contrib-watch? Its configuration is a whole monolithic thing with definitions for each task that are unable to be split.

// tasks/grunt-contrib-watch.js

module.exports = function (grunt) {
  grunt.config('watch', {
    sprites: {/* options */},
    styles: {/* options */},
    templates: {/* options */}
  })
}

We can’t simply use grunt.config to set watch configuration in each file, as it will override the same watch configuration in already imported files. And leaving it in a standalone file sounds like a bad option too — after all, we want to keep all related things close.

Fret not! grunt.config.merge to the rescue!

While grunt.config explicitly sets and overrides any existing values in Grunt config, grunt.config.merge recursively merges values with existing values in other Grunt config files giving us a single Grunt config. A simple, but effective way to keep related things together.

An example:

// tasks/styles.js

module.exports = function (grunt) {
  grunt.config.merge({
    watch: {
      templates: {/* options */}
    }
  })
}
// tasks/templates.js

module.exports = function (grunt) {
  grunt.config.merge({
    watch: {
      styles: {/* options */}
    }
  })
}

This will produce a single Grunt config:

{
  watch: {
    styles: {/* options */},
    templates: {/* options */}
  }
}

Just what we needed! Let’s apply this to the real issue — our styles-related configuration files. Replace our three external task files:

tasks
  ├─ grunt-sass.js
  ├─ grunt-postcss.js   
  └─ grunt-contrib-watch.js

…with a single tasks/styles.js file that combines them all:

module.exports = function (grunt) {
  grunt.config('sass', {
    build: {
      files: [
        {
          expand: true,
          cwd: 'source/styles',
          src: '{,**/}*.scss',
          dest: 'build/assets/styles',
          ext: '.compiled.css'
        }
      ]
    }
  })

  grunt.config('postcss', {
    autoprefix: {
      files: [
        {
          expand: true,
          cwd: 'build/assets/styles',
          src: '{,**/}*.compiled.css',
          dest: 'build/assets/styles',
          ext: '.prefixed.css'
        }
      ]
    }
  })

  // Note that we need to use `grunt.config.merge` here!
  grunt.config.merge({
    watch: {
      styles: {
        files: ['source/styles/{,**/}*.scss'],
        tasks: ['sass', 'postcss:autoprefix']
      }
    }
  })
}

Now it’s much easier to tell just by glancing into tasks/styles.js that styles have three related tasks. I’m sure you can imagine extending this concept to other grouped tasks, like all the things you might want to do with scripts, images, or anything else. That gives us a reasonable configuration organization. Finding things will be much easier, trust me.

And that’s it! The whole point of what we learned.

That’s a wrap

Grunt is no longer the new darling it once was when it first hit the scene. But to date, it is a straightforward and reliable tool that does its job well. With proper handling, it gives even fewer reasons to swap it for something newer.

Let’s recap what we can do to organize our tasks efficiently:

  1. Load tasks using jit-grunt instead of load-grunt-tasks. It’s same but insanely faster.
  2. Move specific task configurations out from Gruntfile and into external config files to keep things organized.
  3. Use native grunt.task.loadTasks to load external config files. It’s simple but powerful as it exposes all Grunt capabilities.
  4. Finally, think about a better way to organize your config files! Group them by feature or domain instead of the task itself. Use grunt.config.merge to split complex tasks like watch.

And, for sure, check Grunt documentation. It’s still worth a read after all these years.

If you’d like to see a real-world example, check out Kotsu, a Grunt-based starter kit and static website generator. You’ll find even more tricks in there.

Got better ideas about how to organize Grunt configs even better? Please share them in the comments!