webpack 快速入門 系列 —— 初步認識 webpack

彭加李發表於2021-05-11

初步認識 webpack

webpack 是一種構建工具

webpack 是構建工具中的一種。

所謂構建,就是將資源轉成瀏覽器可以識別的。比如我們用 less、es6 寫程式碼,瀏覽器不能識別 less,也不支援 es6 的某些語法,這時我們可以通過構建工具將原始碼轉成瀏覽器可以識別的 css 和 js。

webpack 是一種模組化解決方案

以前,前端只需要寫幾個html、css、js就能完成工作,現在前端做的專案更加複雜,在效能、體驗、開發效率等其他方面,都對我們前端提出了更高的要求。

為了能按質按量的完成老闆交代的任務,我們只能站在巨人的肩膀上,也就是引入第三方模組(或稱為庫、框架、包),然後快速組裝我們的專案。

於是這就出現了一個專案依賴多個模組的場景,只有這些模組能相互通訊,十分融洽的在一起,我們才能集中於一處發力把專案做好。

問題在於這些模組不能很好的相處。如何理解?我們可以簡化上面的場景:現在我們有三個模組,moduleA 要使用 moduleB,moduleB 要使用 moduleC。如果需要我們自己維護這三個模組之間的依賴關係,可能就是有一點點麻煩;如果要維護數十個、上百個模組之間的依賴關係呢,可能就很困難了。

於是就出現了各種模組化解決方案。有人曾說 jQuery 之後前端最偉大的發明就是 requirejs,它是一個模組化開發的庫;而 webpack 就是一種優秀的模組化解決方案。

webpack 官方定義

webpack 是一個現代 JavaScript 應用程式的靜態模組打包工具 —— 官方定義

模組才是 webpack 的核心,所以下文先談談模組,再分析 webpack 模組化解決方案的原理。

淺談模組

早期 js 是沒有模組的概念,都是全域性作用域,我們可能會這麼寫程式碼:

// a.js
var name = 'ph';
var age = '18';

// b.js
var name = 'lj';
var age = '21';

如果 html 頁面同時引入 a.js 和 b.js,變數 name 和 age 就會相互覆蓋。

為了避免覆蓋,我們使用名稱空間,可能會這麼寫:

// a.js
var nameSpaceA = {
  name: 'ph',
  age: '18'
}

// b.js
var nameSpaceB = {
  name: 'lj',
  age: '21'
}

雖然不會相互覆蓋,但模組內部的變數沒有得到保護,a 模組仍然可以更改 b 模組的變數。於是我們使用函式作用域:

// a.js
var nameSpaceA = (function(){
  var name = 'ph';
  var age = '18';
  return {
    name: name,
    age: age,
  }
}())

// b.js
var nameSpaceB = (function(){
  var name = 'lj';
  var age = '21';
  return {
    name: name,
    age: age,
  }
}())

這裡使用了函式作用域、立即執行函式和名稱空間,這就是早期模組的實現方式。更通俗的做法,例如 jQuery 會這麼做:

// a.js
(function(window){
  var name = 'ph';
  var age = '18';
  window.nameSpaceA = {
    name: name,
    age: age,
  }
}(window))

之後又出現了各種模組的規範,比如 AMD,代表實現是 requirejs、CommonJS,它的流行得益於 Node 採用了這種方式等等。

終於 es6 帶著官方的模組語法(import和export)來了。

模組化

模組化就是將複雜的系統拆分到不同的模組來編寫。帶來的好處有:

  • 重用。將一些通用的功能提取出來作為模組,需要使用該功能的地方只需要通過特定方式引入即可。
  • 解耦。將一個1萬行的檔案(模組)分解成10個1千行的檔案,模組之間通過暴露的介面進行通訊。
  • 作用域封裝。模組之間不會相互影響。比如2個模組都有變數count,變數count不會被對方模組影響。

webpack 模組化解決方案的原理

下面我們通過一個專案,從程式碼層面上看一下 webpack 模組化解決方案的原理。

首先初始化專案,並安裝依賴包。

// 建立專案
> mkdir webpack-example1
// 進入專案目錄。有的控制檯可能是: cd webpack-example1
> cd .\webpack-example1\
// 使用 npm 初始化專案(會自動生成 package.json)
> npm init -y
// 安裝依賴包。雖然現在有 webpack 5,但筆者使用的是 webpack 4
// 因為有些構建功能所需要的 npm 包暫時不支援 webpack 5。
> npm i -D webpack@4
// 不安裝 webpack-cli,執行時會報錯,會提示需要安裝 webpack-cli
> npm i -D webpack-cli@3

接著在 webpack-example1/src 資料夾下建立三個模組,模組之間的關係是 index 依賴 b,b 依賴 c,內容如下:

// index.js
import './b.js'
console.log('moduleA')

// b.js
import './c.js'
console.log('moduleB')

// c.js
console.log('moduleC')

執行 npx webpack,會將我們的指令碼 src/index.js 作為入口起點,然後會生成 dist/main.js:

// webpack 預設是生產模式,這裡通過引數指定為開發模式
webpack-example1> npx webpack --mode development
Hash: cb88f1c065314d7a6a2c
Version: webpack 4.46.0
Time: 73ms
Built at: 2021-05-10 4:06:03 ├F10: PM┤
  Asset      Size  Chunks             Chunk Names
main.js  4.81 KiB    main  [emitted]  main
Entrypoint main = main.js
[./src/b.js] 39 bytes {main} [built]
[./src/c.js] 22 bytes {main} [built]
[./src/index.js] 39 bytes {main} [built]

Tip:Node 8.2/npm 5.2.0 以上版本提供的 npx 命令,可以執行 webpack 二進位制檔案(即 ./node_modules/.bin/webpack)

webpack-example1> .\node_modules\.bin\webpack
// 等於
webpack-example1> npx webpack

生成的 dist/main.js 就是打包後的檔案(現在無需詳細的看 main.js 的內容):

/******/ (function(modules) { // webpackBootstrap
/******/ 	// The module cache
/******/ 	var installedModules = {};
/******/
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/
/******/ 		// Check if module is in cache
/******/ 		if(installedModules[moduleId]) {
/******/ 			return installedModules[moduleId].exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = installedModules[moduleId] = {
/******/ 			i: moduleId,
/******/ 			l: false,
/******/ 			exports: {}
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ 		// Flag the module as loaded
/******/ 		module.l = true;
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/
/******/
/******/ 	// expose the modules object (__webpack_modules__)
/******/ 	__webpack_require__.m = modules;
/******/
/******/ 	// expose the module cache
/******/ 	__webpack_require__.c = installedModules;
/******/
/******/ 	// define getter function for harmony exports
/******/ 	__webpack_require__.d = function(exports, name, getter) {
/******/ 		if(!__webpack_require__.o(exports, name)) {
/******/ 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ 		}
/******/ 	};
/******/
/******/ 	// define __esModule on exports
/******/ 	__webpack_require__.r = function(exports) {
/******/ 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 		}
/******/ 		Object.defineProperty(exports, '__esModule', { value: true });
/******/ 	};
/******/
/******/ 	// create a fake namespace object
/******/ 	// mode & 1: value is a module id, require it
/******/ 	// mode & 2: merge all properties of value into the ns
/******/ 	// mode & 4: return value when already ns object
/******/ 	// mode & 8|1: behave like require
/******/ 	__webpack_require__.t = function(value, mode) {
/******/ 		if(mode & 1) value = __webpack_require__(value);
/******/ 		if(mode & 8) return value;
/******/ 		if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/ 		var ns = Object.create(null);
/******/ 		__webpack_require__.r(ns);
/******/ 		Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/ 		if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/ 		return ns;
/******/ 	};
/******/
/******/ 	// getDefaultExport function for compatibility with non-harmony modules
/******/ 	__webpack_require__.n = function(module) {
/******/ 		var getter = module && module.__esModule ?
/******/ 			function getDefault() { return module['default']; } :
/******/ 			function getModuleExports() { return module; };
/******/ 		__webpack_require__.d(getter, 'a', getter);
/******/ 		return getter;
/******/ 	};
/******/
/******/ 	// Object.prototype.hasOwnProperty.call
/******/ 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ 	// __webpack_public_path__
/******/ 	__webpack_require__.p = "";
/******/
/******/
/******/ 	// Load entry module and return exports
/******/ 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
/******/ })
/************************************************************************/
/******/ ({

/***/ "./src/b.js":
/*!******************!*\
  !*** ./src/b.js ***!
  \******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./c.js */ \"./src/c.js\");\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_c_js__WEBPACK_IMPORTED_MODULE_0__);\n\r\nconsole.log('moduleB')\n\n//# sourceURL=webpack:///./src/b.js?");

/***/ }),

