При разработке разработке хоть сколько-нибудь большого javascript проекта сразу понимаешь, что писать весь код в одном-единственном файле нельзя. После этого код разносится по нескольким файлам и директориям и пишется простой скрипт для того, чтобы все эти файлы можно было легко объединить в один большой production файл. Спустя какое-то время начинаешь замечать, что чем дальше, тем труднее становится следить за зависимостями между файлами, да и весь разработанный механизм больше похож на костыль. И тут приходить озарение, что неплохо было бы посмотреть какие существуют решения этой проблемы.
К системе управления сборкой проекта выдвигаются следующие требования:
- Компиляция из coffescript в javascript. Если в файле coffeescript содержится ошибка, то в консоле должны отобразиться название файла и сообщение об ошибке.
- Сборка проекта в один javascript файл должна производится с учетом зависимостей.
- Возможность собрать все приложение целиком в один файл в нескольких видах (с комментариями, минимизированный). При этом само приложение может состоять из нескольких модулей.
- Сборка тестовых файлов и их выполнение в консоли (да, разрабатываем для веба, при этом не притрагиваемся к мышке и вообще не вылазим из любимого vim'a).
- Конечно же все это должно быть удобно в использовании.
В данной статье я не буду затрагивать вопрос тестирования, а рассмотрю вариант системы управления сборкой javascript/coffescript проекта (и саму структуру проекта) с использованием rake и Rake::Pipeline (git).
Rake::Pipeline — это система обработки файлов. Она умеет считывать файлы из директории файлы по заданному шаблону, изменять файлы по заданному правилу и записывать полученный результат.
Как не трудно догадаться, Rake::Pipeline использует rake, поэтому для ее работы нужна ruby. Все настройки pipline обычно хранятся в файле «Assetfile». Этот файл — представляет собой скрипт на языке ruby. Он может иметь, например, следующий вид:
# файл Assetfile
#определяем корневую директория из которой будем считывать файлы
input "app/assets/javascripts"
#определяем корневую директория в которую будем записывать обработанные файлы
output "public/javascripts"
#определяем по какому правилу мы будем выбирать файлы из директории,
#описанной в input. В данном случае мы перебираем все файлы с расширением
#*.js, которые лежат непосредственно в директории "app/assets/javascripts"
match "*.js" do
#ConcatFilter - фильтр, который объединяет несколько файлов в один.
#в данном случае все *.js файлы из директории app/assets/javascripts
#объединятся в один application.js, который будет находится в дирекории
#public/javascripts.
filter Rake::Pipeline::ConcatFilter, "application.js"
end
Рассмотрим для примера проект с названием «application». Этот проект будет состоять из 3-х coffeescript файлов: «file1.coffee», «file2.coffee», «file3.coffee». Таким образом получаем следующую структуру каталогов:
-application
--src
---file1.coffee
---file2.coffee
---file3.coffee
Предположим, что у нас есть следующие зависимости:
2-й зависит от 1-го и 3-го
3-й зависит от 1-го
Таким образом в собранном варианте файлы должны располагаться следующим образом: 1-3-2.
Для удобства создадим главный файл «main.coffee». В нем будет содержаться список используемых в проекте файлов. Теперь можно приступить к заполнению файлов:
#Файл main.coffee (просто описание используемых в проекте файлов):
require("file1")
require("file2")
require("file3")
#Файл file1.coffee (не зависит ни от чего):
# ... код ...
file1 = true #просто для проверки
#Файл file2.coffee (зависит от 1-го и 3-го):
require("file1")
require("file3")
# ... код ...
file2 = true #просто для проверки
#Файл file3.coffee (зависит от 1-го):
require("file1")
# ... код ...
file3 = true #просто для проверки
В данном случае require(«file1») — это псевдо-функция. Точнее, это шаблон, указатель на то, что для работы требуется первый файл. Можно настроить так, чтобы вместо require(«file1») нужно было писать:
Уважаемый компьютер!
Подключи, пожалуйста, file1.
С уважением, программист.
То есть синтаксис подключения файла можно сделать каким угодно. Например, можно указывать зависимости в комментариях. Это позволяет использовать pipeline, к примеру, для обработки css файлов.
В нашем случае так как второй файл зависит от первого и третьего, то в файле main.coffee можно было бы прописать только одну строчку: require(«file2»). Остальные файлы должны подключиться автоматически.
Со структурой разобрались, осталось все это собрать. Для этого в корне проекта создаем Gemfile примерно следующего содержания:
# файл Gemfile
source "http://rubygems.org"
gem "rake-pipeline", :git => "https://github.com/livingsocial/rake-pipeline.git"
gem "rake-pipeline-web-filters", :git => "https://github.com/wycats/rake-pipeline-web-filters.git"
gem "uglifier", :git => "https://github.com/lautis/uglifier.git"
group :development do
gem "rack"
gem "github_downloads"
gem "coffee-script"
end
Здесь rake-pipeline-web-filters — вспомогательная библиотека, содержит, в частности, класс для обработки coffe-скриптов. uglifier — библиотека для минимизации javascript.
Теперь создаем Rakefile:
# файл Rakefile
abort "Please use Ruby 1.9 to build application!" if RUBY_VERSION !~ /^1.9/
require "bundler/setup"
def pipeline
require 'rake-pipeline'
Rake::Pipeline::Project.new("Assetfile")
end
task :dist do
puts "build application"
pipeline.invoke
puts "done"
end
task :default => :dist
Здесь Rake::Pipeline::Project.new(«Assetfile») — создается новый объект, «Assetfile» — файл с настройками сборки, которого у нас еще нет, но сейчас мы его создадим.
Сразу же можно прописать корневую директорию для скомпилированных файлов. Путь это будет «target»:
# файл Assetfile
output "target"
Сборку проекта будем проводить в 2 этапа. Сначала скомпилируем все coffescript файлы в javascript, а потом уже скомпилируем сам проект.
Компиляция в javascript
Компиляцию будем проводить в директорию «target/src». При этом каждому файлу '.coffe' будет соответствовать собственный файл '.js' (то есть на этом этапе объединять файлы не будем). Для этого в «Assetfile» добавляем следующие строки
# файл Assetfile
# перебираем все файлы из каталога "src"
input "src" do
# для всех файлов *.coffee (из всех подкаталогов "src")
match "**/*.coffee" do
require "rake-pipeline-web-filters"
# создаем новый фильтр для компиляции в javascript
filter Rake::Pipeline::Web::Filters::CoffeeScriptFilter do |filename|
# определяем, по какому павилу будет вычисляться название (и путь)
# скомпилированных js файлов.
# в данном случае файлы будут сохраняться в поддиректорию "src" директории "target"
# и расширение файлов будет изменено с '.coffee' на '.js'
File.join("src/", filename.gsub('.coffee', '.js'))
end
end
end
Теперь если выполнить команду rake, в директории «target/src/lib» будет создана скомпилированная в javascript версия проекта. При этом если какой-то из файлов не удается скомпилировать, то будет показано сообщение об ошибке.
Сборка javascript проекта
На этот раз мы будем читать уже скомпилированные js файлы из каталога 'src/lib':
# файл Assetfile
# заводим новую переменную, которая будет содержать название приложения
name="application"
# перебираем файлы из каталога "target/src"
input "target/src" do
# находим main.js файл
match "main.js" do
# используем фильтр NeuterFilter.
# Этот фильтр позволяет объединить несколько файлов, которые
# связаны между собой зависимостями в один файл.
neuter(
# указываем, где искать зависимотси.
:additional_dependencies => proc { |input|
# зависимости будем брать из той же директории, что и main
Dir.glob(File.join(File.dirname(input.fullpath),'**','*.js'))
},
# указываем правило преобразования названия
:path_transform => proc { |path, input|
# при указании зависимости require("file1") мы не
# написали расширение файла. Здесь мы это исправляем.
# фактически require("file1") заменяется на require("file1.js")
"#{path}.js"
},
# указываем, что содержимое каждого файла не нужно обертывать в js-функцию
:closure_wrap => false
) do |filename|
"#{name}.js"
end
end
end
Теперь если выполнить команду rake, в директории 'src' появится файл 'application.js' следующего содержания:
# файл application.js
(function() {
var file1;
file1 = true;
}).call(this);
(function() {
var file3;
file3 = true;
}).call(this);
(function() {
require("file2");
}).call(this);
Но постойте! Что же тут делает строчка
require("file2");
? Ведь она же должна была исчезнуть. Это, по всей видимости ошибка фильтра «neuter». Давайте посмотрим на исходный код этого фильтра (код). Нас здесь интересует строчка:
# файл neuter_filter.rb
regexp = @config[:require_regexp] || %r{^s*require(['"]([^'"]*)['"]);?s*}
Как можно видеть, если в параметрах не указан собственное правило для выявления текста с названием требуемого файла, по умолчанию используется регулярное выражение
%r{^s*require(['"]([^'"]*)['"]);?s*}
К сожалению, я так и не смог понять, почему такое регулярное выражение обрабатывает только первый require, а второй не замечает. Буду очень благодарен, если Вы разъясните в чем тут дело. Я же решил эту проблему следующим образом:
# файл Assetfile
input "target/src" do
match "main.js" do
neuter(
....
:closure_wrap => false,
:require_regexp => %r{^s*require(['"]([^'"]*)['"]);?s*$}
...
Обратите внимание на появившийся знак "$". То есть мы ограничили регулярное выражение концом строки. После этого скомпилированный файл выглядит как и должен:
# файл application.js
(function() {
var file1;
file1 = true;
}).call(this);
(function() {
var file3;
file3 = true;
}).call(this);
(function() {
var file2;
file2 = true;
}).call(this);
(function() {
}).call(this);
Шикарно (обратите внимание на порядок файлов). Если вы хотите все это дело обернуть еще в одну большую javascript функцию (не знаю зачем, но мало ли), можно поступить следующим образом. Создадим собственный фильтр:
# файл Assetfile
class ClosureFilter < Rake::Pipeline::Filter
def generate_output(inputs, output)
inputs.each do |input|
#оборачиваем
output.write "(function() {n#{input.read}n})()"
end
end
end
И теперь этот фильтр осталось указать после применения фильтра neuter
# файл Assetfile
input "target/src" do
match "main.js" do
neuter(
.............
) do |filename|
"#{name}.js"
end
filter ClosureFilter
end
end
Вот теперь все в порядке. Осталось только сделать минимизированную версию нашего приложения. Для этого нужно написать всего 5 строчек:
# файл Assetfile
input "target" do
match "#{name}.js" do
# uglify - фильтр для минимизации
uglify{ "#{name}.min.js" }
end
end
Теперь при компиляции помимо «application.js» будет создан файл «application.min.js» с содержанием:
(function(){(function(){var e;e=!0}).call(this),function(){var e;e=!0}.call(this),function(){var e;e=!0}.call(this),function(){}.call(this)})();
Окончательная версия моего Assetfile
# файл Assetfile
require "json"
require "rake-pipeline-web-filters"
name="application"
output "target"
input "src" do
match "**/*.coffee" do
filter Rake::Pipeline::Web::Filters::CoffeeScriptFilter do |filename|
File.join("src/", filename.gsub('.coffee', '.js'))
end
end
end
class ClosureFilter < Rake::Pipeline::Filter
def generate_output(inputs, output)
inputs.each do |input|
output.write "(function() {n#{input.read}n})()"
end
end
end
input "target/src" do
match "main.js" do
neuter(
:additional_dependencies => proc { |input|
Dir.glob(File.join(File.dirname(input.fullpath),'**','*.js'))
},
:path_transform => proc { |path, input|
"#{path}.js"
},
:closure_wrap => false,
:require_regexp => %r{^s*require(['"]([^'"]*)['"]);?s*$}
) do |filename|
"#{name}.js"
end
filter ClosureFilter
end
end
input "target" do
match "#{name}.js" do
uglify{ "#{name}.min.js" }
end
end
# vim: filetype=ruby
Осталось только заметить, что структура проекта может содержать и вложенные директории. Если необходимо подключить файл из поддиректории, то нужно указать
require("dir_name/file_name")
Так же можно написать собственные фильтры, которые, например, будут подставлять в файл текст лицензии, номер версии, дату и время последнего коммита, температуру и влажность в вашем городе на момент сборки и т.д.
Если интересно, могу в следующей статье показать, каким образом можно организовать тестирование javascript с использованием phantom.js (то самое тестирование из консоли) и подключение template файлов на этапе сборки.
Автор: SHTrassEr