從模組化帶你認識 Webpack

#webpack #javascript #frontend
從模組化帶你認識 Webpack
五倍技術部
技術文章
從模組化帶你認識 Webpack

前言

在學習前端的的過程中,多少一定都有聽過 Webpack 這個名詞,而什麼是 Webpack 呢?當我聽到這個問題的時候,第一時間會先愣住,不知從何回答起,經過幾秒鐘的思考後,最後回答會是「它是一個模組打包工具」,不知道大家有沒有和我一樣呢?這個回答沒有錯,不過可能會有更多疑問,那模組又是什麼?為什麼需要打包?

這篇文章,將從模組化切入,來帶大家了解 Webpack 究竟幫我們做了多少事?

模組化

為什麼需要模組化設計?

試著回想我們最開始學習 JavaScript 的時候,我們會把程式碼寫在 HTML 檔案中,如下

檔案:index.html

<script>
  var a = 1

  function double(x){
    return 2 * x
  }

  double(a)
</script>

但隨著功能越來越多我們會把 script 裡面的內容獨立出來到一個 JS 檔中:

檔案:index.html

<script src="./main.js"></script>

檔案:main.js

var a = 1

function double(x) {
  return 2 * x
}

double(a)

var b = 2

function square(x) {
  return x * x
}

square(b)

var c = 4

function add(x, y) {
  return x + y
}

add(c, b)

再來想像一下,當開發人員越來越多,功能也一直再新增,如果都繼續編寫同一個 JS 檔,當行數不再是 24 而是 1,000,將變得非常難以閱讀及維護。為了避免這樣的情況發生,我們可以拆成多個 JS 檔,如下:

檔案:index.html

<script src="./utils.js"></script>
<script src="./main.js"></script>

檔案:utils.js

function double(x) {
  return 2 * x
}

function square(x) {
  return x * x
}

function add(x, y) {
  return x + y
}

檔案:main.js

var a = 1
var b = 2
var c = 4

double(a)
square(b)
add(c, b)

目前我們雖然把程式碼分開了,但實際上它還是在同一個作用域下,其中的變數是會互相影響。例如,我新增了一個 utils2.js 檔,並宣告一個 add 函式,且實作內容和 utils.js 中的 add 函式不一樣,如下

檔案:index.html

<script src="./utils.js"></script>
<script src="./utils2.js"></script>
<script src="./main.js"></script>

檔案:utils.js

function add(x, y){
    return x + y
}

檔案:utils2.js

function add(x, y){
    return x + y + 4
}

檔案:main.js

var b = 2
var c = 4

console.log(add(c, b)) // 10

如果我們期望在 main.js 使用 add 函式會得到兩數之和,會因為 utils2.js 在 utils.js 後面載入,而覆蓋掉 utils.js 的函式宣告,最後導致結果出現不如預期。所以我們需要將 utils.js 與 utils2.js 這兩個檔案的內容各自獨立出來,互不干擾,而想要達到這樣的效果,我們可以使用 IIFE 來完成。

IIFE(立即呼叫函式 Immediately Invoked Functions)

檔案:utils.js

var moduleUtils = (function () {
  var author = "Mr.A"

  return {
    add: function (x, y) {
      return x + y
    },
    getAuthor: function () {
      return author
    },
  }
})()

檔案:utils2.js

var moduleUtils2 = (function () {
  var author = "Mr.B"

  return {
    add: function (x, y) {
      return x + y + 4
    },
    getAuthor: function () {
      return author
    },
  }
})()

檔案:main.js

var b = 2
var c = 4

console.log(moduleUtils.getAuthor()) // 'Mr.A'
console.log(moduleUtils.add(c, b)) // 6

console.log(moduleUtils2.getAuthor()) // 'Mr.B'
console.log(moduleUtils2.add(c, b)) // 10

透過立即呼叫函式的方式,建立作用域幫我們達到三點效果:
1. 解決變數或是函式命名上的衝突
2. 能夠建立私有變數與方法
3. 自由選擇要將哪些變數、方法暴露出去

因此,我們打算在 main.js 檔案中,取得變數 author 的值,只能透過 moduleUtils.getAuthor() 的方法。最值得注意的地方是,這樣子規劃,只能讀取而無法直接修改。這種模式就是最一開始模組化的概念,稱為模組模式。

這個模式看起來好像很完美了,但其實還是有一個小問題。假設 utils2.js 這個模組需要依賴 utils.js 的方法或是變數,我們就一定要注意 script 引入的先後順序,如下:

檔案:utils.js

var moduleUtils = (function () {
  var author = "Mr.A"

  return {
    getAuthor: function () {
      return author
    },
  }
})()