/***/ "./src/c.js":
/*!******************!*\
  !*** ./src/c.js ***!
  \******************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("console.log('moduleC')\n\n//# sourceURL=webpack:///./src/c.js?");

/***/ }),

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b.js */ \"./src/b.js\");\n\r\nconsole.log('moduleA')\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ })

/******/ });

只需要知道 main.js 與我們的原始碼是等價的。我們可以通過 node 執行 main.js 驗證這個結論:

> node dist/main.js
moduleC
moduleB
moduleA

輸出了三句文案。

Tip:你也可以建立一個 html 頁面,通過 src 引用 dist/main.js,然後在瀏覽器的控制檯下驗證,輸出內容應該也是這三句文案。

接著我們來看一下 webpack 模組化解決方案的原理。在此之前我們先優化一下 main.js,核心程式碼如下:

(function(modules){
    // 模組快取
    var installedModules = {};
    // 定義的 require() 方法,用於載入模組
    // 與 nodejs 中的 require() 類似
    function __webpack_require__(moduleId) {
      // 如果快取中有該模組,直接返回
      if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
      }
          // 建立一個新的模組,並放入快取
      var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
      };
          
      // 執行模組函式
      // 並將 __webpack_require__ 作為引數傳入模組,模組就能呼叫其他模組
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

      // 標記此模組已經被載入
      module.l = true;

      // 返回模組的 exports
      return module.exports;
    }
    ...
    // 載入入口模組
    return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
    // b 模組
    "./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./c.js */ \"./src/c.js\");\n/* harmony import */ var _c_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_c_js__WEBPACK_IMPORTED_MODULE_0__);\n\r\nconsole.log('moduleB')\n\n//# sourceURL=webpack:///./src/b.js?");
    }),
    // c 模組
    "./src/c.js": (function(module, exports) {
        eval("console.log('moduleC')\n\n//# sourceURL=webpack:///./src/c.js?");
    }),
    // index 模組
    "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _b_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b.js */ \"./src/b.js\");\n\r\nconsole.log('moduleA')\n\n//# sourceURL=webpack:///./src/index.js?");
    })
});

很顯然,main.js 是一個立即執行函式。立即執行函式的實參是一個物件,裡面包含了所有的模組,key 可以理解成模組名,value 則是準備就緒的模組。如果模組還需要引入其他模組,比如 index.js 依賴於 b.js,則會有形參 webpack_require

