1. 程式人生 > 實用技巧 >深入淺出 webpack 之基礎配置篇

深入淺出 webpack 之基礎配置篇

前言

前端工程化經歷過很多優秀的工具,例如 GruntGulpwebpackrollup 等等,每種工具都有自己適用的場景,而現今應用最為廣泛的當屬 webpack 打包了,因此學習好 webpack 也成為一個優秀前端的必備技能。

由於 webpack 技術棧比較複雜,因此作者打算分兩篇文章進行講解:

  1. 基礎應用篇:講解各種基礎配置;
  2. 高階應用篇:講解 webpack 優化以及原理。

[注] 本文是基於 webpack 4.x 版本

webpack 是什麼

webpack 是模組打包工具。

webpack 可以在不進行任何配置的情況下打包如下程式碼:

[注] 不進行任何配置時,webpack 會使用預設配置。

// moduleA.js
function ModuleA(){
  this.a = "a";
  this.b = "b";
}

export default ModuleA


// index.js
import ModuleA from "./moduleA.js";

const module = new ModuleA();

我們知道瀏覽器是不認識 import 語法的,直接在瀏覽器中執行這樣的程式碼會報錯。我們就可以藉助 webpack 來打包這樣的程式碼,賦予 JavaScript 模組化的能力。

最初版本的 webpack 只能打包 JavaScript 程式碼,隨著發展 css

檔案,圖片檔案,字型檔案都可以被 webpack 打包。

本文將主要講解 webpack 是如何打包這些資源的,屬於比較基礎的文章主要是為了後面講解效能優化和原理做鋪墊,如果已經對 webpack 比較熟悉的同學可以跳過本文。

初始化安裝 webpack

mkdir webpackDemo // 建立資料夾
cd webpackDemo // 進入資料夾
npm init -y // 初始化package.json

npm install webpack webpack-cli -D // 開發環境安裝 webpack 以及 webpack-cli

通過這樣安裝之後,我們就可以在專案中使用 webpack

命令了。

打包第一個檔案

webpack.config.js

const path = require('path');

module.exports = {
  mode: 'development', // {1}
  entry: { // {2}
  	main:'./src/index.js'
  }, 
  output: { // {3}
    publicPath:"", // 所有dist檔案新增統一的字首地址,例如釋出到cdn的域名就在這裡統一新增
    filename: 'bundle.js',
    path: path.resolve(__dirname,'dist')
  }
}

程式碼分析:

  • {1} mode 打包模式是開發環境還是生成環境, development | production
  • {2} entry 入口檔案為 index.js
  • {3} output 輸出到 path 配置的 dist 資料夾下,輸出的檔名為 filename 配置的 bundle.js

建立檔案進行簡單打包:

src/moduleA.js

const moduleA = function () {
  return "moduleA"
}

export default moduleA;

--------------------------------

src/index.js

import moduleA from "./moduleA";

console.log(moduleA());

修改 package.jsonscripts,增加一條命令:

"scripts": {
  "build": "webpack --config webpack.config.js"
}

執行 npm run build 命令

打包後的 bundle.js 原始碼分析

原始碼經過簡化,只把核心部分展示出來,方便理解:

 (function(modules) {
 	var installedModules = {};

 	function __webpack_require__(moduleId) {
		// 快取檔案
 		if(installedModules[moduleId]) {
 			return installedModules[moduleId].exports;
 		}
		// 初始化 moudle,並且也在快取中存入一份
 		var module = installedModules[moduleId] = {
 			i: moduleId,
 			l: false,
 			exports: {}
 		};
		// 執行 "./src/index.js" 對應的函式體
 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

		// 標記"./src/index.js"該模組以及載入
 		module.l = true;
    
 		// 返回已經載入成功的模組
 		return module.exports;
 	}
	// 匿名函式開始執行的位置,並且預設路徑就是入口檔案
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
	// 傳入匿名執行函式體的module物件,包含"./src/index.js","./src/moduleA.js"
	// 以及它們對應要執行的函式體
 ({
   "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\");\n\n\nconsole.log(Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__[\"default\"])());\n\n\n//# sourceURL=webpack:///./src/index.js?");
   
  }),

   "./src/moduleA.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\nconst moduleA = function () {\n  return \"moduleA\"\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (moduleA);\n\n\n//# sourceURL=webpack:///./src/moduleA.js?");

  })

 });

再來看看"./src/index.js" 對應的執行函式

(function(module, __webpack_exports__, __webpack_require__) {
	"use strict";
	eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\");\n\n\nconsole.log(Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__[\"default\"])());\n\n\n//# sourceURL=webpack:///./src/index.js?");
})

你會發現就是一個 eval 執行方法。

