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

от автора

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


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *