打包程式碼必備 bundler:Webpack


Posted by saffran on 2021-02-25

為什麼需要 webpack?

先從「模組化」開始談起(“Node.js” vs “瀏覽器上的 JS”)

在 Node.js 使用模組

  • 在 Node.js 可以用 module.exports = Utils 輸出模組
    utils.js:
const Utils = {
  first: function(str) {
    return str[0]
  },
  last: function(str) {
    return str[str.length - 1]
  }
}

module.exports = Utils
  • 在 Node.js 可以用 require() 引入 module
    index.js:
    要使用模組的人就用 const utils = require('./utils') 來引入這個 module 即可
const utils = require('./utils.js')

console.log(utils.first('abc'))

引入之後,輸入指令 node index.js 就會輸出 console.log(utils.first('abc')) 的結果(就是 a)

只有在 Node.js 上可以使用 CommonJS 這個模組化規範,也就是 module.exports, require() 這些語法

在 Node.js 不會有命名衝突的問題,是因為在用 require() 引入 module (library) 的時候,我可以隨意用一個自己喜歡的變數名稱來引入
例如 const abc = require('./utils') 或是 const banana = require('./utils')
這些變數名稱(abc, banana)並不會影響到我要引入的 module (library)

瀏覽器是透過「全域變數」去輸出 module,因此會有命名衝突的問題

可是,在瀏覽器上不能使用 require() 這個語法,因為瀏覽器不支援(瀏覽器沒有 require() 這個語法)

所以,如果瀏覽器想要引入一個 library,以 jQuery 為例,在瀏覽器上(index.html),我需要在 <header> 裡面加上這行 <script src="https://code.jquery.com/jquery-3.5.1.js"></script> 來把 jQuery 引入進來,然後我就可以有 $ 或是 jQuery 這個全域變數(物件)可以使用

假設我在 index.html 同時引入了兩個 library,分別是 jQuery 和 lidemy
這兩個 library 都使用了 $ 這個全域變數,那這時就會有命名衝突的問題

所以,在 jQuery 有提供了一個 function 叫做 jQuery.noConflict() 可以幫忙解決命名衝突的問題
(只要有用 global 全域變數來命名的 library 都會有命名衝突的問題)

模組化規範

為了要解決使用模組時的命名衝突問題,有很多人就實作了不同的「模組化規範」,像是:CommonJS, AMD, UMD (這幾個都不是官方的規範)

這些不同的規範就像是各家牌子的充電線,充電線的目的都一樣(讓手機充電),但是 Apple 手機和 Android 手機的充電線接頭長得都不一樣

這些規範的用法不一樣,不同規範之間也可能無法相容,但是目的都一樣

這些規範說明了我們要用什麼語法去使用模組、以及要怎麼把模組給別人用

直到後來 ES6 出現後,瀏覽器也支援了原生的模組化規範叫做 ES Modules,就可以使用 import, export 這些語法

範例如下

在 utils.js 輸出 module:

export function first(str) {
  return str[0]
}

在 index.js 引入 module:

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

console.log(first('abc'))

在 index.html 載入 index.js:
若是想要在瀏覽器上直接使用 importexport,都必須以 module 的形式來執行,所以要在 script 標籤加上 type="module",這樣瀏覽器才看得懂

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

但是會發現,當我用瀏覽器打開 index.html 時,會出現一個 CORS 的錯誤,原因為:沒有辦法直接用檔案的方式開啟,必須要在 local 開一個 server

因此,就輸入指令 python -m SimpleHTTPServer 8081 在 local 開一個 server 後,就可以在網址列輸入 localhost:8081 連到同樣的檔案位置(index.html),打開 console 就可以看到 console.log(first('abc')) 的結果(就是 a)

但要注意的是,上面所說的這個「瀏覽器的 module 規範」是新的語法(在近幾年才出來),舊的瀏覽器並不支援

因此在使用 module 規範之後,就可以用一些工具來幫我們做轉換,產生出來的檔案就可以丟到瀏覽器上面執行

