Using Npm Scripts as a Build Tool

In my last article, I compared the popular front-end build tools Grunt and Gulp, and talked a bit about how they are still relevant as an alternative to Webpack. I also mentioned an up-and-coming alternative that I didn’t really go into: npm scripts. Npm scripts are defined in your package.json and allow you to run CLI commands using the npm run <script> command.

As a few of you mentioned in the comments that you’d like to know more about how you can use npm scripts as a build tool/task runner, that’s exactly what we’re going to look at in this article.

Why Npm Scripts?

If you use a build tool (Grunt, Gulp, Webpack etc.) for long enough you’ll begin to find that you start fighting with the tool rather than focusing on writing the code for your application. Each build tool has its own opinion about the way things should be done and that means that each tool comes with its own quirks and gotcha’s that need to be learned.

Then you can run into other issues like:

  • There isn’t a plugin for the package you want to use.
  • The plugin is out of date and doesn’t support the underlying package properly.
  • The plugin doesn’t support a feature you’d like to use for the underlying package.
  • The plugin documentation is lacking or unclear.
  • The plugin doesn’t handle errors well.
  • etc.

Most plugins for build tools simply wrap the underlying package that usually has a CLI. A simple solution to these problems would be to remove the (sometimes complex) abstraction of build tools altogether and run the underlying packages manually on the command-line. This is a great solution, but how are you going to remember all of those CLI commands and their options? And how are you going to chain them together? Wouldn’t it be nice if you could just run a single CLI command and have them all run in the right order at the same time? Enter npm scripts.

Npm Scripts

Npm has a run command that can run scripts defined in the scripts property of a package.json file. If you’ve ever used a package that asked you to run a command like npm run test (or similar) then you have used npm scripts.

To use npm scripts as a build tool we’re going to define a bunch of scripts in a package.json file, similar to defining the tasks we want to run in a config file in other build tools. The difference with npm scripts is that we’re going to run the package CLI without any plugins, then chain the scripts together so we can trigger a build with a single command. For demonstration purposes we’re going to recreate the same build process that we used in my last article:

  • Compile Sass to CSS
  • Concatenate and minify CSS and JavaScript
  • Optimize images

Before we begin the steps to create the build files I should point out that I’ve created a GitHub repo as a demo of what we are testing here with all of the necessary files and some demo content if you want to check it out.

Let’s start by compiling our Sass file to CSS using the node-sass package. First we need to install it:

npm install node-sass --save-dev

Note that the --save-dev flag saves this package in the devDependencies section of the package.json file. This makes it easy for other developers to install the required packages in the future by simply running npm install.

As per the node-sass CLI instructions we can compile our assets/scss/style.scss file into dist/css/style.css using the following command:

node-sass -o dist/css assets/scss/style.scss

So let’s add this command as a scss script to our package.json file:

"scripts": {
    "scss": "node-sass -o dist/css assets/scss/style.scss"
}

Now try running npm run scss. Hopefully, you should see your Sass file successfully compiled to CSS and the command output should be the same as running node-sass manually. This process can then be repeated for the rest of the requirements for our project.

Chaining Scripts

Another important thing to note is that, because npm run is itself a CLI command, it can be run from other scripts to chain scripts together. For example:

"scripts": {
    ...
    "concat:css": "concat -o dist/css/styles.css dist/css/style.css assets/css/test.css",
    "concat:js": "mkdir -p dist/js && concat -o dist/js/scripts.js assets/js/test1.js assets/js/test2.js",
    "concat": "npm run concat:css && npm run concat:js",
    …
}

Here we have two separate scripts that concatenate our CSS and JS files (concat:css and concat:js). We could run these separately, but normally it’s useful to be able to run them as a single command. We can do so by creating a new concat script that runs both npm run ... commands.

Grouping Scripts

Once we have all of our required packages installed, the devDependencies section of our package.json should look like this:

"devDependencies": {
    "clean-css-cli": "^4.1.10",
    "concat": "^1.0.3",
    "imagemin-cli": "^3.0.0",
    "node-sass": "^4.6.0",
    "uglify-js": "^3.1.7"
}

And our scripts should look like this:

"scripts": {
    "scss": "node-sass -o dist/css assets/scss/style.scss",
    "concat:css": "concat -o dist/css/styles.css dist/css/style.css assets/css/test.css",
    "concat:js": "mkdir -p dist/js && concat -o dist/js/scripts.js assets/js/test1.js assets/js/test2.js",
    "concat": "npm run concat:css && npm run concat:js",
    "cssmin": "cleancss -o dist/css/styles.min.css dist/css/styles.css",
    "uglify": "uglifyjs -o dist/js/scripts.min.js dist/js/scripts.js",
    "imagemin": "imagemin --out-dir=dist/img assets/img/**/*.{png,jpg,gif}",
    "build:css": "npm run scss && npm run concat:css && npm run cssmin",
    "build:js": "npm run concat:js && npm run uglify",
    "build:img": "npm run imagemin",
    "build": "npm run build:css && npm run build:js && npm run build:img"
},

