Level Up для новичков: gulp и requirejs
Предисловие
Качество приложения зависит не только от того, какие задачи и с какой скоростью оно решает, но и от таких, казалось бы, второстепенных факторов как «красота кода».
Под красотой кода я (полагаю, и многие другие) понимаю:
- Читабельность
- Простоту изменения и дополнения
- Возможность другим разобраться, как это работает
Каждый на заре своего пути разработчика писал код, который был способен решить определённую (часто даже непростую) задачу, но при попытке что-то изменить или адаптировать под похожую задачу возникали проблемы.
Да и презентабельность такого кода вызывала сомнения.
Давайте разберёмся с двумя инструментами, которые не смотря на свою простоту повысят презентабельность исходников вашего приложения и наведут порядок в голове.
Gulp
Что есть gulp?
Это сборщик проектов.
Для чего он нужен?
Вопрос сложнее.
Не теряя удобства разработки на выходе вы получаете проект в том виде, в котором он должен быть:
- Оптимизированные картинки
- Минимизированные стили и скрипты
- Прочее
По сути вы получаете две копии приложения: рабочую, которая понятна вам, в которую удобно вносить правки и публичную (public), уже собранную из ваших кусочков и оптимизированную.
Говоря о собранном из кусочков проекте я имел в виду осуществление модульного подхода к программированию, который, на мой взгляд крайне полезен и является отправной точкой на пути повышения квалификации разработчика. Причины тому, очевидно: возможность легко интегрировать любой модуль в любой проект (не допиливая напильником), удобство тестирования и, главное, порядок в голове.
Gulp по сути является js скриптом (набором скриптов), который работает на сервере node.js.
Это вовсе не означает, что вам надо изучить nodejs, чтобы использовать gulp, но базовые знания его менеджера пакетов (npm) понадобятся.
Задача первая: поставить локально сервер node.js.
Не буду заострять на этом внимание, слишком просто. После установки мы сможем использовать npm.
Задача вторая: установить (по сути скачать) gulp с помощью менеджера пакетов.
Для маководов это заключается в следующем: в терминале напишем
npm install gulp -g
Флаг -g означает, что мы устанавливаем его глобально, чтобы можно было запускать его в терминале командой
gulp
gulp task
Затем настроим окружение для нашего проекта, перейдя в консоли в нужную папку
mkdir assets public assets/js assets/img assets/css
touch gulpfile.js
npm init
npm i --save-dev gulp
По порядку:
- Создаем структуру папок
- Создаем файл настроек нашего сборщика
- Инициализируем npm, чтобы установить полезные в сборке модули
- Устанавливаем gulp с ключем »--save-dev», то есть в директории нашего проекта
Аналогично с последней строчкой установим все модули, нужные нам в сборке
npm i --save-dev gulp gulp-autoprefixer gulp-concat-css gulp-connect gulp-livereload gulp-minify-css gulp-sass gulp-rename gulp-uncss gulp-uglify gulp-imagemin imagemin-pngquant
- gulp-autoprefixer // Добавляет стили с префиксами для поддержки браузеров
- gulp-concat-css // Склеивает стили в один файл
- gulp-connect // Сервер
- gulp-livereload // Обновляет страницу в браузере после изменений кода
- gulp-minify-css // Минификация стилей
- gulp-sass // Без комментариев (пока не нужно)
- gulp-rename // Переименовывает файлы
- gulp-uncss // Удаляет лишние стили
- gulp-uglify // Минификация скриптов
- gulp-imagemin // Минификация изображений
- imagemin-pngquant // Минификация изображений
Примеры использования и подключения наипонятнейшим образом описаны здесь.
Теперь все, что нам нужно — это описать файл настроек, то как именно надо собирать наш проект.
Для этого в нашем файле настроек (gulpfile.js) создадим задачи (таски)
gulp.task('js',function(){ // Таск "js"
gulp.src('./assets/js/*.js') // С чем работаем
.pipe(uglify()) // Что именно делаем. В данном случае минифицируем
.pipe(gulp.dest('./public/js/')) // Куда складываем результат
.pipe(connect.reload()); // Обновляем страницу (не обязательно)
});
На самостоятельное изучение оставляю полный код моего
var concatCss = require ('gulp-concat-css');
var minifyCss = require ('gulp-minify-css');
var rename = require («gulp-rename»);
var autoprefixer = require ('gulp-autoprefixer');
var livereload = require ('gulp-livereload');
var connect = require ('gulp-connect');
var sass = require ('gulp-sass');
var uglify = require ('gulp-uglify');
var imagemin = require ('gulp-imagemin');
var pngquant = require ('imagemin-pngquant');
// Основные
gulp.task ('css', function () {
gulp.src ('./assets/css/*.css')
.pipe (concatCss («style.min.css»))
.pipe (minifyCss ({compatibility: 'ie8'}))
.pipe (autoprefixer ({
browsers: ['last 10 versions'],
cascade: false
}))
.pipe (gulp.dest ('./public/css/'));
gulp.src ('./assets/css/fight/*.css')
.pipe (concatCss («fight.min.css»))
.pipe (minifyCss ({compatibility: 'ie8'}))
.pipe (autoprefixer ({
browsers: ['last 10 versions'],
cascade: false
}))
.pipe (gulp.dest ('./public/css/'))
.pipe (connect.reload ());
});
gulp.task ('sass', function () {
gulp.src ('./assets/sass/*.ccss')
.pipe (sass («style.css»))
.pipe (minifyCss (''))
.pipe (rename («style.sass.min.css»))
.pipe (autoprefixer ({
browsers: ['last 10 versions'],
cascade: false
}))
.pipe (gulp.dest ('./public/css/'))
.pipe (connect.reload ());
});
gulp.task ('html', function (){
gulp.src ('./assets/*.html')
.pipe (gulp.dest ('./public/'))
.pipe (connect.reload ());
});
gulp.task ('fonts', function (){
gulp.src ('./assets/font/**/*')
.pipe (gulp.dest ('./public/font/'))
.pipe (connect.reload ());
});
gulp.task ('js', function (){
gulp.src ('./assets/js/*.js')
.pipe (uglify ())
.pipe (gulp.dest ('./public/js/'))
.pipe (connect.reload ());
});
gulp.task ('jslibs', function (){
gulp.src ('./assets/js/libs/*.js')
.pipe (uglify ())
.pipe (gulp.dest ('./public/js/libs/'))
.pipe (connect.reload ());
});
gulp.task ('jsmods', function (){
gulp.src ('./assets/js/modules/**/*.js')
.pipe (uglify ())
.pipe (gulp.dest ('./public/js/modules/'))
.pipe (connect.reload ());
});
gulp.task ('img', function (){
gulp.src ('./assets/img/*')
.pipe (imagemin ({
progressive: true,
svgoPlugins: [{removeViewBox: false}],
use: [pngquant ()]
}))
.pipe (gulp.dest ('./public/img/'))
.pipe (connect.reload ());
});
// Connect
gulp.task ('connect', function () {
connect.server ({
root: 'public',
livereload: true
});
});
// Watch
gulp.task ('watch', function (){
gulp.watch (»./assets/css/**/*.css», [«css»]);
gulp.watch (»./assets/*.html», [«html»]);
gulp.watch (»./assets/js/*.js», [«js»]);
gulp.watch (»./assets/js/libs/*.js», [«jslibs»]);
gulp.watch (»./assets/js/modules/**/*.js», [«jsmods»]);
});
// Default
gulp.task ('default', [«html», «css», «sass», «js», «jslibs», «jsmods», «connect», «watch»]);
Заострю внимание лишь на паре вещей.
Любая из созданных задач (тасков) запускается из консоли по шаблону
gulp task
Если мы напишем просто «gulp», что запустится таск по умолчанию, то есть default.
Таск watch — встроенный таск для отслеживания изменений в файлах. Его настройка настолько очевидна (см приведенный код), что уверен, каждый справится. Этот таск позволяет не вызывать каждый раз процесс сборки проекта после любого изменения кода. Как только вы сохраните файл, gulp это увидит и пересоберет проект, а в данном случае еще и обновит страницу в браузере, и вам останется только перевести взгляд на нужный монитор, чтобы увидеть результат.
Чтобы собрать проект с вышеприведенными настройками просто введите в консоли (находясь в папке проекта)
gulp img
gulp
Работу с картинками вынес в отдельный таск для удобства (моего).
После этого не закрывайте консоль, у вас запущен watch’ер и сервер.
По сути вы можете писать в каждом отдельном файле маленькую js функцию, а сборщик соберет все это в один файл.
Тут мы вплотную подошли к вопросу модульности. Вышеприведенная ситуация очень похожа на модульный подход (издалека) — каждой функции (модулю) отдельный файл. Не запутаешься. Но что делать когда один модуль зависит от другого, от нескольких других, а те еще от других.
Да, тут уже посложнее, надо продумать правильный порядок подключения модулей. И тут нам на помощь приходит requirejs, который осуществляет AMD (Asynchronous module definition) подход (есть еще common.js, но о нем в следующей статье).
Require.js
Require.js будем осваивать на живом примере. В одной из предыдущих статей мы делали карточную игрушку. В результате у нас получилась просто куча кода, которую сейчас и будем разгребать.
Для начала скачаем reuirejs и подключим в нашем index.html, это будет единственный скрипт, который мы подключим таким образом.
В параметре data-main передаем путь к точке входа нашего приложения (не указываем расширение).
Config.js в простейшем случае представляет собой список алиасов (хотя можно обойтись и без него)
requirejs.config({
paths: {
"rClck" : "modules/disable_rclck",
"app" : "app",
"socket" : "modules/webSockets",
"Actions" : "modules/Actions",
"User" : "modules/User",
"userList" : "modules/userlist",
"Matreshka" : "libs/matreshka.min",
"Modal" : "modules/Modal",
"fight" : "modules/fight",
"jquery" : "libs/jquery",
"myCards" : "modules/myCards",
"opCards" : "modules/opCards",
"mana" : "modules/mana"
}
});
require(['app'],function(app){
app.menu();
});
Синтаксис requirejs прост — передаем массив зависимостей (допустимы как пути, так и алиасы, все без расширения js указывается), описываем функцию, которая срабатывает после удовлетворения зависимостей (не забываем в качестве аргументов передавать модули, от которых зависим).
require(['app'],function(app){
app.menu();
});
Подключили модуль app, после вызываем метод menu () этого модуля.
Описание модулей выглядит следующим образом: передаем массив зависимостей, пишем функцию-коллбэк, которая возвращает наш модуль.
define(['socket'],function(socket){ // Завтсть от модуля socket
var app = {
menu: function(){
require(['Actions', 'Modal'],function(Actions, Modal){ // Выполняем функцию после удовлетворения зависимостей
if (socket.readyState === 1) {
Actions.exec('loginFailed','Введите желаемый логин (не менее 4 символов)');
}else{
Modal.box('Нет соединения с сервером');
}
});
}
}
return app; // Возвращаем объект модуля
})
Метод socket.readyState проверяет, есть ли соединение с сервером. Если да, то вызываем метод Actions.exec, если нет, модальное окно (Modal.box).
Actions, socket и Modal — отдельные самостоятельные модули наравне с модулем app. У них есть свои зависимости, своя логика, свои методы.
Все, что раньше у нас было в одном файле, мы разбили на модули. Просто потому, что логически это разные куски кода, отвечающие за разные задачи, будь то соединение с сервером по WebSockets, манипулирование картами или «прочие задачи».
К прочим задачам я отношу то, что связывает все модули. Раньше у нас все это было в объекте Actions
var Actions = {
send: function(method, args){
console.log('send: ' + method);
args = args || '';
socketInfo.method = method;
socketInfo.args = args;
socket.send(JSON.stringify(socketInfo));
},
resume: function(){
User = JSON.parse(localStorage['PlayingCardsUser']);
this.send('reConnect',User.login);
},
setMotion: function(login){
console.log('setMotion: ' + login);
User.currentMotion = login;
this.motionMsg();
this.getCard();
this.setTimer();
}
...
Данные с сервера приходят в формате JSON в виде{'function': 'fooName', 'args': [массив/объект аргументов]}
И раньше мы вызывали нужный метод так:
socket.onmessage = function (e){
if (typeof e.data === "string"){
var request = JSON.parse(e.data);
Actions[request.function](request.args);
};
}
Но по мере разрастания и усложнения нашего приложения объект Actions будет разрастаться до уровня, когда уже трудно будет держать его в памяти и ориентироваться в нем. Именно поэтому разобьем его на мелкие модули (простейшие единицы, выполняющие элементарную функцию. Эту идеологию я увидел в gulp, и она мне понравилась).
Для начала нам понадобится обертка вызова методов Actions
define(["socket"],function(socket){
// Config
var path = 'modules/Actions/';
var Actions = {
exec: function(){
var args = Array.prototype.slice.call(arguments);
var actionName = args.shift();
require([path + actionName],function(action){ // Подключаем модуль
action.run.apply(action,args); // Запускаем его
});
}
}
return Actions;
})
В метод exec модуля Actions в качестве первого аргумента принимаем название нашего действия (action), остальные аргументы передаем аргументами в этот модуль.
В простейшем случае модуль действия будет выглядеть так:
define(['myCards'],function(myCards){ // Зависит от модуля myCards
var action = {
run: function(card){ // Метод run() мы вызываем при подключении в Actions.js: action.run.apply(action,args);
myCards.hand.add(card); // Реализация модуля
}
}
return action; // Возвращаем объект модуля
})
Этот модуль является оберткой вызова метода добора карт
myCards.hand.add(card);
Это нужно для того, чтобы максимально изолировать модули друг от друга. Чтобы в любой момент можно было безболезненно переписать его, например, используя другой фреймворк.
Теоретически можно было бы сразу с сервера получать информацию о том, что надо запустить метод myCards.hand.add (), но мы же работаем с тем, что есть. Пытаемся понять, как разгрести кучу кода, которая уже есть, разложить по полочкам.
Итак, имея изначально один js файл, где описывалось все от и до, мы разделили его на модули, отвечающие за реализацию карт (на столе и в руке каждого игрока), websocket’ов и прочего. А так же давайте напишем новый модуль и внедрим его, чтобы лучше во всем разобраться. Это будет модуль, отвечающий за ману:
define(['Matreshka'],function(Matreshka){ // Зависит от модуля Matreshka (js фреймворк)
// var diamond = $('#icons #diamond').html();
var diamond = '';
/////////// Карты в моей руке
var manaModel = Matreshka.Class({ // Модель списка
'extends': Matreshka.Object,
constructor: function(data){
this.jset(data);
this.on('render',function(){
this.bindNode('active', ':sandbox', Matreshka.binders.className( 'active' ));
});
}
});
var manaArray = Matreshka.Class({ // Класс списка
'extends': Matreshka.Array,
Model: manaModel,
itemRenderer: '',
constructor: function(){
this.bindNode('sandbox','#mana'); // Засовываем в песочницу
},
add: function(){
// Добавить активный кристалл
if (this.length >= 10) return;
this.push({active:true});
},
spend: function(num){
var num = num || 0;
var actives = this.filter(function(obj){
if (obj.active) return true;
return false;
});
if(actives.length < num) {
console.log('Недостаточно маны');
return false;
}
// Деактивировать num маны
for (var i = this.length - 1; i >= this.length - num; i--) {
this[i].active = false;
};
return true;
},
setAllActive: function(){
for (var i = this.length - 1; i >= 0; i--) {
this[i].active = true;
};
}
});
var mana = new manaArray; // Экземпляр класса списка
return mana;
})
Модуль представляет собой список (массив) кристаллов маны. Что он должен уметь?
- Добавлять кристалл на каждом ходу: add ()
- Тратить полные (активные) кристаллы маны: spend ()
- Пополнять (делать активными) кристаллы маны каждый ход: setAllActive ()
Смысл в том, что другим модулям не нужно знать, как реализованы эти методы. Это необходимо все для той же возможности безболезненно переписать модуль. Главное, сохранить логику.
Давайте внедрим наш новый модуль в имеющуюся игрушку, пусть отныне учитывается стоимость карт.
Начнем с пополнения кристаллов маны каждый ход. Каждый ход начинается с вызова действия (модуля, вызываемого модулем Actions через наш метод exec) setMotion, который устанавливает то, какой игрок сейчас ходит.
define(['Actions', 'User', 'myCards','mana'],function(Actions, User, myCards, mana){
var action = {
run: function(login){
User.currentMotion = login;
Actions.exec('motionMsg'); // Сообщение о том, чей сейчас ход
Actions.exec('setTimer'); // Установка таймера (ход длится 2 минуты)
if (User.meCurrent()) { // Если мой ход
myCards.arena.enableAll(); // Активируем мои карты на арене. Спящие просыпаются
Actions.exec('getCard'); // Добираем карту
mana.setAllActive(); // Делаем все кристаллы активными
mana.add(); // Добавляем кристалл маны
};
}
}
return action;
})
Как видите, внедрение первого шага оказалось несложным. Мы просто добавили вызов этого метода в нужный момент времени (в начале нашего хода). И если мы перепишем наш модуль, все будет работать как и работало. Главное не забыть реализовать ту же логику.
Как тратить ману? Очевидно, это происходит на этапе выкладывания карт из руки на стол. Внесем изменение в этот метод:
this.on('click::sandbox',function(){ // Клик по карте в руке
if(!User.meCurrent() || myCards.arena.length >= 7) return; // Если хожу не я или на арене много карт - false
myCards.arena.push(this); // Добавляем карту на арену
myCards.hand.splice(myCards.hand.indexOf(this),1); // Убираем ее же из руки
Actions.exec('send', 'putCard',this.toJSON()); // Показываем это сопернику
});
this.on('click::sandbox',function(){ // Клик по карте в руке
if(!User.meCurrent() || myCards.arena.length >= 7) return;
if(!mana.spend(this.mana)) return; // Если не удается потратить нужное кол-во маны - false
myCards.arena.push(this);
myCards.hand.splice(myCards.hand.indexOf(this),1);
Actions.exec('send', 'putCard',this.toJSON());
});
Заключение
Применив простые в освоении вещи, изучение которых не займет у вас более одного дня, мы совершили значительный скачек вперед на пути к качественному приложению.
Конечно, код еще далек от идеала, но гораздо читабельнее, презентабельнее и позволяет работать над ним дальше. Ведь проще заставить себя переписать маленький кусочек, чем огромный скрипт.
Мы будем продолжать улучшать эту игрушку в процессе изучения новых технологий. Чтобы набить руку, рекомендую взять какой-нибудь свой старый проект (не самый простой, чтоб было где шишек набить) и переделывать его вместе с нами.
Повторенье — мать ученья.
Ссылки