即使我在 JS 裡面是使用 require() 的語法,做轉換之後一樣可以在瀏覽器上面執行

webpack 的功用就是:module bundler,把我的 modules(各種資源) 都包在一起,然後拿去做一些轉換,我就可以在瀏覽器上面也使用這些 modules

webpack 把「module」的概念擴充到不只是 JS 的模組,還包括圖片、CSS 檔案、聲音等資源,都可以當作 module(資源)打包在一起

webpack 簡介

首先,要用 npm 來安裝 webpack

建立 npm 的專案

輸入指令 npm init 來產生出一個 package.json 檔案(這個專案就會是一個 npm 的專案了)

安裝 webpack

按照 文件的 Basic Setup,輸入指令 npm install webpack webpack-cli --save-dev 來安裝 webpack

在實務上,開發時會把 source code 寫在一個 src 資料夾裡面,經過一些工具(例如 webpack, babel)轉換之後再放到別的地方去

因此,先建立一個 src 資料夾,裡面建立兩個檔案:utils.js 和 index.js

方式一:採用 Node.js 使用 require() 的語法來引入 module

在 utils.js 輸出 module:

const Utils = {
  first: function(str) {
    return str[0]
  }
}

module.exports = Utils

在 index.js 引入 module:

const utils = require('./utils.js')

console.log(utils.first('abc'))

接下來,就是 webpack 要施展魔力的時刻了!
只要輸入指令 npx webpack,就可以啟用 webpack 來幫我打包

webpack 預設的設定就是會去找「src 資料夾裡面的 index.js 檔案」來當作程式的入口點(入口點的意思就是:程式一開始就會去執行這個檔案)

webpack 會把打包完的東西放在 dist 資料夾(distribution 的簡寫,要發布出去的意思),在 dist 資料夾就會有一個 main.js 檔案(就是 webpack 打包出來的檔案)

打開 main.js 會看到是已經打包好並壓縮過的程式碼(webpack 會自動幫我做 minify, uglify)

這時,就試試看是否能夠執行

先用 Node.js 的方式執行

輸入指令 node dist/main.js,有成功輸出結果 a

接著,要用瀏覽器的方式執行

在 index.html 加上這行,引入 main.js 檔案:

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

接著,用瀏覽器開啟 index.html,打開 console 就可以看到執行的結果了!(也就是 a)
原因為:
webpack 幫我們「在瀏覽器上實作了 require() 的功能」,然後再把 module 包在一起,讓我們在瀏覽器上也可以用 require() 的語法來使用模組

方式二:採用 ES6 的 export, import 語法來使用 module

utils.js 輸出 module:

export function first(str) {
  return str[0]
}

index.js 引入 module:

import $ from 'jquery' // 引入 jQuery
import { first } from './utils.js'

// 開始使用 jQuery
$(document).ready(() => {
  $('.btn').click(function() {
    alert(first('hello!'))
  })
})

新建一個 webpack 的設定檔

前面有提到,webpack 預設的設定就是會去找「src/index.js 檔案」來當作程式的入口點,如果我想要更改這個預設設定,就可以新建一個 webpack 的設定檔

webpack 的設定檔,預設的檔名叫做 webpack.config.js
設定檔的寫法可以參考官方文件 Using a Configuration

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
};
  • entry: './src/index.js' 就是程式的入口點,做為入口點的檔案會把 module 引入進來做一些事情(通常都會是 index.js),但如果就只有一個檔案的話,該檔案本身就會是入口點
    入口點(entry point),就是「主要要執行的檔案」
  • output 就是打包完的檔案,路徑會是 path.resolve(__dirname, 'dist')__dirname 就會去抓「我目前正在執行的資料夾 (__dirname 代表跟 config 檔同一個目錄)」,然後再放到底下的 dist 資料夾

在設定檔裡面還可以設定一個 mode

  • mode 如果沒有寫,預設就會是 production(生產環境、正式環境的版本),打包出來的檔案會是壓縮過的
  • mode 如果設定為 development(開發模式),打包出來的檔案就不會做壓縮,程式碼比較看得懂
