使用 webpack+react+redux+es6 开发组件化前端项目

因为最近在工作中尝试了 webpack、react、redux、es6 技术栈,所以总结出了一套 boilerplate,以便下次做项目时可以快速开始,并进行持续优化。

该项目的 webpack 配置做了不少优化,所以构建速度还不错。文章的最后还对使用 webpack 的问题及性能优化作出了总结。

项目结构规划

每个模块相关的 css、img、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
build
|-- webpack.config.js               # 公共配置
|-- webpack.dev.js                  # 开发配置
|-- webpack.release.js              # 发布配置
docs                                # 项目文档
node_modules                        
src                                 # 项目源码
|-- conf                            # 配置文件
|-- pages                           # 页面目录
|   |-- page1                       
|   |   |-- index.js                # 页面逻辑
|   |   |-- index.scss              # 页面样式
|   |   |-- img                     # 页面图片
|   |   |   |-- xx.png          
|   |   |-- __tests__               # 测试文件
|   |   |   |-- xx.js
|   |-- app.html                    # 入口页
|   |-- app.js                      # 入口JS
|-- components                      # 组件目录
|   |-- loading
|   |   |-- index.js
|   |   |-- index.scss
|   |   |-- __tests__               
|   |   |   |-- xx.js
|-- js
|   |-- actions
|   |   |-- index.js
|   |   |-- __tests__               
|   |   |   |-- xx.js
|   |-- reducers 
|   |   |-- index.js
|   |   |-- __tests__               
|   |   |   |-- xx.js
|   |-- xx.js                 
|-- css                             # 公共CSS目录
|   |-- common.scss
|-- img                             # 公共图片目录
|   |-- xx.png
tests                               # 其他测试文件
package.json                        
READNE.md                  

要完成的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
1.编译 jsx、es6、scss 等资源
2.自动引入静态资源到相应 html 页面
3.实时编译和刷新浏览器
4.按指定模块化规范自动包装模块
5.自动给 css 添加浏览器内核前缀
6.按需打包合并 js、css
7.压缩 js、css、html
8.图片路径处理、压缩、CssSprite
9.对文件使用 hash 命名,做强缓存
10.语法检查
11.全局替换指定字符串
12.本地接口模拟服务
13.发布到远端机

针对以上的几点功能,接下来将一步一步的来完成这个 boilerplate 项目, 并记录下每一步的要点。

准备工作

1、根据前面的项目结构规划创建项目骨架

1
2
3
4
5
6
7
$ make dir webpack-react-redux-es6-boilerplate
$ cd webpack-react-redux-es6-boilerplate
$ mkdir build docs src mock tests
$ touch build/webpack.config.js build/webpack.dev.js build/webpack.release.js
// 创建 package.json
$ npm init
$ ...

2、安装最基本的几个 npm 包

1
2
$ npm i webpack webpack-dev-server --save-dev
$ npm i react react-dom react-router redux react-redux redux-thunk --save

3、编写示例代码,最终代码直接查看 boilerplate

4、根据 webpack 文档编写最基本的 webpack 配置,直接使用 NODE API 的方式

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
/* webpack.config.js */
var webpack = require('webpack');
// 辅助函数
var utils = require('./utils');
var fullPath  = utils.fullPath;
var pickFiles = utils.pickFiles;
// 项目根路径
var ROOT_PATH = fullPath('../');
// 项目源码路径
var SRC_PATH = ROOT_PATH + '/src';
// 产出路径
var DIST_PATH = ROOT_PATH + '/dist';
// 是否是开发环境
var __DEV__ = process.env.NODE_ENV !== 'production';
// conf
var alias = pickFiles({
  id: /(conf\/[^\/]+).js$/,
  pattern: SRC_PATH + '/conf/*.js'
});
 
// components
alias = Object.assign(alias, pickFiles({
  id: /(components\/[^\/]+)/,
  pattern: SRC_PATH + '/components/*/index.js'
}));
 
// reducers
alias = Object.assign(alias, pickFiles({
  id: /(reducers\/[^\/]+).js/,
  pattern: SRC_PATH + '/js/reducers/*'
}));
 
// actions
alias = Object.assign(alias, pickFiles({
  id: /(actions\/[^\/]+).js/,
  pattern: SRC_PATH + '/js/actions/*'
}));
 
var config = {
  context: SRC_PATH,
  entry: {
    app: ['./pages/app.js']
  },
  output: {
    path: DIST_PATH,
    filename: 'js/bundle.js'
  },
  module: {},
  resolve: {
    alias: alias
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || 'development')
    })
  ]
};
module.exports = config;
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
/* webpack.dev.js */
var webpack = require('webpack');
var WebpackDevServer = require('webpack-dev-server');
var config = require('./webpack.config');
var utils = require('./utils');
 