檔案:utils2.js

var moduleUtils2 = (function (module) {
  var author = "Mr.B"
  var authorFromUtils = module.getAuthor()

  return {
    getTwoAuthors: function () {
      return author + "_&_" + authorFromUtils
    },
  }
})(moduleUtils)

檔案:main.js

console.log(moduleUtils.getAuthor()) // 'Mr.A'
console.log(moduleUtils2.getTwoAuthors()) // 'Mr.B_&_Mr.A'

檔案:index.html

<script src="./utils.js"></script>
<script src="./utils2.js"></script>   <!-- utils2 一定要在 utils 後面引入 --> 
<script src="./main.js"></script>

隨著相依性的檔案越來越多,專案的維護難度也會隨之提高。

综合以上,隨著前端專案規模越來越大,就越需要模組化的開發,來提升專案的維護性。隨著趨勢的發展,就有些模組化的規範相繼而出,而其中最為有名的就是 CommonJS。

Node.js 與 CommonJS

Node.js 能夠讓 JavaScript 在 server 環境中執行,並採用了 CommonJS 模組規範。所以在 server 端來說,每個檔案都是獨立的作用域,且都有 require 函式和 module 物件。

  1. module 物件 每個檔案都會有 module 物件,這個物件底下有一個 exports 的屬性,如果在這個檔案中有想要輸出的變數或是方法,可以透過賦值給 exports 來達成,如下:

只有單一值:

var name = 'will'

module.exports = name

多個值 第一種寫法:

var name = 'will'

module.exports.name = name
module.exports.getName = function(){
    return name
}

多個值 第二種寫法:

var name = 'will'

function getName(){
    return name
}

module.exports = {
    name,
    getName
}
  1. require 函式 當需要載入某檔案,取得 module.exports 的值時,會使用 require 函式尋找該檔案,如下:

檔案:a.js

var name = 'will'

module.exports.name = name
module.exports.getName = function(){
    return name
}

檔案:b.js

var aModule = require('./a.js')
aModule.getName() // will

了解 CommonJS 規範之後,我們就可以很輕鬆的將之前的範例改寫,如下:

檔案:utils.js

var author = 'Mr.A'

function getAuthor(){
    return author
}

module.exports.getAuthor = getAuthor

檔案:utils2.js

var moduleUtils = require('./utils')

var author = 'Mr.B'
var authorFromUtils = moduleUtils.getAuthor()

function getTwoAuthors(){
    return author + '_&_' + authorFromUtils
}

module.exports.getTwoAuthors = getTwoAuthors

檔案:main.js

var moduleUtils = require('./utils')
var moduleUtils2 = require('./utils2')

console.log(moduleUtils.getAuthor()) // 'Mr.A'
console.log(moduleUtils2.getTwoAuthors()) // 'Mr.B_&_Mr.A'

這樣寫相較 IIFE 簡單多了,但別忘了改成這樣子,實際上是沒辦法在瀏覽器上執行的,只能在 Node.js 環境下執行。既然瀏覽器不支援,要怎麼樣才能在瀏覽器執行?我們可以借助其他工具,也就是 Webpack,它會將我們的模組翻譯成瀏覽器支援的寫法,並且打包成一個壓縮過的 JS 檔。

不過在介紹 Webpack 之前,相信有些人會有疑惑,在寫專案都沒有使用 requiremodule.exports ,都是用 ES6 的 importexport ,而且瀏覽器也有支援,那為什麼還需要 Webpack? 在回答這個問題之前,先來認識一下 ES6 Module。

ES6 Module

在 ES6 以前,沒有正式的模組化規範,所以有各種模組化的寫法,像是上面介紹的 CommonJS,或是 AMD (Asynchromous Module Definition 非同步模組定義)等等。而 ES6 出來之後,終於有了正式的規範,就是上面提到的 importexport,我們可以再把上面的例子用 ES6 Module 改寫,如下

檔案:utils.js

let author = 'Mr.A'

export function getAuthor(){
    return author
}

檔案:utils2.js

import { getAuthor } from './utils.js'

let author = 'Mr.B'
let authorFromUtils = getAuthor()

export function getTwoAuthors(){
    return author + '_&_' + authorFromUtils
}

檔案:main.js

import { getAuthor, author } from './utils.js'
import { getTwoAuthors } from './utils2.js'

console.log(getAuthor()) // 'Mr.A'
console.log(getTwoAuthors()) // 'Mr.B_&_Mr.A'

檔案:index.html

<script src="./main.js" type="module"></script>

