現代化 Web 開發技術學習分享

0%

Webpack 前端打包工具 - 使用 babel-loader 編譯並轉換 ES6+ 代碼

前言

Babel 是一款 JavaScript 的編譯器,你可能會有疑問,JavaScript 不是可以直接在 Browser 上運行嗎?為何還需要編譯?事實上 JavaScript 從發行到現在,經過了許多版本的更新,常見的 ES6、ES7 都屬於較新的版本,最為穩定的版本為 ES5,兼容性也是最高的, Babel 的用意就是將較新版本的 JavaScript 編譯成穩定版本,以提高兼容性。此篇將介紹如何透過 babel-loader 編譯我們的 ES6+ 代碼,後面也會補充介紹 @babel/runtime 與 @babel/polyfill 組件的使用。

筆記重點

  • babel-loader 安裝
  • babel-loader 基本使用
  • babel-loader 可傳遞選項
  • 補充:@babel/runtime 與 @babel/polyfill 組件使用的必要
  • 補充:@babel/runtime 使用方式
  • 補充:@babel/polyfill 使用方式

babel-loader 安裝

套件連結:babel-loader

主要的套件:

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

package.json:

1
2
3
4
5
6
7
8
9
{
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.0",
"babel-loader": "^8.1.0",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
}
}

Webpack 通過 babel-loader 調用 Babel,直接安裝即可,同時也必須安裝 @babel/core 與 @babel/preset-env,用作 Babel 核心與插件集。

babel-loader 基本使用

初始專案結構:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
webpack-demo/

├─── node_modules/
├─── src/
│ │
│ └─── js/
│ │
│ └─── all.js # JavaScript 主檔案
│ │
│ └─── main.js # entry 入口檔案

├─── index.html # 引入 bundle.js 測試用檔案
├─── webpack.config.js # Webpack 配置檔案
├─── package-lock.json
└─── package.json

撰寫 ES6+ 版本代碼:

1
2
3
4
5
const arr = ['Roya', 'Owen', 'Eric'];

const index = arr.findIndex((item) => item === 'Owen');

console.log(`Owen 排在第 ${index + 1} 順位`);

配置 webpack.config.js 檔案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const path = require('path');

module.exports = {
entry: './src/main.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
module: {
rules: [
// 配置 babel-loader (第一步)
{
test: /\.m?js$/,
// 排除 node_modules 與 bower_components 底下資料 (第二步)
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
// 配置 Babel 解析器 (第三步)
presets: ['@babel/preset-env'],
},
},
},
],
},
};

通常在配置 Babel 時,我們都是習慣把 options 的內容撰寫在獨立的 .babelrc 檔案內,如果 Babel 的配置較為複雜,相比於撰寫在 webpack.config.js 內,使用 .babelrc 更能提高其辨識度,在之後的 @babel/runtime 與 @babel/polyfill 章節會再做補充,讓我們先暫時以此方式進行配置。

entry 入口處 (src/main.js) 引入 JavaScript 檔案:

1
import './js/all'; // JavaScript 預設不需要附檔名

package.json 新增編譯指令:

1
2
3
4
5
{
"scripts": {
"build": "webpack --mode development"
}
}

執行編譯指令:

1
npm run build

讓我們打開編譯完成的 bundle.js 檔案,看看 Babel 究竟做了什麼處理:

1
2
3
4
5
6
7
8
9
/*!********************!*\
!*** ./src/all.js ***!
\********************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("var arr = [\"Roya\", \"Owen\", \"Eric\"];\nvar index = arr.findIndex(function (item) {\n return item === \"Owen\";\n});\nconsole.log(\"Owen \\u6392\\u5728\\u7B2C \".concat(index + 1, \" \\u9806\\u4F4D\"));\n\n//# sourceURL=webpack:///./src/all.js?");

/***/ }),

看到編譯完成的代碼,你的第一個想法大概都是 WTF … 這是什麼鬼?不用擔心,Babel 只是將你的代碼優化為兼容性較高版本的代碼,你也不需要針對這一個檔案做任何修改,可以直接給 HTML 讀取,執行結果如同未編譯的 JavaScript 檔案,你只需要專注於目標的編程,不管你用多新版本的代碼來實現,Babel 都可以幫你改善兼容性等相關問題。