const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

在 package.json 裡面新增一行 "build": "webpack"

{
  "name": "webpack-mtr04",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^5.4.0",
    "webpack-cli": "^4.2.0"
  }
}

這時,當我在 CLI 輸入指令 npm run build 時,就會幫我執行 build 的指令,也就是 webpack(這麼做的目的只是讓我更方便去執行 webpack 而已)

因此,當我輸入指令 npm run build 時 > 就會執行 webpack 指令 > webpack 會去找設定檔(webpack.config.js) > 依據設定檔去做打包

繼續探索 webpack

webpack 除了可以打包我的程式碼,也可以打包我從 npm 安裝的程式碼

例如:
jQuery 也有一個 npm 的版本,我用 npm 的方式引入 jQuery
用 npm 的方式引入跟「用 <script> 的方式引入」是完全不同的
首先,輸入指令 npm install jquery --save-dev 來安裝 jQuery

安裝好之後,在 index.js 就可以引入 jQuery:

const $ = require('jquery') // 引入 jQuery
const utils = require('./utils.js')

console.log(utils.first('abc'))

// 開始使用 jQuery
$(document).ready(() => {
  $('.btn').click(function() {
    alert('hello!')
  })
})

在 index.html 有一個 .btn 的按鈕:

  <button class="btn">click me</button>

記得要再執行一次 npm run build,webpack 才會再做一次打包

打包完後,點擊 .btn 就會出現 alert 了!

npm 是怎麼幫我引入套件的?

其實,用 npm 引入 library 一點都不神奇,它背後其實就是:
以引入 jQuery 為例
在專案資料夾底下,有一個 node modules 的檔案,打開後會找到裡面有一個 jquery 的資料夾 > 打開 jquery 資料夾裡面的 package.json 檔案,會看到有一個欄位是 main

"main": "dist/jquery.js",

main 的意思就是:當我用 require()import 引入 jquery 時,就是使用「dist/jquery.js」這個檔案裡面的程式碼(是入口點)

所以,npm 就是透過這樣的方式來幫我引入 jQuery

模組化的好處

有了模組化的好處是:
在前端開發時會有很多東西,我可以把不同的功能、library 都分散各個檔案(用不同的檔案去管理,結構可以切的比較明確),再用 webpack 幫我打包即可

以串接 api 為例
我不需要把串接 api 的程式碼都寫在 index.js 裡面
我可以把串接 api 的程式碼獨立出來寫在另一個檔案
然後在 index.js 裡面,我就只需要用 const API = require('./api.js') 引入,就可以在下面直接使用這個 api 了

const $ = require('jquery') // 引入 jQuery
const utils = require('./utils.js')
const API = require('./api.js') // 引入 api 的程式碼

console.log(utils.first('abc'))

// 開始使用 jQuery
$(document).ready(() => {
  $('.btn').click(function() {
    alert('hello!')
    API.getCountries(){ // 開始使用 api

    }
  })
})

webpack 還有另一個很厲害的功能是: loader
loader 可以決定我可以載入什麼檔案格式

例如:
我只要用 css 相關的 loader 就可以「用 JavaScript 的方式動態的載入 css 的檔案」(完全不需要用 <link rel="stylesheet" href="style.css"> 引入 css)

第一步:安裝 “style-loader” 和 “css-loader”

  • 輸入指令 npm install --save-dev style-loader 來安裝 style-loader
  • 輸入指令 npm install --save-dev css-loader 來安裝 css-loader

指令裡面的 --save-dev 的意思就是:安裝的這個套件會出現在 package.json 裡面的 devDependencies,只有在開發的時候會用到此套件,打包出去後就不會用到了

第二步:設定 css loader

在 webpack.config.js 新增一個區塊叫做 module.rules

const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};
  • test 就是:要針對什麼樣的檔案去套用這個 loader