依照目前的使用情境,看起來都沒有什麼太大的問題,但假設我們想要使用別人的套件呢?或許我們可以使用 CDN 引入,但如果說這個套件也沒有 CDN,我們就只能透過 NPM 或 Yarn 這類型的套件管理工具來下載。

套件下載完畢後,會放到 nodemodules 這個資料夾裡,這樣子我們引入的路徑就可能要改寫成 `'./nodemodules/xxx/index.js'這樣子。假設哪天套件的入口點換了,整個專案有使用到這個套件的都需要改寫,這樣其實很不方便。除此之外,有些套件輸出是寫module.exports,我們也沒辦法使用import` 來引入,因此這個時候,我們就需要藉由 Webpack 的幫助。

Webpack

初步認識 Webpack

在前面我們就有提到,Webpack 是一模組打包工具,不管我們是寫 CommonJS 又或是 ES6 Module 最終它會將我們的模組以及使用 NPM 安裝他人的模組一併打包成一個 JS 檔。接著讓我們來實際試試看如何使用 Webpack 打包。

如果上面的有跟著練習的話,目前的專案目錄會是這樣子

1.開啟終端機輸入下面兩行指令

npm init -y 
npm install webpack webpack-cli --save-dev

順利的話,就會看到我們的目錄新增了兩個檔案分別是 package.json 和 package-lock.json,以及 node_modules 的資料夾

2.打開 package.json 並在 scripts 底下新增指令

檔案:package.json

{
  "scripts": {
    "build": "webpack" 
  },
}

3.新增 webpack.config.js 檔案

檔案:webpack.config.js

module.exports = {
  mode: 'development', 
  entry: './main.js', // webpack 打包的起始檔案
  output: {
    path: __dirname, // 在這個 config 檔同層的目錄
    filename: 'bundle.js'// 打包輸出的檔名
  }
}

mode 這個屬性不寫的話預設會是 production,代表在生產環境下使用,所以會自動幫你壓縮以及優化。development 這個模式代表開發的時候,打包的速度較快。

最後在終端機下執行:

$ npm run build

執行之後順利的話就會出現建置的成功訊息

接著修改我們 index.html,將 main.js 換成 bundle.js

檔案:index.html

<script src="./bundle.js"></script>

打開瀏覽器 console 之後,就會順利看到我們在 main.js 檔 console.log 的結果

這樣我們就成功地使用 Webpack 來幫我們把模組打包,不過目前為止我們還沒嘗試安裝套件來使用,接下來我們來試著安裝 React:

$ npm install react react-dom

安裝成功後,就來試試看能不能使用 React,並在畫面上印出 Hello, Webpack and React,所以將檔案做一下修改

檔案:index.html

<body>
  <div id="root"></div>

  <script src="./bundle.js"></script>
</body>

檔案:App.js

import React from "react"

function App() {
  return React.createElement('h1', {}, "Hello Webpack and React")
}

export default App

檔案:main.js

import { getAuthor, author } from './utils.js'
import { getTwoAuthors } from './utils2.js'
import App from './App.js'
import ReactDom from 'react-dom'
import React from 'react'

const appEl = React.createElement(App)
ReactDom.render(appEl, document.getElementById('root'))

console.log(getAuthor())
console.log(getTwoAuthors())

最後我們再重新 build 一次:

$ npm run build

成功看一下建置的訊息會發現,Webpack 的確有去 node_modules ,將我們使用的套件模組一併打包。

最後打開畫面,除了之前在 console 印出來的結果外,也可以看到 Hello, Webpack and React!

強大的 Webpack

Webpack 打包 JS 模組只是它功能的冰山一角,如果有看過 Webpack 官網 可以看到首頁的這張圖:

Webpack 把任何資源都當作模組,像是 CSS 或是圖片,我們都可以用 import 的方式把檔案給引入進來,如果不使用 Webpack 是沒辦法做到的。而為了支援這樣子的功能,Webpack 透過定義不同的 loader 來識別檔案類型並轉譯成瀏覽器看得懂的語法。例如 babe-loader 能夠幫助我們將 ES6 以上的新語法轉譯成 ES5。除此之外,還可以透過 plugins 來擴充 Webpack 的功能,像是壓縮 CSS 檔,或是打包過後自動產生 HTML 等等。

結語

到這邊相信大家對 Webpack 應該有個基礎的認識了,也瞭解到為什麼需要使用 Webpack。回過頭來看在使用 creat-react-app 以及 Vue CLI,我們能夠輕鬆的 import CSS 或圖片,背後都是因為有 Webpack 在處理。

最後,如果想要更加瞭解 Webpack,可以參考 五倍學院 Webpack5 入門 線上課程。