現在我們大致理解了 webpack 模組化解決方案的原理:

  1. 根據入口檔案分析所有依賴的模組,組裝好,封裝到一個物件中
  2. 將封裝好的物件作為引數傳給匿名函式執行
  3. 定義載入模組的方法(webpack_require
  4. 載入並執行入口模組(即入口檔案)
  5. 依次載入執行依賴的其他模組

Tip:webpack 又被稱為打包神器,筆者認為打包就是將多個模組整成一個;你也可以賦予打包其他含義,比如構建。

核心概念

webpack 中的核心概念有:

  • entry。指定 webpack 的入口,可以指定單入口或多入口
  • output。打包後輸出的相關配置,例如指定輸出目錄等
  • mode。開發模式或生產模式
  • loader
  • plugin

前3個比較簡單,loader 和 plugin 單獨介紹

entry、output 和 mode 放在 loader 中一起介紹。

loader

根據 webpack 官方定義,webpack 在沒有特殊配置的情況下,只識別 javascript。但我們的前端除了 javascript,還有 css、圖片等其他資源。所以 webpack 提供了 loader 幫我們解決這個問題。

loader 是檔案載入器,用於對模組的原始碼進行轉換,實現的是檔案的轉義和編譯。例如需要將 es6 轉成 es5,或者需要在 javascript 中引入 css 檔案,就需要使用它。可以將它看作成翻譯官

下面我們就使用 loader 處理 css 檔案。

首先我們得建立 webpack 配置檔案(webpack-example1/webpack.config.js),這樣我們可以通過配置指定 loader、外掛(plugin)等其他功能,更加靈活:

const path = require('path');

module.exports = {
  // 給 webpack 指定入口
  entry: './src/index.js',
  // 輸出
  output: {
    // 檔名
    filename: 'main.js',
    // 指定輸出的路徑。即當前檔案所處目錄的 dist 資料夾
    path: path.resolve(__dirname, 'dist')
  },
  // loader 放這裡
  module: {
    rules: [
      {
        // 匹配所有 .css 結尾的檔案
        test: /\.css$/i,
        // 先經過 css-loader 處理,會將 css 檔案翻譯成 webpack 能識別的
        // 接著用 style-loader 處理,也就是將 css 注入 DOM。
        use: ["style-loader", "css-loader"]
      },
    ]
  },
  // 指定為開發模式。webpack 提供了開發模式和生產模式
  // 如果不指定 mode,打包時會在控制檯提示預設 mode,並預設指定為生產模式
  mode: 'development'
};

Tip:配置檔案參考 webpack v4 使用一個配置檔案css-loader

安裝相關依賴包:

// 特意指定版本,否則可能由於不相容而安裝失敗
> npm i -D css-loader@5 style-loader@2

在 src 下建立 a.css 和 index.html:

// a.css
body{color:red;}

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src='../dist/main.js'></script>
</head>
<body>
    <p>我是紅色嗎</p>
</body>
</html>

設定一個執行 webpack 的快捷方式,需要修改 package.json 檔案,在 npm scripts 中新增一個 npm 命令:

{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    // 新增
    "build": "webpack"
  },
}

執行 webpack 重新打包:

// 自定義命令通過”npm run + 命令“即可執行
> npm run build

最後通過瀏覽器開啟 index.html,就可以看到頁面有紅色文字”我是紅色嗎“。

可能你會疑惑:為什麼要在 index.js 中引入 a.css?其實你通過 c.js 引入 a.css 也是相同效果。

上文我們分析 webpack 原理時,知道 webpack 首先從入口檔案開始,分析所有依賴的模組,最後打包生成一個檔案,生成的這個檔案與我們的原始碼是等價的。所以 a.css 必須要在依賴模組中,否則最終生成的這個檔案就不會包含 a.css。

換句話說,如果我們的資源需要被 webpack 打包處理,那麼該資源就得出現在依賴中。

Tip:webpack 中一切皆模組。webpack 除了能匯入 js 檔案,也能把 css、圖片等其他資源都當作模組處理,只是需要相應的 loader 翻譯一下即可。

plugin

loader 用於轉換某些型別的模組,而外掛則可以用於執行範圍更廣的任務。包括:打包優化,資源管理,注入環境變數。

外掛(plugin)可以幫助使用者直接觸及到編譯過程。plugin 強調一個事件監聽的能力,能在 webpack 內部監聽一些事件,並且能改變一些檔案打包後輸出的結果。

目前我們需要自己建立一個 html 頁面,然後引用打包後的資源,感覺不是很方便,於是我們可以使用 html-webpack-plugin 這個包通過 plugin 簡化這一過程。

首先安裝依賴包 npm i -D html-webpack-plugin@4

