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

由于最近在工做中尝试了 webpackreactreduxes6 技术栈,因此总结出了一套 boilerplate,以便下次作项目时能够快速开始,并进行持续优化。对应的项目地址:webpack-best-practicecss

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

项目结构规划

每一个模块相关的 css、img、js 文件都放在一块儿,比较直观,删除模块时也会方便许多。测试文件也一样放在一块儿,哪些模块有没有写测试,哪些测试应该一块儿随模块删除,一目了然。html5

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

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

准备工做

一、根据前面的项目结构规划建立项目骨架react

$ 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
$ ...

二、安装最基本的几个 npm 包webpack

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

三、编写示例代码,最终代码直接查看 boilerplategit

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

/* 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({
      // http://stackoverflow.com/questions/30030031/passing-environment-dependent-variables-in-webpack
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || 'development')
    })
  ]
};

module.exports = config;
/* 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);
});

上面的配置写好后就能够开始构建了github

$ node build/webpack.dev.js

由于项目中使用了 jsx、es六、scss,因此还要添加相应的 loader,不然会报以下相似错误:web

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、es六、scss 等资源

// 首先须要安装 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 文件:

{
  "presets": ["es2015", "react"]
}

在 webpack.config.js 里添加:

// 使用缓存
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:

$ npm i sass-loader node-sass css-loader style-loader --save-dev

在 webpack.config.js 里添加:

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

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

$ npm i html-webpack-plugin --save-dev

在 webpack.config.js 里添加:

// html 页面
var HtmlwebpackPlugin = require('html-webpack-plugin');
config.plugins.push(
  new HtmlwebpackPlugin({
    filename: 'index.html',
    chunks: ['app'],
    template: SRC_PATH + '/pages/app.html'
  })
);

至此,整个项目就能够正常跑起来了

$ node build/webpack.dev.js

实时编译和刷新浏览器

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

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

由于热更新只须要在开发时使用,因此在 webpack.dev.config 里添加以下代码:

// 开启热替换相关设置
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:

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

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

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

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

使用 postcss-loader

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

在 webpack.config.js 里添加:

// 编译 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 添加:

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']

如何拆分 CSS:separate css bundle

压缩 js、css、html、png 图片

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

// 压缩 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

$ npm i url-loader image-webpack-loader --save-dev

在 webpack.config.js 里添加:

// 图片路径处理,压缩
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]

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

本地接口模拟服务

// 直接使用 epxress 建立一个本地服务
$ npm install epxress --save-dev
$ mkdir mock && cd mock
$ touch app.js
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);
});
// 启动服务,若是用 PM2 管理会更方便,增长接口不用本身手动重启服务
$ node app.js &

发布到远端机

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

$ npm i ftp --save-dev
$ touch build/deploy.plugin.js
// 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 里添加:

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,因此热更新、自动刷新仍是能够正常使用

其余的发布插件:

webpack 问题及优化

改变代码时全部的 chunkhash 都会改变

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

因此咱们不能使用 webpack 提供的 chunkhash 来命名文件,那咱们本身根据文件内容来计算 hash 命名不就行了吗。
开发的时候不须要使用 hash,或者使用 hash 也没问题,最终产出时咱们使用本身的方式从新命名:

$ npm i md5 --save-dev
$ touch build/rename.plugin.js
// 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 里添加:

// webpack.release.js

var RenamePlugin = require('./rename.plugin');
config.plugins.push(new RenamePlugin());

最后也推荐使用本身的方式,根据最终文件内容计算 hash,由于这样不管谁发布代码,或者不管在哪台机器上发布,计算出来的 hash 都是同样的。不会由于下次上线换了台机器就改变了不须要改变的 hash。

2016年07月20日20:34:46 更新:

上面的关于hash的说法有点武断了,抱歉。

关于这个问题有两个点须要知道:

一、 webpack 会根据模块第一次被引用的顺序来将模块放到一个数组里面,模块 id 就是它在数组中的位置。好比下面这个模块的 id 是 3, 若是这个模块第一次被引用的顺序变了,它就不是 3 了,因此最终文件的内容仍是可能会发生没必要要的改变。也就是说,即便咱们使用本身的方式计算 hash,仍是没有完全解决这个问题。

/* 3 */
/***/ function(module, exports) {

    module.exports = 'module is ';

/***/ }

二、咱们使用webpack就不须要再使用其余的模块加载器,由于webpack本身实现了。这块代码保留了一份 chunk map,而这块代码被打包到了 lib。也就是说 lib 的内容会由于咱们增长 chunk,或减小 chunk 而变,尤为是使用了 webpack hash 后,只要其余代码的内容变了,map 里的 hash 随着更新,lib 的内容又得变了,而这都不是咱们指望的。坑啊。。。。。。。。

/******/             script.src = __webpack_require__.p + "" + chunkId + "." + ({"0":"app"}[chunkId]||chunkId) + "." + {"0":"f829bbd875a74dae32a2"}[chunkId] + ".js";

三、咱们使用本身计算 hash 重命名产出文件有可能在使用异步加载时形成坑,由于webpack保留chunk map是为了异步加载能映射到正确的文件,但咱们把名字给改了。衰。。。。。。。。

2016年07月21日11:44:08 更新:

看了下这个 issue,这个问题已经算是完美解决了:

一、 针对数字索引module id,解决方法有:

  • 使用recordsPath option记录每次编译的结果,也就是知道哪些 ID 被使用了
  • 再也不使用数字索引作 module id,而使用 hash name,这也是社区上都同意并但愿的支持的方式,通过测试这种方式并不会对文件的大小形成大的影响。并且webpack已经完成了一个插件来支持,会在2.0正式发布

二、针对 chunk map 那段代码,抽取出来就行了,插件 https://github.com/diurnalist...


原文地址:https://52dachu.com/post/201606271753