var PORT = 8080;
var HOST = utils.getIP();
var args = process.argv;
var hot = args.indexOf('--hot') > -1;
var deploy = args.indexOf('--deploy') > -1;
 
// 本地环境静态资源路径
var localPublicPath = 'http://' + HOST + ':' + PORT + '/';
 
config.output.publicPath = localPublicPath; 
config.entry.app.unshift('webpack-dev-server/client?' + localPublicPath);
 
new WebpackDevServer(webpack(config), {
  hot: hot,
  inline: true,
  compress: true,
  stats: {
    chunks: false,
    children: false,
    colors: true
  },
  // Set this as true if you want to access dev server from arbitrary url.
  // This is handy if you are using a html5 router.
  historyApiFallback: true,
}).listen(PORT, HOST, function() {
  console.log(localPublicPath);
});

上面的配置写好后就可以开始构建了

1
$ node build/webpack.dev.js

因为项目中使用了 jsx、es6、scss,所以还要添加相应的 loader,否则会报如下类似错误:

1
2
3
ERROR in ./src/pages/app.js
Module parse failed: /Users/xiaoyan/working/webpack-react-redux-es6-boilerplate/src/pages/app.js Unexpected token (18:6)
You may need an appropriate loader to handle this file type.

编译 jsx、es6、scss 等资源

1
2
3
使用 bael 和 babel-loader 编译 jsx、es6
安装插件: babel-preset-es2015 用于解析 es6
安装插件:babel-preset-react 用于解析 jsx
1
2
3
4
5
6
// 首先需要安装 babel 
$ npm i babel-core --save-dev
// 安装插件 
$ npm i babel-preset-es2015 babel-preset-react --save-dev
// 安装 loader
$ npm i babel-loader --save-dev

在项目根目录创建 .babelrc 文件:

1
2
3
{
  "presets": ["es2015""react"]
}

在 webpack.config.js 里添加:

1
2
3
4
5
6
7
8
9
10
11
12
// 使用缓存
var CACHE_PATH = ROOT_PATH + '/cache';
// loaders
config.module.loaders = [];
// 使用 babel 编译 jsx、es6
config.module.loaders.push({
  test: /\.js$/,
  exclude: /node_modules/,
  include: SRC_PATH,
  // 这里使用 loaders ,因为后面还需要添加 loader
  loaders: ['babel?cacheDirectory=' + CACHE_PATH]
});

接下来使用 sass-loader 编译 sass:

1
$ npm i sass-loader node-sass css-loader style-loader --save-dev
1
2
css-loader 用于将 css 当做模块一样来 import
style-loader 用于自动将 css 添加到页面

在 webpack.config.js 里添加:

1
2
3
4
5
// 编译 sass
config.module.loaders.push({
  test: /\.(scss|css)$/,
  loaders: ['style''css''sass']
});

自动引入静态资源到相应 html 页面

1
2
使用 html-webpack-plugin
$ npm i html-webpack-plugin --save-dev

在 webpack.config.js 里添加:

1
2
3
4
5
6
7
8
9
// html 页面
var HtmlwebpackPlugin = require('html-webpack-plugin');
config.plugins.push(
  new HtmlwebpackPlugin({
    filename: 'index.html',
    chunks: ['app'],
    template: SRC_PATH + '/pages/app.html'
  })
);

至此,整个项目就可以正常跑起来了

1
$ node build/webpack.dev.js

实时编译和刷新浏览器

完成前面的配置后,项目就已经可以实时编译和自动刷新浏览器了。接下来就配置下热更新,使用 react-hot-loader:

1
$ npm i react-hot-loader --save-dev

因为热更新只需要在开发时使用,所以在 webpack.dev.config 里添加如下代码:

1
2
3
4
5
6
7
// 开启热替换相关设置
if (hot === true) {
  config.entry.app.unshift('webpack/hot/only-dev-server');
  // 注意这里 loaders[0] 是处理 .js 文件的 loader
  config.module.loaders[0].loaders.unshift('react-hot');
  config.plugins.push(new webpack.HotModuleReplacementPlugin());
}

执行下面的命令,并尝试更改 js、css:

1
$ node build/webpack.dev.js --hot

按指定模块化规范自动包装模块

webpack 支持 CommonJS、AMD 规范,具体如何使用直接查看文档

自动给 css 添加浏览器内核前缀

使用 postcss-loader

1
npm i postcss-loader precss autoprefixer --save-dev

在 webpack.config.js 里添加:

1
2
3
4
5
6
7
8
9
10
11
12
// 编译 sass
config.module.loaders.push({
  test: /\.(scss|css)$/,
  loaders: ['style''css''sass''postcss']
});
 
// css autoprefix
var precss = require('precss');
var autoprefixer = require('autoprefixer');
config.postcss = function() {
  return [precss, autoprefixer];
}

打包合并 js、css
webpack 默认将所有模块都打包成一个 bundle,并提供了 Code Splitting 功能便于我们按需拆分。在这个例子里我们把框架和库都拆分出来:

在 webpack.config.js 添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
config.entry.lib = [
  'react''react-dom''react-router',
  'redux''react-redux''redux-thunk'
]
 
config.output.filename = 'js/[name].js';
 
config.plugins.push(
    new webpack.optimize.CommonsChunkPlugin('lib''js/lib.js')
);
 
// 别忘了将 lib 添加到 html 页面
// chunks: ['app', 'lib']

压缩 js、css、html、png 图片

压缩资源最好只在生产环境时使用

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
// 压缩 js、css
config.plugins.push(
    new webpack.optimize.UglifyJsPlugin({
        compress: {
            warnings: false
        }
    })
);
 
// 压缩 html
// html 页面
var HtmlwebpackPlugin = require('html-webpack-plugin');
config.plugins.push(
  new HtmlwebpackPlugin({
    filename: 'index.html',
    chunks: ['app''lib'],
    template: SRC_PATH + '/pages/app.html',
    minify: {
      collapseWhitespace: true,
      collapseInlineTagWhitespace: true,
      removeRedundantAttributes: true,
      removeEmptyAttributes: true,
      removeScriptTypeAttributes: true,
      removeStyleLinkTypeAttributes: true,
      removeComments: true
    }
  })
);

图片路径处理、压缩、CssSprite

1
2
压缩图片使用 image-webpack-loader
图片路径处理使用 url-loader
1
$ npm i url-loader image-webpack-loader --save-dev

在 webpack.config.js 里添加:

1
2
3
4
5
6
7
8
// 图片路径处理,压缩
config.module.loaders.push({
  test: /\.(?:jpg|gif|png|svg)$/,
  loaders: [
    'url?limit=8000&name=img/[hash].[ext]',
    'image-webpack'
  ]
});

雪碧图处理:webpack_auto_sprites

对文件使用 hash 命名,做强缓存

根据 docs,在产出文件命名中加上 [hash]

1
config.output.filename = 'js/[name].[hash].js';

本地接口模拟服务

1
2
3
4
// 直接使用 epxress 创建一个本地服务
$ npm install epxress --save-dev
$ mkdir mock && cd mock
$ touch app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var express = require('express');
var app = express();
 
// 设置跨域访问,方便开发
app.all('*'function(req, res, next) {
    res.header('Access-Control-Allow-Origin''*');
    next();
});
 
// 具体接口设置
app.get('/api/test'function(req, res) {
    res.send({ code: 200, data: 'your data' });
});
 
var server = app.listen(3000, function() {
    var host = server.address().address;
    var port = server.address().port;
    console.log('Mock server listening at http://%s:%s', host, port);
});
1
2
// 启动服务,如果用 PM2 管理会更方便,增加接口不用自己手动重启服务
$ node app.js &

发布到远端机

写一个 deploy 插件,使用 ftp 上传文件

1
2
$ npm i ftp --save-dev
$ touch build/deploy.plugin.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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// build/deploy.plugin.js
 
var Client = require('ftp');
var client = new Client();
 
// 待上传的文件
var __assets__ = [];
// 是否已连接
var __connected__ = false;
 
var __conf__ = null;
 