我們拆開 eval 來仔細看看裡面是什麼內容,簡化後代碼如下:

var moduleA = __webpack_require__("./src/moduleA.js");
console.log(Object(moduleA["default"])());

上面原始碼中其實已經呼叫了 __webpack_require__(__webpack_require__.s = "./src/index.js"); ,然後 "./src/index.js" 又遞迴呼叫了去獲取 "./src/moduleA.js" 的輸出物件。

我們看看 "./src/moduleA.js" 程式碼會輸出什麼:

const moduleA = function () {
  return "moduleA"
}
__webpack_exports__["default"] = (moduleA);

再回頭看看上面的程式碼就相當於:

console.log(Object(function () {
  return "moduleA"
})());

最後執行列印了 "moduleA"

通過這段原始碼的分析可以看出:

  1. 打包之後的模組,都是通過 eval 函式進行執行的;
  2. 通過呼叫入口函式 ./src/index.js 然後遞迴的去把所有模組找到,由於遞迴會進行重複計算,因此 __webpack_require__ 函式中有一個快取物件 installedModules

loader

我們知道 webpack 可以打包 JavaScript 模組,而且也早就聽說 webpack 還可以打包圖片、字型以及 css,這個時候就需要 loader 來幫助我們識別這些檔案了。

[注意] 碰到檔案不能識別記得找 loader 。

打包圖片檔案

修改配置檔案:webpack.config.js

const path = require('path');

module.exports = {
  mode: 'development',
  entry: { 
    main:'./src/index.js'
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname,'dist')
  },
  module:{
    rules:[
      {
        test:/\.(png|svg|jpg|gif)$/,
        use:{
          loader: 'url-loader',
          options: {
            name: '[name]_[hash].[ext]',
            outputPath:"images", // 打包該資源到 images 資料夾下
            limit: 2048 // 如果圖片的大小,小於2048KB則時輸出base64,否則輸出圖片
          }
        }
      }
    ]
  }
}

修改:src/index.js

import moduleA from "./moduleA";
import header from "./header.jpg";

function insertImg(){
  const imageElement = new Image();
  imageElement.src = `dist/${header}`;
  document.body.appendChild(imageElement);
}

insertImg();

執行打包後,發現可以正常打包,並且 dist 目錄下也多出了一個圖片檔案。

我們簡單分析:

webpack 本身其實只認識 JavaScript 模組的,當碰到圖片檔案時便會去 module 的配置 rules 中找,發現 test:/\.(png|svg|jpg|gif)$/ ,正則匹配到圖片檔案字尾時就使用 url-loader 進行處理,如果圖片小於 2048KB (這個可以設定成任意值,主要看專案)就輸出 base64

打包樣式檔案

{
  test:/\.scss$/, // 正則匹配到.scss樣式檔案
    use:[
      'style-loader', // 把得到的CSS內容插入到HTML中
      {
        loader: 'css-loader',
        options: {
          importLoaders: 2, // scss中再次import scss檔案,也同樣執行 sass-loader 和 postcss-loader
          modules: true // 啟用 css module
        }
      },
      'sass-loader', // 解析 scss 檔案成 css 檔案
      'postcss-loader'// 自動增加廠商字首 -webket -moz,使用它還需要建立postcss.config.js配置檔案
    ]
}

postcss.config.js

module.exports = {
  plugins: [
    require('autoprefixer')
  ]
};

打包解析:

  1. webpack 遇到 xx.scss 樣式檔案是;
  2. 依次呼叫 postcss-loader 自動增加廠商字首 -webket -moz
  3. 呼叫 sass-loaderscss 檔案轉換成 css 檔案;
  4. 呼叫 css-loader 處理 css 檔案,其中 importLoaders:2 ,是 scss 檔案中引入了其它 scss 檔案,需要重複呼叫 sass-loader postcss-loader 的配置項;
  5. 最後呼叫 style-loader 把前面編譯好的 css 檔案內容以 <style>...</style> 形式插入到頁面中。

[注意] loader的執行順序是陣列後到前的執行順序。

打包字型檔案

{
  test: /\.(woff|woff2|eot|ttf|otf)$/, // 打包字型檔案
  use: ['file-loader'] // 把字型檔案移動到dist目錄下
}

plugins

plugins 可以在 webpack 執行到某個時刻幫你做一些事情,相當於 webpack 在某一個生命週期,呼叫外掛做一些輔助的事情。

html-webpack-plugin

作用:

會在打包結束後,自動生成一個 HTML 檔案(也可通過模板生成),並把打包生成的 js 檔案自動引入到 HTML 檔案中。

使用:

const HtmlWebpackPlugin = require('html-webpack-plugin');

