使用 webpack + react + redux + es6 開發組件化前端項目

由於最近在工做中嘗試了 webpackreactreduxes6 技術棧,因此總結出了一套 boilerplate,以便下次作項目時能夠快速開始,並進行持續優化。對應的項目地址:webpack-react-redux-es6-boilerplatecss

該項目的 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 等資源node

  2. 自動引入靜態資源到相應 html 頁面react

  3. 實時編譯和刷新瀏覽器webpack

  4. 按指定模塊化規範自動包裝模塊git

  5. 自動給 css 添加瀏覽器內核前綴es6

  6. 按需打包合併 js、cssgithub

  7. 壓縮 js、css、htmlweb

  8. 圖片路徑處理、壓縮、CssSprite

  9. 對文件使用 hash 命名,作強緩存

  10. 語法檢查

  11. 全局替換指定字符串

  12. 本地接口模擬服務

  13. 發佈到遠端機

針對以上的幾點功能,接下來將一步一步的來完成這個 boilerplate 項目, 並記錄下每一步的要點。

準備工做

一、根據前面的項目結構規劃建立項目骨架

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

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

三、編寫示例代碼,最終代碼直接查看 boilerplate

四、根據 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、es六、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、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。

相關文章
相關標籤/搜索