./index.html 引入打包而成的 bundle.js 檔案:

1
2
3
4
5
<!-- 其他省略 -->
<body>
<!-- 引入打包生成的 JavaScript -->
<script src="dist/bundle.js"></script>
</body>

查看結果:

babel-loader console

babel-loader source

如果你覺得 bundle.js 檔案閱讀起來很吃力,你也可以先將其引入至 index.html 內,之後再按 F12 切換至 Source 觀察編譯結果,可能會更好理解喔!

從上面結果可以得知,我們的 Babel 是有成功運行的,但這邊要注意的是,Babel 默認只針對 Syntax 做轉換,像是上面範例的 findIndex 實例就沒有被轉換,因為他不屬於 Syntax,關於這一個問題,可以使用 @babel/runtime 或 @babel/polyfill 進行處理,這點在下面會有補充說明,讓我們先以此方式進行。

babel-loader 可傳遞選項

可參考 babel-loader Options 可傳遞參數列表,以下為常用的參數配置:

  • presets:Array
    Babel 插件集,默認為 none

  • cacheDirectory:Boolean
    用於利用緩存加載程序的結果,默認 false

範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
cacheDirectory: true,
},
},
},
],
},
};

補充:@babel/runtime 與 @babel/polyfill 組件使用的必要

Babel 默認只針對 Syntax 做轉換,例如:箭頭函式、ES6 變數、Class 語法糖等等,而自帶的 API 與原生內置的 methods 需要透過 polyfill 後才能在瀏覽器正常運行。

當前使用 Babel 版本:v7.9.0


Babel 7 版本時,各種運行錯誤,官方 API 雖然完整,但各章節並沒有連貫性,操作下來也不知道問題在哪,在我們探討這兩個組件之前,我們先來解釋這兩個組件到底是要幫我們解決什麼問題。

./src/js/all.js 檔案,修改為如下:

1
2
3
4
5
6
7
8
9
/* --- 箭頭函式、ES6 變數、ES6 陣列方法 --- */
let color = [1, 2, 3, 4, 5];
let result = color.filter((item) => item > 2);

/* --- Class 語法糖 --- */
class Circle {}

/* --- Promise 物件 --- */
const promise = Promise.resolve();

針對上面這一個 JavaScript 檔案,我們使用之前配置好的 webpack.config.js 來編譯它,編譯結果如下:

babel compile

聰明的你應該發現問題了,Babel 不是會幫我們處理兼容性的問題嗎?Array.prototype.filterPromise 物件好像都沒有編譯到的感覺,不要懷疑!Babel 真的沒有幫我們編譯到;事實上,如果你採用預設的編譯環境,Babel 只會針對語法 (Syntax) 做編譯,底層的 API 與原型擴展都不會進行編譯,這也就代表兼容性的問題根本沒有解決,在 IE 11 等較舊瀏覽器上面,它還是不知道什麼是 Promise,運行時就會發生錯誤;在這邊還有一個問題,Babel 針對 Class 語法糖的處理,你會發現它新增了一個全域的 function 當作語法糖的呼叫,這樣子的處理會造成嚴重的全域汙染,如果你有多個 JavaScript 檔案,同時都進行編譯的動作,產生出來的 function 都會是一模一樣的,不僅造成檔案的肥大,也有可能發生全域汙染影響運行等問題;這時候就會需要 @babel/runtime 與 @babel/polyfill 的幫忙,在介紹這兩個組件時,我們先將 Babel 的設定移置專屬的設定檔,如下所示:

路徑 ./webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = {
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
// 將可傳遞選項移至 .babelrc
loader: 'babel-loader',
},
},
],
},
};

將原有的可傳遞選項移除,並新增 ./.babelrc 專屬配置檔:

1
2
3
{
"presets": ["@babel/preset-env"]
}

此時運行 npm rum build 指令,結果會是一模一樣的,在之後針對 Babel 所作的處理,我們都會使用 .babelrc 這一個檔案做修改,接下來讓我們開始正式介紹 @babel/runtime 與 @babel/polyfill。

補充:@babel/runtime 使用方式