plugins: [
  new HtmlWebpackPlugin({
    template: 'src/index.html' // 使用模板檔案
  })
]

clean-webpack-plugin

作用:

每次輸出打包結果時,先自動刪除 output 配置的資料夾

使用:

const { CleanWebpackPlugin } = require('clean-webpack-plugin');

plugins: [
  ...
  new CleanWebpackPlugin() // 使用這個外掛在每次生成dist目錄前,先刪除dist目錄
]

source map

在開發過程中有一個功能是很重要的,那就是錯誤除錯,我們在編寫程式碼過程中出現了錯誤,編譯後的包如果提示不友好,將會嚴重影響我們的開發效率。而通過配置 source map 就可以幫助我們解決這個問題。

示例:
修改:src/index.js,增加一行錯誤的程式碼

console.log(a);

由於mode: 'development' 開發模式是預設會開啟 source map 功能的,我們先關閉它。

devtool: 'none' // 關閉 source map 配置

執行打包來看下控制檯的報錯資訊:

錯誤堆疊資訊,竟然給的是打包之後的 bundle 檔案中的資訊,但其實我們在開發過程中的檔案結構並不是這樣的,因此我們需要它能指明我們是在 index.js 中的多少行發生錯誤了,這樣我們就可以快速的定位到問題。

我們去掉 devtool:'none' 這行配置,再執行打包:

此時它就把我們在開發中的具體錯誤在堆疊中輸出了,這就是 source map 的功能。

總結下:source map 它是一個對映關係,它知道 dist 目錄下 bundle.js 檔案對應的實際開發檔案中的具體行列。

webpackDevServer

每次修改完程式碼之後都要手動去執行編譯命令,這顯然是不科學的,我們希望是每次寫完程式碼,webpack 會進行自動編譯,webpackDevServer 就可以幫助我們。

增加配置:

devServer: {
  contentBase: './dist', // 伺服器啟動根目錄設定為dist
  open: true, // 自動開啟瀏覽器
  port: 8081, // 配置服務啟動埠,預設是8080
  proxy:{
    '/api': 'http://www.baidu.com' // 當開發環境時傳送/api請求時都會代理到http://www.baidu.com host下
  }
},

它相當於幫助我們開啟了一個 web 服務,並監聽了 src 下檔案,當檔案有變動時,自動幫助我們進行重新執行 webpack 編譯。

我們在 package.json 中增加一條命令:

"scripts": {
  "start": "webpack-dev-server"
},

現在我們執行 npm start 命令後,可以看到控制檯開始實行監聽模式了,此時我們任意更改業務程式碼,都會觸發 webpack 重新編譯。

webpack-dev-server 實現請求代理

在前後端分離的專案中進行前端開發時,想必每個同學都會碰到一個棘手的問題就是請求跨域。一般在生產環境下我們通過 nginx 進行代理,那麼開發環境下我們一般如何處理呢,答案非常簡單,配置webpack-dev-server 也可以輕易實現

devServer: {
  ...
  proxy:{
  	'/api': 'http://www.baidu.com' // 當開發環境時傳送/api請求時都會代理到http://www.baidu.com host下
  }
}

proxy 的配置項非常豐富具體可以參考文件,我們只需要記住,它可以提供代理伺服器的功能給我們。

手動實現簡單版 webpack-dev-server

專案根目錄下增加:server.js

載入包: npm install express webpack-dev-middleware -D

const express = require('express');
const app = express();
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const config = require('./webpack.config.js'); // 引入webpack配置檔案
const compiler = webpack(config); // webpack 編譯執行時

// 告訴 express 使用 webpack-dev-middleware,
// 以及將 webpack.config.js 配置檔案作為基礎配置
app.use(webpackDevMiddleware(compiler, {}));

// 監聽埠
app.listen(3000,()=>{
  console.log('程式已啟動在3000埠');
});

webpack-dev-middleware 作用:

  1. 通過 watch mode 監聽資源的變更然後自動打包,本質上是呼叫 compiler 物件上的 watch 方法;
  2. 使用記憶體檔案系統編譯速度快 compiler.outputFileSystem = new MemoryFileSystem() ;

package.json 增加一條命令:

"scripts": {
  "server": "node server.js"
},

執行命令 npm run server 啟動我們自定義的服務,瀏覽器中輸入 http://localhost:3000/ 檢視效果。

熱更新 Hot Moudule Replacement(HMR)

模組熱更新功能會在應用程式執行過程中,替換、新增或刪除模組,而無需重新載入整個頁面。

HMR 配置

const webpack = require('webpack');
module.exports = {
  devServer: {
    contentBase: './dist',
    open: true,
    port: 8081,
    hot: true // 熱更新配置
  },
  plugins:[
      new webpack.HotModuleReplacementPlugin() // 增加熱更新外掛
  ]
}

