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


因為最近在工作中嘗試了 webpackreactreduxes6 技術棧,所以總結出了一套 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 

要完成的功能

  1. 編譯 jsx、es6、scss 等資源

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

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

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

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

  6. 按需打包合並 js、css

  7. 壓縮 js、css、html

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

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

  10. 語法檢查

  11. 全局替換指定字符串

  12. 本地接口模擬服務

  13. 發布到遠端機

針對以上的幾點功能,接下來將一步一步的來完成這個 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 等資源

// 首先需要安裝 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。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM