Rails Rails 配置 Webpack 终极篇

citysheep · 2016年04月08日 · 最后由 citysheep 回复于 2016年11月26日 · 10600 次阅读

之前写过一篇 webpack 初步 https://ruby-china.org/topics/27537,后来随着深入使用做了一些修改和优化。这篇文章将更详细地介绍如何从 Rails Asset Pipeline 迁移到 webpack(包括 javascript、stylesheets 以及其他资源文件),并且在生产环境使用。

为什么不继续使用 Asset Pipeline

Rails Asset Pipeline 是个很不错的解决方案,如果前端代码不复杂的话还是非常推荐的。然而如果一个项目有如下的特征,建议还是使用 npm 来管理前端的 package,并用 webpack 或者其他工具来做前端工程化:

  • 使用了最新的一些前端 package,找不到对应的 gem,需要人工添加到 vendor。
  • 前端有复杂的结构和交互,需要更清晰地管理不同组件的倚赖。

STEP 1:安装 npm 和 webpack

  • 使用 nvm 安装 npm(和咱 Ruby 的 rvm 基本一样)
  • 在 Rails 项目的目录下运行 npm init,按提示操作,会自动创建一个 package.json 文件
  • 接着通过 npm 安装我们需要的 package,比如 webpack:
// 安装 webpack
npm install webpack --save-dev 

// webpack 命令行
npm install -g webpack 

######注意:

  • npm install XXX --save-devnpm install XXX -save 会自动把要安装的 package 添加到 package.json 文件里的 dependencies 和 devDependencies 部分。
  • npm 国内可能比较慢,建议使用 cnpm:http://npm.taobao.org/

最终你的 package.json 文件差不多长这样,有一些 package 我们之后会提到。

{
  "name": "my app",
  "description": "my rails app using webpack",
  "version": "1.0.0",
  "dependencies": {
    "jquery": "^2.1.4",
    "jquery-ujs": "~1.1.0-1",
    "lodash": "~3.0.0",
  },
  "devDependencies": {
    "babel-core": "^5.8.25",
    "babel-loader": "^5.3.2",
    "babel-runtime": "^6.5.0",
    "coffee-loader": "^0.7.2",
    "coffee-script": "^1.10.0",
    "css-loader": "^0.23.0",
    "exports-loader": "~0.6.2",
    "expose-loader": "~0.6.0",
    "extract-text-webpack-plugin": "^0.9.1",
    "file-loader": "^0.8.5",
    "imports-loader": "~0.6.3",
    "node-sass": "^3.4.2",
    "sass-loader": "^3.1.2",
    "style-loader": "^0.13.0",
    "url-loader": "^0.5.7",
    "webpack": "^1.12.14",
    "webpack-manifest-plugin": "^1.0.0"
  }
}

STEP 2:搬迁前端代码

代码结构

现在我们考虑把前端的 javascript、stylesheets、images 都抽出来,在原项目目录下创建一个新文件夹 frontend,项目文件夹结构如下:

/app
   /assets
   /controllers
   /models
   /...
/config
   /...    
/frontend
   /fonts
   /images
   /stylesheets
   /javascripts
   /development.config.js
   /production.config.js

现在我们添加一些 javascript、stylesheet 和 image:

/frontend
   /fonts
   /images
       /banner.jpg
   /stylesheets
       /home.scss
   /javascripts
       /home.coffee
       /app.js
   /development.config.js
   /production.config.js
配置 webpack

webpack 的配置文件一如既往的复杂,development.config.js 是开发环境的配置,加了一些注释,就不赘述了:

var path = require('path');
var _ = require('lodash');
var webpack = require('webpack');
var assetPath = path.join(__dirname, '../', 'public', 'assets');
var ExtractTextPlugin = require("extract-text-webpack-plugin");
var ManifestPlugin = require('webpack-manifest-plugin');

var config = module.exports = {
  context: path.join(__dirname, '../'),

  // 告诉 webpack 去哪里找 entry 文件
  // webpack 按需加载和打包里面使用的 module,这里用 CommonJS/AMD/ES6 的语法都可以
  entry: './frontend/javascripts/app.js',

  // 开发环境 debug 的一些配置
  debug: true,
  displayErrorDetails: true,
  outputPathinfo: true,
  devtool: 'cheap-module-eval-source-map'
};

config.output = {
  path: assetPath,

  // 打包出来的文件会是 [文件名]_bundle.js
  // 比如我们的 entry 叫 app.js,打包出来的文件就是 app_bundle.js
  filename: '[name]_bundle.js',
  publicPath: '/assets/'
};

config.resolve = {
  extensions: ['', '.js', '.coffee', '.json'],
  modulesDirectories: ['node_modules'],
  root: path.resolve(__dirname),
};