這段 rule 的意思就是:針對所有「.css 結尾的檔案」,要套用 "style-loader" 和 "css-loader" 來把 css 檔案載入進來。檔案載入進來後,因為有了這兩個 loader,webpack 就會知道要怎麼去解析這個 css 檔案

  • css-loader 用來把 css 轉成某個形式
  • style-loader 用來把 css 放到 DOM 上面

第三步:開始寫 style.css

style.css 也要放在 src 資料夾裡面

style.css:

body{
  background-color: rgba(255, 0, 0, 0.2);
}
.btn{
  padding: 30px;
}

第四步:在 index.js 引入 css

要用 ./ 才會是相對路徑,代表:同資料夾底下的 style.css

import css from './style.css'; // 引入 style.css
import $ from 'jquery' // 引入 jQuery
import { first } from './utils.js'

// 開始使用 jQuery
$(document).ready(() => {
  $('.btn').click(function() {
    alert(first('hello!'))
  })
})

第五步:輸入指令 npm run build 讓 webpack 打包

webpack 打包完之後,就可以看到網頁已經套用我寫好的 css 樣式了

webpack 背後幫我做的事情是:
會先把 css 的內容當作一個字串(很大的字串)

`body{
  background-color: rgba(255, 0, 0, 0.2);
}
.btn{
  padding: 30px;
}`

然後透過 JavaScript 的方式先在 <head> 建立一個 <style> 的節點,然後再把這個字串動態的插入到<style> 裡面,因此 css 就可以套用在網頁上

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body{
      background-color: rgba(255, 0, 0, 0.2);
    }
    .btn{
      padding: 30px;
    }
  </style>
</head>

webpack 實戰

babel-loader

babel-loader 會先用 babel 幫我把 ES6 轉換成 ES5 之後,再把 JS 檔案打包

第一步:輸入指令 npm install -D babel-loader @babel/core @babel/preset-env 來安裝 babel-loader

第二步:設定 babel-loader

在 webpack.config.js 新增一個 rule:

const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      },
    ],
  },
};
  • exclude: /(node_modules|bower_components)/ 是因為:通常 node modules 裡面的檔案都已經被轉換過了,就不需要再轉換一次。bower_components 是另一個工具會用到的東西,也不需要被 babel 轉換
  • 這段 presets 可以寫在這裡,或是也可以寫在 .babelrc 裡面:
            options: {
              presets: ['@babel/preset-env']
            }
    

第三步:npm run build

sass-loader

第一步:輸入指令 npm install sass-loader sass --save-dev 來安裝 sass-loader

第二步:設定 sass-loader

在 webpack.config.js 新增一個 rule:

const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        use: [
          // Creates `style` nodes from JS strings
          "style-loader",
          // Translates CSS into CommonJS
          "css-loader",
          // Compiles Sass to CSS
          "sass-loader",
        ],
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      },
    ],
  },
};

第三步:開始寫 scss 檔案

$color: rgba(186, 189, 21, 0.2);
body{
  background-color: $color;
  .btn{
  padding: 30px;
  }
}

第四步:在 index.js 引入 scss 檔案

import css from './style.scss'; // 引入 style.scss

第五步:npm run build

DevServer 自動偵測檔案變動

詳細可參考 Using webpack-dev-server

DevServer 會自動偵測,當我的檔案有變動時(按下儲存),DevServer 就會自動幫我 compile(把有修改的地方重新載入)

可以讓我不用每次修改完程式碼都要自己手動再打包一次

第一步:輸入指令 npm install --save-dev webpack-dev-server 來安裝 DevServer

第二步:在 webpack.config.js 加上這段:

contentBase 的意思就是:tell the dev server where to look for files (dev server 要打開哪一個資料夾裡面的檔案)
This tells webpack-dev-server to serve the files from the dist directory on localhost:8080.

  devServer: {
  contentBase: './dist',
  },

第三步:在 package.json 新增一個指令 "start": "webpack serve --open 'safari'"

error: option '--open ' argument missing