function uploadFile(startTime) {
  var file = __assets__.shift();
  // 没有文件就关闭连接
  if (!file) return client.end();
  // 开始上传
  client.put(file.source, file.remotePath, function(err) {
    // 本次上传耗时
    var timming = Date.now() - startTime;
    if (err) {
      console.log('error ', err);
      console.log('upload fail -', file.remotePath);
    else {
      console.log('upload success -', file.remotePath, timming + 'ms');
    }
    // 每次上传之后检测下是否还有文件需要上传,如果没有就关闭连接
    if (__assets__.length === 0) {
      client.end();
    else {
      uploadFile();
    }
  });
}
 
// 发起连接
function connect(conf) {
  if (!__connected__) {
    client.connect(__conf__);
  }
}
 
// 连接成功
client.on('ready'function() {
  __connected__ = true;
  uploadFile(Date.now());
});
 
// 连接已关闭
client.on('close'function() {
  __connected__ = false;
  // 连接关闭后,如果发现还有文件需要上传就重新发起连接
  if (__assets__.length > 0) connect();
});
 
/**
 * [deploy description]
 * @param  {Array}   assets  待 deploy 的文件
 * file.source      buffer
 * file.remotePath  path
 */
function deployWithFtp(conf, assets, callback) {
  __conf__ = conf;
  __assets__ = __assets__.concat(assets);
  connect();
}
 
var path = require('path');
 
/**
 * [DeployPlugin description]
 * @param {Array} options
 * option.reg 
 * option.to 
 */
function DeployPlugin(conf, options) {
  this.conf = conf;
  this.options = options;
}
 
DeployPlugin.prototype.apply = function(compiler) {
  var conf = this.conf;
  var options = this.options;
  compiler.plugin('done'function(stats) {
    var files = [];
    var assets = stats.compilation.assets;
    for (var name in assets) {
      options.map(function(cfg) {
        if (cfg.reg.test(name)) {
          files.push({
            localPath: name,
            remotePath: path.join(cfg.to, name),
            source: new Buffer(assets[name].source(), 'utf-8')
          });
        }
      });
    }
    deployWithFtp(conf, files);
  });
};
 
module.exports = DeployPlugin;

运用上面写的插件,实现同时在本地、测试环境开发,并能自动刷新和热更新。在 webpack.dev.js 里添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
var DeployPlugin = require('./deploy.plugin');
// 是否发布到测试环境
if (deploy === true) {
  config.plugins.push(
    new DeployPlugin({
      user: 'username',
      password: 'password'
      host: 'your host'
      keepalive: 10000000
    }, 
    [{reg: /html$/, to: '/xxx/xxx/xxx/app/views/'}])
  );
}

在这个例子里,只将 html 文件发布到测试环境,静态资源还是使用的本地的webpack-dev-server,所以热更新、自动刷新还是可以正常使用
其他的发布插件:

1
2
sftp-webpack-plugin
webpack-sftp-client

webpack 问题及优化

改变代码时所有的 chunkhash 都会改变

在这个项目中我们把框架和库都打包到了一个 chunk,这部分我们自己是不会修改的,但是当我们更改业务代码时这个 chunk 的 hash 却同时发生了变化。这将导致上线时用户又得重新下载这个根本没有变化的文件。

所以我们不能使用 webpack 提供的 chunkhash 来命名文件,那我们自己根据文件内容来计算 hash 命名不就好了吗。
开发的时候不需要使用 hash,或者使用 hash 也没问题,最终产出时我们使用自己的方式重新命名:

1
2
$ npm i md5 --save-dev
$ touch build/rename.plugin.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
// rename.plugin.js
 
var fs = require('fs');
var path = require('path');
var md5 = require('md5');
 
function RenamePlugin() {
}
 
RenamePlugin.prototype.apply = function(compiler) {
  compiler.plugin('done'function(stats) {
    var htmlFiles = [];
    var hashFiles = [];
    var assets = stats.compilation.assets;
 
    Object.keys(assets).forEach(function(fileName) {
      var file = assets[fileName];
      if (/\.(css|js)$/.test(fileName)) {
        var hash = md5(file.source());
        var newName = fileName.replace(/(.js|.css)$/, '.' + hash + '$1');
        hashFiles.push({
          originName: fileName,
          hashName: newName
        });
        fs.rename(file.existsAt, file.existsAt.replace(fileName, newName));
      
      else if (/\.html$/) {
        htmlFiles.push(fileName);
      }
    });
 
    htmlFiles.forEach(function(fileName) {
      var file = assets[fileName];
      var contents = file.source();
      hashFiles.forEach(function(item) {
        contents = contents.replace(item.originName, item.hashName);
      });
      fs.writeFile(file.existsAt, contents, 'utf-8');
    });
  });
};
 
module.exports = RenamePlugin;

在 webpack.release.js 里添加:

1
2
3
4
// webpack.release.js
 
var RenamePlugin = require('./rename.plugin');
config.plugins.push(new RenamePlugin());

最后也推荐使用自己的方式,根据最终文件内容计算 hash,因为这样无论谁发布代码,或者无论在哪台机器上发布,计算出来的 hash 都是一样的。不会因为下次上线换了台机器就改变了不需要改变的 hash。

虚拟主机
《编写高质量JavaScript代码的68个有效方法》PDF
《你不知道的Javascript(上卷)》PDF
《JavaScript高级程序设计(第3版)》PDF
《HTML5从入门到精通》PDF
广告也精彩