手動編寫 HMR 程式碼

在編寫程式碼時經常會發現熱更新失效,那是因為相應的 loader 沒有去實現熱更新,我們看看如何簡單實現一個熱更新。

import moduleA from "./moduleA";

if (module.hot) {
  module.hot.accept('./moduleA.js', function() {
    console.log("moduleA 支援熱更新拉");
    console.log(moduleA());
  })
}

程式碼解釋:

我們引人自己編寫的一個普通 ES6 語法模組,假如我們想要實現熱更新就必須手動監聽相關檔案,然後當接收到更新回撥時,主動呼叫。

還記得上面講 webpack 打包後的原始碼分析嗎,webpack 給模組都建立了一個 module 物件,當你開啟模組熱更新時,在初始化 module 物件時增加了(原始碼經過刪減):

function hotCreateModule(moduleId) {
  var hot = {
    active: true,
    accept: function(dep, callback){
      if (dep === undefined) hot._selfAccepted = true;
      else if (typeof dep === "function") hot._selfAccepted = dep;
      else if (typeof dep === "object")
      for (var i = 0; i < dep.length; i++)
 	  hot._acceptedDependencies[dep[i]] = callback || function() {};
 	  else hot._acceptedDependencies[dep] = callback || function() {};
    }
  }
}

module 物件中儲存了監聽檔案路徑和回撥函式的依賴表,當監聽的模組發生變更後,會去主動呼叫相關的回撥函式,實現手動熱更新。

[注意] 所有編寫的業務模組,最終都會被 webpack 轉換成 module 物件進行管理,如果開啟熱更新,那麼 module 就會去增加 hot 相關屬性。這些屬性構成了 webpack 編譯執行時物件。

編譯 ES6

顯然大家都知道必須要使用 babel 來支援了,我們具體看看如何配置

配置

1、安裝相關包

npm install babel-loader @babel/core @babel/preset-env @babel/polyfill -D

2、修改配置 webpack.config.json

還記得文章上面說過,碰到不認識的檔案型別的編譯問題要求助 loader

module:{
  rules:[
    {
      test: /\.js$/, // 正則匹配js檔案
      exclude: /node_modules/, // 排除 node_modules 資料夾
      loader: "babel-loader", // 使用 babel-loader
      options:{
        presets:[
          [
            "@babel/preset-env", // {1}
           { useBuiltIns: "usage" } // {2}
          ]
        ]
      }
    }
  ]
}

babel 配置解析:

  • {1} babel presets 是一組外掛的集合,它的作用是轉換 ES6+ 的新語法,但是一些新 API 它不會處理的
    • Promise Generator 是新語法
    • Array.prototype.map 方法是新 APIbabel 是不會轉換這個語法的,因此需要藉助 polyfill 處理
  • {2} useBuiltIns 的配置是處理 @babel/polyfill 如何載入的,它有3個值 false entry usage
    • false: 不對 polyfills做任何操作;
    • entry: 根據 target中瀏覽器版本的支援,將polyfills拆分引入,僅引入有瀏覽器不支援的 polyfill
    • usage:檢測程式碼中ES6/7/8等的使用情況,僅僅載入程式碼中用到的polyfills

演示

新建檔案 src/moduleES6.js

const arr = [
  new Promise(()=>{}),
  new Promise(()=>{})
];
function handleArr(){
  arr.map((item)=>{
    console.log(item);
  });
}
export default handleArr;

修改檔案 src/index.js

import moduleES6 from "./moduleES6";
moduleES6();

執行打包後的原始檔(簡化後):

