由於最近在工做中嘗試了 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...