Всем привет!
Это не руководство, я делюсь опытом о том, как мы в большом 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 полностью с примером приложения.
Автор: alxdnlnko