11.11.2015

WordPress: сборка JS с помощью Grunt

Как я использую Grunt для оптимизации Javascript при разработке сайтов на Wordpress? Об этом и пойдет речь ниже. Статья будет интересна (наверное) прежде всего тем, кто только открыл для себя Grunt: то есть хотя бы установил и запустил его.

Я начал разбираться с Grunt’ом чтобы решить две популярные задачи: склеить и минифицировать JS-файлы. При этом в процессе разработки JS нужен «разобранным»: видеть в каком файле и какой строке случилась ошибка. А в продакшене совсем наоборот: JS-ресурсы нужно объединять в один обфусцированный файл, потому что меньше число HTTP-запросов, меньше вес страницы… вот это все.

Сначала это выглядело примерно так.

footer.php

<? if (SCRIPT_DEBUG) { ?> // SCRIPT_DEBUG определяется в wp-config.php
    <script src="/wp-content/themes/some-theme/js/libs/underscore.js"></script>
    <script src="/wp-content/themes/some-theme/js/libs/jquery.js"></script>
    <script src="/wp-content/themes/some-theme/js/main.js"></script>
<? } else { ?>
    <script src="/wp-content/themes/some-theme/js/main.min.js"></script>
<? } ?>

Gruntfile.js

concat: {
    options: {
        separator: ''
    },
    dist: {
        src: [
            'wp-content/themes/some-theme/js/libs/underscore.js',
            'wp-content/themes/some-theme/js/libs/jquery.js',
            'wp-content/themes/some-theme/js/libs/main.js'
        ],
        dest: 'wp-content/themes/some-theme/js/main.js'
    }
},

uglify: {
    dist: {
        files: {
            'wp-content/themes/some-theme/js/main.min.js': ['<%= concat.dist.dest %>']
        }
    }
}

Проблемы

— если необходимо добавить-убрать файл из проекта, то нужно править это в двух местах
— если надо собирать не один файл (main.min.js), а несколько, то…
— если у сайта несколько тем, то…

Решения

И WordPress, и Grunt берут исходные файлы из одного источника: /wp-content/some-theme/grunt/script_source.json.
В нем определяются названия файлов, которые получаются на выходе. В примере ниже это site.js и additional.js.

script_source.json

{
  "site": [
    "js/libs/underscore.js",
    "js/libs/jquery.js",
    "js/project/main.js"
  ],
  "additional": [
    "js/libs/underscore.js",
    "js/libs/jquery.js",
    "js/some-file.js"
  ]
}

Название темы нужно вордпрессу, потому что как вы заметили в script_source.json пути относительные (относительно темы). Для этого объявляется переменная $theme_path в functions.php и используется с помощью инструкции global $theme_path; в любом файле темы.

footer.php

<?
if (SCRIPT_DEBUG) {
    $file_name = 'site';
    $script_source = get_template_directory() . '/grunt/script_source.json';
    $json_file     = file_get_contents($script_source);
    $script_json   = json_decode($json_file, true);

    foreach($script_json[$file_name] as $script) {
        echo '<script src="' . $theme_path . $script . '"></script>' . "\n";
    }

} else {
    echo '<script src="' . $theme_path . 'js/dist/' . $file_name .  '.js"></script>';
}
?>

Чтобы Grunt собирал не один, а несколько файлов я написал пару дополнительных тасков. Сначала во временную папку js/temp складываются «склееные» файлы. Потом эти файлы минифицируются и кладутся в папку js/dist, после чего содержимое временной папки удаляется.

— Финальный листинг грантфайла ниже.
— Основные переменные вынесены наверх. Они помимо прочего будут использоваться в баннере в начале каждого собранного файла (см. initConfig uglify.options).
— Конечно, решение не идеальное (как и всё в этом мире), буду рад вашим конструктивным замечаниям и предложениям. Спасибо за внимание!

Gruntfile.js

module.exports = function(grunt) {

    'use strict';

    /* Example: $ grunt --project=theme_name
    *  defaultTheme declared below will be processed: $ grunt 
    * */
    
    
    var defaultTheme = 'vseva',
        authorName = 'sevadenisov.ru',
        authorEmail = 'sevadenisov@gmail.com';


    var packageJson = grunt.file.readJSON('package.json'),
        targetProject = grunt.option('project') || defaultTheme,
        projectPath   = 'wp-content/themes/' + targetProject + '/';

    grunt.initConfig({

        pkg: packageJson,


        concat: {
            options: {
                separator: ''
            }
        },


        uglify: {
            options: {
                banner: '/*! WordPress theme "' + targetProject + '" by ' + authorName + '\n' +
                'Updated: <%= grunt.template.today("dd-mm-yyyy H:M") %>\n' +
                'Email: ' + authorEmail + ' */\n'
            },
            settings: {
                ext: '.min.js',
                flatten: true,
                files: {}
            }
        },


        clean: [projectPath + 'js/temp']
    });


    grunt.task.registerTask('prepareConcat', 'Save concatenated files to temporary directory.', function() {

        var source = grunt.file.readJSON(projectPath + 'grunt/script_source.json'),
            concat  = grunt.config.get('concat') || {};

        for (var key in source) {

            if (source.hasOwnProperty(key)) {
                var sourceArr = [];

                for (var i = 0, l = source[key].length; i < l; i++) {
                    sourceArr.push(projectPath + source[key][i]);
                }

                concat[key] = {
                    src: sourceArr,
                    dest: projectPath + 'js/temp/' + key + '.js'
                };
            }
        }

        grunt.config.set('concat', concat);
    });


    grunt.task.registerTask('prepareUglify', 'Set "source-dist" map to uglify task', function() {

        var uglify = grunt.config.get('uglify') ||
            {
                project: {}
            };

        uglify.settings.files = grunt.file.expandMapping(
            [
                projectPath + 'js/temp/*.js'
            ],
            projectPath + 'js/dist/',
            {
                rename: function(destBase, destPath) {
                    return destBase + destPath.replace(projectPath + 'js/temp/', '');
                }
            }
        );

        grunt.config.set('uglify', uglify);
    });


    grunt.loadNpmTasks('grunt-contrib-concat');
    grunt.loadNpmTasks('grunt-contrib-uglify');
    grunt.loadNpmTasks('grunt-contrib-clean');


    grunt.registerTask('default', ['prepareConcat', 'concat', 'prepareUglify', 'uglify', 'clean']);


};

package.json

{
  "name": "vseva-postbirthday-theme",
  "version": "2015.11.11",
  "devDependencies": {
    "grunt": "^0.4.5",
    "grunt-contrib-clean": "^0.6.0",
    "grunt-contrib-concat": "~0.5.1",
    "grunt-contrib-uglify": "~0.5.0"
  }
}