Ещё один пост о сборке front-end проекта

Js app starterЯ потратил прилично времени на структуризацию и автоматизацию сборки фронта. Задача это интересная и стоит того, чтобы о ней рассказать.

Что умеет делать сборщик:

Собирать front-end проект для development & production окружений. Собирать по несколько js/css бандлов на проект. Использовать стиль CommonJS модулей в браузере. Использовать ES6-синтаксис. Спрайты, картинки и многое другое ВступительноеЧтобы было удобней следить за мыслью, сразу кидаю ссылку на репозиторий с шаблоном проекта: github.com/alexfedoseev/js-app-starterКак его завести Убедитесь, что установлен npm. npm -v Установите необходимые глобальные модули (если ещё не установлены):

npm install -g gulp browserify babel jade stylus http-server Сделайте форк репозитория.

git clone https://github.com/alexfedoseev/js-app-starter.git Установите зависимости проекта (исполнять в корне репозитория):

npm install Соберите проект в development окружении и запустите локальный сервер:

npm start Откройте браузер и перейдите на lvh.me:3500

В качестве сборщика будем использовать Gulp.Что включает процесс сборки и какие технологии используются: Сборка HTMLШаблонизатор: Jade Сборка CSSПрепроцессор: StylusПрефиксер: Autoprefixer Сборка JSМодульная система: Browserify + Babel (ES6 transpiler)Проверка качества кода: jsHint Оптимизация изображенийОптимизатор: Imagemin При необходимости: сборка спрайтов, обработка json, копирование фонтов и прочих файлов в public папкуСборщик спрайтов: SpritesmithОбработка json: gulp-json-editor Вобще я люблю Slim и Sass, но Ruby к Ruby, a JS к JS: для frontend-проекта будем использовать только штуки из npm. При желании любой инструмент можно заменить.Структура проекта | dist/

| lib/ |-- gulp/ |-- helpers/ |-- tasks/ |-- config.js

| node_modules/

| public/ |-- css/ |-- files/ |-- fonts/ |-- img/ |-- js/ |-- json/ |-- favicon.ico |-- index.html

| src/ |-- css/ |-- files/ |-- fonts/ |-- html/ |-- img/ |-- js/ |-- json/ |-- sprite/ |-- favicon.ico

| .gitignore | .npmignore | gulpfile.js | npm-shrinkwrap.json | package.json

Github.gitignore & .npmignoreВнутри этих файлов находится список того, что будет игнорироваться git и npm при коммитах/паблишах.

node_modules/В эту директорию падают все модули, которые мы установим через npm.

npm-shrinkwrap.jsonЯ не держу в репозитории содержимое node_modules/. Вместо этого лочу все зависимости через этот файл. Он генерируется автоматически командой: `npm shrinkwrap`.

package.jsonЭто файл с глобальными настройками проекта. К нему ещё вернемся.

gulpfile.jsОбычно тут хранятся все таски для сборки проекта, но в нашем случае он просто определяет значение переменной окружения и пробрасывает нас дальше в папку с gulp-тасками.

lib/gulp/Здесь храним все настройки и задачи сборщика.

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

|-- helpers/Вспомогательные методы сборщика.

|-- tasks/И сами gulp-таски.

src/Исходники проекта.

public/Результат сборки. Абсолютно всё содержимое этой папки генерируется сборщиком и перед каждой новой сборкой она полностью очищается, поэтому тут никогда и ничего не храним.

dist/Иногда я пишу opensource-модули. В этой папке после сборки оказываются обычная и минифицированная версии написанной js-библиотеки. При этом директория public/ используется как хранилище для демки. Если вы делаете обычный сайт или страницу приземления, то оно не понадобится.

