За работу frontend части приложения в Ruby on Rails отвечает библиотека Sprockets, которая не дотягивает до потребностей современного frontend приложения. В чем именно не дотягивает можно почитать, например, здесь и здесь.
Хотя уже есть достаточно статей на тему связки webpack+rails и даже специальный гем есть, предлагаю посмотреть на еще один велосипед, умеющий также деплой делать.
![](https://habrastorage.org/files/46b/72c/225/46b72c225cf54a15aaa4055c07a8ed6b.png)
Итак, все 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
, чтобы не накапливать на продакшне неиспользуемые ассеты. Такое решение имеет недостаток: синхронизация с удалением делает невозможным откат ассетов к предыдущему релизу. Поэтому при деплое нужно делать бекап манифеста и восстанавливать по нему необходимую версию ассетов в случае отката.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (9)
llxff
01.05.2016 22:48Тоже давно отказались от sprockets и смигрировали на webpack (иногда просто gulp).
Полезно было бы так же выпилить полностью части sprockets из приложения. Например, убрать загрузку из rails/all в application.rb, почистить различные настройки в environments конфигах.
Подключение манифеста мы сделали по-другому, через патч ActionView::Helpers::AssetUrlHelper#compute_asset_path. Это позволило нам подключать и работать ассетами в Rails приложении точно так же, как если бы мы использовали sprockets.Darkside73
01.05.2016 23:00В моем случае sprockets пришлось оставить из-за tinymce, который не захотел заводиться через webpack. Интересно, какой патч применили? И если вы полностью отказались от sprockets, то инлайновые картинки тоже через webpack подключаете?
llxff
02.05.2016 18:50ActionView::Helpers::AssetUrlHelper#compute_asset_path отвечает за генерацию пути к нужному ассету, поэтому мы его обернули для поиска в манифесте, который генерирует webpack. Что-то типа:
# примерный код module Webpack module AssetsUrlHelper def compute_asset_path(path, options = {}) asset_path = super(path, options) rev_path = Rails.application.config.assets_manifest[asset_path] || asset_path [Rails.application.config.assets.prefix, rev_path].join('/') end end end
Инлайн картинки не использовали. Иконки обычно в шрифты или спрайты экспортируем, а других кейсов вроде как на ум не приходит :)
ianbrode
04.05.2016 13:55Коллега написал гем для интеграции вебпака с рельсами. Одна строчка в Gemfile и останется только запустить и настроить сам вебпак.
Исходники тут: https://github.com/rambler-digital-solutions/webpack-pipelineDarkside73
04.05.2016 14:53С какими параметрами вебпак запускать? Насколько я понял, используетя встроенный механизм генерации манифеста, т.е. нужно не забыть опцию добавить при вызове вебпака. Документация не помешала бы)
Coffin
Что может вебпак, чего не может sprocket?
Darkside73
Если сравнивать по фичам, то все, что может webpack, может и Sprockets (хоть и далеко не всегда это решается лекго и нативно в Sprockets). Для меня разница в подходах. Например, у webpack модульность — это основа. При управлении зависимостями webpack оперирует модулями (библиотеками), а Sprockets — файлами. Не нужно думать о явном подключении зависимостей через
//= require
в начале каждого файла.Ну и каждый раз, запуская деплой, я радуюсь, что подготовка ассетов происходит за минуту и не убивает CPU на продакшне
Coffin
У меня все просто :)
есть наборы файлов для
1. Незареганных
2. Зареганных
3. Админов
4. Общие для всех.
В зависимости от этого и подключается.
Прекомпайл занимает не так уж и много времени, если не первый раз.
А с вебпаком, надо еще ноду, нпм + еще 100500 зависимостей :)
Darkside73
Да, Sprockets конечно умеет делать разные бандлы, но для выделения общего бандла уже нужно самому думать, что туда добавлять.
По поводу devops оверхеда у меня тоже были опасения, но все очень быстро поставил и настроил, хоть опыта с node и не много было