I’ve created build scripts for individual script groups (css, js, img) and then chained them together in a single script called build. Splitting things into logical groups not only makes it easy to chain scripts together, but also helps when you might only want to compile a certain group of your assets. For example, watching for changes.

Watching for Changes

Being able to compile all of your front-end assets using a single npm run build command is useful, but it will quickly get annoying if you need to run it after every time you change part of your code. In these situations, it’s better to let a script “watch” for changes to your code and then run build scripts automatically.

We can achieve this by using a package like onchange. First, install it by running:

npm install onchange --save-dev

The onchange command works by watching files you specify using a glob pattern, then running a command you specify (after --). For example, to build our CSS files when our Sass files change:

onchange 'assets/scss/*.scss' -- npm run build:css

So we can create some new watch scripts and add them to package.json:

"scripts": {
    ...
    "watch:css": "onchange 'assets/scss/*.scss' -- npm run build:css",
    "watch:js": "onchange 'assets/js/*.js' -- npm run build:js"
    …
}

Now if we run npm run watch:css it will watch for changes to our Sass files and run npm run build:css if it detects any changes (and the same for JS).

Running Scripts in Parallel

Say you want to run both watch scripts at the same time as you are working on both your CSS and JS files. Running npm run watch:css && npm run watch:js isn’t going to work, as these commands are run in sequence (i.e. watch:js won’t run until watch:css is finished). So how do we run these scripts in parallel (at the same time)?

We can achieve this using another package called npm-run-all:

npm install npm-run-all --save-dev

npm-run-all has a --parallel flag that allows us to run groups of tasks in parallel. So let’s create a new watch script:

"scripts": {
    ...
    "watch": "npm-run-all --parallel watch:*"
    …
}

This command will run any watch:* scripts we have defined in parallel so we can work on both our CSS and JS files and have them be re-built at the same time. Success!

Verdict

You should now have a good idea of what it looks like to create a build tool using only npm scripts and CLI packages. So should you use npm scripts instead of a build tool like Grunt or Gulp?

I’m inclined to think that npm scripts do seem like a neater solution than having to install and learn the abstractions of build tools like Grunt and Gulp (especially for smaller projects). However, I can imagine your package.json file getting pretty messy and complex for larger projects with loads of scripts. At that point having a separate, dedicated config file for your build probably makes more sense.

Also performance seems to be much worse than both Grunt and Gulp. Npm scripts don’t have the advantage of being able to use node streams the way Gulp does, but even Grunt ran significantly quicker than npm scripts. Compared to Grunt/Gulp performance, npm scripts are slow:

  • Grunt: 1.6 secs
  • Gulp: 0.59 secs
  • Npm scripts: 4.0 secs

That being said, one thing to remember is that using npm scripts or Grunt/Gulp aren’t mutually exclusive. You could always run Grunt/Gulp inside a npm script as part of your build process. This might help with performance issues but it also would be helpful if you’re trying to migrate your team from Grunt/Gulp to npm scripts and want to take a more staggered approach before completely replacing Grunt/Gulp.

Do you think using npm scripts is a better alternative to other build tools like Grunt or Gulp? Have you used npm scripts before and have any good tips to share? Are you going to give npm scripts a try now that you’ve read this post? Let us know in the comments.

About the Author

Gilbert Pellegrom

Gilbert loves to build software. From jQuery scripts to WordPress plugins to full blown SaaS apps, Gilbert has been creating elegant software his whole career. Probably most famous for creating the Nivo Slider.

  • Nicolas Scott

    Thanks for this. I do find myself in moments where it seems like I’m wasting more time than I should be, grunting. I’ll give this a shot next project!

  • Cantilever, Inc.

    Hi Gilbert, thanks for this! We recently started down this path as well and noticed the performance issue – even though our scripts are doing less than before, they still take some time. Can you provide any more insight on the underlying reason for the efficiency of the task runners, and how one might access that efficiency with direct scripts? It feels like fewer middle-men must be better, but in practice it doesn’t feel that way. Thanks!

    • Gulp does not write stuff to the disk in between tasks. It uses streams to keep everything in memory. Less i/o = more speed.

      • True that. That’s why I still stick with WPGulp. Especially when you project size grows it becomes a thing.

        Good one @Gilbert!

        • Cantilever, Inc.

          Thanks Peter and Ahmad!

  • Phi Phan

    I’ve used npm as build tool for some of my recent projects. I recommemd some other packages like scss-lint for linting sass, postcss for autoprefix, eslint for linting js, babel for es2015. I also use nodemon instead of onchange for watching files.

  • Jonny Kates

    I’ve been considering moving to using npm scripts as a build tool for a while, but the performance hit which you mention at the end is what stops me adopting it over Gulp. If I’m watching all my Sass partials for example, then time to compile is really my top priority.

  • Can you not speed up some of the processes by running them in parallel? For example, in these two:

    “`
    “concat:js”: “mkdir -p dist/js && concat -o dist/js/scripts.js assets/js/test1.js assets/js/test2.js”,
    “concat”: “npm run concat:css && npm run concat:js”,
    “`

    What if you use single `&` instead of `&&`? Wouldn’t that run the various commands in parallel, instead of in sequence when you use `&&`?