@babel/runtime 是由 Babel 提供的 polyfill 套件,由 core-js 和 regenerator 組成,core-js 是用於 JavaScript 的組合式標準化庫,它包含各種版本的 polyfills 實現;而 regenerator 是來自 facebook 的一個函式庫,主要用於實現 generator/yeild,async/await 等特性,我們先從安裝開始講起。

套件連結:@babel/runtime@babel/plugin-transform-runtime

@babel/runtime:

1
npm install @babel/runtime

@babel/plugin-transform-runtime:

1
npm install @babel/plugin-transform-runtime --save-dev

在安裝 @babel/runtime 時,記得不要安裝錯誤,新版的是帶有 @ 開頭的;同時也必須安裝 @babel/plugin-transform-runtime 這個套件,babel 在運行時是依賴 plugin 去做取用,這兩個套件雖然不是相依套件,但實際使用時缺一不可,在後面會有相關說明,在這邊我們先把這兩個套件裝好就可以了。

修改 ./.babelrc 內容為下面範例:

1
2
3
4
5
6
7
8
9
10
11
{
"presets": ["@babel/preset-env"],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": false
}
]
]
}

我們以之前的 JavaScript 檔案進行示範,執行 npm run build 指令進行編譯,結果如下:

babel 搭配 @babel-runtime

從編譯後的結果可以得知,之前提到的 Class 語法糖全域汙染問題已經解決了,透過 @babel/plugin-transform-runtime 這個套件,它會幫我們分析是否有 polyfill 的需求,並自動透過 require 的方式,向 @babel/runtime 拿取 polyfill,簡單來講,@babel/runtime 提供了豐富的 polyfill 供組件使用,開發者可以自行 require,但自行 require 太慢了,使用 @babel/plugin-transform-runtime 可以自動分析並拿取 @babel/runtime 的 polyfill,這也是為什麼這兩個套件缺一不可的原因。

可能有些人還是有疑問,透過 require 的方式為什麼就能避免全域污染的問題?事實上,當初我也很困惑,結果恍然大悟,終於理解了,簡單來講,當初是因為 babel 會在全域環境宣告 function,只要同時有 1 個檔案以上需要編譯時,這些 function 就會相遇干擾,實際運行就會發生錯誤,透過 @babel/runtime 直接 require 的方式進行取用,最後編譯出來的檔案就不會汙染到全域環境,而是生成許多的 require 指令,Node.js 默認是從緩存中載入模組,一個模組被加載一次之後,就會在緩存中維持一個副本,如果遇到重複取用問題,會直接向緩存拿取副本,這也就代表每個模組在緩存中止存在一個實例

仔細觀察,Babel 還是沒有幫我們編譯 Promise 物件,那是因為我們還沒有解放 @babel/runtime 這一個套件全部力量,由上面範例,你會發現我在 plugin 中傳遞了一個 corejs 選項,預設是關閉的,可傳遞的選項為:

corejs 選項 安裝指令
false npm install --save @babel/runtime
2 npm install --save @babel/runtime-corejs2
3 npm install --save @babel/runtime-corejs3

事實上 @babel/runtime 有許多的擴展版本,在之前的範例中,我們都是將 corejs 給關閉,這也就導致它並沒有幫我們編譯底層的 API 與相關的方法,這次我們就來使用各版本進行編譯,記得要執行相對應的安裝指令喔!

修改 ./.babelrc 內容為下面範例:

1
2
3
4
5
6
7
8
9
10
11
{
"presets": ["@babel/preset-env"],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": 2
}
]
]
}

corejs2 版本編譯結果:

babel 搭配 @babel-runtime-corejs2 結果

corejs3 版本編譯結果:

babel 搭配 @babel-runtime-corejs3 結果

從上面結果可以得知,corejs2 版本主要針對底層 API 做編譯,如 Promise、Fetch 等等;corejs3 版本主要針對底層 API 和相關實例方法,如 Array.pototype.filter,Array.pototype.map 等等,簡單來講,如果你要將兼容性的問題徹底解決,就得使用 corejs3 版本,到了這邊,我們之前所提到 Babel 的種種問題都已經獲得解決。

