django

Как мы в django-проекте js собираем + пара хитростей в Gulp

  • вторник, 2 декабря 2014 г. в 02:10:33
http://habrahabr.ru/post/244641/

Всем привет!

Это не руководство, я делюсь опытом того, как мы в большом Django проекте от безобразной помойки скриптов на jQuery постепенно пришли к сборке и минификации сложных frontend-приложений на AngularJS при помощи gulp и browserify.

Предыстория


Имеется большой многолетний Django-проект с кучей legacy кода, миллиардом зависимостей и командой без официального frontend-разработчика. Как-то так повелось, что я постепенно все больше занимался js, втягивался во фронтенд и сейчас это уже занимает больше половины моего рабочего времени.

В истории фронтенда нашего проекта (и, соответственно, моего развития, как js-разработчика) можно выделить три больших этапа:

jQuery — наше всё

Это был тот период, когда, овладев парой методов jQuery, освоив селекторы и научившись с анимацией показывать/прятать элементы на странице, считаешь себя состоявшимся frontend-разработчиком. Все новички через это проходили и все знают, как это выглядит: каждый кусок функционала — отдельный файл, на больших страницах десяток подключений скриптов, никакой системы — каждый скрипт сам за себя, со всеми вытекающими, как говорится. Не было определенного места для хранения вендорных библиотек, каждый следующий разработчик закидывал новую либу, куда ему вздумается. В добавок ко всему, что писал я сам, была еще громадная куча старых скриптов, написанных до меня.

Knockout + RequireJS

Появилась необходимость написания более сложных интерфейсов, визардов и прочего для админки. К этому времени пришло понимание, что jQuery — не панацея, и что нужно как-то организовывать свой код. Тут на помощь пришли Knockout и RequireJS. RequireJS позволил разбивать код на модули, указывать зависимости, переиспользовать модули на разных страницах, выстраивать нормальную структуру файлов для каждого приложения. Появилась хоть какая-то система: был создан конфиг-файл для RequireJS с путями до всех библиотек, он использовался на всех нокаутных страницах, все вендорные библиотеки поселились в одном месте. Осталась только одна проблема: хоть теперь в шаблоне подключался только один скрипт, остальная куча зависимостей тянулась уже самим RequireJS, причем зачастую файлы модулей были настолько маленькие, что пинг до сервера был дольше времени скачивания — бессмысленные тормоза. Я часто указывал на эту проблему и предлагал разные варианты решения, но ответ начальства всегда был один: “Это админка. Тут это не критично. Не будем тратить на это время.”

AngularJS + Gulp + Browserify + Uglify

Наконец, руки дошли до Customer Area: хитрые интерфейсы, плюс — требования к UX. Игнорировать проблему загрузки скриптов уже было нельзя. На тот момент я уже набрался опыта в разработке на NodeJS с использованием сборки скрипов для фронтенда. Теперь смотреть без слёз на конфиг-файл для RequireJS и на систематизированную помойку вендорных библиотек не получалось.

Немного о том, как вообще функционирует проект. Каждое django-приложение имеет свою папку static, во время разработки джанговский dev-сервер ищет подключенные на страницах скрипты в этих папках. Во время деплоя на продакшне делается collectstatic, который собирает все файлы в одну папку, чтобы их мог отдавать web-сервер. Ничего необычного.

Мне хотелось получить следующее:
  • нормальный менеджер пакетов для фронтенда;
  • нормальное оформление кода в виде reusable-модулей;
  • сборка js-приложения в один файл и его минификация.


Встал вопрос — с какого боку это всё прикрутить к проекту, чтобы сильно не нарушить привычный воркфлоу и не напугать начальство новыми зависимостями в виде NodeJS (читать, как «новый язык в команде питонистов») и его утилит?

Было решено, что все манипуляции с js-кодом (сборка, минификация) будут производиться до коммита, готовый пакет будет копироваться в папку со статикой соответствующего django-приложения и подключаться оттуда. Таким образом, процесс деплоя останется неизменным, плюс — никаких новых зависимостей в продакшне.

Встаем на путь истинный


Окружение

Итак, первым делом нам понадобится:

  • nodejs;
  • gulp — для описания тасков сборки;
  • npm — для установки пакетов, необходимых для сборки;
  • bower — для установки пакетов необходимых во фронтенде.


Ставиться они должны глобально в систему, т.к. нам нужны их консольные утилиты. Благо, разработку мы ведём в Vagrant, так что я всего лишь добавил соответствующие chef-рецепты в его конфиг. После установки в корне проекта необходимо выполнить npm init и bower init и задать минимально необходимые параметры, на выходе получим package.json и bower.json. Завершающим шагом подготовки окружения будет внесение node_modules/ и bower_components/ в .gitignore, так как вся сборка у нас будет производиться непосредственно при разработке.

