Who needs tasks runners?

22 November 2017

It’s very easy for a developer to keep using tools out of habit during large expanses of time. It creates security and stability in the day-to-day aspects of the job. Often one can start copying patterns and reusing code from one project to another to have a common starting point. One issue arising from this process is that you might not optimize parts of the workflow because trying something else requires a lot more energy.

One of the parts being reused over and over is the task runner configuration used for frontend compiling purposes.

Projects should be expected to require vendor packages but I feel it’s quite another for them to require and maintain a list of build tools and build configurations as well.

Can we simplify the minimal requirements required by the common web project?

A common configuration

In 2012 Grunt was released. The task runner allowed developers who had Node.js installed to build a set of simple tasks using Javascript and Node.js’ API, already a familiar language by then. Gulp followed a year later with similar ambitions in mind but boasting a different syntax.

Both of these task runners, which are still very popular today, had gained rapid acceptance because they simplified how frontend files were fetched, grouped and maintained within a project. This was in large part due to the runner’s endlessly growing list of available plugins. Many of these plugins revolved around compressing and annotating distribution versions of frontend files. From then on all web design projects seemed to require:

With these files, you could rebuild a project by running a few command line commands across multiple settings while keeping dependencies outside your code repository.

There was a notable issue with this setting: both NPM and Bower are doing the same thing. They are both pulling files from somewhere and saving them to your project. The good news is that time has ironed out this issue for us with Bower’s explicit recommendation to switch to either Yarn or Webpack.

Package management

At the moment there is a tendency towards the unification Node package managers. Even if Yarn is currently in competition with NPM to become your default Node package manager, there is a key difference from when NPM was competing with Bower: both principal parties support most of the same API.

In fact, you can say the community is generally making gains from the head-butting as the competition between managers certainly hinges on performance rather than end-user implementation.

Worthy packages which could only be found or loaded through Bower have all migrated to NPM. Even if these libraries are not strict Node.js packages it still makes sense to handle them through NPM. Though the vendor code can be Javascript or CSS meant for the browser, you will likely be packaging your vendor files using a tool that in effect runs within Node.

It follows that a package.json file generally should contain many more devDependencies than regular dependencies because your files are compiled, mushed, grouped or compressed as part of the development process and almost never used as-is. This means the required package is not the actual dependency but rather it’s the generated file that is the real requirement.

Task management

NPM comes with its own method of running commands through the scripts configuration block of the package.json file. This configuration block allows you to create an abstraction for application scripts by exposing methods to the npm run [your_command] API.

One can argue whether it’s a package manager’s job to handle customized project scripts. In comparison, PHP’s Composer does not seem to think so. Instead, it allows you to attach scripts to events raised while installing or updating your PHP dependencies.

I tend to prefer Composer’s approach as I feel it prevents the abuse of the scripting system with weird uses cases. Picture for example a npm run clearDB command that would map to php scripts/clearDB.php a PHP script. That does not seem particularly elegant, though it would be allowed.

Whatever my reserves may be on the feature the point I am really raising here is that you should attempt porting build tasks to NPM script blocks. Knowing the full scope of what you can pull off with commands set directly in NPM Gulp and Grunt quickly start to look like unnecessary abstractions in most cases.

They look even more out of place when you notice how often both Gulp and Grunt tasks are simple wrappers around a common code-base that can run without the context of a task runner. Node-sass is a good example of this as both Gulp-sass and Grunt-sass depend on it.

When you come across these wrappers it becomes trivial to call the main library from a script block instead of scaffolding the process within a task runner. Keith Cirkel wrote on how to implement basically everything you could ask for. Here’s how he would pipe his CSS generation without any task runner:

{
"devDependencies": {
"autoprefixer": "latest",
"cssmin": "latest"
},
"scripts": {
"build:css": "autoprefixer -b 'last 2 versions' < assets/styles/main.css | cssmin > dist/main.css"
}
}

When to use task runners

I personally draw the line by asking a simple question: “Does it make sense to call this from a Cron job?”. If it does not make sense to call your script from a Cron job, maybe it shouldn’t be configured in a task runner.

In the case of actual entry points into an application, especially when these entry points intertwine between each other, it does make sense to implement a task runner. I find I do not gain value out of implementing scripts used only during development or during the build process because they are not entry points into the application.

Webpack

Even when using packagers like Webpack, you can abstract complex commands behind npm run scripts. Laravel’s default package.json configuration does just that, streamlining and simplifying project dependencies even further.

{
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
}
}

But that’s another dependency!

Why am I saying one should limit the use of task runners while almost instantly suggesting you should be replacing them with Webpack? Would it not amount to the same thing in the end?

Well yes and no. You can do most of the same actions using either implementation. The difference is that task runners can be used for anything while Webpack is only intended to pack websites.

The result of this small distinction is that Webpack is able to comparatively simplify its configuration and become more opinionated on how plugins should work together.

It will never be NPM’s job to fully package websites by itself (and should never be), but you need something that will do it for you. The reason I am inclined to use Webpack over task runners is that it is its main raison d’être to do just that.

NPM & Webpack are enough

Configuring Webpack within NPM’s custom command API, one can achieve a clean and powerful project installation. You can enforce conventions, methodologies, and processes because you are using building blocks made for doing one thing and doing that thing well.

You should use stand-alone optimizers and compressors as much as you can. You should script abstractions to them behind the simpler and more accessible npm run commands. These commands are what you should be migrating from one project to another.

Only when your applications require extensive job access from the command line at runtime would it become appealing to implement a task runner for them.

A final side note

Though that is slightly off topic, often times you see Gulp and Grunt installed globally on the host computer. It makes sense to do so when you are actually running tasks from a global scope. For example, a server that needs to run a batch of tasks that may act on multiple locations.

When you are running tasks on a project level from a global installation, you may start to blur these scopes. To get the process working you either have to have the same version of the task runner installed across each environment, developer and CI image or to include the task runner as a devDependency to your project.

You can argue that it is fine to do so, that you can add a new NPM script entry which would call node_modules/bin/gulp and that we would reach a point that looks exactly as what I am describing with alternatives.

That may be so but my counterpoint is to ask whether a task runner is a correct tool for this job. I don’t believe it is because even though it can be configured to solve the problem of asset compilation, there are simpler avenues to take beforehand.