由于最近在工做中尝试了 webpack、react、redux、es6 技术栈,因此总结出了一套 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
针对以上的几点功能,接下来将一步一步的来完成这个 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.
es6
jsx
// 首先须要安装 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
import
在 webpack.config.js 里添加:
// 编译 sass config.module.loaders.push({ test: /\.(scss|css)$/, loaders: ['style', 'css', 'sass'] });
$ 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 规范,具体如何使用直接查看文档
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]; }
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 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 } }) );
$ 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
根据 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,因此热更新、自动刷新仍是能够正常使用
其余的发布插件:
在这个项目中咱们把框架和库都打包到了一个 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。
上面的关于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是为了异步加载能映射到正确的文件,但咱们把名字给改了。衰。。。。。。。。
看了下这个 issue,这个问题已经算是完美解决了:
一、 针对数字索引module id,解决方法有:
二、针对 chunk map 那段代码,抽取出来就行了,插件 https://github.com/diurnalist...