Автоматическая сборка javascript/coffeescript проекта

в 2:40, , рубрики: ruby, Веб-разработка, Программирование

Автоматическая сборка javascript/coffeescript проекта При разработке разработке хоть сколько-нибудь большого javascript проекта сразу понимаешь, что писать весь код в одном-единственном файле нельзя. После этого код разносится по нескольким файлам и директориям и пишется простой скрипт для того, чтобы все эти файлы можно было легко объединить в один большой production файл. Спустя какое-то время начинаешь замечать, что чем дальше, тем труднее становится следить за зависимостями между файлами, да и весь разработанный механизм больше похож на костыль. И тут приходить озарение, что неплохо было бы посмотреть какие существуют решения этой проблемы.

К системе управления сборкой проекта выдвигаются следующие требования:

  1. Компиляция из coffescript в javascript. Если в файле coffeescript содержится ошибка, то в консоле должны отобразиться название файла и сообщение об ошибке.
  2. Сборка проекта в один javascript файл должна производится с учетом зависимостей.
  3. Возможность собрать все приложение целиком в один файл в нескольких видах (с комментариями, минимизированный). При этом само приложение может состоять из нескольких модулей.
  4. Сборка тестовых файлов и их выполнение в консоли (да, разрабатываем для веба, при этом не притрагиваемся к мышке и вообще не вылазим из любимого vim'a).
  5. Конечно же все это должно быть удобно в использовании.

В данной статье я не буду затрагивать вопрос тестирования, а рассмотрю вариант системы управления сборкой 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

* - обязательные к заполнению поля