config.plugins = [
  // 如果多个文件里使用了 jquery,以下这个 plugin 可以让你不用每次都 require('jquery')
  new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery'
  }),

  // 用来抽取 js 文件里引用的 css 文件,最终的文件名也会是 [js文件名]_bundle.css 的形式
  new ExtractTextPlugin('[name]_bundle.css', {
    allChunks: true
  })
];

// webpack 强大的 loader
config.module = {
  loaders: [
    // 下面两行将 jquery 暴露到外面的 $ 和 jQuery 里,这样 webpack 以外的 js 也可以顺利使用 jquery
    {test: require.resolve("jquery"), loader: "expose?jQuery" },
    {test: require.resolve("jquery"), loader: "expose?$" },

    // 使用 babel-loader 来支持 es6 语法
    {test: /\.js$/, loader: 'babel-loader'},

    // 使用 coffee-loader 来编译 CoffeeScript
    {test: /\.coffee$/, loader: 'coffee-loader'},

    // 使用 url-loader 来编译字体文件和图片,如果文件小于8kb就直接变成 DataUrl
    {test: /\.(woff|woff2|eot|ttf|otf)\??.*$/, loader: 'url-loader?limit=8192&name=[name].[ext]'},
    {test: /\.(jpe?g|png|gif|svg)\??.*$/, loader: 'url-loader?limit=8192&name=[name].[ext]'},

    // 使用 style-loader、css-loader 来打包 css,sass-loader 打包 sass
    // 使用 ExtractTextPLugin 生成独立的 css 文件
    {test: /\.css$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader')},
    {test: /\.scss$/, loader: ExtractTextPlugin.extract('style', 'css!sass')}
  ]
};

这样就可以在 js 里引用其他 js/coffee、css/sass 啦,例如我们的 app.js 里可以这样写:

// 引用 home.scss
require('../stylesheets/home.scss');

// 引用 home.coffee
require('./home');

也可以在 css/sass 里引用 image 了,注意使用 url 方法(类似 Rails 里的 asset-url)。例如我们的 home.scss

.home-banner {
    background-image: url('../images/banner.jpg');
}

生产环境

生产环境的话需要做一些调整:

  • 打包出来的资源名称加 hash(类似 Rails 里的 fingerprint)
  • 使用 CDN
config.output = {
  ...
  // 给 js 加 fingerprint
  filename: '[name]_bundle-[chunkhash].js',

  // 假设我们的 cdn 是 http://cdn.test.com
  publicPath: 'http://cdn7.test.com/assets/',
  ...
});

config.plugins = [
    ...
    // 给抽出来的 css 加 fingerprint
    new ExtractTextPlugin('[name]_bundle-[chunkhash].css', {
      allChunks: true
    }),

    // 这个 pulgin 我们下一步介绍
    new ManifestPlugin({
      fileName: 'webpack_manifest.json'
    }),

    // 一些生产环境优化用的 plugin
    new webpack.optimize.UglifyJsPlugin(),
    new webpack.optimize.OccurenceOrderPlugin(),
    ...
];

config.module = {
  loaders: [
     ...
     // 给 image、font 等资源加 fingerprint
    {test: /\.(woff|woff2|eot|ttf|otf)\??.*$/, loader: 'url-loader?limit=8192&name=[name]-[hash].[ext]'},
    {test: /\.(jpe?g|png|gif|svg)\??.*$/, loader: 'url-loader?limit=8192&name=[name]-[hash].[ext]'},
     ...
  ];
};

运行 webpack

开发环境运行下面的命令,webpack 会根据配置文件打包资源。按之前的例子,就会在 assets/public 里打包出 app_bundle.js app_bundle.css banner.jpg 并随时更新:

webpack --config frontend/development.config.js --display-reasons --display-chunks --progress --color

生产环境使用以下命令:

webpack --config frontend/production.config.js -p

STEP3:Rails 后端

webpack 打包完成后,在 Rails 里如何引用打包生成的资源呢?在开发环境可以直接使用文件名引入:

<%= javascript_include_tag 'app_bundle' %>
<%= stylesheet_link_tag 'app_bundle' %>

但是生产环境打包出来资源的名称加了 fingerprint,导致 Rails 找不到资源。这时候我们使用 webpack 的 ManifestPlugin,它会在打包的时候生成一个 json 文件,里面有原文件和生成文件的对应关系。例如:

{
  "banner.jpg": "banner-51aad7eb9e12db5cd6b1fd8688aadc8a.jpg",
  "app.css": "app_bundle-d5c3643adae965258b70.css",
  "app.js": "app_bundle-d5c3643adae965258b70.js",
}

在 webpack 里配置如下:

