webpack面試學習筆記

2020-08-14 21:08:15

最近開始學習webpack,所謂好記性不如爛筆頭,現在開始日常筆記。

這次打算換個方式記筆記,我會以各種webpack問題爲切入點進行記錄。

1. 什麼是webpack

webpack是一個對JS模組進行打包的開源工具,其最核心的功能是解決模組之間的依賴,把各個模組按照特定的規則和順序組織在一起,最終合併爲一個或多個JS檔案。這個過程就叫模組打包

2.模組打包原理是什麼

2.1 webpack打包結果

回答這個問題之前,首先看一個簡單的webpack打包結果(bundle),看看它是如何將有依賴關係的模組串聯一起的:

// 最外層立即執行匿名函數。用來包裹整個bundle,並構成自己的作用域
 (function(modules) { 
 	// 用於模組快取。被載入過的模組儲存到這個物件裏面
 	var installedModules = {};
 	// 執行入口模組的載入
 	function __webpack_require__(moduleId) {
 		// 程式碼部分
 }
 	// 執行入口模組載入 ./src/index.js是我們入口檔案
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
 ({
 // 以下爲打包的模組,它是以Key-value的形式儲存被打包的模組,Key是模組id,value是匿名函數包裹的模組實體
 "./src/calcute.js":
 (function(module, exports) {
    // 模組的內容
}),

 "./src/index.js":
 (function(module, exports, __webpack_require__) {
   // 打包入口
    const add = __webpack_require__(/*! ./calcute */ "./src/calcute.js").add
    const count = __webpack_require__(/*! ./calcute */ "./src/calcute.js").count
})
});

2.2 bundle在瀏覽器中執行的順序

接着瞭解一下webpack打包結果(bundle)是如何在瀏覽器中執行的

1.在最外層的匿名函數會初始化瀏覽器執行的環境,爲模組的載入和執行做一些準備工作。

2.載入入口模組,瀏覽器會從入口模組開始,一個bundle只有一個入口模組。

3.執行模組程式碼,如果執行到了module.exports則記錄下模組的導出值;如果中間有_webpack_require_函數,需要引入其他模組,則會暫時交出執行權,進入載入其他模組的邏輯。

4.在_webpack_require_函數中會判斷即將載入的模組是否
載入過,如果載入過,這直接去installedModules 取值,否則回到第三步,執行該模組的程式碼來獲取導出值。

5.所有依賴的模組執行完畢之後,最後執行權回到入口模組。當入口模組的程式碼執行到結果,也就代表整個bundle執行結束。

2.3 webpack打包原理

通過了解整個流程之後,就可以開始回答這個小結的問題,webpack打包原理是什麼?

答:webpack爲每個模組創造了一個可以匯入和導出模組的環境,但本質上沒有修改程式碼的執行邏輯,因此程式碼執行的順序和模組載入的順序完全一致。

3.CommonJS(cjs)和ES6Module(esm)的區別

3.1 CommonJS是什麼

CommonJS是包含模組、檔案、IO、控制檯在內的一系列標準
CommonJS中規定每個檔案是一個模組,會形成一個只屬於模組自身的作用域,所有的變數及函數只能自己存取,對外不可見。

3.1.1 導出方式

導出是一個模組向外暴露自身的唯一方式。使用module.exports方式導出模組中的內容,如:

// calcute.js
module.exports = {
    name:'commonJS',
    add(a,b){return a+b}
}

或者爲了書寫方便,也可用另一種寫法:

// calcute.js
exports.name = 'commonJS'
exports.add = function(a,b)

3.1.2 匯入方式

在CJS中使用require的方式進行模組匯入,如:

// index.js
const add = require('./calcute').add
const sum = add(2,3)
console.log(sum)

3.2 ES6 Module是什麼

ES6 Module也是將每個檔案作爲一個模組,不同區別在於匯入和導出的方式。

3.2.1 導出方式

esm用export的命令導出模組。export有兩種形式:

  • 命名導出

  • 預設導出

  • 1.命名導出

命名有兩種寫法,如下:
第一種:將變數宣告和導出寫在一行

// demo.js
export const a = 1
export const b = {
    name: '1'
}

第二種:先進行變數宣告,然後再用同一個export語句導出

//demo.js
const a = 1
const b = {name:'1'}
export {a,b}
  • 2.預設導出
    命名導出可導出多個模組,但是預設的只能有一個
//demo.js
export default {
  a:1,
  b:{name:'1'}
}

3.2.2 匯入方式

esm使用import的方式進行引入,如:

import { a, b } from './demo1'
console.log(a, b)

3.3 cjs和esm區別

3.3.1 動態與靜態

cjs:模組依賴關係的建立發生在程式碼執行階段(動態);
esm:模組依賴關係的建立發生在程式碼編譯階段(靜態);

這也是二者最本質的區別。
cjs中require的模組路徑可以動態指定,支援傳入一個表達式,甚至可以用if語句判斷是否載入某個模組;

但是esm卻不行,它的匯入和導出語句必須爲宣告式,而且得位於模組的頂層作用域,不能像cjs那樣放在if中。

3.3.2 值拷貝和動態對映

cjs:匯入一個模組時,cjs獲取的是一份導出值的拷貝;
esm:是值的動態對映,並且對映爲只讀的;

可以看一個例子:

// calcute.js
var count = 0
module.exports = {
    name:'commonJS',
    count: count,
    add(a,b){
        count +=1
        return a+b
    }
}
//index.js
const add = require('./calcute').add
const count = require('./calcute').count
console.log(count) //0
add(2,3)
console.log(count) //0

使用cjs的方式,輸出count的兩次結果都是0,從這個例子可以看出cjs是值拷貝,當匯入的模組(calcute.js)發生變化時,使用這個模組的檔案(index.js)是不會有任何影響,但這個情況有時並不是我們想要的。

接着看一下esm的參照:

// calcute.js
const count = 0
const add = function(a,b){
    count +=1
    return a+b
}
export default {count,add}
// index.js
import { add, count } from './calcute6'
console.log(count) //0
add(2,3)
console.log(count) //1

使用esm方法的兩次輸出分別爲0,1,從這個例子中可以看出esm匯入的變數其實是對原有值的動態對映。index中的count對calcute中的count是實時反映。

另外,從webpack編譯出來的結果也可以看出來他們的區別:

再看另一個簡單的例子
首先是cjs的寫法:

// demo.js
exports.a = 1
exports.b = {name:'haha'}
// index.js
const {a,b} = require('./demo') 
console.log(a,b)

這就是一個很簡單的寫法,這時候看webpack打包出來的結果(下面 下麪的結果是提取出來的部分,重點關注使用的兩個模組):

 (function(modules) { 
 	// 執行入口的檔案
 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })
 // 模組打包部分
({
 "./src/demo.js": (function(module, exports) {
    exports.a = 1
    exports.b = {name:'haha'}
 }),

 "./src/index.js":
 (function(module, exports, __webpack_require__) {
   const {a,b} = __webpack_require__(/*! ./demo */ "./src/demo.js") 
   console.log(a,b)
 })
});

從這個模組打包模組可以看出來,模組打包的內容就是原檔案的內容,基本就是將內容進行的賦值,也就是上面說的一份導出值的拷貝

接着再看esm的寫法:

// demo6.js
const a = 1;
const b = {name:'haha'};
export {a,b}
// index6.js
import {a,b} from './demo6'
console.log(a,b)

採用ES6寫法的程式碼和上面的CommonJS的內容近乎一致,接下來看看打包出來的結果:

 (function(modules) { // webpackBootstrap
 	// Load entry module and return exports
 	return __webpack_require__(__webpack_require__.s = "./src/index6.js");
 })
({
 "./src/demo6.js":
 (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return a; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() { return b; });
  const a = 1;
  const b = {name:'haha'};
}),

"./src/index6.js":
(function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _demo6__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./demo6 */ "./src/demo6.js");

console.log(_demo6__WEBPACK_IMPORTED_MODULE_0__["a"],_demo6__WEBPACK_IMPORTED_MODULE_0__["b"])

})
});

這個打包的結果可以明顯看出來,在處理模組時候就有不同了,在esm中模組不在是將內容進行的賦值,而是使用了_demo6__WEBPACK_IMPORTED_MODULE_0__這種物件來獲取值,這樣就和原模組建立了一種對映接,原先模組的內容變換,_demo6__WEBPACK_IMPORTED_MODULE_0__就會進行變換,這樣引入該模組檔案中的內容也就會同樣進行改變,實現了動態對映。

3.3.3 回圈依賴

回圈依賴是指模組A依賴模組B,同時模組B依賴模組A,如下例子:

// a.js
import {foo} from './b.js'
foo()

// b.js
import {bar} from 'a.js'
bar()

對於這種情況首先要說肯定不支援這麼寫法,這會帶來很大的麻煩。
另外對於這種情況,cjs是沒有辦法得到預想中的結果,但是使用esm,利用它的動態對映的特性可以支援回圈依賴,但是開發者必須確保匯入的值被使用時已經設定好正確的導出值。

4.webpack如何確保被載入過的模組不會再次執行模組程式碼

在載入模組過程中:

  • 如果該模組第一次被載入,webpack首先會執行該模組,然後導出內容;
  • 如果該模組曾被載入過,這時該模組的程式碼不會再次執行,而是直接導出上次執行後得到的結果。