使用 @babel/runtime 能夠在不汙染全域環境下提供相對應的 polyfill,擁有自動識別功能,在某些情況下,編譯出來的檔案大小可能比使用 @babel/polyfill 來的小,適合開發組件庫或對環境較為嚴格的專案

補充:@babel/polyfill 使用方式

@babel/polyfill 與 @babel/runtime 一直以來這兩者的差別都很模糊,網上的文章大多也都是複製官方的說明文檔,並沒有實際去使用,造成開發者一知半解的疑慮,這一次我們就來討論 @babel/polyfill 究竟要如何使用。先從安裝開始說起:

Babel 版本 < v7.4.0

1
npm install @babel/polyfill

Babel 版本 >= v7.4.0

1
npm install core-js regenerator-runtime/runtime

從 Babel >= 7.4.0 後,@babel/polyfill 組件庫已被棄用,事實上 @babel/polyfill 本身就是由 stable 版本的 core-js 和 regenerator-runtime 組成,我們可以直接下載這兩個組件庫當作 @babel/polyfill 來使用,官方也推薦此做法,這邊要注意的是 regenerator-runtime 為 @babel/runtime 的相依套件,可以自行檢查是否有正確安裝。

修改 ./.babelrc 內容為下面範例:

1
2
3
4
5
6
7
8
9
10
11
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": false,
"corejs": 3 // 當前 core-js 版本
}
]
]
}

我們使用之前的 JavaScript 檔案進行示範,執行 npm run build 指令進行編譯,結果如下:

babel 編譯結果

編譯結果就如同單純使用 Babel 一樣,只有針對語法 (Syntax) 做編譯,那是因為我們尚未開啟 polyfill 的功能,可通過更改 useBuiltIns 來變更模式,可選模式為 falseusageentry,以下為各模式的編譯結果:

useBuiltIns:usage

babel useBuiltIns 更改為 usage

很明顯的將 useBuiltIns 更改為 usage,就如同使用 @babel/runtime-corejs3 一樣,自動識別需要 require 的新語法,將兼容性問題徹底解決,不同的地方在於,@babel/runtime 在不汙染全域環境下提供 polyfill,而 @babel/polyfill 則是將需要兼容的新語法掛載到全局對象,這樣子的做法即會造成所謂的全局汙染,讓我們來看最後一個 useBuiltIns 選項。

useBuiltIns:entry

使用 entry 選項記得在前面 import core-js/stable 和 regenerator-runtime/runtime 組件庫

代編譯檔案:

1
2
3
4
5
6
7
8
9
10
11
12
import 'core-js/stable';
import 'regenerator-runtime/runtime';

/* --- 箭頭函式、ES6 變數、ES6 陣列方法 --- */
let color = [1, 2, 3, 4, 5];
let result = color.filter((item) => item > 2);

/* --- class 語法糖 --- */
class Circle {}

/* --- Promise 物件 --- */
const promise = Promise.resolve();

完成編譯檔案

babel useBuiltIns 更改為 entry

entry 這一個選項就簡單多了,沒有做任何的識別,直接將整個 ES 環境掛載到全局對象,確保瀏覽器可以兼容所有的新特性,但這樣子做的缺點也顯而易見,整個專案環境會較為肥大,你可能會好奇 entry 選項的必要,事實上 Babel 默認不會檢測第三方依賴組件,所以使用 usage 選項時,可能會出現引入第三方的代碼包未載入模組而引發的 Bug,這時就有使用 entry 的必要。

@babel/polyfill 提供一次性載入或自動識別載入 polyfill 的功能,使用掛載全局對象的方法,達到兼容新特性目的,適合開發在專案環境,較不適合開發組件庫或工具包,存在汙染全局對象疑慮。

經過了一番對於 @babel/runtime 與 @babel/polyfill 的討論,相信各位已經了解兩者的差別,在這邊做一個總結:

  1. Babel 版本 < 7.4.0

    • 開發組件庫、工具包,選擇 @babel/runtime
    • 開發本地專案,選擇 @babel/polyfill
  2. Babel 版本 >= 7.4.0

    • 配置較簡單,會汙染全域環境,選擇 @babel/polyfill
    • 配置較繁瑣,不會汙染全域環境,選擇 @babel/runtime