new ManifestPlugin({
  fileName: 'webpack_manifest.json'
})

这样在 Rails 里我们可以依据这个 json 文件来写一些 helper,让 Rails 找到 webpack 打包出来的资源:

// config/application.rb 里添加配置
config.webpack = {
  asset_manifest: {}
}

// 加一个 config/initializers/webpack 来加载这个配置
asset_manifest = Rails.root.join('public', 'assets', 'webpack_manifest.json')
if File.exist?(asset_manifest)
  Rails.configuration.webpack[:manifest] = JSON.parse(
    File.read(asset_manifest),
  ).with_indifferent_access
end

application_helper.rb 里添加加载 webpack 资源的 helper 方法:

// cdn_assets_url 是我们自定义的一个方法
// 如果是生产环境会返回该资源在我们自己 cdn  url
// 如果是开发环境直接返回本地的 url

def webpack_javascript_include_tag(name)
  full_name = "#{name}_bundle.js"
  src = cnd_assets_url("/assets/#{full_name}")
  if Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.js"]
    if asset_name
      src = cnd_assets_url("/assets/#{asset_name}")
    end
  end
  "<script src=\"#{src}\"></script>".html_safe
end

def webpack_stylesheet_link_tag(name)
  full_name = "#{name}_bundle.css"
  src = cnd_assets_url("/assets/#{full_name}")
  if Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.css"]
    if asset_name
      src = cnd_assets_url("/assets/#{asset_name}")
    end
  end
  "<link rel=\"stylesheet\" href=\"#{src}\">".html_safe
end

这样我们只需要用以上两个方法在 Rails 的 view 层引用资源就可以了。比如我们之前的例子里的 app_bundle.jsapp_bundle.css

<%= webpack_javascript_include_tag 'app' %>
<%= webpack_stylesheet_link_tag 'app' %>

其他功能

Hot Module Replacement

如果使用了 React,可以在开发环境配合 webpack 实现热替换(HMR)。需要先安装一个 webpack-dev-server,它会默认运行在 8080 端口,然后实时根据代码改动打包更新 webpack 资源,并在浏览器更新 React 组件,而不需要刷新页面。

这么强大的功能如何使用?我们先在 webpack 配置里修改 publicPath:

config.output = {
  ...   
  publicPath: 'http://localhost:8080/assets/',
  ...
});

添加 react-hot-loader 来编译 js:

{test: /\.js$/, loaders: ['react-hot', 'babel-loader']},

使用以下命令打包:

webpack-dev-server --config frontend/development.config.js --hot --inline

在 Rails 里将我们之前写的两个 helper 方法再进一步改进就可以了:

def webpack_javascript_include_tag(name)
  full_name = "#{name}_bundle.js"
  src = cnd_assets_url("/assets/#{full_name}")
  if Rails.env.development?
    # 热替换
    src = "http://localhost:8080/assets/#{full_name}"
  elsif Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.js"]
    if asset_name
      src = cnd_assets_url("/assets/#{asset_name}")
    end
  end
  "<script src=\"#{src}\"></script>".html_safe
end

def webpack_stylesheet_link_tag(name)
  full_name = "#{name}_bundle.css"
  src = cnd_assets_url("/assets/#{full_name}")
  if Rails.env.development?
    # 热替换
    src = "http://localhost:8080/assets/#{full_name}"
  elsif Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.css"]
    if asset_name
      src = cnd_assets_url("/assets/#{asset_name}")
    end
  end
  "<link rel=\"stylesheet\" href=\"#{src}\">".html_safe
end
webpack 性能调优

webpack 性能优化可以参考这篇文章 https://github.com/wyvernnot/webpack_performance/tree/master/moment-example。可通过添加别名、使用 cdn 来优化性能,这里就不一一列举了。建议先用 webpack 自带的 profile 方法分析出哪里是瓶颈后再调优。

后记

