[前端進階課] 構建自己的 webpack 知識體系
阿新 • • 發佈:2020-05-21
# webpack
> webpack 最出色的功能之一就是,除了 `JavaScript`,還可以通過 `loader` 引入**任何其他型別的檔案**。
### Webpack 核心概念:
* `Entry`(入口):Webpack 執行構建的第一步將從 Entry 開始,可抽象成輸入。
* `Output`(出口):指示 webpack 如何去輸出、以及在哪裡輸出
* `Module`(模組):在 Webpack 裡一切皆模組,一個模組對應著一個檔案。Webpack 會從配置的 Entry 開始遞迴找出所有依賴的模組。
* `Chunk`(程式碼塊):一個 Chunk **由多個模組組合而成**,用於程式碼合併與分割。
* `Loader`(模組轉換器):用於把模組原內容按照需求轉換成新內容。
* `Plugin`(擴充套件外掛):在 Webpack 構建流程中的特定時機會廣播出對應的事件,外掛可以監聽這些事件,並改變輸出結果
### 配置項
1. 入口 Entry
```js
entry: {
a: "./app/entry-a",
b: ["./app/entry-b1", "./app/entry-b2"]
},
```
多入口可以通過 `HtmlWebpackPlugin` 分開注入
```js
plugins: [
new HtmlWebpackPlugin({
chunks: ['a'],
filename: 'test.html',
template: 'src/assets/test.html'
})
]
```
2. 出口 Output
修改路徑相關
* `publicPath`:並不會對生成檔案的目錄造成影響,主要是對你的頁面裡面引入的資源的路徑做對應的補全
* `filename`:能修改檔名,也能更改檔案目錄
匯出庫相關
* `library`: 匯出庫的名稱
* `libraryTarget`: 通用模板定義方式
3. 模組 Module
webpack 一切皆模組,配置項 Module,定義模組的各種操作,
Module 主要配置:
* `loader`: 各種模組轉換器
* `extensions`:使用的副檔名
* `alias`:別名、例如:vue-cli 常用的 `@` 出自此處
4. 其他
* `plugins`: 外掛列表
* `devServer`:開發環境相關配置,譬如 `proxy`
* `externals`:打包排除模組
* `target`:包應該執行的環境,預設 `web`
### Webpack 執行流程
webpack從啟動到結束會依次執行以下流程:
1. 初始化:解析webpack配置引數,生產 `Compiler` 例項
2. 註冊外掛:呼叫外掛的`apply`方法,給外掛傳入`compiler`例項的引用,外掛通過compiler呼叫Webpack提供的API,讓外掛可以監聽後續的所有事件節點。
3. 入口:讀取入口檔案
4. 解析檔案:使用`loader`將檔案解析成抽象語法樹 `AST`
5. 生成依賴圖譜:找出每個檔案的依賴項(遍歷)
6. 輸出:根據轉換好的程式碼,生成 `chunk`
7. 生成最後打包的檔案
ps:由於 webpack 是根據依賴圖動態載入所有的依賴項,所以,每個模組都可以明確表述自身的依賴,可以避免打包未使用的模組。
### Babel
Babel 是一個工具鏈,主要用於將 ECMAScript 2015+ 版本的程式碼轉換為向後相容的 `JavaScript` 語法,以便能夠執行在當前和舊版本的瀏覽器或其他環境中:
> Babel 內部所使用的語法解析器是 Babylon
主要功能
* 語法轉換
* 通過 `Polyfill` 方式在目標環境中新增缺失的特性 (通過 `@babel/polyfill` 模組)
* 原始碼轉換 (`codemods`)
主要模組
* `@babel/parser`:負責將程式碼解析為抽象語法樹
* `@babel/traverse`:遍歷抽象語法樹的工具,我們可以在語法樹中解析特定的節點,然後做一些操作
* `@babel/core`:程式碼轉換,如ES6的程式碼轉為ES5的模式
### Webpack 打包結果
在使用 webpack 構建的典型應用程式或站點中,有三種主要的程式碼型別:
1. 原始碼:你或你的團隊編寫的原始碼。
2. 依賴:你的原始碼會依賴的任何第三方的 `library` 或 "`vendor`" 程式碼。
3. 管理檔案:`webpack` 的 `runtime` 使用 `manifest` 管理所有模組的互動。
`runtime`:在模組互動時,連線模組所需的**載入和解析邏輯**。包括瀏覽器中的已載入模組的連線,以及懶載入模組的執行邏輯。
`manifest`:當編譯器(compiler)開始執行、解析和對映應用程式時,它會保留所有模組的詳細要點。這個資料集合稱為 "Manifest",
當完成打包併發送到瀏覽器時,會在執行時通過 Manifest 來解析和載入模組。無論你選擇哪種模組語法,那些 import 或 require 語句現在都已經轉換為 __webpack_require__ 方法,此方法指向模組識別符號(module identifier)。通過使用 manifest 中的資料,runtime 將能夠查詢模組識別符號,檢索出背後對應的模組。
其中:
* `import` 或 `require` 語句會轉換為 `__webpack_require__`
* 非同步匯入會轉換為 `require.ensure`(在Webpack 4 中會使用 Promise 封裝)
### 比較
* `gulp` 是任務執行器(task runner):就是用來自動化處理常見的開發任務,例如專案的檢查(lint)、構建(build)、測試(test)
* `webpack` 是打包器(bundler):幫助你取得準備用於部署的 JavaScript 和樣式表,將它們轉換為適合瀏覽器的可用格式。例如,JavaScript 可以壓縮、拆分 chunk 和懶載入,
### 實現一個 loader
`loader` 就是一個js檔案,它匯出了一個返回了一個 `buffer` 或者 `string` 的函式;
譬如:
```js
// log-loader.js
module.exports = function (source) {
console.log('test...', source)
return source
}
```
在 use 時,如果 `log-loader` 並沒有在 `node_modules` 中,那麼可以使用路徑匯入。
### 實現一個 plugin
plugin: 是一個含有 `apply` 方法的 `類`。
譬如:
```js
class DemoWebpackPlugin {
constructor () {
console.log('初始化 外掛')
}
apply (compiler) {
}
}
module.exports = DemoWebpackPlugin
```
apply 方法中接收一個 `compiler` 引數,也就是 webpack例項。由於該引數的存在 plugin 可以很好的運用 webpack 的生命週期鉤子,在不同的時間節點做一些操作。
### Webpack 優化概況
Webpack 加快打包速度的方法
1. 使用 `include` 或 `exclude` 加快檔案查詢速度
2. 使用 `HappyPack` 開啟多程序 `Loader` 轉換
3. 使用 `ParallelUglifyPlugin` 開啟多程序 JS 壓縮
4. 使用 `DllPlugin` + `DllReferencePlugin` 分離打包
1. 將 `庫` 和 `專案程式碼` 分離打包
2. 需要 dll 對映檔案
5. 配置快取(外掛自帶 loader,不支援的可以用 `cache-loader`)
Webpack 加快程式碼執行速度方法
1. 程式碼壓縮
2. 抽離公共模組
3. 懶載入模組
4. 將小圖片轉成 base64 以減少請求
5. 預取(`prefetch`) || 預載入(`preload`)
6. 精靈圖
7. `webpack-bundle-analyzer` 程式碼分析
### Webpack 優化細節
### webpack 4.6.0+增加了對預取和預載入的支援。
動態匯入
```
import(/* webpackChunkName: "lodash" */ 'lodash')
// 註釋中的使用webpackChunkName。
// 這將導致我們單獨的包被命名,lodash.bundle.js
// 而不是just [id].bundle.js。
```
預取(`prefetch`):將來可能需要一些導航資源
* 只要父`chunk`載入完成,`webpack`就會新增 `prefetch`
```
import(/* webpackPrefetch: true */ 'LoginModal');
// 將其附加在頁面的開頭
```
預載入(`preload`):當前導航期間可能需要資源
* `preload` chunk 會在父 chunk 載入時,以並行方式開始載入
* 不正確地使用 `webpackPreload` 會有損效能,
```
import(/* webpackPreload: true */ 'ChartingLibrary');
// 在載入父 chunk 的同時
// 還會通過 請求 charting-library-chunk
```
##### DllPlugin + DllReferencePlugin
為了極大減少構建時間,進行分離打包。
DllReferencePlugin 和 DLL外掛DllPlugin 都是在_另外_的 webpack 設定中使用的。
`DllPlugin`這個外掛是在一個額外的獨立的 webpack 設定中建立一個只有 dll 的 bundle(dll-only-bundle)。 這個外掛會生成一個名為 manifest.json 的檔案,這個檔案是用來讓 `DLLReferencePlugin` 對映到相關的依賴上去的。
webpack.vendor.config.js
```
new webpack.DllPlugin({
context: __dirname,
name: "[name]_[hash]",
path: path.join(__dirname, "manifest.json"),
})
```
webpack.app.config.js
```
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require("./manifest.json"),
name: "./my-dll.js",
scope: "xyz",
sourceType: "commonjs2"
})
```
##### CommonsChunkPlugin
通過將公共模組拆出來,最終合成的檔案能夠在最開始的時候載入一次,便存到快取中供後續使用。這個帶來速度上的提升,因為瀏覽器會迅速將公共的程式碼從快取中取出來,而不是每次訪問一個新頁面時,再去載入一個更大的檔案。
如果把公共檔案提取出一個檔案,那麼當用戶訪問了一個網頁,載入了這個公共檔案,再訪問其他依賴公共檔案的網頁時,就直接使用檔案在瀏覽器的快取,這樣公共檔案就只用被傳輸一次。
```
entry: {
vendor: ["jquery", "other-lib"], // 明確第三方庫
app: "./entry"
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "vendor",
// filename: "vendor.js"
// (給 chunk 一個不同的名字)
minChunks: Infinity,
// (隨著 entry chunk 越來越多,
// 這個配置保證沒其它的模組會打包進 vendor chunk)
})
]
// 打包後的檔案
```
##### UglifyJSPlugin
基本上腳手架都包含了該外掛,該外掛會分析JS程式碼語法樹,理解程式碼的含義,從而做到去掉無效程式碼、去掉日誌輸入程式碼、縮短變數名等優化。
```
const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
//...
plugins: [
new UglifyJSPlugin({
compress: {
warnings: false, //刪除無用程式碼時不輸出警告
drop_console: true, //刪除所有console語句,可以相容IE
collapse_vars: true, //內嵌已定義但只使用一次的變數
reduce_vars: true, //提取使用多次但沒定義的靜態值到變數
},
output: {
beautify: false, //最緊湊的輸出,不保留空格和製表符
comments: false, //刪除所有註釋
}
})
]
```
##### ExtractTextPlugin + PurifyCSSPlugin
ExtractTextPlugin 從 bundle 中提取文字(CSS)到單獨的檔案,PurifyCSSPlugin純化CSS(其實用處沒多大)
```
module.exports = {
module: {
rules: [
{
test: /\.css$/,
loader: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [
{
loader: 'css-loader',
options: {
localIdentName: 'purify_[hash:base64:5]',
modules: true
}
}
]
})
}
]
},
plugins: [
...,
new PurifyCSSPlugin({
purifyOptions: {
whitelist: ['*purify*']
}
})
]
};
```
##### DefinePlugin
> DefinePlugin能夠自動檢測環境變化,效率高效。
在前端開發中,在不同的應用環境中,需要不同的配置。如:開發環境的API Mocker、測試流程中的資料偽造、列印除錯資訊。如果使用人工處理這些配置資訊,不僅麻煩,而且容易出錯。
使用`DefinePlugin`配置的全域性常量
注意,因為這個外掛直接執行文字替換,給定的值必須包含字串本身內的實際引號。通常,有兩種方式來達到這個效果,使用 `' "production" '`, 或者使用 `JSON.stringify('production')`。
```
new webpack.DefinePlugin({
// 當然,在執行node伺服器的時候就應該按環境來配置檔案
// 下面模擬的測試環境執行配置
'process.env':JSON.stringify('dev'),
WP_CONF: JSON.stringify('dev'),
}),
```
測試`DefinePlugin`:編寫
```
if (WP_CONF === 'dev') {
console.log('This is dev');
} else {
console.log('This is prod');
}
```
打包後`WP_CONF === 'dev'`會編譯為`false`
```
if (false) {
console.log('This is dev');
} else {
console.log('This is prod');
}
```
##### 清除不可達程式碼
當使用了`DefinePlugin`外掛後,打包後的程式碼會有很多冗餘。可以通過`UglifyJsPlugin`**清除不可達程式碼**。
```
[
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false, // 去除warning警告
dead_code: true, // 去除不可達程式碼
},
warnings: false
}
})
]
```
最後的打包打包程式碼會變成`console.log('This is prod')`
附Uglify文件:https://github.com/mishoo/UglifyJS2
使用DefinePlugin區分環境 + UglifyJsPlugin清除不可達程式碼,以減輕打包程式碼體積
##### HappyPack
[HappyPack](https://github.com/amireh/happypack)可以**開啟多程序Loader轉換**,將任務分解給多個子程序,最後將結果發給主程序。
使用
```
exports.plugins = [
new HappyPack({
id: 'jsx',
threads: 4,
loaders: [ 'babel-loader' ]
}),
new HappyPack({
id: 'styles',
threads: 2,
loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
})
];
exports.module.rules = [
{
test: /\.js$/,
use: 'happypack/loader?id=jsx'
},
{
test: /\.less$/,
use: 'happypack/loader?id=styles'
},
]
```
##### ParallelUglifyPlugin
[ParallelUglifyPlugin](https://github.com/gdborton/webpack-parallel-uglify-plugin)可以**開啟多程序壓縮JS檔案**
```
import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';
module.exports = {
plugins: [
new ParallelUglifyPlugin({
test,
include,
exclude,
cacheDir,
workerCount,
sourceMap,
uglifyJS: {
},
uglifyES: {
}
}),
],
};
```
##### BundleAnalyzerPlugin
webpack打包結果分析外掛
```
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
```
##### test & include & exclude
減小檔案搜尋範圍,從而提升速度
示例
```
{
test: /\.css$/,
include: [
path.resolve(__dirname, "app/styles"),
path.resolve(__dirname, "vendor/styles")
]
}
```
##### 外部擴充套件(externals)
這玩意不是外掛,是wenpack的配置選項
externals 配置選項提供了「從輸出的 bundle 中排除依賴」的方法。相反,所建立的 bundle 依賴於那些存在於使用者環境(consumer's environment)中的依賴。此功能通常對 library 開發人員來說是最有用的,然而也會有各種各樣的應用程式用到它。
```
entry: {
entry: './src/main.js',
vendor: ['vue', 'vue-router', 'vuex']
},
externals: {
// 從輸出的 bundle 中排除 echarts 依賴
echarts: 'echarts',
}
```
### Webpack HMR 原理解析
> Hot Module Replacement(簡稱 HMR)
包含以下內容:
1. 熱更新圖
2. 熱更新步驟講解
![](https://user-gold-cdn.xitu.io/2020/5/21/17235fbc6668c635?w=720&h=749&f=jpeg&s=52697)
##### 第一步:webpack 對檔案系統進行 watch 打包到記憶體中
webpack-dev-middleware 呼叫 webpack 的 api 對檔案系統 watch,當檔案發生改變後,webpack 重新對檔案進行編譯打包,然後儲存到記憶體中。
webpack 將 bundle.js 檔案打包到了記憶體中,不生成檔案的原因就在於訪問記憶體中的程式碼比訪問檔案系統中的檔案更快,而且也減少了程式碼寫入檔案的開銷。
這一切都歸功於[memory-fs](https://github.com/webpack/memory-fs),memory-fs 是 webpack-dev-middleware 的一個依賴庫,webpack-dev-middleware 將 webpack 原本的 outputFileSystem 替換成了MemoryFileSystem 例項,這樣程式碼就將輸出到記憶體中。
webpack-dev-middleware 中該部分原始碼如下:
```
// compiler
// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers &&
compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
fs = compiler.outputFileSystem;
} else {
fs = compiler.outputFileSystem = new MemoryFileSystem();
}
```
##### 第二步:devServer 通知瀏覽器端檔案發生改變
在啟動 devServer 的時候,[sockjs]((https://github.com/sockjs/sockjs-client)) 在服務端和瀏覽器端建立了一個 webSocket 長連線,以便將 webpack 編譯和打包的各個階段狀態告知瀏覽器,最關鍵的步驟還是 webpack-dev-server 呼叫 webpack api 監聽 compile的 done 事件,當compile 完成後,webpack-dev-server通過 _sendStatus 方法將編譯打包後的新模組 hash 值傳送到瀏覽器端。
```
// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
// stats.hash 是最新打包檔案的 hash 值
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});
...
Server.prototype._sendStats = function (sockets, stats, force) {
if (!force && stats &&
(!stats.errors || stats.errors.length === 0) && stats.assets &&
stats.assets.every(asset => !asset.emitted)
) { return this.sockWrite(sockets, 'still-ok'); }
// 呼叫 sockWrite 方法將 hash 值通過 websocket 傳送到瀏覽器端
this.sockWrite(sockets, 'hash', stats.hash);
if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); }
else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); } else { this.sockWrite(sockets, 'ok'); }
};
```
##### 第三步:webpack-dev-server/client 接收到服務端訊息做出響應
webpack-dev-server 修改了webpack 配置中的 entry 屬性,在裡面添加了 webpack-dev-client 的程式碼,這樣在最後的 bundle.js 檔案中就會接收 websocket 訊息的程式碼了。
webpack-dev-server/client 當接收到 type 為 hash 訊息後會將 hash 值暫存起來,當接收到 type 為 ok 的訊息後對應用執行 reload 操作。
在 reload 操作中,webpack-dev-server/client 會根據 hot 配置決定是重新整理瀏覽器還是對程式碼進行熱更新(HMR)。程式碼如下:
```
// webpack-dev-server/client/index.js
hash: function msgHash(hash) {
currentHash = hash;
},
ok: function msgOk() {
// ...
reloadApp();
},
// ...
function reloadApp() {
// ...
if (hot) {
log.info('[WDS] App hot update...');
const hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
// ...
} else {
log.info('[WDS] App updated. Reloading...');
self.location.reload();
}
}
```
##### 第四步:webpack 接收到最新 hash 值驗證並請求模組程式碼
首先 webpack/hot/dev-server(以下簡稱 dev-server) 監聽第三步 webpack-dev-server/client 傳送的 `webpackHotUpdate` 訊息,呼叫 webpack/lib/HotModuleReplacement.runtime(簡稱 HMR runtime)中的 check 方法,檢測是否有新的更新。
在 check 過程中會利用 webpack/lib/JsonpMainTemplate.runtime(簡稱 jsonp runtime)中的兩個方法 hotDownloadManifest 和 hotDownloadUpdateChunk。
hotDownloadManifest 是呼叫 AJAX 向服務端請求是否有更新的檔案,如果有將發更新的檔案列表返回瀏覽器端。該方法返回的是最新的 hash 值。
hotDownloadUpdateChunk 是通過 jsonp 請求最新的模組程式碼,然後將程式碼返回給 HMR runtime,HMR runtime 會根據返回的新模組程式碼做進一步處理,可能是重新整理頁面,也可能是對模組進行熱更新。該 方法返回的就是最新 hash 值對應的程式碼塊。
最後將新的程式碼塊返回給 HMR runtime,進行模組熱更新。
附:為什麼更新模組的程式碼不直接在第三步通過 websocket 傳送到瀏覽器端,而是通過 jsonp 來獲取呢?
我的理解是,功能塊的解耦,各個模組各司其職,dev-server/client 只負責訊息的傳遞而不負責新模組的獲取,而這些工作應該有 HMR runtime 來完成,HMR runtime 才應該是獲取新程式碼的地方。再就是因為不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模組熱更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它沒有使用 websocket,而是使用的 EventSource。綜上所述,HMR 的工作流中,不應該把新模組程式碼放在 websocket 訊息中。
##### 第五步:HotModuleReplacement.runtime 對模組進行熱更新
這一步是整個模組熱更新(HMR)的關鍵步驟,而且模組熱更新都是發生在HMR runtime 中的 hotApply 方法中
```
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while(queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for(j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if(!child) continue;
idx = child.parents.indexOf(moduleId);
if(idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
// ...
// insert new code
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
```
模組熱更新的錯誤處理,如果在熱更新過程中出現錯誤,熱更新將回退到重新整理瀏覽器,這部分程式碼在 dev-server 程式碼中,簡要程式碼如下:
```
module.hot.check(true).then(function(updatedModules) {
if(!updatedModules) {
return window.location.reload();
}
// ...
}).catch(function(err) {
var status = module.hot.status();
if(["abort", "fail"].indexOf(status) >= 0) {
window.location.reload();
}
});
```
##### 第六步:業務程式碼需要做些什麼?
當用新的模組程式碼替換老的模組後,但是我們的業務程式碼並不能知道程式碼已經發生變化,也就是說,當 hello.js 檔案修改後,我們需要在 index.js 檔案中呼叫 HMR 的 accept 方法,新增模組更新後的處理函式,及時將 hello 方法的返回值插入到頁面中。程式碼如下
```
// index.js
if(module.hot) {
module.hot.accept('./hello.js', function() {
div.innerHTML = hello()
})
}
```
### 最後
1. 覺得有用的請點個贊
2. 本文內容出自 [https://github.com/zhongmeizhi/FED-note](https://github.com/zhongmeizhi/FED-note)
3. 歡迎關注公眾號「前端進階課」認真學前端,一起進階。
![](https://user-gold-cdn.xitu.io/2019/12/18/16f16cbe587e5f27?w=258&h=258&f=jpeg&