Let's add a script to easily run the dev server.
"webpack serve --open 'safari'" 意思就是:用 safari 把 webpack dev server 打開

  "scripts": {
    "start": "webpack serve --open 'safari'",
    "build": "webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

第四步:npm run start

safari 就會自動把 dev server 打開了

要把 index.html 也放到 dist 資料夾裡面,dev server 才會打開 index.html
(index.html 載入 main.js 的路徑就要改成 <script src="main.js"></script>

這時,當我修改了程式碼,例如我改了 style.css 裡面的背景顏色,按下存檔後,在 dev server 的 index.html 就會重新 compile 成我剛剛修改的樣子了!

用 source map 方便 debug

詳細可參考 Using source maps

讓「打包後的程式碼」對應到「我原本的程式碼」,這樣在 console 做 debug 時,我就可以立刻知道錯誤是發生在我寫的哪一行

在 webpack.config.js 加上這段即可:

devtool: 'inline-source-map',

HtmlWebpackPlugin 自動產生一個 html 檔案

詳細可參考 HtmlWebpackPlugin

用 HtmlWebpackPlugin 這個 plugin 就可以自動幫我產生出一個 html 檔案,我就不需要自己建一個 html

注意,會需要用到 HtmlWebpackPlugin 是因為,有很多會用到 webpack 的網頁或 APP,它們的 html 元素都是用 JS 動態產生的,所以在 html 裡面不會有任何的元素

而且當我在使用 webpack 時,因為 CSS 和 JS 都是寫在獨立的檔案,所以 index.html 幾乎就沒有什麼內容了

第一步:輸入指令 npm install --save-dev html-webpack-plugin 來安裝 HtmlWebpackPlugin

第二步:在 webpack.config.js 加上這兩段:

把 HtmlWebpackPlugin 引入進來

var HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports 裡面加上這行:

plugins: [new HtmlWebpackPlugin()]

這樣,webpack 就會自動幫我產生出一個 html 檔案,裡面放入我要載入的 CSS 和 JS

gulp 跟 webpack 差多了好嗎?

gulp 跟 webpack 在本質上是「完全不一樣的」

gulp 的產品定位是「task manager」,task 的內容可以非常多元(基本上可以做到任何事情)

gulp 裡面有很多任務,我可以自己決定「每一個 task 的內容要是什麼」,基本上,只要我能夠寫出這個 task,gulp 就什麼事情都可以做到。例如:

  • 校正時間
  • 抓取某一個網站的圖片
  • 定時 call API
  • 用 babel 轉換程式碼
  • 把 scss 編譯成 css

「gulp 本身」做不到的事情是:bundle
但是,gulp 可以透過 webpack-plugin 去打包

webpack 的定位則是「bundler」

我有很多資源(例如 .js, .scss, img),webpack 可以幫我把這些資源都 bundle 在一起

在 bundle 之前,需要透過 loader 把 .js, .scss, img 檔案載入進去 webpack ,webpack 再把這些檔案包起來

在 loader 載入檔案時,就可以順便做一些「資源的轉換」(這就是為什麼會跟 gulp 很像的原因)

例如:

  • babel loader -> 可以把 .js 檔案先經過 babel 轉換之後,再載入進去 webpack
  • scss loader -> 可以把 .scss 檔案先編譯成 css 之後,再載入進去 webpack

webpack 做不到的事情,例如:校正時間、定時 call API
webpack 主要的功能就是 bundle

那為什麼需要 bundle?

因為瀏覽器原生沒有支援 require() 語法,所以要引入 module(資源)很不方便。有 webpack 幫我 bundle 這些資源之後,瀏覽器就可以支援 require() 語法了


#Webpack







Related Posts

WEB 網路基礎概念

WEB 網路基礎概念

使用 AntV 製作資料圖表-台灣老年人口與長照機構供需比

使用 AntV 製作資料圖表-台灣老年人口與長照機構供需比

Day 2 - Google Apps Script 設置

Day 2 - Google Apps Script 設置


Comments