При разработке хоть сколько-нибудь большого javascript проекта сразу понимаешь, что писать весь код в одном-единственном файле нельзя. После этого код разносится по нескольким файлам и директориям и пишется простой скрипт для того, чтобы все эти файлы можно было легко объединить в один большой production файл. Спустя к��кое-то время начинаешь замечать, что чем дальше, тем труднее становится следить за зависимостями между файлами, да и весь разработанный механизм больше похож на костыль. И тут приходить озарение, что неплохо было бы посмотреть, какие существуют решения этой проблемы.К системе управления сборкой проекта выдвигаются следующие требования:
- Компиляция из coffescript в javascript. Если в файле coffeescript содержится ошибка, то в консоли должны отобразиться название файла и сообщение об ошибке.
- Сборка проекта в один javascript файл должна производится с учетом зависимостей.
- Возможность собрать все приложение целиком в один файл в нескольких видах (с комментариями, минимизированный). При этом само приложение может состоять из нескольких модулей.
- Сборка тестовых файлов и их выполнение в консоли (да, разрабатываем для веба, при этом не притрагиваемся к мышке и вообще не вылазим из любимого vim'a).
- Конечно же все это должно быть удобно в использовании.
В данной статье я не буду затрагивать вопрос тестирования, а рассмотрю вариант системы управления сборкой javascript/coffescript проекта (и саму структуру проекта) с использованием rake и Rake::Pipeline (git).
Rake::Pipeline — это система обработки файлов. Она умеет считывать файлы из директории по заданному шаблону, изменять файлы по заданному правилу и записывать полученный результат.
Как не трудно догадаться, Rake::Pipeline использует rake, поэтому для ее работы нужна ruby. Все настройки pipeline обычно хранятся в файле «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 файлов на этапе сборки.