接着看一下打包的結果,就會了解它的快取原理:

// 最外層立即執行匿名函數
 (function(modules) { 
 	// 用於模組快取。被載入過的模組儲存到這個物件裏面
 	var installedModules = {};

 	// The require function
 	function __webpack_require__(moduleId) {

 		// 檢查快取中是否有這個模組
 		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;
 	}
 }

從上面程式碼可以看到,在模組中會有一個installedModules 物件用來存放模組的資訊,在這個物件中有個屬性爲loaded即爲l,用於記錄該模組是否被載入過。它的預設值爲false,當這個模組第一次被載入和執行過後會置爲true,後面再次載入時候檢查到installedModules有這個模組資訊時,就不會再次執行模組程式碼了。

5.webpack常見的loader

5.1 loader的定義

前處理器(loader),它賦予了webpack可處理不同資源型別的能力。

爲了更好說明loader是如何工作的,看一下loader的原始碼結構:

module.exports = function loader (content,map,meta) {
    var callback = this.async()
    var result = handler(content,map,meta)
    callback(
        null,                //error
        result.content,     //轉換後的內容
        result.map,         //轉換後的source-map
        result.meta         //轉換後的AST
    )
}

從程式碼上可以看出,每個loader的本質都是一個函數,在該函數中對接受到的內容進行轉換,然後返迴轉換後的結果,用公式表達loader的本質爲以下形式:
output = loader(input)

5.2 常用的loader

  1. babel-loader:用來處理ES6,將它編譯爲ES5
  2. vue-loader:用來載入 Vue.js 單檔案元件
  3. file-loader:處理png,jpg,gif這類圖片資源時使用,把檔案輸出到一個資料夾中,並返回pubilcPath,在程式碼中通過相對 URL 去參照輸出的檔案
  4. url-loader:與 file-loader 類似,區別是使用者可以設定一個閾值,大於閾值時返回其 publicPath,小於閾值時返迴檔案 base64 形式編碼 (處理圖片和字型)
  5. css-loader:載入 CSS,支援模組化、壓縮、檔案匯入等特性
  6. style-loader:把 CSS 程式碼注入到 JavaScript 中,通過 DOM 操作去載入 CSS
  7. eslint-loader:通過 ESLint 檢查 JavaScript 程式碼
  8. i18n-loader: 國際化
  9. ts-loader: 將 TypeScript 轉換成 JavaScript
  10. html-loader: 將HTML檔案轉化爲字串並進行格式化
  11. handlebars-loader: 將 Handlebars 模版編譯成函數並返回

6.webpack常見的plugin

6.1 Plugin的定義

webpack的plugins是用於接收一個外掛陣列,我們可以使用webpack內部提供的一些外掛,也可以載入外部外掛。

6.2 常用plugin

  1. mini-css-extract-plugin: 分離樣式檔案,CSS 提取爲獨立檔案,支援按需載入 (替代extract-text-webpack-plugin)
  2. ignore-plugin:忽略部分檔案
  3. html-webpack-plugin:簡化 HTML 檔案建立 (依賴於 html-loader)
  4. web-webpack-plugin:可方便地爲單頁應用輸出 HTML,比 html-webpack-plugin 好用
  5. webpack-parallel-uglify-plugin: 多進程執行程式碼壓縮,提升構建速度
  6. webpack-bundle-analyzer: 視覺化 Webpack 輸出檔案的體積 (業務元件、依賴第三方模組)

7.loader和plugin的區別

loader的存在是做翻譯官使用的,因爲webpack只認JS,所以需要前處理器對其他型別的資源進行轉譯。loader的本質就是一個函數,在該函數中對接收的內容進行轉換。

plugin就是外掛,用來擴充套件webpack的功能,在 Webpack 執行的生命週期中會廣播出許多事件,Plugin 可以監聽這些事件,在合適的時機通過 Webpack 提供的 API 改變輸出結果。

Loader 在 module.rules 中設定,也就是說他作爲模組的解析規則而存在,型別爲陣列

Plugin 在 plugins 中單獨設定,型別爲陣列,每一項是一個 plugin 的範例,參數都通過建構函式傳入

8.source map是什麼?生產環境怎麼用?

8.1 source map是什麼

source map 是將編譯、打包、壓縮後的程式碼對映回原始碼的過程。
經過webpack打包壓縮後的程式碼不具備良好的可讀性,這樣程式碼出現了問題就不好排查,而有了source map,再加上dev-tools,想要偵錯原始碼就非常容易了。

map檔案只要不開啓開發者工具,瀏覽器是不會載入的。

8.2 生產環境怎麼用?

爲了他提升source map的安全性,線上環境一般有三種處理方案:

hidden-source-map:不會再bundel檔案中新增對於Map檔案的參照,可以藉助第三方錯誤監控平臺 Sentry 使用map檔案

nosources-source-map:只會顯示具體行數以及檢視原始碼的錯誤棧。安全性比 sourcemap 高

sourcemap:通過 nginx 設定將 .map 檔案只對白名單開放(公司內網)

9. Webpack 的熱更新原理(必考點)

9.1 什麼是HMR

webpack熱更新又稱爲模組熱替換(Hot Module Replacement, HMR),這個機制 機製可以讓程式碼在網頁不重新整理的前提下得到最新的改動,甚至不需要重新發起請求就能看到更新後的效果。

使用HMR是有些前提的,webpack本身的命令列不支援HMR,我們需要確保專案是基於webpack-dev-server或者webpack-dev-middle進行開發的。

9.2 HMR的原理

在本地開發環境下,瀏覽器是用戶端,webpack-dev-server(WDS)相當於我們的伺服器端。

HMR的核心就是用戶端從伺服器端拉取更新後的檔案,更準確說是拉取的不是整個資原始檔,而是chunk diff(即chunk需要更新的部分)

接下來說一下HMR如何知道更改什麼部分:

第一步就是瀏覽器什麼時候去拉取這些更新。這需要WDS對本地原始檔進行監聽。實際上WDS與瀏覽器之間維護了一個websocket,當本地資源發生變化時WDS會向瀏覽器推播更新事件,並帶上這個構建的hash,讓用戶端與上一次資源進行對比。通過hash的比對可以防止冗餘更新的出現。

第二步知道拉取什麼。現在用戶端已經知道新的構建結果和當前的有了差別,就會向WDS發起Ajax請求來獲取更改內容(檔案列表、hash),該結果返回用戶端,這樣用戶端就可再藉助這些資訊繼續向WDS發起jsonp請求獲取該chunk的增量更新。

用戶端獲取到了chunk的更新之後如何處理?哪些狀態該保留?哪些又需要更新?這個就不屬於webpack的工作了,但它提供了相關 API 以供開發者針對自身場景進行處理。

10.如何優化 Webpack 的構建速度?

優化構建速度的方法有很多,就看要講多久,簡單說幾種方法:

  1. 使用高版本的webpack: 高版本支援的功能更多,進行了更多優化

  2. 使用多進程構建:HappyPack(不維護了)、thread-loader

  3. 壓縮程式碼: 通過 mini-css-extract-plugin 提取 Chunk 中的 CSS 程式碼到單獨檔案

  4. 縮小打包作用域:

    • 使用include或者exclude指定打包的內容
    • 使用noParse 對完全不需要解析的庫進行忽略,雖然不會解析,但仍會打包到bundle中
    • 使用IgnorePlugin 完全排除一些模組,被排除的模組即使被參照了,也不會被打包進資原始檔中
  5. 使用 html-webpack-externals-plugin,將基礎包通過 CDN 引入,不打入 bundle 中

  6. 使用 SplitChunksPlugin(webpack4+)進行程式碼分割

  7. 使用DllPlugin:借鑑動態鏈接庫的思路,對於第三方模組或者一些不常變化的模組,將他們預先編譯和打包,然後在專案實際構建中直接取用。

  8. 利用tree shaking:
    1 tree shaking功能可以在打包過程中檢測「死程式碼」,對這部分程式碼進行標記,並在資源壓縮時將他們從最終的bundle中去掉。
    2 tree shaking只對ES6 Module生效,所以要多用ES6 Module模組
    3 禁用babel-loader 的模組依賴解析,否則 Webpack 接收到的就都是轉換過的 CommonJS 形式的模組,無法進行 tree-shaking
    4 使用壓縮工具去除死程式碼:如 terser-webpack-plugin

11.什麼是程式碼分割(或者程式碼分片)

11.1 程式碼分片意義

程式碼分片是webpack作爲打包工具所特有的一項技術,通過這項技術我們可以把程式碼按照特定的形式進行拆分,使使用者不必一次全部載入,而是按需載入。

11.2 分片方法

webpack提供了外掛進行分片操作,CommonChunkPlugin是webpack 4之前的內部外掛,SplitChunks是webpack 4之後的外掛,比CommonChunkPlugin功能更加強大,還簡單易用。

除了使用外掛,還可以進行資源的非同步載入。當模組數量過多,資源體積過大時,可以把一些暫時用不到的模組延遲載入,這樣頁面初次渲染的時候使用者下載的資源也會盡可能的小,後續的模組等到適當實際再去出發載入。

使用這些方法進行程式碼分片,可以有效地縮小資源體積,同時更好的利用快取,提高使用者體驗。

12.webpack與grunt、gulp的不同

三者都是前端構建工具
grunt 和 gulp 是基於任務和流的。找到一個(或一類)檔案,對其做一系列鏈式操作,更新流上的數據, 整條鏈式操作構成了一個任務,多個任務就構成了整個web的構建流程.其缺點是整合度不高,要寫很多設定後纔可以用,無法做到開箱即用。

webpack 是基於入口的。webpack 會自動地遞回解析入口所需要載入的所有資原始檔,然後用不同的 Loader 來處理不同的檔案,用 Plugin 來擴充套件 webpack 功能

從構建思路來說 gulp和grunt需要開發者將整個前端構建過程拆分成多個Task,併合理控制所有Task的呼叫關係

webpack需要開發者找到入口,並需要清楚對於不同的資源應該使用什麼Loader做何種解析和加工