接著給 webpack.config.js 增加兩處程式碼:

// 增加 +
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  // +
  plugins: [
    new HtmlWebpackPlugin()
  ]
};

再次打包,會發現 build 資料夾下多出了一個檔案(index.html),內容如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Webpack App</title>
  <meta name="viewport" content="width=device-width, initial-scale=1"></head>
  <body>
  <script src="main.js"></script></body>
</html>

該檔案自動引入打包後的資源(main.js)。瀏覽器訪問這個頁面(build/index.html),發現控制檯正常輸出,但頁面是空白的。

如果我們需要在這個 html 頁面中增加一些內容,比如一句話,可以配置一個模板。

修改 webpack.config.js,指定模板為 src/index.html:

plugins: [
  new HtmlWebpackPlugin({
      // 指定模板
      template: 'src/index.html'
  })
],

修改模板(src/index.html)內容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=`, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <p>請檢視控制檯</p>
</body>
</html>

重新打包後:

> npm run build

> build  
> webpack
// 打包會生成一個hash。以後會使用到。
Hash: 0751d9e63f9e32eac13d
// webpack 的版本是 4.46.0
Version: webpack 4.46.0
// 構建所花費的時間
Time: 396ms
Built at: 2021-05-11 7:56:19 ├F10: PM┤
// 下面3行4列是一個表格
// Asset,打包輸出的資源(index.html 和 main.js)
// Size,輸出資源。 main.js 的大小是 17.3Kb
// Chunks,main [發射]
// Chunk Names,main
     Asset       Size  Chunks             Chunk Names
index.html  307 bytes          [emitted]
   main.js   17.3 KiB    main  [emitted]  main
Entrypoint main = main.js
[./node_modules/css-loader/dist/cjs.js!./src/a.css] 314 bytes {main} [built]
[./src/a.css] 322 bytes {main} [built]
[./src/b.js] 58 bytes {main} [built]
[./src/c.js] 22 bytes {main} [built]
[./src/index.js] 68 bytes {main} [built]
    + 2 hidden modules
Child HtmlWebpackCompiler:
     1 asset
    Entrypoint HtmlWebpackPlugin_0 = __child-HtmlWebpackPlugin_0
    [./node_modules/html-webpack-plugin/lib/loader.js!./src/index.html] 560 bytes {HtmlWebpackPlugin_0} [built]

生成的 html 檔案(dist/index.html)內容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=`, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <p>請檢視控制檯</p>
<script src="main.js"></script></body>
</html>

這樣,重新生成的 html 頁面就以我們的檔案為模板,並自動引入打包後的資源。

webpack-dev-server

webpack-dev-server 提供了一個簡單的 web server,方便我們除錯。

在 loader 這個示例上繼續做如下修改:

// 安裝依賴包
> npm i -D webpack-dev-server@3

// 修改配置檔案 webpack.config.js
module.exports = {
  devServer: {
    // 預設開啟瀏覽器
    open: true,
    // 告訴伺服器從哪個目錄中提供內容
    // serve(服務) 所有來自專案根路徑下 dist/ 目錄的檔案
    contentBase: path.join(__dirname, 'dist'),
    // 開啟壓縮 gzip
    compress: true,
    // 埠號
    port: 9000,
  },
};

// 修改 package.json,增加自定義命令
"scripts": {
  // +
  "dev": "webpack-dev-server"
},

執行 npm run dev 就會預設開啟瀏覽器,頁面就是 src/index.html。

啟動 devServer 不會打包輸出產物,也就是不會生成 dist 目錄,而是存在於記憶體中。

修改 src 中的 html、js,儲存後瀏覽器會自動重新整理並顯示最新效果,十分方便。

:之前執行 npm run dev 報錯,後來將 webpack-cli 從版本4改成版本3,然後就能正常啟動服務了。

學習建議

不要執著於 API 和命令 —— API 當然也是需要看的哈。

因為 webpack 迭代速度比較快,api 也會相應的更新,以後 webpack 配置也會更簡單好用。

相關文章