Pull to refresh

Используем Webpack вместо Sprockets в Ruby on Rails

Reading time 10 min
Views 14K

За работу frontend части приложения в Ruby on Rails отвечает библиотека Sprockets, которая не дотягивает до потребностей современного frontend приложения. В чем именно не дотягивает можно почитать, например, здесь и здесь.


Хотя уже есть достаточно статей на тему связки webpack+rails и даже специальный гем есть, предлагаю посмотреть на еще один велосипед, умеющий также деплой делать.




Итак, все frontend приложение будет находиться в #{Rails.root}/frontend. В стандартной assets останутся только файлы изображений, которые подключаются через image_tag.
Для старта необходим Node JS, npm, сам webpack и плагины к нему. Также нужно добавить в .gitignore следующее:


/node_modules
/public/assets
/webpack-assets.json
/webpack-assets-deploy.json

Конфигурация webpack


При использовании консольной утилиты webpack загружает файл webpack.config.js.
В нашем случае он будет использован для разделения различных окружений, определяемых в переменной NODE_ENV:


// frontend/webpack.config.js

const webpack = require('webpack');
const merge = require('webpack-merge');

const env = process.env.NODE_ENV || 'development';

module.exports = merge(
  require('./base.config.js'),
  require(`./${env}.config.js`)
);

В базовой конфигурации для всех окружений мы задаем общие настройки директорий, загрузчиков, плагинов. Также определяем точки входа для frontend приложения


// frontend/base.config.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  context: __dirname,
  output: {
    // путь к сгенерированным файлам
    path: path.join(__dirname, '..', 'public', 'assets', 'webpack'),
    filename: 'bundle-[name].js'
  },

  //  точки входа (entry point)
  entry: {
    // здесь должен быть массив: ['./app/base-entry'], чтобы можно было
    // подключать одни точки входа в другие
    // обещают исправить в версии 2.0
    application: ['./app/base-entry'],
    main_page: ['./app/pages/main'],
    admin_panel: ['./app/pages/admin_panel']
  },
  resolve: {
    // можно использовать require без указания расширения
    extensions: ['', '.js', '.coffee'],
    modulesDirectories: [ 'node_modules' ],

    // еще одно улучшение для require: из любого файла можно вызвать
    // require('libs/some.lib')
    alias: {
      libs: path.join(__dirname, 'libs')
    }
  },
  module: {
    loaders: [
      // можно писать на ES6
      {
        test: /\.js$/,
        include: [ path.resolve(__dirname + 'frontend/app') ],
        loader: 'babel?presets[]=es2015'
      },

      // для CoffeeScript
      { test: /\.coffee$/, loader: 'coffee-loader' },

      // для Vue JS компонентов
      { test: /\.vue$/, loader: 'vue' },

      // автоматическая загрузка jquery при
      // первом обращении к переменным $ или
      { test: require.resolve('jquery'), loader: 'expose?$!expose?jQuery' }
    ],
  },
  plugins: [
    // можно использовать значение RAILS_ENV в js коде
    new webpack.DefinePlugin({
      __RAILS_ENV__: JSON.stringify(process.env.RAILS_ENV || 'development')
      ),
    })
  ]
};

Окружение development


Конфигурация для development окружения отличается включенным режимом отладки и source map. Я использую Vue JS, поэтому также добавил здесь небольшой фикс для правильного отображения исходного кода компонентов фреймворка.
Также здесь определяем загрузчики для стилей, изображений и шрифтов (для production окружения настройки этих загрузчиков будут другими с учетом особенностей кеширования).


// frontend/development.config.js

const webpack = require('webpack');
const AssetsPlugin = require('assets-webpack-plugin');