Настройка проекта package.json Это файл, в котором хранятся глобальные настройки проекта.Подробное описание его внутренностей можно посмотреть тут: browsenpm.org/package.jsonНиже я остановлюсь только на некоторых важных частях. { // Название проекта «name»: «js-app-starter»,

// Версия проекта // Использую для версионирования модулей / обновления js+css в кэше браузера при обновлении версии сборки «version»:»0.0.1»,

// Если вы пишете js-библиотеку, то тут указываем путь к файлу, // который будет отзываться на `require ('your-lib')` «main»:»./dist/app.js»,

// Настойки browserify // В данном случае говорим, что нужно перед сборкой превратить ES6 в ES5 «browserify»: { «transform»: [ «babelify» ] },

// Консольные команды (подробнее ниже) «scripts»: { «start»: «NODE_ENV=development http-server -a lvh.me -p 3500 & gulp», «build»: «NODE_ENV=production gulp build» },

// Настройки jshint (проверка качества кода) «lintOptions»: { «esnext»: true … },

// Frontend зависимости «dependencies»: { «jquery»:»^2.1.3» … },

// Development зависимости «devDependencies»: { «gulp»:»^3.8.11» … } } GithubКонсольные команды В package.json мы можем прописать алиасы для консольных команд, которые будем часто выполнять в процессе разработки. «scripts»: { «start»: «NODE_ENV=development http-server -a lvh.me -p 3500 & gulp», «build»: «NODE_ENV=production gulp build» } Development сборкаПеред началом работы с проектом нам нужно:

собрать его из исходников (с sourcemaps для дебага) запустить «наблюдателей», которые будут пересобирать проект при изменении исходных файлов запустить локальный сервер # команда, которую исполняем npm start

# что исполняется на самом деле NODE_ENV=development http-server -a lvh.me -p 3500 & gulp Разбираем по частям # устанавливаем переменную окружения NODE_ENV=development

# запускаем локальный сервер на домене lvh.me и порте 3500 http-server -a lvh.me -p 3500

# запускаем gulp таски gulp Production сборкаКогда мы готовы релизить проект — делаем production-сборку.

# нажмите Ctrl+C, чтобы остановить локальный сервер и наблюдателей, если они запущены

# команда, которую исполняем npm run build

# что исполняется на самом деле NODE_ENV=production gulp build Разбираем по частям # устанавливаем переменную окружения NODE_ENV=production

# запускаем gulp-таск `build` gulp build Gulp Переходим к Gulp. Структура тасков взята из сборщика от Dan Tello.Перед тем, как нырнуть, небольшой комментарий по порядку выполнения обычного gulp-таска:

var gulp = require ('gulp');

gulp.task ('task_1', ['pre_task_1', 'pre_task_2'], function () { console.log ('task_1 is done'); });

// Здесь мы объявили `task_1`, который выводит в консоль сообщение `task_1 is done` // Запускается он командой `gulp task_1` // Но перед выполнением основного `task_1` должны выполниться задачи `['pre_task_1', 'pre_task_2']` // Важно понимать, что 'pre_task_1' & 'pre_task_2' — выполняются асинхронно, // то есть порядок выполнения не зависит от позиции задачи в массиве, //, а `task_1` стартует только после того, как отработали 2 pre-задачи — то есть синхронно

Теперь разберемся что и в каком порядке будем собирать.

Development сборка`npm start` запускает команду `gulp`. Что происходит дальше:

Gulp ищет в текущей директории gulpfile.js. Обычно в него складываются все таски, но здесь он просто определит значение переменной окружения и пробросит нас дальше в папку с gulp-тасками.Код с комментариями /* file: gulpfile.js */

// модуль, позволяющий включать таски из вложенных директорий var requireDir = require ('require-dir');

// устанавливаем значение глобальной переменной, // позволяющей различать в тасках development & production окружения global.devBuild = process.env.NODE_ENV!== 'production';

// пробрасываем сборщик в папку с тасками и конфигом requireDir ('./lib/gulp/tasks', { recurse: true }); Github После того, как нас пробросило в директорию, сборщик ищет таск с названием `default`, который сначала запускает «наблюдателей» над исходниками, потом: очищает папки `public/` & `dist/` линтит js-файлы и собирает спрайты После этого собирается проект (html, css, js и всё остальное).Код с комментариями default /* file: lib/gulp/tasks/default.js */

var gulp = require ('gulp');

// Запускаем пустой таск `default`, но предварительно исполняем таск `watch` gulp.task ('default', ['watch']);

Githubwatch

/* file: lib/gulp/tasks/watch.js */

var gulp = require ('gulp'), finder = require ('…/helpers/finder'), // хелпер для поиска файлов config = require ('…/config'); // конфиг

// Запускаем таск `watch`, перед ним исполняем таски `watching` & `build` gulp.task ('watch', ['watching', 'build'], function () {

// Вешаем наблюдателей на все файлы в директориях `css`, `images` & `html` // При изменении одного из файлов в указанной директории gulp выполнит соответствующий таск gulp.watch (finder (config.css.src), ['css']); gulp.watch (finder (config.images.src), ['images']); gulp.watch (finder (config.html.src), ['html']);

});