Rails 完成了 webpack 的配置之后,前端完全融入了现在的前端生态,之后可以玩的就多了。比如最近我们在用 React + Redux 重构 T 社的编辑器(http://tshe.com/campaigns/new)就非常方便 :)

折腾,我疑问是 webpack 实现 Assets Pipeline 已有的功能都这么复杂,怎么管理更大型的前端项目代码呢?

npm 包可以通过 browserify-rails 引入。

#1 楼 @rei rails asset pipeline 在某些情况下已经显得封闭,和现在前端生态结合的不是很好,使用 browserify 也好 webpack 也好都只是一种选择。webpack 配置本身就比 browserify 复杂,但功能更多,可以打包 js 的同时也按需打包 css、image 等。至于 webpack 和 browserify 孰优孰劣我觉得本身就没有一个结论,看不同场景和需求。

#3 楼 @rubyonlinux 我们 react 组件的服务器渲染有用这个 😄

5 楼 已删除
匿名 #6 2016年04月08日

不清楚 assets pipeline 能否按需打包和引入文件?

比如页面 a,需要打包 a.js, b.js

页面 b,需要打包 a,js, c.js

#6 楼 @u1370743666 如果 asset pipeline 下需要不同页面用不同的 manifest file。

#4 楼 @citysheep react_on_rails 现在可以用 CommonJS 那种方式引入了吗?以前不可以这样的。。。

#9 楼 @imwildcat react_on_rails 可以的,用 npm 里的 react-on-rails,后端还是引入 react_on_rails gem。

#1 楼 @rei 复杂是因为 webpack 初始设计就不是 target 给做一页一页的传统网站的。做 spa 用 webpack 就是 out of the box 非常直接顺手并且功能非常强大。同时经过配置也可以为一页一页的传统网站 build 前端。

相反 assets pipeline 就明显不够灵活了

同感觉利用 webpack 实现模块化是实现复杂前端构建的必要一步

webpack -p Equals to --optimize-minimize --optimize-occurence-order 所以 config.plugin 可以去掉最后的那两个了

typo: s/cnd_assets_url/cdn_assets_url/g

其实我觉得 middleman 的这种形式的整合挺好 https://github.com/matthewlehner/middleman-webpack/blob/master/config.rb#L11-L15

我还是倾向于只有 js 用 webpack 生成。sass 这种,如果要用到 bourbon 之类的扩展的话稍显麻烦了。

#16 楼 @hxgdzyuyi 嗯,具体看使用场景啦。如果前端比较重,特别用 react 之后,会有一些 stylesheet 和 js 组件绑得很紧。另外因为有些 npm package 里同时有 jscss ,引用起来比较方便。

#17 楼 @citysheep 作为一名前端。。我都觉得我有点保守了。。 。贵圈发展真快

您好,我这边是前端对 rails 不熟,能告知下 cnd_assets_url 具体代码么,其他都 OK 了

#19 楼 @deng19891006asset_url 也可以,然后在环境配置 (development.rb/production.rb) 里面配置:

config.action_controller.asset_host = "assets.example.com"

@citysheep 瑞哥,我最近在学 react 玩,想问下如果要使用 es6 的模块功能,是不是要使用类似 webpack 进行编译才可以?

#21 楼 @x1nyhh 用 webpack 会管理起来方便,但不一定要用。用一个 polyfill 就行了,比如这个 https://github.com/ModuleLoader/es6-module-loader

citysheep 如何实现 Rails 项目的前后端分离? 提及了此话题。 09月20日 23:38

看上去,现在的大趋势式,前后台彻底分离,这不管是 SPA 还是传统的网站,样就会导致 Rails 这种全栈式的开发框架很难跟上当前的发展潮流,这样理解对吗?

#24 楼 @lilijreey 我觉得还是要看具体使用场景,Rails 的全栈思路在项目初期、人少的时候还是挺有帮助的,可以快速搭出原型来验证想法。另外现在的潮流主要还是因为 javascript 的流行和社区的壮大,出了很多前端工程化的工具和框架,而 Rails 里对于前端的解决方案和主流 javascript 的工程化方案毕竟还是不太一样的。

@citysheep 谢谢楼主~~~很棒的文章。

我看到你引入 bundle.js 的方式,是用<%= webpack_javascript_include_tag 'app' %> 在 view 里用 script tag 的方式引用?

为什么不把这个 bundle.js 再交给 asset pipeline?

Rails.application.config.assets.precompile += %w(app_bundle.js)

然后在 view 里简单的引用。

#26 楼 @u1440247613 因为我们也用 webpack 打包 stylesheet 和图片资源。如果用 asset pipeline,在 stylesheet 里就需要使用 webpack 无法编译的一些 rails helper,比如 asset-url :

background-image: asset-url("foobar.png")

而如果统一用 webpack 管理资源,就只需要配置一个 url-loader 就可以了:

background-image: url("../images/foobar.png");

#27 楼 @citysheep 明白了,谢谢,两者不能混用

citysheep 在 Rails 中使用 Yarn 管理三方 assets 提及了此话题。 11月25日 09:48

能混用的啊

sass-loader 的选项会直接传给 node-sass, 你需要这两个:

  • functions, 然后在里面添加一个 asset-url 做处理
  • includePaths, 可以指定在哪里找 -- 这个地方你可以把各种 bundle show xxx_gem + '/app/assets/stylesheets' 的结果传给它

#30 楼 @luikore 学习了。不过我们还有一些页面里面也用到了 webpack 加载图片(比如 react 组件里面直接 require 图片),所以就用 webpack 统一打包所有图片然后弃掉了 asset pipeline。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号