Я потратил прилично времени на структуризацию и автоматизацию сборки фронта. Задача это интересная и стоит того, чтобы о ней рассказать.
Что умеет делать сборщик:
- Собирать front-end проект для development & production окружений.
- Собирать по несколько js/css бандлов на проект.
- Использовать стиль CommonJS модулей в браузере.
- Использовать ES6-синтаксис.
- Спрайты, картинки и многое другое
Вступительное
Чтобы было удобней следить за мыслью, сразу кидаю ссылку на репозиторий с шаблоном проекта: github.com/alexfedoseev/js-app-starter
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
.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"
...
}
}
Консольные команды
В 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 });
- После того, как нас пробросило в директорию, сборщик ищет таск с названием `default`, который сначала запускает «наблюдателей» над исходниками, потом:
- очищает папки `public/` & `dist/`
- линтит js-файлы
- и собирает спрайты
После этого собирается проект (html, css, js и всё остальное).
Код с комментариямиdefault/* file: lib/gulp/tasks/default.js */ var gulp = require('gulp'); // Запускаем пустой таск `default`, но предварительно исполняем таск `watch` gulp.task('default', ['watch']);
watch
/* 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; });
build
/* 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; });
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 = {
/* тут настройки тасков */
};
Сборка HTML
Для шаблонизации используем Jade. Он позволяет делать вставки партиалов, использовать inline-javascript, переменные, миксины и ещё много разных крутых штук.
/* file: lib/gulp/config.js */
html: {
src: _src + _html, // путь до jade-исходников
dest: _public, // куда сохраняем собранное
params: { // параметры для jade
pretty: devBuild, // убиваем отступы в html?
locals: { // переменные, которые мы передаем в шаблоны
pkgVersion: pkg.version // сохраняем версию релиза в переменную `pkgVersion`
}
}
}
Таск
/* 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, сигнализирующий о завершении таска
});
});
| src
|-- html
|-- index.jade # скелет страницы
|-- components/ # компоненты страницы
|-- _header.jade
|-- helpers/ # переменные, миксины
|-- _params.jade
|-- _mixins.jade
|-- meta/ # содержимое head, коды аналитики и пр.
|-- _head.jade
Все партиалы снабжаем префиксом `_` (нижнее подчеркивание), чтобы при сборке мы могли их отфильтровать и игнорировать.
helpers/_variables.jade
Сохраняем необходимые параметры в переменные. Например, если у нас телефон стоит в нескольких местах страницы, то его лучше сохранить в переменную и в шаблонах использовать именно её.
/* file: src/html/helpers/_variables.jade */
- var release = pkgVersion // переменная из gulp-конфига
- var phone = '8 800 CALL-ME-NOW' // телефон
helpers/_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)
index.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
meta/_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}")
...
Сборка JavaScript
В качестве модульной системы используем Browserify. C ним мы можем использовать стиль подключения CommonJS модулей непосредственно в браузере. Кроме этого мы теперь можем использовать ES6-синтаксис: Babel преобразует его в ES5 перед тем, как Browserify соберет js. И перед сборкой мы проходимся jsHint для проверки качества кода.
У Browserify есть один минус: если вы пишите библиотеку с внешними зависимостями (например jQuery-плагин), то он не сможет сделать правильную UMD-обертку. В этом случае я заменяю Browserify на конкатенацию и пишу обёртку руками.
Например вы пишите фронт + админку. Или библиотеку в 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
}
];
js/css cборщики будут искать в папке с js/css исходниками соответствующий end-point файл (`app.js` или `app.styl`). Через этот end-point файл мы управляем всеми зависимостями бандла. Их структуру я покажу чуть ниже.
Перед передачей бандлов сборщику, мы предварительно пропускаем массив через хелпер `bundler`, который формирует объект с настройками.
/* 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
}
}
Таск
/* 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);
});
| src/
|-- js/
|-- components/ # код компонентов
|-- helpers/ # js-хелперы
|-- app.js # end-point бандла
app.js
Через этот файл мы рулим всеми зависимостями и порядком исполнения js-компонентов. Имя файла должно совпадать с именем бандла.
/* file: src/js/app.js */
/* Vendor */
import $ from 'jquery';
/* Components */
import myComponent from './components/my-component';
/* App */
$(document).ready(() => {
myComponent();
});
Добавляем в `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 автопрефиксером, чтобы не прописывать вендорные префиксы руками.
/* 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: {} // если нужны настройки минификации - указываем тут
}
Таск
/* 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);
});
| 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 бандла
app.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.com/alexfedoseev/js-app-starter
Если есть косяки или дополнения — буду рад обратной связи через комментарии тут или issues / pull requests на Github. Удач!
Автор: alexfedoseev