При использовании bower и npm для установки пакетов не забываем использовать аргумент --save-dev, чтобы информация о пакете была сохранена в bower.json и package.json соответственно, и остальные разработчики могли легко поднять окружение, просто запустив npm install и bower install в корне проекта.

Структура каталогов

Исходный код js-приложений я решил хранить в отдельном каталоге в корне проекта. Сначала я хотел анализировать структуру каталогов на лету при сборке, но подумал, что на каждый умный анализатор рано или поздно появится задача, которую придётся подпирать костылями, поэтому решил просто создать конфиг, в котором буду описывать все эти приложения. Так в корне проекта появился файл config-spa.js:

module.exports = {
    apps: {
        'appname': {        // имя js-приложения
            main: 'app.js',  // имя главного файла
            path: './spa/dj-app/appname/',  // путь до приложения
            bundle: 'appname.min.js',         // имя скомпилированного пакета
            dest: './dj-app/static/dj-app/js/',  // путь до каталога со статикой соответствующего django-приложения
            watch: ['./spa/dj-app/appname/**/*.js']  // список glob-путей для слежения за изменениями (для автоматической перекомпиляции при разработке)
        },
        ...
    }
}


  • spa/ — каталог, в котором будут находиться все js-приложения
  • dj-app — названия django-приложения, в котором будет использован собранный пакет


Таким образом легко понять, к какому приложению относятся скрипты. Общие модули выносятся в каталоги с именем common.

gulpfile.js


Осталось дело за малым — описание заданий для сборки. В общем то, получился стандартный gulpfile, но есть пара хитростей, которые могут быть кому-то полезными.

Парсинг аргументов командной строки и первая хитрость

Так как у нас несколько приложений, то надо было как-то указывать, какое именно приложение нужно собрать, либо указать, что нужно пересобрать их все.
Другой аргумент — флаг, отменяющий минификацию приложения, чтобы можно было видеть нормальные стэк-трейсы при отладке.

В чем же хитрость? Во-первых, в том, что парсинг аргументов я оформил в виде отдельного таска, чтобы его можно было указывать в зависимостях других тасков, и, во-вторых, один раз разобранные аргументы сохраняются в глобальной переменной так что при вызове одних тасков из других они будут работать с одинми и теми же настройками.

// подключения библиотек опущены, см. полный файл в конце

var config = require('./config-spa'),
    argv = {parsed: false}
gulp.task('parseArgs', function() {
    // prevent multiple parsing when watching
    if (argv.parsed) return true

    // check the process arguments
    var options = minimist(process.argv)
    if (_.size(options) === 1) {
        printArgumentsErrorAndExit()
    }

    // готовим список приложений, сверяя его с конфигом
    var apps = []
    if (options.app && config.apps[options.app]) {
        apps.push(options.app)
    } else if (options.all) {
        apps = _.keys(config.apps)
    }

    if (!apps.length) printArgumentsErrorAndExit()
    argv.apps = apps

    // dev - флаг, отменяющий минификацию
    if (options.dev) argv.dev = true

    argv.parsed = true
})

function printArgumentsErrorAndExit() {
    gutil.log(gutil.colors.red('You must specify the app or'), gutil.colors.yellow('--all'))
    gutil.log(gutil.colors.red('Available apps:'))
    _.each(config.apps, function(item, i) {
        gutil.log(gutil.colors.yellow('  --app ' + i))
    })

    // break the task on error
    process.exit()
}


Сборка приложения

function bundle() {
    return through.obj(function(file, enc, cb) {
        var b = browserify({entries: file.path})

        file.contents = b.bundle()
        this.push(file)
        cb()
    })
}


gulp.task('build', ['parseArgs'], function(cb) {
    var prefix = gutil.colors.yellow('  ->')
    async.each(argv.apps,
        function(app, cb) {
            gutil.log(prefix, 'Building', gutil.colors.cyan(app), '...')
            var conf = config.apps[app]
            if (!conf) return cb(new Error('No conf for app ' + app))

            gulp.src(path.join(conf.path, conf.main))
                .pipe(bundle())
                .pipe(gulpif(!argv.dev, streamify(uglify())))
                .pipe(rename(conf.bundle))
                .pipe(gulp.dest(conf.dest))
                .on('end', function() { cb() })
        },
        function(err) {
            cb(err)
        }
    )
})


  • function bundle() {...} — самописная обёртка для browserify. Кто его использует, тот давно знает, что browserify сам умеет работать с потоками, поэтому пакет gulp-browserify давно уже не используется;
  • [parseArgs] — указываем в зависимостях таск для парсинга аргументов командной строки. Таким образом мы уверены, что в переменной argv лежат уже валидные настройки;
  • async.each, cb() — перебор указанных в аргументах приложений. Зачем тут асинк и заморочки с коллбэками? Дело в том, что сама процедура сборки (gulp.src().pipe()...) — дело асинхронное, и таск может завершиться до того, как выполнится вся цепочка, а это, в свою очередь, ведёт к тому, что зависящие от него таски начинают своё выполнение раньше. Есть три варианта решения — коллбэк у таска, возвращение из таска потока — return gulp.src()... и возвращение promise. Вернуть поток мы тут не сможем, потому что их несколько, так что я остановился на коллбэке;
  • .pipe(gulp.dest(conf.dest)) — собранный пакет копируется в папку со статикой, указанную в конфиге js-приложения, так что при деплое collectstatic выполнит своё дело без доплнительных телодвижений.


