因為最近在工作中嘗試了 webpack、react、redux、es6 技術棧,所以總結出了一套 boilerplate,以便下次做項目時可以快速開始,並進行持續優化。對應的項目地址:webpack-react-redux-es6-boilerplate
該項目的 webpack 配置做了不少優化,所以構建速度還不錯。文章的最后還對使用 webpack 的問題及性能優化作出了總結。
項目結構規划
每個模塊相關的 css、img、js 文件都放在一起,比較直觀,刪除模塊時也會方便許多。測試文件也同樣放在一起,哪些模塊有沒有寫測試,哪些測試應該一起隨模塊刪除,一目了然。
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
要完成的功能
-
編譯 jsx、es6、scss 等資源
-
自動引入靜態資源到相應 html 頁面
-
實時編譯和刷新瀏覽器
-
按指定模塊化規范自動包裝模塊
-
自動給 css 添加瀏覽器內核前綴
-
按需打包合並 js、css
-
壓縮 js、css、html
-
圖片路徑處理、壓縮、CssSprite
-
對文件使用 hash 命名,做強緩存
-
語法檢查
-
全局替換指定字符串
-
本地接口模擬服務
-
發布到遠端機
針對以上的幾點功能,接下來將一步一步的來完成這個 boilerplate 項目, 並記錄下每一步的要點。
准備工作
1、根據前面的項目結構規划創建項目骨架
$ 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 包
$ 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 的方式
/* 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); });
上面的配置寫好后就可以開始構建了
$ node build/webpack.dev.js
因為項目中使用了 jsx、es6、scss,所以還要添加相應的 loader,否則會報如下類似錯誤:
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 等資源
-
使用 bael 和 babel-loader 編譯 jsx、es6
-
安裝插件: babel-preset-es2015 用於解析
es6
-
安裝插件:babel-preset-react 用於解析
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
-
css-loader 用於將 css 當做模塊一樣來
import
-
style-loader 用於自動將 css 添加到頁面
在 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 添加瀏覽器內核前綴
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
-
壓縮圖片使用 image-webpack-loader
-
圖片路徑處理使用 url-loader
$ 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。