module.exports = {
  debug: true,
  displayErrorDetails: true,
  outputPathinfo: true,
  // включаем source map
  devtool: 'eval-source-map',
  output: {
    // фикс для правильного отображения source map у Vue JS компонентов
    devtoolModuleFilenameTemplate: info => {
      if (info.resource.match(/\.vue$/)) {
        $filename = info.allLoaders.match(/type=script/)
                  ? info.resourcePath : 'generated';
      } else {
        $filename = info.resourcePath;
      }
      return $filename;
    },
  },
  module: {
    loaders: [
      { test: /\.css$/, loader: 'style!css?sourceMap' },

      // нужно дополнительно применить плагин resolve-url,
      // чтобы логично работали относительные пути к изображениям
      // внутри *.scss файлов
      {
        test: /\.scss$/,
        loader: 'style!css?sourceMap!resolve-url!sass?sourceMap'
      },

      // изображения
      {
        test: /\.(png|jpg|gif)$/,
        loader: 'url?name=[path][name].[ext]&limit=8192'
      },

      // шрифты
      {
        test: /\.(ttf|eot|svg|woff(2)?)(\?.+)?$/,
        loader: 'file?name=[path][name].[ext]'
      }
    ]
  },
  plugins: [
    // плагин нужен для генерация файла-манифеста, который будет использован
    // фреймворком для подключения js и css
    new AssetsPlugin({ prettyPrint: true })
  ]
};

Для разработки еще понадобится сервер, который будет отдавать статику, следить за изменениями в файлах и делать перегенерацию по необходимости. Приятный бонус — hot module replacement — изменения применяются без перезагрузки страницы. В моем случае для стилей это работает всегда, а Javascript — только для Vue JS компонентов


// frontend/server.js

const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const config = require('./webpack.config');
const hotRailsPort = process.env.HOT_RAILS_PORT || 3550;

config.output.publicPath = `http://localhost:${hotRailsPort}/assets/webpack`;
['application', 'main_page',
  'inner_page', 'product_page', 'admin_panel'].forEach(entryName => {
  config.entry[entryName].push(
    'webpack-dev-server/client?http://localhost:' + hotRailsPort,
    'webpack/hot/only-dev-server'
  );
});

config.plugins.push(
  new webpack.optimize.OccurenceOrderPlugin(),
  new webpack.HotModuleReplacementPlugin(),
  new webpack.NoErrorsPlugin()
);

new WebpackDevServer(webpack(config), {
  publicPath: config.output.publicPath,
  hot: true,
  inline: true,
  historyApiFallback: true,
  quiet: false,
  noInfo: false,
  lazy: false,
  stats: {
    colors: true,
    hash: false,
    version: false,
    chunks: false,
    children: false,
  }
}).listen(hotRailsPort, 'localhost', function (err, result) {
  if (err) console.log(err)
  console.log(
    '=> Webpack development server is running on port ' + hotRailsPort
  );
})

Окружение production


Для production можно выделять CSS в отдельный файл, используя extract-text-webpack-plugin. Также применены различные оптимизации для генерируемого кода.


// frontend/production.config.js

const path = require('path')
const webpack = require('webpack');
const CleanPlugin = require('clean-webpack-plugin');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const CompressionPlugin = require("compression-webpack-plugin");
const AssetsPlugin = require('assets-webpack-plugin');

module.exports = {
  output: {
    // добавлем хеш в имя файла
    filename: './bundle-[name]-[chunkhash].js',
    chunkFilename: 'bundle-[name]-[chunkhash].js',
    publicPath: '/assets/webpack/'
  },
  module: {
    loaders: [
      // используем плагин для выделения CSS в отдельный файл
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract("style-loader", "css?minimize")
      },

      // sourceMap пришлось оставить из-за бага
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract(
          "style-loader", "css?minimize!resolve-url!sass?sourceMap"
        )
      },
      { test: /\.(png|jpg|gif)$/, loader: 'url?limit=8192' },
      {
        test: /\.(ttf|eot|svg|woff(2)?)(\?.+)?$/,
        loader: 'file'
      },
    ]
  },
  plugins: [
    // используем другое имя для манифеста, чтобы при релизе не перезаписывать
    // developoment версию
    new AssetsPlugin({
      prettyPrint: true, filename: 'webpack-assets-deploy.json'
    }),

    // файл с общим js-кодом для всех точек входа
    // Webpack самостоятельно его генерирует, если есть необходимость
    new webpack.optimize.CommonsChunkPlugin(
      'common', 'bundle-[name]-[hash].js'
    ),

    // выделяем CSS в отдельный файл
    new ExtractTextPlugin("bundle-[name]-[chunkhash].css", {
      allChunks: true
    }),

    // оптимизация...
    new webpack.optimize.DedupePlugin(),
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.optimize.UglifyJsPlugin({
      mangle: true,
      compress: {
        warnings: false
      }
    }),

    // генерация gzip версий
    new CompressionPlugin({ test: /\.js$|\.css$/ }),

    // очистка перед очередной сборкой
    new CleanPlugin(
      path.join('public', 'assets', 'webpack'),
      { root: path.join(process.cwd()) }
    )
  ]
};

