Rails 配置 Webpack

这篇文章将详细地介绍如何从 Rails Asset Pipeline 迁移到 webpack(包括 javascript、stylesheets 以及其他资源文件),并且在生产环境使用。

为什么不使用 Asset Pipeline

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

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

安装 npm 和 webpack

  • 使用 nvm 安装 npm (和咱 Ruby 的 rvm 基本一样)
  • 在 Rails 项目的目录下运行 npm init,按提示操作,会自动创建一个 package.json 文件
  • 接着通过 npm 安装我们需要的 package,比如 webpack:
1
2
3
4
5
// 安装 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 我们之后会提到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"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"
}
}

搬迁前端代码

代码结构

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/app
/assets
/controllers
/models
/...
/config
/...
/frontend
/fonts
/images
/stylesheets
/javascripts
/development.config.js
/production.config.js

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

1
2
3
4
5
6
7
8
9
10
11
/frontend
/fonts
/images
/banner.jpg
/stylesheets
/home.scss
/javascripts
/home.coffee
/app.js
/development.config.js
/production.config.js
配置 webpack

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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 里可以这样写:

1
2
3
4
5
// 引用 home.scss
require('../stylesheets/home.scss');

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

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

1
2
3
.home-banner {
background-image: url('../images/banner.jpg');
}
生产环境

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

  • 打包出来的资源名称加 hash(类似 Rails 里的 fingerprint )
  • 使用 CDN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 并随时更新:

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

生产环境使用以下命令:

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

Rails 后端

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

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

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

1
2
3
4
5
{
"banner.jpg": "banner-51aad7eb9e12db5cd6b1fd8688aadc8a.jpg",
"app.css": "app_bundle-d5c3643adae965258b70.css",
"app.js": "app_bundle-d5c3643adae965258b70.js",
}

在 webpack 里配置如下:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
// 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 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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

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

其他功能

Hot Module Replacement

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

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

1
2
3
4
5
config.output = {
...
publicPath: 'http://localhost:8080/assets/',
...
});

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

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

使用以下命令打包:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 可以通过添加别名、使用 cdn 来优化性能,这里就不一一列举了。建议先用 webpack 自带的 profile 方法分析出哪里是瓶颈后再调优。可以参考 这篇文章

后记

Rails 完成了 webpack 的配置之后,前端完全融入了现在的前端生态,之后可以玩的就多了。比如最近我们重构T社的在线编辑器就非常方便的采用了 React + Redux。

注:keynote中图片部分来源于网络