В данной статье я хотел бы показать один из примеров того каким образом, как мне кажется, может выглядеть современный процесс разработки веб-приложений. Статья может быть интересна начинающим frontend-разработчикам, которые слышали такие слова как модульная разработка, тестирование, системы сборки, менеджер пакетов и прочие, но еще не осознали как это все связано между собой.
В качестве примера приложения мы реализуем простой "компонент" для создания блог-поста на основе markdown-разметки. В ходе работы мы будем использовать следующие компоненты и инструменты:
Итак, что мы будет реализовывать? В качестве примера мы сделаем форму создания блог-поста. У поста будут следующие поля:
Код итогового приложения доступен на github, а результат выглядит следующим образом:
Первым делом нам необходимо создать файл-описание нашего проекта для менеджера пакетов npm. Файл имеет имя - "package.json", в нем, помимо базовой информации о проекте (название, описание, ...), будут хранится все серверные зависимости нашего приложения (инструменты и ресурсы, которые необходимы для работы). Задачей этого файла является возможность в будущем развернуть аналогичное окружение на другой машине будь то рабочая станция вашего коллеги или же production-сервер. Файл можно создать вручную, но для простоты лучше воспользоваться специальной командой npm:
npm init
Вам будут заданы вопросы касательно вашего приложения/пакета, после ответов на которые будет сгенерирован файл package.json примерно следующего содержания:
{
"name": "Demo",
"version": "1.0.0",
"description": "Showcase: современный процесс разработки web-приложений",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "krustnic",
"license": "MIT"
}
Теперь с помощью npm мы можем установить необходимые на серверные компоненты. Первым из которым мы установим bower. Bower - это менеджер пакетов, как и npm, но заточенный чисто на frontend.
npm
от bower
является их подход к управлению зависимостями. В npm
зависимости вложенные, а в
bower
- плоские. Это означает, что с npm
для каждого пакета у вас будут подтягиваться зависимости, зависимости зависимостей и так далее.
На серверной стороне это очень полезно, например, если две необходимые вам библиотеки работают с разными версиями jQuery, то будут установлены обе версии jQuery.
На стороне клиента такое поведение крайне не желательно (зачем вам подгружать разные версии jQuery?!) и bower
установит только одну версию, оставляя
разбираться в их совместимости вам. По этой причине большинство команд выбирают bower
в качестве системы пакетов для frontend (по крайней мере пока npm
не эволюционирует). Однако если вашим задачам такая особенность npm
не мешает - используйте его, консистентность всегда хорошо.
И так, чтобы установить bower
выполним следующую команду:
npm install bower --save
Важным моментом здесь является ключ "--save". Он говорит npm
о том, что нужно добавить информацию об этом пакете (bower
)
в список зависимостей для нашего проекта. Зависимости описываются в конфигурационном файле нашего проекта/пакета "package.json". После выполнения
этой команды файл должен выглядеть следующим образом:
{
"name": "Demo",
"version": "1.0.0",
"description": "Showcase: современный процесс разработки web-приложений",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "krustnic",
"license": "MIT",
"dependencies": {
"bower": "^1.4.1"
}
}
bower
) нам нужно лишь принести файл "package.json" и
выполнить команду npm install.
Так же как и npm
, bower
, сохраняет зависимости вашего проекта в свой конфигурационный файл "bower.json". Поэтому нам так же как и с
npm
вначале нужно его создать:
bower init
В процессе вам будет задан ряд вопросов, ответив на которые вы получите файл "bower.json". Отметим, что на вопрос о том какую технологию загрузки модулей
мы собираемся использовать следует отметить "amd", так как мы планируем использовать библиотеку RequireJS
.
{
"name": "Demo",
"version": "1.0",
"authors": [
"krustnic "
],
"description": "Showcase: современный процесс разработки web-приложений",
"main": "app.js",
"moduleType": [
"amd"
],
"license": "MIT",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
]
}
Установив bower
воспользуемся им для установки необходимых нам клиентских библиотек, а именно:
Bower
знает об этом, поэтому эти зависимые библиотеки мы не будем указывать (хотя никто и не запрещает):
bower install bootstrap backbone requirejs --save
Так же как и с npm
важно указать ключ "--save", чтобы установленные библиотеки добавились в качестве зависимостей к
нашему проекту в файл "bower.json":
{
"name": "Demo",
"version": "1.0",
"authors": [
"krustnic "
],
"description": "Showcase: современный процесс разработки web-приложений",
"main": "app.js",
"moduleType": [
"amd"
],
"license": "MIT",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"bootstrap": "~3.3.4",
"backbone": "~1.1.2",
"requirejs": "~2.1.17"
}
}
Все устанавливаемые библиотеки сохраняются в папку "bower_components" и если мы заглянем в нее, то увидим все наши библиотеки и их зависимости. Из этой папки мы и будет загружать их к нам на страницу.
bower_components
├── backbone
├── bootstrap
├── jquery
├── requirejs
└── underscore
Ну вот и добралась до самого frontend-а. Так как мы будем использовать загрузчик модулей - RequireJS, то первым делом нам нужно его настроить.
RequireJS
лишь один из возможных способов работы с модулями в JavaScript. Из наиболее популярных технологий
работы с модулями можно привести:
RequireJS
реализует технологию AMD. Основным преимуществом AMD в общем и RequireJS
в частности является работа "из коробки"
в браузере. Остальные технологии подразумевают ряд предварительных действий перед отправкой их в браузер. Для CommonJS
вначале необходимо
собрать все зависимости в один файл при помощи специального инструментария (например browserify или webpack). ES6-модули в итоге должны отлично работать
в браузере сразу, но пока EcmaScript 6 поддерживается не всеми браузерами и не в полном объеме. Поэтому так же как и CommonJS
необходимо вначале
"конвертировать" код в пригодный для текущих браузеров формат.
RequireJS
позволяет определить модуль (блок кода) и его зависимости, а затем загрузить их на страницу без необходимости прописывать каждый
<script>
отдельно:
define( [ "a", "b" ], function( a, b ) {
var c = function() {};
return c;
});
В данном примере объявлен модуль с двумя зависимостями a
и b
. Это означает, что перед добавлением этого модуля на страницу вначале
будут загружены модули a
и b
. Для того чтобы импортировать модули существует функция require
. В качестве имени модуля,
с помощью которого его можно импортировать служит путь к этому файлу с опущенным расширением ".js" (Для задания пути к корневой папке скриптов есть специальное
свойство в конфигурационном файле RequireJS
).
require( [ "c" ], function( c ) {
// Using module "c"
});
define
), однако этого не рекомендуется
делать в связи с тем, что это создает проблемы с переносимостью данного модуля (коллизии имен между разработчиками). Вместо этого при желании можно задать произвольные
имена для модулей в централизованном месте - конфигурационном файле RequireJS
Работу с библиотекой RequireJS
мы начнем с создания её конфигурационного файла. Создадим файл "assets/js/requirejs-config.js" следующего
содержания:
require.config({
// Путь относительно, которого будет осуществляться поиск модулей
baseUrl: "/assets/js",
// Псевдонимы для наших модулей/библиотек
paths: {
bootstrap: "../../bower_components/bootstrap/dist/js/bootstrap",
jquery: "../../bower_components/jquery/dist/jquery"
},
// Описание зависимостей для библиотек, которые не рассчитаны на работу с модулями AMD.
// Для них мы задаем зависимости вручную.
shim: {
bootstrap: {
deps: [ "jquery" ]
}
}
});
// Загружаем наше приложение (главный скрипт)
require( [ "app" ] );
И создадим файл который будет точкой входа в наше приложение assets/js/app.js:
require( [ "bootstrap" ], function() {
// Проверим подгрузилась ли вместе с "bootstrap" его зависимость - jQuery
console.info( "jQuery exists: " + (typeof jQuery == "function" ? true : false ) );
});
Теперь создадим файл index.html со следующим содержимым и проверим работу RequireJS
:
<!DOCTYPE html>
<html>
<head>
<title>Showcase: современный процесс разработки web-приложений</title>
<link href="bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<script data-main="assets/js/requirejs-config" src="bower_components/requirejs/require.js"></script>
</head>
<body>
</body>
</html>
Теперь если мы откроем это страницу в браузере и посмотрим в консоль, то увидим сообщение о том, что jQuery успешно загрузилось вместе со скриптами "bootstrap".
Теперь, разобравшись с конфигурационным файлом RequireJS
нам нужно добавить в него пути к остальным загруженным нами библиотекам. Не будем
спешить делать это в ручную. Большой частью современной разработки web-приложений является автоматизация рутинных действий web-разработчика таких как: минификация,
конкатенация, проверка принятого стиля кодирования и прочих. Не исключением является и обновление конфигурационного файла RequireJS
. Действительно, при
каждой установке нового пакета при помощи bower
нам необходимо обновить конфигурационный файл RequireJS
. Почему бы не автоматизировать этот
процесс?
Для автоматизации различных действий существуют различные системы сборки. Наиболее популярными для веб-разработки сейчас являются системы
Grunt, Gulp, Volo (в меньшей степени). Мы будем использовать систему
Grunt
как ниболее "классическую".
Для начала нам нужно её установить. Так как это серверный компонент, то устанавливается он при помощи пакетного менеджера npm
:
npm install grunt grunt-cli --save-dev
Мы утсанавливаем два пакета: grunt
и grunt-cli
. Первый - это сама система сборки, а второй - это инструменты командной строки, которые
позволят нам запускать grunt
из консоли.
Так же, обратите внимание, что в этот раз вместо ключа "--save"
мы используем ключ "--save-dev"
. Отличие заключается в том, что устанавливаемый пакет
будет добавлен в конфигурационный файл "package.json" не в секцию "dependencies", а в секцию "devDependencies" (зависимости необходимые только в процессе
разработки), что позволит нам не устанавливать их на "production" систему.
Теперь пакет grunt
загружен и лежит в папке "node_modules". Сразу же скачаем пакет
- задачу для нашей системы сборки, которая будет обновлять за нас конфигурационный файл RequireJS
на основе данных из файла "bower.json":
npm install grunt-bower-requirejs --save-dev
Теперь необходимо описать системе grunt
что мы от нее хотим. Описание задач для системы сборки происходит в её конфигурационном файле "Gruntfile.js". Давайте
создаим его со следующим содержимым:
module.exports = function(grunt) {
// Конфигуграция задач
grunt.initConfig({
bowerRequirejs : {
target: {
// Путь к конфигурационному файлу RequireJS
rjsConfig: 'assets/js/requirejs-config.js'
},
options: {
// Опция указывающая, что зависимости установленных пакетов так же следует
// добавлять в конфигурационный файл RequireJS
// Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
transitive: true
}
}
});
// Загружаем задачу
grunt.loadNpmTasks('grunt-bower-requirejs');
// Создаем другое имя задачи по которому мы будем ее вызывать
grunt.registerTask('update-requirejs', ['bowerRequirejs']);
}
Все, задача готова, теперь ее можно запустить:
node_modules/grunt-cli/bin/grunt update-requirejs
grunt
явно указывая путь к нему, так как grunt
установлен локально, только для текущего проекта.
Алтернативой является глобавльная установка пакетов в систему (пакет будет доступен везде, не только для данного проекта). Для того чтобы установить пакет
глобально существует ключ npm install ... -g
. Однако в этом случае информация о данном пакете не будет добавлена в зависимости вашего проекта,
поэтому в данном примере мы будем везде использовать локальную установку (ключи --save
и --save-dev
).
После сообщения о том, что задача "bowerRequirejs" успешно отработала, конфигурационный файл RequireJS
принял следующий вид:
require.config({
baseUrl: "assets/js",
paths: {
bootstrap: "../../bower_components/bootstrap/dist/js/bootstrap",
jquery: "../../bower_components/jquery/dist/jquery",
backbone: "../../bower_components/backbone/backbone",
underscore: "../../bower_components/underscore/underscore",
marked: "../../bower_components/marked/lib/marked",
requirejs: "../../bower_components/requirejs/require"
},
shim: {
bootstrap: {
deps: [
"jquery"
]
}
},
packages: [
]
});
require( [ "app" ] );
bower
.
Теперь после установки новых пакетов с помощью bower
нам достаточно просто запустить эту задачу и конфигурационный файл RequireJS
будет обновлен автоматически.
Приступим непосредственно к разработке нашей формы создания блог-поста. В качестве фреймворка мы будем использовать Backbone
.
Backbone
позволяет сформировать базовую архитектуру, скелет приложения и для этих целей предоставляет следующие компоненты:
Для начала определимся с файловой структурой нашего приложения. При работе с Bacbkone
я предпочитаю создавать папку на каждый логический
блок интерфейса, а внутри делить все файлы по типам: models, collections, views, templates. Для нашей задачи я предлагаю следующую структуру файлов:
assets └── js └── create-post ├── templates ├── views ├── models └── collections
Теперь определимся с иерархией отображений (Views). На самом верхнем уровне будет простая структура - MainView. В ней две дочерние CreateView и PreviewView. CreateView сразу будет отобржать поля для ввода: заголовок, текст. PreviewView в свою очередь будет состоять из трех других: TitleView, MetaView и TextView.
Приступим к созданию этих отображений. Начнем с верстки, создадим файл assets/js/create-post/templates/main:
<div class="row">
<div data-eid="create-view" class="col-md-6">CreateView</div>
<div data-eid="preview-view" class="col-md-6">PreviewView</div>
</div>
Так как все зависимости мы собираемся загружать через RequireJS
то этот модуль, кусок html-верстки так же следует загружать при помощи RequireJS
.
Из коробки RequireJS
умеет загружать только скрипты, однако он поддержиет добавление различных плагинов. Плагин для загрузки текстовых файлов
называется text и доступен в bower
под именем "requirejs-text". Установим его:
node_modules/bower/bin/bower install requirejs-text --save
И сразу же обновим конфигурационный файл RequireJS
при помощи нашей grunt
задачи:
node_modules/grunt-cli/bin/grunt update-requirejs
Теперь мы можем загружать обычные текстовые файлы при помощи RequireJS
. Для активации плагина в пути к файлу необходимо добавить префикс
"requirejs-text!" (название модуля с плагином и восклицательный знак на конце). Создаим файл assets/js/create-post/views/main:
define([
"backbone",
"requirejs-text!../templates/main.html"
], function( Backbone, tpl ) {
var view = Backbone.View.extend({
// Кэшируем html-шаблон
template : _.template( tpl ),
initialize : function( options ) {
},
render : function() {
// Отключаем привязанные события, очищаем элемент и
// добавляем в верстку шаблон
this.$el.empty().append( this.template() );
return this;
}
});
return view;
});
Немного отредактируем верстку нашей основной страницы - добавим мето куда будем добавлять нашу MainView. Файл index.html:
...
<body>
<div id="content" class="container"></div>
</body>
...
Теперь осталось лишь создать экземпляр нашего отображения MainView и добавить его в верстку. Отредактируем файл assets/js/app.js:
require( [ "create-post/views/main", "bootstrap" ], function( MainView ) {
var mainView = new MainView();
$("#content").append( mainView.render().$el );
});
Перед тем как приступить к созданию самой формы блог-поста, вначале сделаем модель, которая будет хранить все введенные данные (заголовок, текст). Создадим файл assets/js/models/post:
define( [ "backbone" ], function( Backbone ) {
var model = Backbone.Model.extend({
// Значения по-умолчанию
defaults : {
"title" : "",
"text" : ""
},
initialize : function( options ) {
}
});
return model;
});
Теперь приступим к реализации формы создания блог-поста CreateView. По аналогии с MainView создадим два файла assets/js/create-post/templates/create и assets/js/create-post/views/create:
<!-- assets/js/create-post/templates/create -->
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label>Заголовок</label>
<input data-eid="title" type="text" class="form-control">
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label>Текст</label>
<textarea data-eid="text" cols="30" rows="10" class="form-control"></textarea>
</div>
</div>
</div>
// assets/js/create-post/views/create
define([
"backbone",
"requirejs-text!../templates/create.html"
], function( Backbone, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
events : {
"input [data-eid=title]" : "update",
"input [data-eid=text]" : "update"
},
initialize : function( options ) {
},
render : function() {
this.$el.empty().append( this.template() );
return this;
},
update : function() {
this.model.set( "title", this.$("[data-eid=title]").val() );
this.model.set( "text" , this.$("[data-eid=text]").val() );
}
});
return view;
});
Здесь мы подразумеваем, что в CreateView уже есть модель. Мы добавили два слушателя событий, которые следят за любым изменением полей поста и тут же изменяют модель. Теперь нам нужно добавить отрисовку CreateView в MainView. Модифицируем assets/js/views/main:
define([
"backbone",
"../models/post",
"../views/create",
"requirejs-text!../templates/main.html"
], function( Backbone, PostModel, CreateView, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
initialize : function( options ) {
this.model = new PostModel();
},
render : function() {
this.$el.empty().append( this.template() );
var createView = new CreateView({
model : this.model
});
this.$("[data-eid=create-view]").empty().append( createView.render().$el );
return this;
}
});
return view;
});
При инициализации MainView создается модель для нашего поста и передается во вновь созданное отображение "CreateView". Благодаря тому, что сама модель создается в MainView в будущем мы сможем передать тот же экземпляр модели в отображение PreviewView и изменять его при каждом ее изменении.
Создадим отображение PreviewView. По аналогии создаем два файла assets/js/create-post/templates/preview и assets/js/create-post/views/preview
<!-- assets/js/create-post/templates/preview -->
<div data-eid="title-view"></div>
<div data-eid="meta-view"></div>
<div data-eid="text-view"></div>
// assets/js/create-post/views/preview
define([
"backbone",
"requirejs-text!../templates/preview.html"
], function( Backbone, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
initialize : function( options ) {
},
render : function() {
this.$el.empty().append( this.template() );
return this;
}
});
return view;
});
Так же как и в случае с CreateView, обновим MainView добавив отрисовку вновь созданного отображения.
...
var previewView = new PreviewView({
model : this.model
});
this.$("[data-eid=preview-view]").empty().append( previewView.render().$el );
...
Создадим TitleView assets/js/create-post/views/title и assets/js/create-post/templates/title
<!-- assets/js/create-post/templates/title -->
<div class="row post-title">
<div class="col-md-12">
<div><%= title %></div>
</div>
</div>
// assets/js/create-post/views/title
define([
"backbone",
"requirejs-text!../templates/title.html"
], function( Backbone, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
initialize : function( options ) {
// При изменения поля "заголовок" в модели - перерисовывем
// отображение
this.listenTo( this.model, "change:title", this.render );
},
render : function() {
// Передаем данные из модели в шаблон
this.$el.empty().append( this.template( this.model.toJSON() ) );
return this;
}
});
return view;
});
Добавим в PreviewView код для отображения TitleView. assets/js/create-post/views/preview:
// assets/js/create-post/views/preview
define([
"backbone",
"../views/title",
"requirejs-text!../templates/preview.html"
], function( Backbone, TitleView, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
initialize : function( options ) {
},
render : function() {
this.$el.empty().append( this.template() );
var titleView = new TitleView({
model : this.model
});
this.$("[data-eid=title-view]").empty().append( titleView.render().$el );
return this;
}
});
return view;
});
Теперь по мере ввода текста в поле "Заголовок" в CreateView, благодаря передаче данных через модель, TitleView будет соотвественно изменяться.
Следующее на очереди отображение - TextView, которое должно реагировать на изменение поля "Текст" в CreateView, преобразовывать Markdown-текст в HTML и
выводить его на экран. Для преобразования Markdown в HTML воспользуемся библотекой marked. Для начала установим ее при
помощи bower
:
node_modules/bower/bin/bower install marked --save
И, конечно же, не забудем обновить конфигурационный файл RequireJS
node_modules/grunt-cli/bin/grunt update-requirejs
Теперь приступим к созданию TextView. assets/js/create-post/templates/text и assets/js/create-post/views/text:
<!-- assets/js/create-post/templates/text -->
<div class="row post-text">
<div class="col-md-12 ">
<%= html %>
</div>
</div>
// assets/js/create-post/views/text
define([
"backbone",
"marked",
"requirejs-text!../templates/text.html"
], function( Backbone, marked, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
initialize : function( options ) {
// При изменения поля "текст" в модели - перерисовывем
// отображение
this.listenTo( this.model, "change:text", this.render );
},
render : function() {
var html = marked( this.model.get("text") );
// Передаем в шаблон HTML-представление текста
this.$el.empty().append( this.template( {
html : html
} ) );
return this;
}
});
return view;
});
Не забудем так же обновить PreviewView для отрисовки нового отображения assets/js/create-view/views/preview:
...
var textView = new TextView({
model : this.model
});
this.$("[data-eid=text-view]").empty().append( textView.render().$el );
...
Теперь мы можем видеть преобразование из Markdown в HTML по мере ввода текста.
Начнем реализацию отображения для вывода примерного времени чтения поста. Пока реализуем простой вариант - просто будем делить количество символов в посте на среднюю скорость чтения - 2500 знаков в минуту.
assets/js/create-post/templates/meta.html и assets/js/create-post/views/meta.html
<!-- assets/js/create-post/templates/meta -->
<div class="row post-meta">
<div class="col-md-12">
Время чтения: ~<%= readingTime %> минут
</div>
</div>
// assets/js/create-post/views/meta
define([
"backbone",
"requirejs-text!../templates/meta.html"
], function( Backbone, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
// Средняя скорость чтения
SYMBOLS_PER_MINUTE : 2500,
initialize : function( options ) {
// При изменения поля "текс" в модели - перерисовывем
// отображение
this.listenTo( this.model, "change:text", this.render );
},
render : function() {
var readingTime = this.getReadingTime();
// Передаем в шаблон время чтения
this.$el.empty().append( this.template( {
readingTime : readingTime
} ) );
return this;
},
// Считаем скорость чтения
getReadingTime : function() {
var symbolsCount = this._getSymbolsCount( this.model.get("text") );
return parseFloat( symbolsCount / this.SYMBOLS_PER_MINUTE ).toFixed (2);
},
// Получаем количество символов в посте
_getSymbolsCount : function( text ) {
var count = text.length
return count;
}
});
return view;
});
Так же внесем измения в PreviewView assets/js/create-post/views/preview:
...
var metaView = new MetaView({
model : this.model
});
this.$("[data-eid=meta-view]").empty().append( metaView.render().$el );
...
Теперь по мере ввода текста будет изменяться примерное время чтения поста. Пока вычисление времени уж очень примерное и не учитывает большое количиство символов разметки Markdow. Однако мы немного улучшим расчет в следующем разделе.
В принципе, функционально наше приложение готово. Далее мы немного улучшим вычисление среднего времени чтения и займемся настройкой стилей.
Важность тестирования приходит с опытом. На данный момент тестирование - это единственный способ избежать возникновения новых ошибок в процессе рефакторинга или изменения кода. Видов тестирования существует большое множество, но для frontend-а я бы выделил два основных:
Функциональные тесты проверяют не столько ваш код сколько ваш интерфейс и функционал. Тесты представляют собой не последовательность вызовов ваших функций, а последовательность действий пользователя вашего приложения (клик, набор текста, нажатие на кнопку). Когда запускаются функциональные тесты они имитируют работу обычного человека, который пользуется вашим приложением. Для написания таких тестов существует ряд инструментов таких как Selenium, CaspersJS, JarvisJS, Webdriver.io, DalekJS и другие. В текущем примере функциональные тесты мы рассматривать не будем.
Unit-тесты в свою очередь проверяют именно ваш код, соответствие результатов работы функции некоторым референсным значениям. Рассмотрим пример разработки тестов при
помощи фреймворка Jasmine
на примере нашей функции вычисления среднего времени чтения поста.
В свою очередь unit-тесты можно разделить на два типа:
В браузере запускаются тесты, которым нужно соответсвующее окружение, например, нужен DOM. Если бы мы хотели протестировать наши Backbone
-отображения, то
следовало бы запускать наши тесты в браузере. Минусом запуска тестов в браузере является относительная сложность автоматизации тестирования - для этих целей нужно
подключать и настраивать PhantomJS или тот же Selenium.
Тестирование на сервере наоборот легко поддается автоматизации, так как не зависит ни от чего кроме NodeJS
. Так как отображения в нашем примере довольно
простые и у нас есть намерение максимально использовать grunt
в процессе нашей разработки, то мы займемся тестированием именно на стороне сервера.
Первым делом нам нужно немного отрефакторить наш код. Мы хотим покрыть тестами функционал вычисления примерного времини чтения нашего поста. Сейчас он находится в
Backbone
-view, что услажняет нам тестирование на стороне сервера. Вынесем функции расчета в отделный файл assets/js/create-post/utils/tools.js:
define( function() {
return {
// Считаем скорость чтения
getReadingTime : function( text, speed ) {
var symbolsCount = this.getSymbolsCount( text );
return parseFloat( symbolsCount / speed ).toFixed (2);
},
// Получаем количество символов в строке
getSymbolsCount : function( text ) {
var count = text.length
return count;
}
};
} );
И соответствующим образом поправим MetaView, assets/js/create-post/views/meta:
// assets/js/create-post/views/meta
define([
"backbone",
"../utils/tools",
"requirejs-text!../templates/meta.html"
], function( Backbone, Tools, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
// Средняя скорость чтения
SYMBOLS_PER_MINUTE : 2500,
initialize : function( options ) {
// При изменения поля "текс" в модели - перерисовывем
// отображение
this.listenTo( this.model, "change:text", this.render );
},
render : function() {
var readingTime = Tools.getReadingTime( this.model.get("text"), this.SYMBOLS_PER_MINUTE );
// Передаем в шаблон время чтения
this.$el.empty().append( this.template( {
readingTime : readingTime
} ) );
return this;
}
});
return view;
});
Теперь функционал вычисления среднего времени чтения никак не связан с наличием DOM и мы можем легко тестировать наши функции на сервере. Перед тем как начать
разработку тестов настроим рабочее окружение. Установим библиотеку Jasmine-node
:
npm install jasmine-node --save-dev
Для запуска тестов jasmine-node
необходимо указать папку, в которой jasmine-node
будет искать файлы заканчивающиейся на *.spec.js.
(на *spec.coffee и *spec.litcoffee если вы пишете на CoffeeScript). Я предпочитаю не выносить тесты в отдельную папку и хранить их вместе с тем кодом, который они
покрывают. Создадим файл assets/js/create-post/utils/tools.spec.js со следующим содержимым:
describe("Пример теста", function() {
it("Пример верного утверждения: true === true", function() {
expect(true).toBe(true);
});
it("Пример не верного утверждения: true === false", function() {
expect(true).toBe(false);
});
});
Теперь перед нами пример самого простого теста. Глобальная функция describe
создает конкретный тест, а функция it
добавляет в
него "утверждения" (конкретные проверки для теста). В одном файле может множетсво тестов (describe
), а в каждом тесте может быть множество
утверждений (it
). Теперь запустим jasmine-node
указав в качестве папки - текущую (корень нашего проекта):
node_modules/jasmine-node/bin/jasmine-node .
expect( subject ).toVerb( value )
:expect(fn).toThrow(e);
expect(instance).toBe(instance);
expect(mixed).toBeDefined();
expect(mixed).toBeFalsy();
expect(number).toBeGreaterThan(number);
expect(number).toBeLessThan(number);
expect(mixed).toBeNull();
expect(mixed).toBeTruthy();
expect(mixed).toBeUndefined();
expect(array).toContain(member);
expect(string).toContain(substring);
expect(mixed).toEqual(mixed);
expect(mixed).toMatch(pattern);
Как мы видим jasmine-node
нашел спецификацию нашего теста и запустил её. Как и ожидалось в нашем тесте содержится одна ошибка. Теперь приступим к
реализации тестов для нашего функционала. Для начала необходимо загрузить наш модуль (файл "tools.js") в тесте. Сложность заключается в том, что наш модуль
описан в AMD-стиле, а из коробки NodeJS
понимает только CommonJS-стиль. Для решения этой проблемы установим билиотеку для загрузки AMD-модулей,
например, amdrequire:
npm install amdrequire --save-dev
Теперь используем её в нашем тесте, assets/js/create-post/utils/tools.spec.js:
require = require('amdrequire');
require( [ "./tools" ], function(Tools) {
describe("Получение количества значащих символов в строке", function() {
it("Только буквы", function() {
var text = "qwerty";
expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
});
});
describe("Вычисление примерного времени чтения поста", function() {
it("Пустая строка", function() {
var text = "";
expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
});
it("Проверка алгоритма: 6 / 3", function() {
var text = "qwerty";
expect( Tools.getReadingTime( text, 3 ) == 2 ).toBe( true );
});
it("Значение - вещественное число, округление до 2 знаков: 2 / 2500.", function() {
var text = "ab";
expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
});
});
});
И так - простой набор тестов реализован, теперь добавим в grunt
задачу для запуска наших тестов. Для этого установим пакет
grunt-jasmine-node:
npm install grunt-jasmine-node --save-dev
И обновим конфигурационный файл grunt
Gruntfile.js
module.exports = function(grunt) {
// Конфигуграция задач
grunt.initConfig({
bowerRequirejs : {
target: {
// Путь к конфигурационному файлу RequireJS
rjsConfig: 'assets/js/requirejs-config.js'
},
options: {
// Опция указывающая, что зависимости установленных пакетов так же следует
// добавлять в конфигурационный файл RequireJS
// Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
transitive: true
}
},
jasmine_node: {
options: {
forceExit: true,
match: '.',
matchall: false,
extensions: 'js',
specNameMatcher: 'spec'
},
all: ['.']
}
});
// Загружаем задачи
grunt.loadNpmTasks('grunt-bower-requirejs');
grunt.loadNpmTasks('grunt-jasmine-node');
// Создаем другое имя задачи по которому мы будем ее вызывать
grunt.registerTask('update-requirejs', ['bowerRequirejs']);
grunt.registerTask('test', ['jasmine_node']);
}
Теперь запускать тесты можно с помощью задачи grunt
:
node_modules/grunt-cli/bin/grunt test
grunt
не кажется действительно необходимым, так как одну команду запуска jasmine-node
мы заменили на
одну команду запуска grunt
. Однако использование grunt
несет дополнительные бонусы. Во-первых в будущем мы сможем объединять целые
цепочки команд в одну. Например задача "build", которая будет запускать тесты, минифицировать код, компилировать и минифицировать стили. Так же это решает ряд
проблем с общением внутри команды, все параметры запуска сохранены в центральном месте - "Gruntfile.js", а выполнив команду grunt --help
можно просмотреть список всех доступных в данном проекте задач.
Чтож, теперь давайте немного улучшим наш алгоритм определения среднего времени чтения блог-поста:
require = require('amdrequire');
require( [ "./tools" ], function(Tools) {
describe("Получение количества значащих символов в строке.", function() {
it("Только буквы", function() {
var text = "qwerty";
expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
});
it("Исключение знаков препинания", function() {
var text = "qwe,,.. ,rty,..! ";
expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
});
it("Исключение специальных знаков", function() {
var text = "qwe##r#__t__-=*";
expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
});
});
describe("Вычисление примерного времени чтения поста.", function() {
it("Пустая строка", function() {
var text = "";
expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
});
it("Проверка алгоритма: 6 / 3", function() {
var text = "qwerty";
expect( Tools.getReadingTime( text, 3 ) == 2 ).toBe( true );
});
it("Значение - вещественное число, округление до 2 знаков: 2 / 2500.", function() {
var text = "ab";
expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
});
});
describe("Получение человеко-читаемого обозначения времени.", function() {
it("Меньше минуты", function() {
expect( Tools.getReadingTimeText( 0.5 ) == "меньше минуты" ).toBe( true );
expect( Tools.getReadingTimeText( 1.5 ) == "меньше минуты" ).toBe( true );
});
it("Около трех минут", function() {
expect( Tools.getReadingTimeText( 1.6 ) == "около трех минут" ).toBe( true );
expect( Tools.getReadingTimeText( 2.5 ) == "около трех минут" ).toBe( true );
expect( Tools.getReadingTimeText( 3 ) == "около трех минут" ).toBe( true );
expect( Tools.getReadingTimeText( 3.5 ) == "около трех минут" ).toBe( true );
});
it("Меньше 5 минут", function() {
expect( Tools.getReadingTimeText( 3.6 ) == "меньше 5 минут" ).toBe( true );
expect( Tools.getReadingTimeText( 4 ) == "меньше 5 минут" ).toBe( true );
expect( Tools.getReadingTimeText( 5 ) == "меньше 5 минут" ).toBe( true );
expect( Tools.getReadingTimeText( 5.5 ) == "меньше 5 минут" ).toBe( true );
});
it("Около 10 минут", function() {
expect( Tools.getReadingTimeText( 5.6 ) == "около 10 минут" ).toBe( true );
expect( Tools.getReadingTimeText( 7 ) == "около 10 минут" ).toBe( true );
expect( Tools.getReadingTimeText( 12 ) == "около 10 минут" ).toBe( true );
});
it("Стоит запастись терпением", function() {
expect( Tools.getReadingTimeText( 13 ) == "стоит запастись терпением" ).toBe( true );
expect( Tools.getReadingTimeText( 30 ) == "стоит запастись терпением" ).toBe( true );
});
});
});
Теперь у нас простая задача - сделать так, чтобы наши тесты проходили. Отредактируем файл assets/js/create-post/utils/tools:
define( function() {
return {
// Считаем скорость чтения
getReadingTime : function( text, speed ) {
var symbolsCount = this.getSymbolsCount( text );
return parseFloat( symbolsCount / speed ).toFixed (2);
},
getReadingTimeText : function( minutes ) {
var text = "стоит запастись терпением";
if ( minutes <= 1.5 ) {
return "меньше минуты";
}
if ( minutes <= 3.5 ) {
return "около трех минут";
}
if ( minutes <= 5.5 ) {
return "меньше 5 минут";
}
if ( minutes <= 12 ) {
return "около 10 минут";
}
return text;
},
// Получаем количество символов в строке
getSymbolsCount : function( text ) {
// Предварительно уберем все незначащие символы
var re;
var special = " .,!#?-_+*=[]{}()<>$@%^&";
for( var i = 0; i < special.length; i++ ) {
var s = special[i];
re = new RegExp("\\" + s, "g");
text = text.replace( re, '' );
};
var count = text.length
return count;
}
};
} );
Теперь обновим MetaView, так чтобы она использовала новую функцию getReadingTimeText
. assets/js/create-post/templates/meta и
assets/js/create-post/views/meta:
<!-- assets/js/create-post/templates/meta -->
<div class="row post-meta">
<div class="col-md-12">
Время чтения: <%= readingTime %>
</div>
</div>
// assets/js/create-post/views/meta
define([
"backbone",
"../utils/tools",
"requirejs-text!../templates/meta.html"
], function( Backbone, Tools, tpl ) {
var view = Backbone.View.extend({
template : _.template( tpl ),
// Средняя скорость чтения
SYMBOLS_PER_MINUTE : 2500,
initialize : function( options ) {
// При изменения поля "текс" в модели - перерисовывем
// отображение
this.listenTo( this.model, "change:text", this.render );
},
render : function() {
var readingTime = Tools.getReadingTimeText( Tools.getReadingTime( this.model.get("text"), this.SYMBOLS_PER_MINUTE ) );
// Передаем в шаблон время чтения
this.$el.empty().append( this.template( {
readingTime : readingTime
} ) );
return this;
}
});
return view;
});
Займемся теперь внешним видом нашего приложения. Для создания CSS стилей мы воспользуемся препроцессором. Препроцессоры CSS - это языки, призванные исправить
недостатки CSS. Препроцессор предлагает альтернативный синтаксис и новые возможности такие как: переменные, примеси, продвинутое наследование и т. п. Затем код
написанный на препроцессоре конвертируется в обычный CSS. Существует несколько популярных на данный момент препроцессоров это: LESS,
SASS, Stylus. В нашем примере мы воспользуемся препроцессором LESS
.
Начнем с того, что создадим две папки: css и less. И создадим файл assets/less/main.less:
// Цвет заголовка и мета-информации
@titleColor : #8E8E8E;
#content {
margin-top: 50px;
}
.post-title {
min-height: 50px;
font-size: 24px;
font-style: italic;
margin-top: 24px;
color: @titleColor;
}
.post-meta {
text-align: right;
color : @titleColor;
}
.post-text {
border: 1px solid rgb(208, 208, 208);
border-radius: 4px;
margin-top: 6px;
min-height: 50px;
}
Для того, чтобы преобразовать less-файл в css-файл нам понадобиться установить одноименный пакет less.js:
npm install less --save-dev
И сразу же установим grunt
-задачу grunt-contrib-less
npm install grunt-contrib-less --save-dev
Добавим задачу в конфигурационный файл Gruntfile.js
:
module.exports = function(grunt) {
// Конфигуграция задач
grunt.initConfig({
bowerRequirejs : {
target: {
// Путь к конфигурационному файлу RequireJS
rjsConfig: 'assets/js/requirejs-config.js'
},
options: {
// Опция указывающая, что зависимости установленных пакетов так же следует
// добавлять в конфигурационный файл RequireJS
// Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
transitive: true
}
},
jasmine_node: {
options: {
forceExit: true,
match: '.',
matchall: false,
extensions: 'js',
specNameMatcher: 'spec'
},
all: ['.']
},
less: {
compile: {
files: {
'assets/css/main.css': 'assets/less/main.less'
}
}
}
});
// Загружаем задачи
grunt.loadNpmTasks('grunt-bower-requirejs');
grunt.loadNpmTasks('grunt-jasmine-node');
grunt.loadNpmTasks('grunt-contrib-less');
// Создаем другое имя задачи по которому мы будем ее вызывать
grunt.registerTask('update-requirejs', ['bowerRequirejs']);
grunt.registerTask('test', ['jasmine_node']);
}
Теперь запустив grunt-задачу "less" мы получим файл assets/css/main.css:
node_modules/grunt-cli/bin/grunt less
#content {
margin-top: 50px;
}
.post-title {
min-height: 50px;
font-size: 24px;
font-style: italic;
margin-top: 24px;
color: #8e8e8e;
}
.post-meta {
text-align: right;
color: #8e8e8e;
}
.post-text {
border: 1px solid #d0d0d0;
border-radius: 4px;
margin-top: 6px;
min-height: 50px;
}
Добавим загрузку файла assets/css/main.css в файл index.html. Теперь наши стили, описанные в less-файле применились к нашей странице. Используя
LESS
мы больше не будем работать с CSS-файлами напрямую, поэтому сразу займемся их минификацией. Для этого установим очередную grunt
-задачу
grunt-contrib-cssmin
npm install grunt-contrib-cssmin --save-dev
Добавим задачу в конфигурационный файл grunt
Gruntfile.js:
module.exports = function(grunt) {
// Конфигуграция задач
grunt.initConfig({
bowerRequirejs : {
target: {
// Путь к конфигурационному файлу RequireJS
rjsConfig: 'assets/js/requirejs-config.js'
},
options: {
// Опция указывающая, что зависимости установленных пакетов так же следует
// добавлять в конфигурационный файл RequireJS
// Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
transitive: true
}
},
jasmine_node: {
options: {
forceExit: true,
match: '.',
matchall: false,
extensions: 'js',
specNameMatcher: 'spec'
},
all: ['.']
},
less: {
compile: {
files: {
'assets/css/main.css': 'assets/less/main.less'
}
}
},
cssmin: {
// Минифицируем все файлы в папке assets/css и
// добавим им расширение ".min.css"
target: {
files: [{
expand: true,
cwd: 'assets/css',
src: ['*.css', '!*.min.css'],
dest: 'assets/css',
ext: '.min.css'
}]
}
}
});
// Загружаем задачи
grunt.loadNpmTasks('grunt-bower-requirejs');
grunt.loadNpmTasks('grunt-jasmine-node');
grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-contrib-cssmin');
// Создаем другое имя задачи по которому мы будем ее вызывать
grunt.registerTask('update-requirejs', ['bowerRequirejs']);
grunt.registerTask('test', ['jasmine_node']);
}
Теперь выполним grunt
-задачу "cssmin" мы получим минифицированный файл assets/css/main.min.css
#content{margin-top:50px}.post-title{min-height:50px;font-size:24px;font-style:italic;margin-top:24px;color:#8e8e8e}.post-meta{text-align:right;color:#8e8e8e}.post-text{border:1px solid #d0d0d0;border-radius:4px;margin-top:6px;min-height:50px}
Разобравшись с минификацией CSS-файлов нужно сделать тоже и с javascript-файлами. Однако перед тем как минифицировать сначала нужно собрать все наши скрипты в
один файл особым образом в порядке разрешения их зависимостей. Задача не самая простая, но к с счастью разработчики RequireJS
так же предоставляют и
инструмент для сборки и минификации скриптов написанных при помощи RequireJS
. Инструмент называется r.js
.
Как обычно установим grunt
-задачу grunt-contrib-requirejs:
npm install grunt-contrib-requirejs --save-dev
И обновим Gruntfile.js:
...
requirejs: {
compile: {
options: {
baseUrl: "assets/js",
mainConfigFile: "assets/js/requirejs-config.js",
name: "app",
out: "assets/js/dist.js"
}
}
}
...
Теперь выполнив grunt
-задачу "requirejs" мы получим один минифицированный файл со всеми нашими модулями собранными в нужно порядке. Теперь, если
мы подменим вызов "app" на вызов "dist" в файле assets/js/requirejs-config.js, то получим окончательный результат - production-версию нашего приложения.
Теперь c помощью grunt
мы умеем делать следующие вещи:
grunt
Gruntfile.js следующий код:
grunt.registerTask('build', [ 'bowerRequirejs', 'jasmine_node', 'less', 'cssmin', 'requirejs' ]);
Теперь вызов grunt
-задачи build
будет выполнять полную сборку нашего приложения. На этом, пожалуй, можно и закончить - мы реализовали
простое веб-приложение при помощи современных инструментов разработки. Спасибо за внимание.