Интеграция с Ruby on Rails


В конфигурацию приложения добавим новую опцию для включения/отключения вставки webpack статики на странице. Полезно, например, при запуске тестов, когда нет необходимости генерировать статику.


# config/application.rb

config.use_webpack = true

# config/environments/test.rb

config.use_webpack = false

Создаем инициализатор для парсинга манифеста при старте Rails-приложения


# config/initializers/webpack.rb

assets_manifest = Rails.root.join('webpack-assets.json')
if File.exist?(assets_manifest)
  Rails.configuration.webpack = {}
  manifest = JSON.parse(File.read assets_manifest).with_indifferent_access
  manifest.each do |entry, assets|
    assets.each do |kind, asset_path|
      if asset_path =~ /(http[s]?):\/\//i
        manifest[entry][kind] = asset_path
      else
        manifest[entry][kind] = Pathname.new(asset_path).cleanpath.to_s
      end
    end
  end
  Rails.configuration.webpack[:assets_manifest] = manifest

  # я использую Sprockets генерацию статических версий страниц для серверных ошибок;
  # поэтому webpack хелперы (см. ниже) нужно сделать доступными в контексте Sprockets
  Rails.application.config.assets.configure do |env|
    env.context_class.class_eval do
      include Webpack::Helpers
    end
  end
else
  raise "File #{assets_manifest} not found" if Rails.configuration.use_webpack
end

Также полезными будут webpack хелперы webpack_bundle_js_tags и webpack_bundle_css_tags, представляющие из себя обертки для javascript_include_tag и stylesheet_link_tag. Аргументом является название точки входа из конфига webpack


# lib/webpack/helpers.rb

module Webpack
  module Helpers
    COMMON_ENTRY = 'common'

    def webpack_bundle_js_tags(entry)
      webpack_tags :js, entry
    end

    def webpack_bundle_css_tags(entry)
      webpack_tags :css, entry
    end

    def webpack_tags(kind, entry)
      common_bundle = asset_tag(kind, COMMON_ENTRY)
      page_bundle   = asset_tag(kind, entry)
      if common_bundle
        common_bundle + page_bundle
      else
        page_bundle
      end
    end

    def asset_tag(kind, entry)
      if Rails.configuration.use_webpack
        manifest = Rails.configuration.webpack[:assets_manifest]
        if manifest.dig(entry, kind.to_s)
          file_name = manifest[entry][kind]
          case kind
          when :js
            javascript_include_tag file_name
          when :css
            stylesheet_link_tag file_name
          else
            throw "Unknown asset kind: #{kind}"
          end
        end
      end
    end
  end
end

Добавим вспомогательный метод в базовый контроллер, для связи контроллера с точкой входа


# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base

  attr_accessor :webpack_entry_name
  helper_method :webpack_entry_name

  def self.webpack_entry_name(name)
    before_action -> (c) { c.webpack_entry_name = name }
  end
end

Теперь в контроллере можно делать так:


# app/controllers/main_controller.rb

class MainController < ApplicationController
  webpack_entry_name 'main_page'
end

Использование во view:


<html>
  <head>
    <%= webpack_bundle_css_tags(webpack_entry_name) %>
  </head>
  <body>

    <%= webpack_bundle_js_tags(webpack_entry_name) %>    
  </body>
</html>

Команда npm


Теперь все frontend библиотеки должны устанавливаться так:


npm install <package_name> --save

Крайне желательно "заморозить" точные версии всех пакетов в файле npm-shrinkwrap.json (аналог Gemfile.lock). Сделать это можно командой (хотя npm при установке/обновлении пакетов следит за актуальностью npm-shrinkwrap.json, лучше перестраховаться):


npm shrinkwrap --dev

Для удобства в package.json можно добавить в секцию scripts webpack-команды для быстрого запуска:


"scripts": {
  "server": "node frontend/server.js",
  "build:dev": "webpack -v --config frontend/webpack.config.js --display-chunks --debug",
  "build:production": "NODE_ENV=production webpack -v --config frontend/webpack.config.js --display-chunks"
}

Например, запустить webpack сервер можно командой:


npm run server

Деплой: рецепт для capistrano


