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

由於最近在工做中嘗試了 webpackreactreduxes6 技術棧,因此總結出了一套 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

要完成的功能

  1. 編譯 jsx、es六、scss 等資源
  2. 自動引入靜態資源到相應 html 頁面
  3. 實時編譯和刷新瀏覽器
  4. 按指定模塊化規範自動包裝模塊
  5. 自動給 css 添加瀏覽器內核前綴
  6. 按需打包合併 js、css
  7. 壓縮 js、css、html
  8. 圖片路徑處理、壓縮、CssSprite
  9. 對文件使用 hash 命名,作強緩存
  10. 語法檢查
  11. 全局替換指定字符串
  12. 本地接口模擬服務
  13. 發佈到遠端機

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

編譯 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。

2016年07月20日20:34:46 更新:

上面的關於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是爲了異步加載能映射到正確的文件,但咱們把名字給改了。衰。。。。。。。。

2016年07月21日11:44:08 更新:

看了下這個 issue,這個問題已經算是完美解決了:

一、 針對數字索引module id,解決方法有:

  • 使用recordsPath option記錄每次編譯的結果,也就是知道哪些 ID 被使用了
  • 再也不使用數字索引作 module id,而使用 hash name,這也是社區上都同意並但願的支持的方式,通過測試這種方式並不會對文件的大小形成大的影響。並且webpack已經完成了一個插件來支持,會在2.0正式發佈

二、針對 chunk map 那段代碼,抽取出來就行了,插件 https://github.com/diurnalist...


原文地址:https://52dachu.com/post/201606271753

相關文章
相關標籤/搜索