За работу 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