За работу 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'), 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/`; ['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/' }, 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'), { 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. Генерация происходит условно: если были изменения в fronend коде или после установки/обновления пакетов. При желании можно добавить свои условия, установив переменную :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)}", __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') ) end roles(:all).each do |host| run_locally do `rsync -avzr #{fetch(:local_assets_dir)} #{host.user}@#{host.hostname}:#{shared_path.join('public')}` end end end end end
На этом все 😉
ссылка на оригинал статьи https://habrahabr.ru/post/282584/

Добавить комментарий