gulp.task ('watching', function () {

// Объявляем глобальную переменную `isWatching`, // которая сигнализирует, что наблюдатели запущены global.isWatching = true;

});

Githubbuild

/* file: lib/gulp/tasks/build.js */

var gulp = require ('gulp'); // Запускаем таск `build`, перед ним исполняем таски: // `clean` — перед сборкой очищаем директории `public/` & `dist/` // `lint` — проходимся jshint по js-файлам (проверка качества кода) // `sprite` — собираем спрайты gulp.task ('build', ['clean', 'lint', 'sprite'], function () {

// После того, как отработали три таска выше, запускается таск `bundle` // Вобще метод `gulp.start` deprecated, //, но нормальное управление sync/async задачами появится только в Gulp 4.0, // поэтому используем пока его gulp.start ('bundle');

});

// Собираем проект gulp.task ('bundle', ['scripts', 'css', 'images', 'html', 'copy'], function () {

// Если мы в dev-окружении, то после сборки выставляем значение переменной `doBeep` = true // `notifier` хелпер покажет нам уведомления об ошибках или окончании работы тасков // (в консоли и всплывающим баннером) if (devBuild) global.doBeep = true;

});

Github Production сборкаС ней всё проще. `npm run build` запускает команду `gulp build`, которая очищает целевые папки, линтит js-код, собирает спрайты и после этого собирет проект (без sourcemaps). Код с комментариями выше.Файл конфигураций gulp-тасков Все основные конфигурации тасков вынесены в отдельный файл lib/gulp/config.js: /* file: lib/gulp/config.js */

var pkg = require ('…/…/package.json'), // импортируем package.json bundler = require ('./helpers/bundler'); // импортируем хелпер для созлания бандлов

/* Настраиваем пути */

var _src = './src/', // путь до исходников _dist = './dist/', // куда будем сохранять дистрибутив будущей библиотеки _public = './public/'; // куда будем сохранять сайт или примеры использования библиотеки

var _js = 'js/', // папка с javascript файлами _css = 'css/', // папка с css _img = 'img/', // папка с картинками _html = 'html/'; // папка с html

/* * Настраиваем js / css бандлы * * Пример: app.js, app.css — сайт * admin.js, admin.css — админка * * Пример: your-lib.js — модуль без зависимостей * your-lib.jquery.js — модуль в формате jquery-плагина * */

var bundles = [ { name: 'app', // название бандла global: 'app', // если пишем модуль, это имя объекта, экспортируемого в глобальное пространство имён compress: true, // минифицируем? saveToDist: true // сохраняем в папку `/dist`? (true — если пишем модуль, false — если делаем сайт) } ];

module.exports = {

/* тут настройки тасков */

}; GithubСборка HTML Для шаблонизации используем Jade. Он позволяет делать вставки партиалов, использовать inline-javascript, переменные, миксины и ещё много разных крутых штук.Gulp Конфиг /* file: lib/gulp/config.js */

html: { src: _src + _html, // путь до jade-исходников dest: _public, // куда сохраняем собранное params: { // параметры для jade pretty: devBuild, // убиваем отступы в html? locals: { // переменные, которые мы передаем в шаблоны pkgVersion: pkg.version // сохраняем версию релиза в переменную `pkgVersion` } } }

GithubТаск

/* file: lib/gulp/tasks/html.js */

var gulp = require ('gulp'), jade = require ('gulp-jade'), jadeInherit = require ('gulp-jade-inheritance'), gulpif = require ('gulp-if'), changed = require ('gulp-changed'), filter = require ('gulp-filter'), notifier = require ('…/helpers/notifier'), config = require ('…/config').html;

gulp.task ('html', function (cb) {

// берём все jade-файлы из директории src/html gulp.src (config.src + '*.jade') // если dev-сборка, то watcher пересобирает только изменённые файлы .pipe (gulpif (devBuild, changed (config.dest))) // корректно обрабатываем зависимости .pipe (jadeInherit ({basedir: config.src})) // отфильтровываем не-партиалы (без `_` вначале) .pipe (filter (function (file) { return!/\/_/.test (file.path) || !/^_/.test (file.relative); })) // преобразуем jade в html .pipe (jade (config.params)) // пишем html-файлы .pipe (gulp.dest (config.dest)) // по окончании запускаем функцию .on ('end', function () { notifier ('html'); // уведомление (в консоли + всплывашка) cb (); // gulp-callback, сигнализирующий о завершении таска });

});

Github Исходники Структура папки src/html | src |-- html |-- index.jade # скелет страницы |-- components/ # компоненты страницы |-- _header.jade |-- helpers/ # переменные, миксины |-- _params.jade |-- _mixins.jade |-- meta/ # содержимое head, коды аналитики и пр. |-- _head.jade

GithubВсе партиалы снабжаем префиксом `_` (нижнее подчеркивание), чтобы при сборке мы могли их отфильтровать и игнорировать.

helpers/_variables.jadeСохраняем необходимые параметры в переменные. Например, если у нас телефон стоит в нескольких местах страницы, то его лучше сохранить в переменную и в шаблонах использовать именно её.

/* file: src/html/helpers/_variables.jade */

— var release = pkgVersion // переменная из gulp-конфига  — var phone = '8 800 CALL-ME-NOW' // телефон

Githubhelpers/_mixins.jadeЧасто используемые блоки можно обернуть в mixin.

/* file: src/html/helpers/_mixins.jade */

mixin phoneLink (phoneString)  — var cleanPhone = phoneString.replace (/\(|\)|\s|\-/g, '') a (href=«tel:#{cleanPhone}»)= phoneString

// в верстке вставляем // +phoneLink (phone)

Githubindex.jadeСкелет главной страницы.

/* file: src/html/index.jade */

include helpers/_variables // импортируем переменные include helpers/_mixins // импортируем миксины

doctype html html

head include meta/_head

body include components/_header include components/_some_component include components/_footer

Githubmeta/_head.jadeСодержимое head.

/* file: src/html/meta/_head.jade */

meta (charset=«utf-8»)

// Используем версию сборки, если нужно обновить js/css в кэше браузеров link (rel=«stylesheet» href=«css/app.min.css? v=#{release}») script (src=«js/app.min.js? v=#{release}»)

Github Сборка JavaScript В качестве модульной системы используем Browserify. C ним мы можем использовать стиль подключения CommonJS модулей непосредственно в браузере. Кроме этого мы теперь можем использовать ES6-синтаксис: Babel преобразует его в ES5 перед тем, как Browserify соберет js. И перед сборкой мы проходимся jsHint для проверки качества кода.У Browserify есть один минус: если вы пишете библиотеку с внешними зависимостями (например jQuery-плагин), то он не сможет сделать правильную UMD-обертку. В этом случае я заменяю Browserify на конкатенацию и пишу обёртку руками.

О бандлах На проекте может возникнуть необходимость формировать несколько наборов js/css.Например вы пишите фронт + админку. Или библиотеку в 2 вариантах: без зависимостей и в формате jQuery-плагина. Эти сборки нужно разделять. Для этого в настройках сборщика мы создаем массив:

/* file: lib/gulp/config.js */

/* Для библиотеки */ var bundles = [ { name: 'myLib', // название бандла global: 'myLib', // это имя объекта, экспортируемого в глобальное пространство имён compress: true, // минифицируем? (неминифицированная версия сохранятся всегда) saveToDist: true // сохраняем в папку `/dist`? } ];

/* Для сайта / страницы приземления */ var bundles = [ { name: 'app', // название бандла global: false, // ничем отсвечивать не надо compress: true, // минифицируем? saveToDist: false // сохраняем в папку `/dist`? }, name: 'admin', global: false, compress: true, saveToDist: false } ];

Githubjs/css cборщики будут искать в папке с js/css исходниками соответствующий end-point файл (`app.js` или `app.styl`). Через этот end-point файл мы управляем всеми зависимостями бандла. Их структуру я покажу чуть ниже.

Перед передачей бандлов сборщику, мы предварительно пропускаем массив через хелпер `bundler`, который формирует объект с настройками.

Gulp Конфиг /* file: lib/gulp/config.js */

scripts: { bundles: bundler (bundles, _js, _src, _dist, _public), // пакуем бандлы banner: '/** ' + pkg.name + ' v' + pkg.version + ' **/\n', // задаем формат баннера для min.js extensions: ['.jsx'], // указываем дополнительные расширения lint: { // параметры для jshint options: pkg.lintOptions, dir: _src + _js } }

GithubТаск

/* file: lib/gulp/tasks/scripts.js */

var gulp = require ('gulp'), browserify = require ('browserify'), watchify = require ('watchify'), uglify = require ('gulp-uglify'), sourcemaps = require ('gulp-sourcemaps'), derequire = require ('gulp-derequire'), source = require ('vinyl-source-stream'), buffer = require ('vinyl-buffer'), rename = require ('gulp-rename'), header = require ('gulp-header'), gulpif = require ('gulp-if'), notifier = require ('…/helpers/notifier'), config = require ('…/config').scripts;

gulp.task ('scripts', function (cb) {

// считаем кол-во бандлов var queue = config.bundles.length;

// поскольку бандлов может быть несколько, оборачиваем сборщик в функцию, // которая в качестве аргумента принимает bundle-объект с параметрами // позже запустим её в цикл var buildThis = function (bundle) {

// отдаем bundle browserify var pack = browserify ({ // это для sourcemaps cache: {}, packageCache: {}, fullPaths: devBuild, // путь до end-point (app.js) entries: bundle.src, // если пишем модуль, то через этот параметр // browserify обернет всё в UMD-обертку // и при подключении объект будет доступен как bundle.global standalone: bundle.global, // дополнительные расширения extensions: config.extensions, // пишем sourcemaps? debug: devBuild });

// сборка var build = function () {

return ( // browserify-сборка pack.bundle () // превращаем browserify-сборку в vinyl .pipe (source (bundle.destFile)) // эта штука нужна, чтобы нормально работал `require` собранной библиотеки .pipe (derequire ()) // если dev-окружение, то сохрани неминифицированную версию в `public/` (зачем — не помню)) .pipe (gulpif (devBuild, gulp.dest (bundle.destPublicDir))) // если сохраняем в папку `dist` — сохраняем .pipe (gulpif (bundle.saveToDist, gulp.dest (bundle.destDistDir))) // это для нормальной работы sourcemaps при минификации .pipe (gulpif (bundle.compress, buffer ())) // если dev-окружение и нужна минификация — инициализируем sourcemaps .pipe (gulpif (bundle.compress && devBuild, sourcemaps.init ({loadMaps: true}))) // минифицируем .pipe (gulpif (bundle.compress, uglify ())) // к минифицированной версии добавляем суффикс `.min` .pipe (gulpif (bundle.compress, rename ({suffix: '.min'}))) // если собираем для production — добавляем баннер с названием и версией релиза .pipe (gulpif (! devBuild, header (config.banner))) // пишем sourcemaps .pipe (gulpif (bundle.compress && devBuild, sourcemaps.write ('./'))) // сохраняем минифицированную версию в `/dist` .pipe (gulpif (bundle.saveToDist, gulp.dest (bundle.destDistDir))) // и в `public` .pipe (gulp.dest (bundle.destPublicDir)) // в конце исполняем callback handleQueue (определен ниже) .on ('end', handleQueue) );

};

// если нужны watchers if (global.isWatching) { // оборачиваем browserify-сборку в watchify pack = watchify (pack); // при обновлении файлов из сборки — пересобираем бандл pack.on ('update', build); }

// в конце сборки бандла var handleQueue = function () { // сообщаем, что всё собрали notifier (bundle.destFile); // если есть очередь if (queue) { // уменьшаем на 1 queue--; // если бандлов больше нет, то сообщаем, что таск завершен if (queue === 0) cb (); } };

return build (); };

// запускаем массив бандлов в цикл config.bundles.forEach (buildThis);

});

Github Исходники Структура папки src/js | src/ |-- js/ |-- components/ # код компонентов |-- helpers/ # js-хелперы |-- app.js # end-point бандла

Githubapp.jsЧерез этот файл мы рулим всеми зависимостями и порядком исполнения js-компонентов. Имя файла должно совпадать с именем бандла.

/* file: src/js/app.js */

/* Vendor */ import $ from 'jquery';

/* Components */ import myComponent from './components/my-component';

/* App */

$(document).ready (() => {

myComponent ();

});

Github Что делать, если зависимости нет в npm В таких случаях используем browserify-shim: плагин, который позволяет превращать обычные библиотеки в CommonJS-совместимые модули. Итак, у нас есть jQuery-плагин `maskedinput`, которого нет в npm.Добавляем в `package.json` преобразование и выставляем настройки для зависимости:

/* file: package.json */

«browserify»: { «transform»: [ «babelify», «browserify-shim» // добавляем преобразование ] },

// у `browserify-shim` много вариантов подключения библиотек // смотрите доки на github: https://github.com/thlorenz/browserify-shim «browser»: { «maskedinput»:»./path/to/jquery.maskedinput.js» }, «browserify-shim»: { «maskedinput»: { «exports»: «maskedinput», «depends»: [ «jquery: jQuery» ] } }

После этого мы можем подключать модуль:

require ('maskedinput'); Сборка CSS В качестве препроцессора используем Stylus. Плюс проходимся по css автопрефиксером, чтобы не прописывать вендорные префиксы руками.Gulp Конфиг /* file: lib/gulp/config.js */

css: { bundles: bundler (bundles, _css, _src, _dist, _public), // пакуем бандлы src: _src + _css, // указываем где лежать исходники для watcher params: {}, // если нужны настройки для stylus — указываем тут autoprefixer: { // настраиваем autoprefixer browsers: ['> 1%', 'last 2 versions'], // подо что ставим префиксы cascade: false // красиво не надо, всё равно минифицируем }, compress: {} // если нужны настройки минификации — указываем тут }

GithubТаск

/* file: lib/gulp/tasks/css.js */

var gulp = require ('gulp'), process = require ('gulp-stylus'), prefix = require ('gulp-autoprefixer'), compress = require ('gulp-minify-css'), gulpif = require ('gulp-if'), rename = require ('gulp-rename'), notifier = require ('…/helpers/notifier'), config = require ('…/config').css;

/* Логика css-таска повторяет логику js-таска */

gulp.task ('css', function (cb) {

var queue = config.bundles.length;

var buildThis = function (bundle) {

var build = function () { return ( gulp.src (bundle.src) .pipe (process (config.params)) .pipe (prefix (config.autoprefixer)) .pipe (gulpif (bundle.compress, compress (config.compress))) .pipe (gulpif (bundle.compress, rename ({suffix: '.min'}))) .pipe (gulp.dest (bundle.destPublicDir)) .on ('end', handleQueue) ); };

var handleQueue = function () { notifier (bundle.destFile); if (queue) { queue--; if (queue === 0) cb (); } };

return build (); };

config.bundles.forEach (buildThis);

});

Github Исходники Структура папки src/css | src/ |-- css/ |-- components/ # стили компонентов |-- header.styl |-- footer.styl |-- globals/ |-- fonts.styl # подключаем фонты |-- global.styl # глобальные настройки проекта |-- normalize.styl # нормализуем / ресетим |-- variables.styl # переменные |-- z-index.styl # z-индексы проекта |-- helpers/ |-- classes.styl # вспомогательные классы |-- mixins.styl # и миксины |-- sprite/ |-- sprite.json # json, генерируемый gulp.spritesmith |-- sprite.styl # создаем из json css-классы |-- vendor/ # вендорные css складываем сюда |-- app.styl # end-point бандла

Githubapp.stylЧерез этот файл мы рулим порядком подключения css-компонентов. Имя файла должно совпадать с именем бандла.

/* file: src/css/app.styl */

@import «helpers/mixins» @import «helpers/classes» @import «globals/variables» @import «globals/normalize» @import «globals/z-index» @import «globals/fonts» @import «globals/global» @import «sprite/sprite» @import «vendor/*» @import «components/*»

Github Все остальные таски — картинки, спрайты, очистка и пр. — не требуют дополнительных комментариев (на самом деле я просто устал уже строчить). Исходники лежат в репозитории: github.com/alexfedoseev/js-app-starter

Если есть косяки или дополнения — буду рад обратной связи через комментарии тут или issues / pull requests на Github. Удач!

© Habrahabr.ru