Перекомпиляция при изменениях в файлах

Таск наблюдения за изменениями в файлах js-приложения:

gulp.task('watch', ['build'], function() {
    var targets = []
    _.each(argv.apps, function(app) {
        var conf = config.apps[app]
        if (!conf) return

        if (conf.watch) {
            if (_.isArray(conf.watch)) {
                targets = _.union(targets, conf.watch)
            } else {
                targets.push(conf.watch)
            }
        }
    })
    targets = _.uniq(targets)

    // start watching files
    gulp.watch(targets, ['build'])
})


  • ['build'] — указываем таск сборки в зависимостях. Во-первых, он пересоберёт приложение перед началом наблюдения, во-вторых, мы знаем, что перед таском build делается разбор аргументов командной строки;
  • _.each(argv.apps, ...) — перебираем указанные в аргументах приложения, смотрим их настройки в конфиге, собираем таргеты для наблюдения за изменениями;
  • gulp.watch(targets, ['build']) — запускаем наблюдение, таск build выполняется при изменениях. Тут есть один недостаток — если мы запускаем watch для некольких приложений, то при любых изменениях они будут пересобраны все, но на деле вряд ли когда-либо (никогда) понадобится одновременно следить за несколькими приложенями, поэтому не заморачиваемся.


Пересобираем с минификацией после завершения watch — хитрость вторая

Процесс разработки выглядит так: запускаем django dev server, запускаем gulp watch и пишем/отлаживаем фронтенд-приложение. Таким образом, сам процесс разработки гарантирует, что актуальное собранное приложение окажется незамедлительно в папке статики при любых изменениях, и нам уже не нужны дополнительные шаги при деплое. Но проблема в том, что разработка обычно ведётся с параметром --dev (без минификации), и вот, пару раз по запарке закоммитив в продакшн неминифицированный пакет размером под 2 мегабайта, я задумался, что надо бы придумать какую-то напоминалку, а еще лучше — автоматизацию.

Так в таске watch появился такой код:

    // handle Ctrl+C and build a minified version on exit
    process.on('SIGINT', function() {
        if (!argv.dev) process.exit()

        argv.dev = false

        console.log()
        gutil.log(gutil.colors.yellow('Building a minified version...'))

        gulp.stop()
        gulp.start('build', function() {
            process.exit()
        })
    })


  • отлавливаем CTRL+C;
  • если watch был запущен с минификацией, то просто завершаем процесс;
  • argv.dev = false — отменяем запрет минификации, чтобы следующий build собрал нам пакет для продакшна;
  • gulp.stop() — завершаем все текущие таски;
  • gulp.start('build', function() {...}) — вызываем таск build и после его завершения выходим. Тут очень важно, чтобы в таске build был правильно вызван коллбэк после сборки, про что я уже говорил ранее, иначе таск завершится до того, как пакет скопируется в папку со статикой, и произойдет выход из процесса. Метода start нет в документации к gulp, потому что на самом деле это не его метод: он был унаследован от Orchestrator.


В результате получается: запускаем gulp watch --app appname --dev, отлаживаем приложение, нажимаем CTRL+C, чтобы остановить watch и gulp тут же нам собирает минфицированную версию пакета. Спокойно коммитим и наслаждаемся результатом своих трудов в продакшне.

Итог


Мы получили систему сборки js-приложений без изменений в процессе деплоя и без новых зависимостей на продакшне. Она позволила нам делить код на модули и получать на выходе один компактный файл. Сюда же можно добавить js-linter, тесты и много другое.

Таким же образом можно без труда перевести, например, стили на какой-нибудь Stylus и тоже минифицировать их, но в виду некоторых человеко-причин мы не стали пока этого делать.

Всем, кто дочитал, спасибо за внимание.

Gulpfile полностью с примером приложения.