Я выбрал экономный вариант: не тащить весь JS-зоопарк на production сервер, а делать webpack сборку локально и загружать ее на сервер при помощи rsync.
Делается это командой deploy:webpack:build, реализация которой основана на геме capistrano-faster-assets. Генерация происходит условно: если были изменения в frontend коде или были установлены/обновлены пакеты. При желании можно добавить свои условия (файлы, папки, по которым делается diff), установив переменную :webpack_dependencies. Также необходимо указать локальную папку для сгенерированной статики и файл-манифест:


# config/deploy.rb

set :webpack_dependencies, %w(frontend npm-shrinkwrap.json)
set :local_assets_dir, proc { File.expand_path("../../public/#{fetch(:assets_prefix)}/webpack", __FILE__) }
set :local_webpack_manifest, proc { File.expand_path("../../webpack-assets-deploy.json", __FILE__) }

Команда deploy:webpack:build запускается автоматически перед стандартной deploy:compile_assets.


Сам код рецепта для capistrano:


# lib/capistrano/tasks/webpack_build.rake

class WebpackBuildRequired < StandardError; end

namespace :deploy do
  namespace :webpack do
    desc "Webpack build assets"
    task build: 'deploy:set_rails_env' do
      on roles(:all) do
        begin
          latest_release = capture(:ls, '-xr', releases_path).split[1]
          raise WebpackBuildRequired unless latest_release
          latest_release_path = releases_path.join(latest_release)
          dest_assets_path = shared_path.join('public', fetch(:assets_prefix))

          fetch(:webpack_dependencies).each do |dep|
            release = release_path.join(dep)
            latest = latest_release_path.join(dep)
            # skip if both directories/files do not exist
            next if [release, latest].map{ |d| test "test -e #{d}" }.uniq == [false]
            # execute raises if there is a diff
            execute(:diff, '-Nqr', release, latest) rescue raise(WebpackBuildRequired)
          end

          info "Skipping webpack build, no diff found"

          execute(
            :cp,
            latest_release_path.join('webpack-assets.json'),
            release_path.join('webpack-assets.json')
          )
        rescue WebpackBuildRequired
          invoke 'deploy:webpack:build_force'
        end
      end
    end
    before 'deploy:compile_assets', 'deploy:webpack:build'

    task :build_force do
      run_locally do
        info 'Create webpack local build'
        %x(RAILS_ENV=#{fetch(:rails_env)} npm run build:production)
        invoke 'deploy:webpack:sync'
      end
    end

    desc "Sync locally compiled assets with current release path"
    task :sync do
      on roles(:all) do
        info 'Sync assets...'
        upload!(
          fetch(:local_webpack_manifest),
          release_path.join('webpack-assets.json')          
        )
        execute(:mkdir, '-p', shared_path.join('public', 'assets', 'webpack'))
      end
      roles(:all).each do |host|
        run_locally do
          `rsync -avzr --delete #{fetch(:local_assets_dir)} #{host.user}@#{host.hostname}:#{shared_path.join('public', 'assets')}`
        end
      end
    end
  end

end

Впечатления от использования в пользу webpack: модульность из коробки, четкий контроль версий библиотек и их легкое обновление, development сервер не занят обработкой статики, деплой происходит быстрее и не нагружает прекомпиляцией production сервер.


На этом все ;)


Update!. Если параллельно используется Sprockets (или что-то кроме webpack'a использует public/assets), то для генерации webpack ассетов лучше выделить отдельную директорию, например: public/assets/webpack (внес соответствующие правки в пост). Теперь при деплое можно запускать rsync с опцией --delete, чтобы не накапливать на продакшне неиспользуемые ассеты. Такое решение имеет недостаток: синхронизация с удалением делает невозможным откат ассетов к предыдущему релизу. Поэтому при деплое нужно делать бекап манифеста и восстанавливать по нему необходимую версию ассетов в случае отката.


Update 2. Оформил описанный выше процесс интеграции в виде гема https://github.com/Darkside73/webpacked

Only registered users can participate in poll. Log in, please.
Нужна ли замена Sprockets?
70.59% да, мне нужно больше от frontend 60
21.18% нет, и так все хорошо 18
8.24% нет, мне даже Sprockets не нужен 7
85 users voted. 29 users abstained.
Tags:
Hubs:
+7
Comments 10
Comments Comments 10

Articles