"./node_modules/core-js/modules/es6.array.map.js":
(function(module, exports, __webpack_require__) {
"use strict";
var $export = __webpack_require__("./node_modules/core-js/modules/_export.js");
var $map = __webpack_require__("./node_modules/core-js/modules/_array-methods.js")(1);

$export($export.P + $export.F * !__webpack_require__(/*! ./_strict-method */ "./node_modules/core-js/modules/_strict-method.js")([].map, true), 'Array', {
  map: function map(callbackfn) {
    return $map(this, callbackfn, arguments[1]);
  }
});

看程式碼就應該能明白了 polyfill 相當於是使用 ES5 的語法重新實現了 map 方法來相容低版本瀏覽器。

polyfill 實現了 ES6+ 所有的語法,十分龐大,我們不可能全部引入,因此才會有這個配置 useBuiltIns: "usage" 只加載使用的語法。

編譯 React 檔案

配置

安裝相關依賴包

npm install @babel/preset-react -D
npm install react react-dom

webpack.config.js

module:{
  rules:[
    {
      test: /\.js$/, // 正則匹配js檔案
      exclude: /node_modules/, // 排除 node_modules 資料夾
      loader: "babel-loader", // 使用 babel-loader
      options:{
        presets:[
          [
            "@babel/preset-env",
           { useBuiltIns: "usage" }
          ],
          ["@babel/preset-react"]
        ]
      }
    }
  ]
}

直接在 presets 配置中增加一個 ["@babel/preset-react"] 配置即可, 那麼這個 preset 就會幫助我們把 ReactJSX 語法轉換成 React.createElement 這樣的語法。

演示

修改檔案:src/index.js

import React,{Component} from 'react';
import ReactDom from 'react-dom';

class App extends Component{
  render(){
    const arr = [1,2,3,4];
    return (
      arr.map((item)=><p>num: {item}</p>)
    )
  }
}

ReactDom.render(<App />, document.getElementById('root'));

執行打包命令 yarn build 可以正確打包並且顯示正常介面。

隨著專案的複雜度增加,babel 的配置也隨之變的複雜,因此我們需要把 babel 相關的配置提取成一個單獨的檔案進行配置方便管理,也就是我們工程目錄下的 .babelrc 檔案。

.babelrc

{
  "presets":[
    ["@babel/preset-env",{ "useBuiltIns": "usage" }],
    ["@babel/preset-react"]
  ]
}

[注意] babel-laoder 執行 presets 配置順序是陣列的後到前,與同時使用多個 loader 的執行順序是一樣的。

也就是把 webpack.config.js 中的 babel-loader 中的 options 物件提取成一個單獨檔案。

通過編譯記錄,我們可以發現一個問題就是打包後的 bundle.js 檔案足足有 1M 大,那是因為 react 以及 react-dom 都被打包進來了。

Tree Shaking

Tree shaking 的本質是消除無用的 JavaScript 程式碼。

import { forEach } from "lodash"

forEach([1,2,3],(item)=>{
  console.log(item);
})

在專案中引入了 lodash 庫,只使用了其中的 forEach 方法,在 jquery 時代我們只能引入整個 lodash 檔案。但通過 import 引入則支援 Tree Shaking ,下面讓我們一起來配置它。

webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'production',
  devServer: {
    contentBase: './dist', // 伺服器啟動根目錄設定為dist
    open: true, // 自動開啟瀏覽器
    port: 8081, // 配置服務啟動埠,預設是8080
  },
  entry: { // 入口檔案
    main:'./src/index.js'
  },
  output: { // 出口檔案
    publicPath:"",
    filename: 'bundle.js',
    path: path.resolve(__dirname,'dist')
  },
  module:{ // loader 配置
    rules:[
      {
        test: /\.js$/, // 正則匹配js檔案
        exclude: /node_modules/, // 排除 node_modules 資料夾
        loader: "babel-loader", // 使用 babel-loader
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/index.html' // 使用模板檔案
    }),
    new CleanWebpackPlugin()
  ]
}

只需要配置 mode: 'production' 生成環境下會預設 Tree Shaking

打包後依然有 72kb 大小,顯然 Tree Shaking 失敗了,這是為什麼呢?

Tree Shaing 執行的前提是:必須是使用 import export ESModule 語法的類庫才能被 Tree Shakinglodash 也提供了相應的庫給我們使用 lodash-es

修改業務程式碼: src/index.js

import { forEach } from "lodash-es";

forEach([1,2,3],(item)=>{
  console.log(item);
})

再次執行打包:

打包後的大小隻有 5.55Kb ,說明 Tree Shaking 生效了。

為什麼要 ESModule

前面說了必須要使用 ES6 提供的模組化語法才可以實現 Tree Shaking ,使用 CommonJs 的語法能實現 Tree Shaking 嗎?答案肯定是不能的。

CommonJS 模組是執行時載入,ES6 模組是編譯時輸出介面。

ES6 模組不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成。所謂靜態分析就是不執行程式碼,從字面量上對程式碼進行分析。

拿上面程式碼分析:我們引入了 lodash-es 中的 forEach ,在靜態分析階段就可以知道,我們只使用了 forEach 一個函式,因此沒有必要把整個 lodash-es 中所有的函式都引入,這個時候就剔除了那些沒有用的程式碼,只保留 forEach

sideEffects

在配置 Tree Shaking 時必須要配置 sideEffects

package.json

{
  "sideEffects": false
}
  • "sideEffects": false 表示 webpack 它可以安全地刪除未用到的 export
  • "sideEffects": ["*.css"] 表示 *.css 的引入不做 Tree Shaking 處理。

為什麼需要對 css 做處理呢?因為我們經常這樣引入全域性 css

import "xxx.css"

如果不進行特殊的配置, Tree Shaking 會誤認為 xxx.css 只匯入了,但是沒有使用,因此相關 css 程式碼會被刪除,當配置為 ["*.css"] 時,會排除所有 css 檔案不做 Tree Shaking 處理。

Code Splitting

程式碼分割,顧名思義就是把打包好的程式碼的進行分割。

看一個場景:

import { forEach } from "lodash-es";

forEach([1,2,3],(item)=>{
  console.log(item);
})

執行打包命令:

我們發現 lodash 也被打包進 bundle.js 了。

在實際開發中,我們可能會使用多種類庫共同工作,如果都打包到 bundle.js 中,那麼這個檔案勢必會非常大!

還有另外一個問題就是,我們打包的靜態檔案都會新增相應的 hash 值,如下配置:

output: {
  filename: '[hash]_bundle.js', // 打包出的檔案類似:07b62441b18e3aaa6c93_bundle.js
  path: path.resolve(__dirname,'dist')
}

這麼做的目的想必大家都知道,就是瀏覽器會對同一個名字的靜態資源進行快取,假設我們不新增 hash 值,但是線上又發現了 bug,我再次打包後把靜態資源更新到伺服器後,使用者的瀏覽器由於有快取是不會立馬顯示最新效果的,而需要手動去清空快取。

一般外部引入的類庫檔案是不會改變的,而我們的業務程式碼是會經常變動的。我們把會變動的和不變動的程式碼都打包到一起,顯然是不合理的,至少會造成當我們重新打包後,使用者需要載入全部的程式碼。

假如我們做了程式碼分割再配合瀏覽器的快取機制,使用者網站只需要載入更新後的業務程式碼,而類庫的程式碼則不需要重新載入。

以上就是我們需要做程式碼分割的理由。接下來我們看看 webpack 中可以如何進行程式碼分割配置。

SplitChunksPlugin

它的配置應該算是 webpack 外掛中比較複雜的配置了而且又非常重要,因此本文會詳細講解它的核心配置的含義。

我們有以下兩種方式引入一個第三方模組:

同步方式:

import { forEach } from "lodash";
import $ from "jquery";

$(function () {
  forEach([1,2,3],(item)=>{
    console.log(item);
  })
});

非同步方式:

import("lodash").then(({default:_})=>{
  console.log(_.join(["a","b"],"-"));
})

SplitChunksPlugin 外掛已經提供了一套開箱即用的預設配置,讓我們可以快速對以上兩種模組引入方式進行程式碼分割打包優化。下面我們來分析下它的預設配置的意思:

  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      minRemainingSize: 0,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 6,
      maxInitialRequests: 4,
      automaticNameDelimiter: '~',
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }

chunks

  • async 非同步模組生效
  • initial 同步模組生效
  • all 非同步同步都生效

minSize

chunk 檔案最小打包尺碼,例如這裡預設設定是 30000 kb ,假設我們要打包的庫小於 30000kb 則不會進行分模組打包。

maxSize

最大打包尺寸,假設 lodash1MB ,這裡設定為 500KBwebpack 會嘗試把 lodash 拆分成2個檔案,但其實 lodash 這種類庫是不好做拆分的,所以最終結果是一樣的,只會打出一個包。

minChunks

一個模組被用了多少次才對它進行程式碼分割。

maxAsyncRequests

最多載入的 chunk 數量

maxInitialRequests

入口檔案做程式碼分割的最大數量

automaticNameDelimiter

檔名的連線符

name

設定為 true 時,cacheGroups 中的 filename 才能生效

cacheGroups

快取組,該物件裡面的 defaultVendorsdefault 相當於兩條模組快取陣列。
一般是同步引入的模組,命中該快取策略就把該模組 push 到該陣列中,最後合併輸出一個 chunk

快取策略是這樣配置的:

cacheGroups: {
  vendors: {
    chunks: 'initial', // 只針對同步模組
    test: /[\\/]node_modules[\\/]/, // 對同步程式碼進行打包時,會先判斷是否在node_modules下面
    priority: -10, // 打包一個模組有可能既符合vendors的規則也符合default的規則,這個時候根據priority的來判斷選擇哪個值越大優先順序越高
    filename: '[name].chunk.js' // 輸出的檔名
  },
  default: {
    minChunks: 2, // 當模組被使用了兩次
    priority: -20, // 表示許可權值
    reuseExistingChunk: true // 會去檢查迴圈引用,避免打包一些無用的模組進來
  }
}

同步模組打包:

import { forEach } from "lodash";
import $ from "jquery";

$(function () {
  forEach([1,2,3],(item)=>{
    console.log(item);
  })
});

分析:

  • lodash 模組命中 vendors 策略,推入 vendors 策略快取組中;
  • jquery 模組同樣命中 vendors 策略,推入 vendors 策略快取組中;
  • 沒有其它模組了,因此合併輸出一個檔名字 vendors~main.chunk.js ,其中 verdors 是策略的名字,~ 波浪線是 automaticNameDelimiter 的配置, mainentry 入口檔案的名字, chunk.jsfilename 中設定的字尾。

非同步模組打包:

import(/* webpackChunkName: "lodash" */"lodash").then(({default:_})=>{
  console.log(_.join(["a","b"],"-"));
})

import(/* webpackChunkName: "jquery" */"jquery").then(({default:$})=>{
  $(function () {
    console.log("jquery 已經載入完成");
  })
})

分析:

  • 首先模組為了滿足懶載入需求會根據魔法註釋 webpackChunkName 打包成單獨的模組如 jquery.bundle.jslodash.bundle.js
  • 同樣它會去 cacheGroups 中查詢是否匹配相應的策略,此時發現 vendors 匹配不了, default 策略可以匹配,但是 default 中有一個配置是 reuseExistingChunk: true 表示會去已經打包好模組中查詢,如果已經被打包了就輸出。把它改為 false 後,則會把 jquery.bundle.js 根據策略重新命名為 default~jquery.bundle.js 由於它是非同步載入的,首頁兩個模組不會被合併,分別輸出。

cacheGroups 中的策略可以根據專案自行新增,因此而且 webpack 提供了各種回撥方法使得配置更加靈活。

CSS 檔案程式碼分割

隨著專案的增大 css 檔案是非常的多,如果都打包到 js 中,勢必是的 js 檔案過於臃腫,影響載入速度,因此我們要把 css 分離打包。

  • MiniCssExtractPlugin 它會幫助我們建立一個新的 css 檔案
  • OptimizeCSSAssetsPlugin 它會幫助我們合併壓縮 css 檔案

我們來看看具體配置:

...
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");

module.exports = {
  mode: 'production', // 只有在production的模式下,才會去執行minimizer裡面的配置
  optimization: {
	...
    minimizer: [
      new OptimizeCSSAssetsPlugin({})
    ]
  },
  module:{
    rules:[
      {
        test:/\.css$/,
        use:[
          MiniCssExtractPlugin.loader, // 這裡要使用 MiniCssExtractPlugin 提供的 loader
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
	...
    new MiniCssExtractPlugin({ // 在外掛中初始化MiniCssExtractPlugin,並且配置好獨立出來的CSS檔案的命名規則
      filename: "[name].css",
      chunkFilename: "[id].chunk.css"
    })
  ]
}

還有一個重點要記得,就是生產環境下,我們預設開啟了 Tree Shaking ,因此需要配置 package.json 中的 sideEffects ,否則 css 檔案會被 Tree Shaking 掉。

"sideEffects": ["*.css"] // 所有 .css 的檔案都不進行 tree shaking

配置好後執行打包命令發現可以單獨分離出 css 檔案並且 css 檔案是經過壓縮的。

配置 CSS cacheGroups

splitChunks: {
	...
      cacheGroups: {
        ...
        styles: {
          name: 'styles',
          test: /\.css$/,
          chunks: 'all',
          enforce: true,
        },
        ...
      }
    }

增加一條 styles 策略,這樣打包輸出的名字為 styles.css 就是這條策略的名字 enforce: true 表示 styles 策略忽略 splitChunks 的其它引數配置。

模組懶載入

模組懶載入不需要我們再去做 webpack 的配置,而是使用 ES6 提供的 import() 語法來支援

我們來對比下面兩段業務程式碼:

document.addEventListener("click",()=>{
  import(/* webpackChunkName: "lodash" */"lodash").then(({default:_})=>{
    console.log(_.join(["a","b"],"-"));
  });
},false);
import { join } from "lodash";

document.addEventListener("click",()=>{
  console.log(_.join(["a","b"],"-"));
},false);

第一段程式碼表示:點選 document 後去非同步載入 lodash 模組,當模組載入成功後輸出一個字串。

第二段程式碼表示:進入介面先載入 lodash 模組,當點選頁面後輸出字串。

顯然第一段程式碼是一個非同步載入方式,如果使用者沒有去點選頁面就不必要去載入相應的模組,節省資源。這就是非同步載入,只需要 webpack 配置 babel 支援 ES6 語法即可。

Preload and Prefetch

我們看一張這樣的業務場景:

使用者點選登入,彈出登入框。這個登入彈框模組其實沒有必要在最開始就載入完成的,因為它是屬於互動性質的內容,是必須在使用者看到首頁後才會進行的動作,也就意味著這個模組可以在首頁載入完成之後再去載入。

如何去實現這一的效果呢?瀏覽器給我們提供了資源載入的方式:

<link rel="preload" href="loginModule.js" as="script">
<link rel="prefetch" href="loginModule.js" as="script">
  • preload 會以並行方式開始載入;
  • prefetch 會在首頁模組載入完成之後,再去載入。

實現這樣的效果我們並不需要對 webpack 的配置做任何改動,依然是利用 ES6 提供的 import() 語法配合魔法註釋來實現。

document.addEventListener("click",()=>{
  import(/* webpackPrefetch: true */ /* webpackChunkName: "lodash" */ "lodash").then(({default:_})=>{
    console.log(_.join(["a","b"],"-"));
  });
},false);

執行打包命令後檢視瀏覽器控制檯:

檔案快取策略

我們打包的靜態資原始檔是要釋出到伺服器上的,例如靜態資源名字為 main.js ,此時如果線上有一個 bug,我們肯定是立即修復,然後立即打包並把靜態資源更新到伺服器上,如果不更改檔名,由於瀏覽器的快取問題,使用者是沒有辦法立馬看到效果的,因此我們可以給檔名新增 hash 的配置

output: { // 出口檔案
  publicPath:"",
  filename: '[name].[hash].js',
  chunkFilename:'[name].[hash].js',
  path: path.resolve(__dirname,'dist')
},

業務程式碼:

import { forEach } from "lodash";
import $ from "jquery";

$(function () {
  forEach([1,2,3],(item)=>{
    console.log(item);
  })
});

打包之後會輸出兩個 ja 檔案

  • main.[hash].js 的入口檔案
  • vendors~main.[hash].jschunk 檔案

它們公用同一個 hash 值,此時當我修改了業務程式碼:

$(function () {
  forEach([1,2,3,4,5,6],(item)=>{
    console.log(item);
  })
});

業務程式碼變了,但是我們引入的第三方庫是沒有任何改變的,當再次執行打包,兩類檔案的 hash 值都改變了,此時我們部署到伺服器,使用者瀏覽器的確可以重新載入並且立馬看到效果,但是使用者不應該重新載入第三庫的程式碼呀,這些可是沒有變化的。此時我們就應該使用 webpack 提供的 [contenthash] 配置,它代表的意思是,只有內容改變的模組檔案 hash 值會變化,內容不改變的檔案 hash 值保持原樣

output: {
  publicPath:"",
  filename: '[name].[contenthash].js',
  chunkFilename:'[name].[contenthash].js',
  path: path.resolve(__dirname,'dist')
}

開發環境與生成環境

webpack 配置中提供了 mode 屬性配置開發環境與生產環境,我們來總結這兩個環境它們在工程配置上有什麼區別:

功能 \ 環境 Develoment(開發) Production(生產)
程式碼壓縮 不壓縮(方便除錯) 壓縮(減小程式碼體積)
Tree Shaking 預設不開啟 預設開啟
Source Map cheap-module-eval-source-map cheap-module-source-map
webpackDevServer(本地服務) 需要開啟 不需要
HMR(熱更新) 需要配置 不需要

正常我們去編寫 webpack 配置時,會分檔案進行配置的,因為生產環境和開發環境差異還是非常大的。

配置檔案分離思路:

  1. 提取一個公共配置,例如 js 處理,css 處理,圖片等資源的處理,在開發環境和生產環境都是一樣的;
  2. 單獨配置一個開發環境和生產環境配置,然後通過 webpack-merge 合併公共配置:
const webpack = require('webpack');
const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common,{
  mode: 'development',
  ...
});

配置全域性變數

plugins: [
  ...
  new webpack.ProvidePlugin({
    $:"jquery",
    _:"loadsh"
  })
]

配置好了 $_ 的全域性變數後,我們在後續編寫模組時可以不需要引入而直接使用:

export function ui (){
    $('body').css('background','green');
}

使用環境變數

package.json

 "scripts": {
    "dev-build": "webpack --env.development --config webpack.common.js",
    "dev": "webpack-dev-server --env.development --config webpack.common.js",
    "build": "webpack --env.production --config webpack.common.js"
  },

增加了: --env.development--env.production

webpack.common.js

module.exports = (env)=>{
  console.log(env); // {development:true} || {production:true}
  if(env && env.production){
    return merge(commonConfig,prodConfig);
  }else{
    return merge(commonConfig,devConfig);
  }
}

檢視具體配置程式碼

小結

通過本文的學習並且自己能動手實踐一遍的話,相信對於 webpack 的基礎配置會有一個更加全面的瞭解,併為之後學習如何優化以及 webpack 原理打好堅實的基礎。

本文所有程式碼託管地址

喜歡本文請點個贊把~