Webpack是JavaScript的模組建置工具,運行在Node.js上,它可以將零散的JavaScript檔案用各式工具優化並打包起來,加快網頁的載入時間。Webpack也並不限於用在JavaScript上,舉凡網頁有用到的靜態資源(如JS、CSS、圖片檔等),甚至是HTML網頁,Webpack都有辦法打包。



Webpack官方網站:

https://webpack.js.org/

官方網站上給的一張圖例,簡潔扼要地說明了Webpack的功用:

webpack

Webpack的Node.js環境

Webpack官方建議使用Node.js最新的LTS版本來運行Webpack。Windows或是macOS的使用者可以直接到Node.js官方網站下載到Node.js的安裝檔,而Linux作業系統的使用者可以參考這篇文章來安裝指定版本的Node.js。

建立Webpack專案

Webpack可以被加進現有的Node.js專案中,在終端機執行以下指令來安裝webpackwebpack-cli這兩個套件。

npm i -D webpack webpack-cli

指令中的-D參數即是--save-dev

接著在package.json中加入新的腳本指令:

{
  ...

  "scripts": {
    ...

    "build": "webpack --mode production",
    "dev": "webpack --mode development"

    ...
  }

  ...
}

production其實就是Webpack預設的模式,它會將NODE_ENV設為production,並且會自動啟用它自帶的一些相關外掛。development則會將NODE_ENV設為development,也會自動啟用它自帶的一些相關外掛。

再來就是在Node.js專案根目錄中,新增Webpack的設定檔webpack.config.js,內容可以初始如下:

module.exports = {
    plugins: [],
    module: {
        rules: []
    }
};

使用npm指令打包Webpack專案

由於我們已經事先在package.json中加入builddev腳本指令了,因此可以直接使用npm run buildnpm run dev指令來執行它們。

進入點(Entry Point)和輸出

Webpack會去執行某個或是某幾個JavaScript檔案作為進入點,預設的進入點只有一個,路徑為src/index.js,且其輸出的JS檔案的預設路徑為dist/main.js。(其實也可以指定非JS檔案作為程式進入點,但筆者覺得這樣結構會比較亂。)

如果要修改進入點的路徑,要在webpack.config.js設定檔中,加上entry欄位,設定方式如下:

module.exports = {
    ...

    entry: './path/to/js-file'

    ...
};

例如:

module.exports = {
    ...

    entry: './src/app.js'

    ...
};

至於進入點的JS程式寫法,稍候會再提到。

如果要修改輸出的JS檔案的路徑,要在webpack.config.js設定檔中,加上output欄位,設定方式如下:

module.exports = {
    ...

    output: {
        path: '/path/to/folder',
        filename: './path/to/js-file'
    }

    ...
};

output欄位中的path欄位表示要儲存輸出檔(不限於JS檔)的目錄,注意這個路徑必須要是絕對路徑!而filename欄位則是輸出的JS檔要儲存的路徑,注意這個路徑必須要是相對路徑,才能使輸出檔存在於path欄位的絕對路徑下。當然,如果您想要讓輸出檔放置在Node.js專案的根目錄下的dist目錄的話,可以不必設定path欄位。

例如:

const path = require('path');

module.exports = {
    ...

    output: {
        path: path.join(__dirname, 'output'),
        filename: './js/bundle.min.js'
    }

    ...
};

以上的__dirname即表示目前執行的JS檔案(此處即webpack.config.js)的所在目錄之絕對路徑(此處即Node.js專案的根目錄)。

如果要設定多個進入點,entry欄位和output欄位的設定方式如下:

const path = require('path');

module.exports = {
    ...

    entry: {
        'name-1': './path/to/js-file-1',
        'name-2': './path/to/js-file-2'
    },
    output: {
        path: path.join(__dirname, 'output'),
        filename: './js/[name].min.js'
    }

    ...
};

filename欄位的字串中加入[name],可以讓Webpack自動用進入點的名稱來取代它。如果entry欄位有設定多個進入點,但是沒有設定output欄位的話,Webpack預設的output欄位值如下:

const path = require('path');

module.exports = {
    ...

    output: {
        path: path.join(__dirname, 'dist'),
        filename: './[name].js'
    }

    ...
};

如果網頁的靜態資源有需要使用CDN(Content Delivery Network),output欄位值可以再加上publicPath欄位,來替輸出檔的網址連結加上前綴,如此一來在HTML或是CSS中有用到的靜態資源網址連結就可以被取代為CDN的網址連結。

例如:

const path = require('path');

module.exports = {
    ...

    output: {
        path: path.join(__dirname, 'output'),
        publicPath: 'https://cdn.example.com/',
        filename: './js/bundle.min.js'
    }

    ...
};

為了確保HTML或是CSS的版本和其連結到的靜態資源的版本是一樣的,我們可以在pathpublicPath欄位值的字串中使用[hash],讓Webpack自動用專案的雜湊值來取代它。

例如:

const path = require('path');

module.exports = {
    ...

    output: {
        path: path.join(__dirname, 'output', '[hash]'),
        publicPath: 'https://cdn.example.com/[hash]/',
        filename: './js/bundle.min.js'
    }

    ...
};

在打包前先清空目錄

Webpack在打包專案前並不會先將輸出目錄清空,因此一不小心就會留有前次產生出來但現在已經沒有用到的檔案。所以要使用第三方外掛clean-webpack-plugin來解決這個問題,這也可以說是Webpack必裝的好用外掛,沒有之一。

執行以下指令,來將clean-webpack-plugin安裝到目前的Node.js專案中。

npm i -D clean-webpack-plugin

webpack.config.js設定檔中,將CleanWebpackPlugin的實體加進plugins欄位的陣列中。

如下:

const {CleanWebpackPlugin} = require('clean-webpack-plugin');

module.exports = {
    ...

    plugins: [
        ...

        new CleanWebpackPlugin()

        ...
    ]

    ...
};

打包JavaScript檔案

Babel

現在開發JavaScript程式大多是靠Node.js,Node.js能夠支援的ECMAScript版本很新(可以查看這個網頁),但是主流的網頁瀏覽器對於比較新的ECMAScript語法並沒有完全支援(可以查看這個網頁)。所以為了使Node.js開發的JavaScript程式能夠在各大網頁瀏覽器上正常運行,我們需要使用「Babel」這套工具來轉換JS語法。

在終端機執行以下指令來安裝babel-loader@babel/core@babel/register@babel/preset-env這四個套件。

npm i -D babel-loader @babel/core @babel/register @babel/preset-env

webpack.config.js設定檔中,於module欄位的rules陣列內,加入Babel的載入器(loader)規則。Webpack會去讀取進入點的JS檔案所require或是import的檔案,以及這些檔案再引用的檔案(例如被引用的JS檔案又去引用其它的JS檔案,或是被引用的SCSS檔案又去引用其它的SCSS檔案,又或是被引用的CSS檔案又去引用其它的圖片檔案),這些靜態資源的檔案路徑,除了JS檔案外,都必須要在rules陣列中被匹配到,並指派正確的載入器來負責處理,否則Webpack就無法成功打包專案。

雖然JS檔可不必寫在rules陣列中來匹配,但是如果我們要指定別的載入器就得要寫了。Babel的載入器規則寫法如下:

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }

            ...
        ]
    }

    ...
};

混淆與最小化JS程式

為了避免我們辛苦開發的JavaScript程式碼被輕易盜用,同時也希望Webpack打包出來的JS檔案可以愈小愈好,那就要替Webpack加上Terser外掛。這個Terser的前身其實就是UglifyES

在終端機執行以下指令來安裝terser-webpack-plugin這個套件。

npm i -D terser-webpack-plugin

事實上,Webpack本身就自帶terser-webpack-plugin套件,因此以上指令實際上並不會再另外安裝套件。但誰也不能保證在未來Webpack會不會就把terser-webpack-plugin套件拿掉了,所以還是用指令將它加到自己專案的package.json中比較好。

webpack.config.js設定檔中,加入optimization欄位,其底下還有個minimizer欄位,值為一個陣列,接著將terser-webpack-plugin的實體加進minimizer欄位的陣列中。

如下:

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
    ...

    optimization: {
        minimizer: [
            new TerserPlugin({
                cache: true,
                parallel: true,
                terserOptions: {
                    output: {
                        comments: false,
                    }
                }
            })
        ]
    }

    ...
};

cacheparallel欄位設為true可以加快Terser進行混淆和最小化的速度。之所以不把terser-webpack-plugin的實體放進plugins欄位的陣列中,是為了要讓它只在使用npm run build來建置專案時,也就是production環境時,才會作用。而在執行npm run build時,Webpack預設其實就會去啟用terser-webpack-plugin,只是我們沒有辦法對其進行額外的設定,所以才要自行加入我們自己產生的terser-webpack-plugin實體。

打包任意檔案

將檔案引用至JavaScript中

JavaScript原先的require函數和import關鍵字只能夠引用JavaScript程式,但是在Webpack的框架下,它們要引用什麼樣的類型的檔案都是可以的。因此如果要打包非JS檔案的話,只要在進入點的JS檔案中,加上如以下的程式即可:

import './path/to/file';

檔案載入器

前面有提到過,靜態資源的檔案路徑,除了JS檔案外,都必須要在webpack.config.js設定檔中的rules陣列內被匹配到,並指派正確的載入器來負責處理,否則Webpack就無法成功打包專案。

file-loader是一個經常被拿來協助載入任意檔的套件,可以在終端機執行以下指令來安裝。

npm i -D file-loader

webpack.config.js設定檔中,於module欄位的rules陣列內,加入檔案載入器的規則。若是要載入如SVG、JPEG、PNG、GIF、WEBP等網頁常用的圖片格式,以及EOT、WOFF、WOFF2、TTF、OTF、SVG等網頁常用的字型格式的話,規則如下:

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.(eot|woff|woff2|[ot]tf)$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './fonts/',
                        publicPath: '/fonts/'
                    }
                }
            },
            {
                test: /.*font.*\.svg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './fonts/',
                        publicPath: '/fonts/'
                    }
                }
            },
            {
                test: /^(?!.*font).*\.svg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './images/',
                        publicPath: '/images/'
                    }
                }
            },
            {
                test: /\.(jpe?g|png|gif|webp)$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './images/',
                        publicPath: '/images/'
                    }
                }
            }

            ...
        ]
    }

    ...
};

如果想保留靜態資源檔案的檔名和副檔名,就如上面規則中,把name欄位值設為[name].[ext],如果不設定name欄位,就會以雜湊值當作是輸出檔名。這邊比較有問題的地方會是publicPath欄位的設定,如果您需要將有用到其它靜態資源檔案的HTML或CSS打包進JS檔案的話,這個publicPath欄位值應該要用一個絕對路徑(因為網頁很可能不會全都擠在同一個網址層級下),或是CDN的網址(不過如果有用CDN,那網址應該就已經設在output欄位下的publicPath欄位了,這邊就不需要再設一次)。但是用絕對路徑的話,網頁的靜態資源檔案就只能放置在指定的網站目錄中,如果要更改,又要重新打包才行。

如果沒有絕對路徑的需求,將publicPath欄位的值設為相對路徑或是不設定會比較方便一些。如下:

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.(eot|woff|woff2|[ot]tf)$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './fonts/',
                        publicPath: '../fonts/'
                    }
                }
            },
            {
                test: /.*font.*\.svg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './fonts/',
                        publicPath: '../fonts/'
                    }
                }
            },
            {
                test: /^(?!.*font).*\.svg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './images/',
                        publicPath: '../images/'
                    }
                }
            },
            {
                test: /\.(jpe?g|png|gif|webp)$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './images/',
                        publicPath: '../images/'
                    }
                }
            }

            ...
        ]
    }

    ...
};

如此一來,待會兒要介紹的CSS檔案如果都被輸出在css目錄中,也可以正常連結到這些靜態資源檔案。而且這些包好的靜態資源不管被部署在網站的哪層目錄下都是可以正常使用的。

打包SCSS/CSS檔案

SCSS是用來產生CSS的程式,提供比CSS還要更方便的語法結構,也相容原本的CSS。若您原先只用CSS,在使用Webpack時改用SCSS,可以讓CSS變得容易維護許多。SCSS(Sassy CSS)是Sass提供的一種新格式,而Sass原本在用的舊格式也稱為Sass。

Sass/SCSS的官方網站:

https://sass-lang.com/

為了能夠讓Webpack在打包時候去解析SCSS/CSS檔案,我們不能直接指派檔案載入器來處理SCSS/CSS檔案。

Sass/SCSS/CSS載入器

sass-loader是一個能夠讀取Sass、SCSS和CSS檔案的載入器,檔案在被讀取後會被轉成CSS格式,可以在終端機執行以下指令來安裝。

npm i -D sass-loader node-sass

webpack.config.js設定檔中,於module欄位的rules陣列內,加入Sass/SCSS/CSS載入器的規則。如下:

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    'sass-loader'
                ]
            }

            ...
        ]
    }

    ...
};

在這邊的規則中,use欄位值使用了陣列結構,表示要使用多個載入器。接下來繼續再介紹更多相關的載入器。

PostCSS載入器

postcss-loader是一個能對CSS做前處理的載入器。autoprefixerpostcss-loader的外掛,可以自動添加所有網頁瀏覽器的CSS前綴。cssnano也是postcss-loader的外掛,可以最小化CSS。在終端機執行以下指令來安裝這些套件:

npm i -D postcss-loader autoprefixer cssnano

webpack.config.js設定檔中,於sass-loader之上,加入PostCSS載入器。如下:

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: function () {
                                return [
                                    require('autoprefixer'),
                                    require('cssnano')({preset: ['default', {discardComments: {removeAll: true}}]})
                                ];
                            }
                        }
                    },
                    'sass-loader'
                ]
            }

            ...
        ]
    }

    ...
};

關於autoprefixer的作用,這邊再舉個例子。例如以下CSS語法:

.example {
    display: grid;
    transition: all .5s;
    user-select: none;
    background: linear-gradient(to bottom, white, black);
}

經過postcss-loaderautoprefixer處理後會變成:

.example {
    display: grid;
    -webkit-transition: all .5s;
    transition: all .5s;
    -webkit-user-select: none;
       -moz-user-select: none;
        -ms-user-select: none;
            user-select: none;
    background: -webkit-gradient(linear, left top, left bottom, from(white), to(black));
    background: linear-gradient(to bottom, white, black);
}

如此一來就不必太擔心同一個CSS到不同的網頁瀏覽器上顯示出來會有很大的不同啦!

CSS載入器

css-loader才是真正讀取CSS的載入器,它會去尋找CSS中使用@import或是url()引用的其它CSS檔案或是圖片檔案,將它們也丟給Webpack一起打包。在終端機執行以下指令來安裝這個套件:

npm i -D css-loader

webpack.config.js設定檔中,於postcss-loader之上,加入CSS載入器。如下:

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: function () {
                                return [
                                    require('autoprefixer'),
                                    require('cssnano')({preset: ['default', {discardComments: {removeAll: true}}]})
                                ];
                            }
                        }
                    },
                    'sass-loader'
                ]
            }

            ...
        ]
    }

    ...
};

輸出CSS

光有CSS載入器的話會把CSS檔案包進JS檔案中一起輸出(若再加上style-loader,就能讓CSS在JS執行之後被注入到HTML中,但筆者不太喜歡這樣的作法),如果要把CSS檔案從JS檔案中分離出來的話,我們還需要加上mini-css-extract-plugin這個外掛,可以在終端機執行以下指令來安裝。

npm i -D mini-css-extract-plugin

webpack.config.js設定檔中,將mini-css-extract-plugin的實體加進plugins欄位的陣列中。

如下:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    ...

    plugins: [
        ...

        new MiniCssExtractPlugin({
            filename: './path/to/css-file',
        })

        ...
    ]

    ...
};

filename欄位用來設定CSS檔案的輸出路徑,其設定方式和JS檔案的filename是一樣的。如果不設定filename欄位,預設值會是./[name].css

接著也還需要在webpack.config.js設定檔中,於postcss-loader之上,加入mini-css-extract-plugin提供的載入器。

如下:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    ...

    module: {
        rules: [
            ...

            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader
                    },
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: function () {
                                return [
                                    require('autoprefixer'),
                                    require('cssnano')({preset: ['default', {discardComments: {removeAll: true}}]})
                                ];
                            }
                        }
                    },
                    'sass-loader'
                ]
            }

            ...
        ]
    }

    ...
};

移除無用的CSS

現在開發網頁很少自己從無到有把CSS寫出來,通常會直接套用如Bootstrap等的CSS框架,但是我們不太可能將CSS框架提供的CSS全都應用上,肯定會留有完全沒有被使用到CSS設定。這些沒有用到的CSS在沒有經過特殊處理的情況下也還是會被網頁瀏覽器給讀取,不僅會佔用伺服器流量,網頁的載入時間也會比較長。

為了移除沒有用到的CSS,我們需要安裝purgecss-webpack-plugin外掛,以及方便用來取得檔案路徑的glob套件。可以在終端機執行以下指令來安裝這些套件:

npm i -D purgecss-webpack-plugin glob

webpack.config.js設定檔中,將purgecss-webpack-plugin的實體加進plugins欄位的陣列中。

如下:

const path = require('path');
const glob = require('glob');
const PurgecssPlugin = require('purgecss-webpack-plugin');

const PATHS = {
    views: path.resolve(__dirname, '/path/to/html-or-templates-folder'),
};

module.exports = {
    ...

    plugins: [
        ...

        new PurgecssPlugin({
            whitelist: function () {
                return [];
            },
            whitelistPatterns: function () {
                return [];
            },
            whitelistPatternsChildren: function () {
                return [];
            },
            paths: glob.sync(`${PATHS.views}/**/*`, {nodir: true}),
        })

        ...
    ]

    ...
};

以上程式中,用了PATHS變數來儲存一些固定、可能會被重複使用的路徑。views這個路徑是用來放置HTML網頁檔案或是HTML模板檔案的目錄。在purgecss-webpack-plugin外掛的選項中,paths欄位的值為多個路徑的字串陣列,一個路徑應指向一個純文字檔案,purgecss-webpack-plugin會自動從這些路徑指到的多個純文字檔案中分析出現過的字詞,並記錄下來。如果CSS的元素名稱並沒有出現在這些字詞中,該元素就會被刪除,除非該名稱存在於白名單之中。

purgecss-webpack-plugin的白名單分為三種,會被同時使用。

首先是whitelist,這個是很單純的名稱對應。例如:

whitelist: function () {
    return ['example'];
}

若輸入的CSS為:

.example {
    width: 100%;
}

.example p {
    font-size: 15px;
}

#example {
    width: 100%;
}

.example-2 {
    width: 100%;
}

經過白名單的檢查之後,只會剩下:

.example {
    width: 100%;
}

#example {
    width: 100%;
}

如果將白名單改為:

whitelist: function () {
    return ['example', 'p'];
}

繼續使用以上的例子,最後它被保留的CSS元素則為:

.example {
    width: 100%;
}

.example p {
    font-size: 15px;
}

#example {
    width: 100%;
}

再來是第二種白名單whitelistPatterns,這個也是用來對應名稱,但是可以使用正規表示式來匹配元素名稱。例如:

whitelistPatterns: function () {
   return [/^example/];
}

繼續使用同樣的例子,最後它被保留的CSS元素為:

.example {
    width: 100%;
}

#example {
    width: 100%;
}

.example-2 {
    width: 100%;
}

注意這邊是.example p被刪除了!因為p這個名稱並沒有被匹配到。

如果想要讓.example p也被保留下來,可以使用第三種白名單whitelistPatternsChildren,用這個白名單的正規表示式匹配成功的元素名稱,其後面的其它所有名稱都會被忽略。

例如把剛才的whitelistPatterns白名單直接換成whitelistPatternsChildren,繼續使用同樣的例子,最後例子中所有的CSS元素都可以被保留下來。

HTML網頁

Webpack的HTML網頁大致上可以分為兩種,一種是將主要的CSS語法甚至是HTML語法都寫進JavaScript中,而HTML檔案內只有撰寫如下的內容:

<!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8"/>
        <title>Title</title>
        <!-- 可能會有簡單的CSS -->
    </head>
    <body>
        <!-- 可能會有讀取畫面 -->
        <script type="text/javascript" src="./path/to/bundle.min.js"></script>
    </body>
</html>

當網頁瀏覽器開啟這個HTML網頁時,網頁瀏覽器就會下載JavaScript檔案來執行,接著將HTML和CSS注入到HTML網頁中,此時網頁才會顯示出真正的內容。

這種網頁通常會利用大量的JavaScript程式來完成畫面的操作流程,讓使用者在操作網頁時不太需要載入新網頁,但這種網頁在開啟的時候通常會有一個等待時間。這部份就留在其它篇文章再做介紹。

這篇文章要介紹的是第二種的網頁,就是傳統將HTML語法直接寫在HTML檔案內的作法。例如:

<!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8"/>
        <title>Title</title>
        <script type="text/javascript" src="./path/to/bundle.min.js"></script>
        <link href="./path/to/bundle.min.css" rel="stylesheet">
    </head>
    <body>
        <main role="main" class="container">
            <h1 class="mt-5">歡迎</h1>
            <p class="lead">這是我用Webpack做的第一個網頁。</p>
            <p>如果你也有興趣的話可以瀏覽<a href="https://magiclen.org/webpack/">這篇文章</a>。</p>
        </main>
    
        <footer class="footer">
            <div class="container">
                <span class="text-muted">沒有著作權,歡迎分享</span>
            </div>
        </footer>
    </body>
</html>

其實這種網頁,HTML的部份也不太需要用Webpack來打包,不過放進Webpack框架中有個最大的好處,那就是可以讓它被最小化!

最小化HTML網頁

html-webpack-plugin外掛可以幫助我們產生HTML檔案(如剛才提到的第一種網頁),或是將現有的HTML檔案最小化。可以在終端機執行以下指令來安裝這個套件:

npm i -D html-webpack-plugin

webpack.config.js設定檔中,將html-webpack-plugin的實體加進plugins欄位的陣列中,有幾個HTML檔案就加幾個html-webpack-plugin的實體。

如下:

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

module.exports = {
    ...

    plugins: [
        ...

        new HtmlWebpackPlugin({
            template: './path/to/original-html-file',
            filename: './path/to/output-html-file',
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true
            },
            inject: false
        })

        ...
    ]

    ...
};

filename欄位用來設定HTML檔案的輸出路徑,其設定方式和JS檔案的filename是一樣的。

如此一來,經過Webpack打包後就會得到最小化的HTML檔案。

把HTML檔案當作進入點來用

事實上有個html-loader載入器,它可以去解析HTML中有連結用到的靜態資源網址,並且如css-loader載入器一樣可以做到取代網址的動作。換句話說,利用它可以使HTML檔案也能夠作為進入點,去打包其有用到的靜態檔案。不過這個載入器的問題有點多,不去用它還比較省時間。

舉一些例子來說明常見問題

關於libraryTarget

現在Node.js專案中有以下幾個檔案。

export function greet(name) {
    alert('Hello, ' + name + '!');
}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>Greeting</title>
        <script src="./js/bundle.min.js"></script>
    </head>
    <body>
        You should see an alert message.
    
        <script>
            greet('Webpack');
        </script>
    </body>
</html>
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

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

module.exports = {
    output: {
        filename: './js/bundle.min.js'
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            template: './views/index.html',
            filename: './index.html',
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true
            },
            inject: false
        })
    ],
    module: {
        rules: [
            {
                test: /\.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
        ]
    },
    optimization: {
        minimizer: [
            new TerserPlugin(
                {
                    cache: true,
                    parallel: true,
                    terserOptions: {
                        output: {
                            comments: false,
                        }
                    }
                }
            )
        ]
    }
};

記得用以下指令安裝所需的套件:

npm i -D webpack webpack-cli clean-webpack-plugin babel-loader @babel/core @babel/register @babel/preset-env terser-webpack-plugin html-webpack-plugin

在執行npm run build之後,用網頁瀏覽器開啟dist/index.html。我們預期會看到一個對話框,上面寫著Hello, Webpack,然而實際上並沒有,而且網頁瀏覽器的主控台(console)還跳出錯誤訊息,說是greet函數沒有定義。

這個其實是因為我們在webpack.config.jsoutput欄位中,並沒有去設定library欄位的值,如果我們將library欄位的值設為lib,如下:

module.exports = {
    ...

    output: {
        ...

        library: 'lib'

        ...
    }

    ...
};

就可以將HTML語法改成以下這樣來呼叫到greet函數。

...

    <body>
        ...
    
        <script>
            lib.greet('Webpack');
        </script>
    </body>

...

為什麼會這樣?這是因為若我們沒有在webpack.config.jsoutput欄位中設定libraryTarget欄位的值,這個值預設為var,在var模式下,用來設定全域變數的library欄位的值若沒有設定,就無法正常使用了,因為沒有一個JS全域變數有儲存到我們的JS模組。

如果要將我們的JS模組指派給網頁瀏覽器下的window物件來儲存的話,可以將libraryTarget欄位的值改為window,此時的library欄位的值就代表我們的JS模組會存在window物件的哪個欄位。例如:

module.exports = {
    ...

    output: {
        ...

        libraryTarget: 'window',
        library: 'lib'

        ...
    }

    ...
};
...

    <body>
        ...
    
        <script>
            window.lib.greet('Webpack');
        </script>
    </body>

...

不過由於網頁中的JavaScript會自動去看沒有被定義的變數是否是window物件的屬性,所以HTML中的JS程式其實也可以寫成最一開始的模樣:

...

    <body>
        ...
    
        <script>
            lib.greet('Webpack');
        </script>
    </body>

...

libraryTarget欄位的值為window,但沒有設定library欄位的值的話,我們的JS模組內有被暴露(export)的項目會被塞給window物件。如此一來就可以直接在網頁中呼叫greet函數了!

...

    <body>
        ...
    
        <script>
            greet('Webpack');
        </script>
    </body>

...

不過最多人喜歡使用的libraryTarget欄位值應該是umd,它除了有支援root(即windowglobal,要看webpack.config.jstarget是設為Node.js還是網頁瀏覽器,預設為web,即網頁瀏覽器)外,還支援AMD(Asynchronous Module Definition,用define來引用模組;用return來暴露)、CommonJS(用require來引用模組;用module.exportsexports來暴露)等JS模組化的機制。

如果library欄位值為lib,它的JS輸出格式會像是這樣:

(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object') // CommonJS
		module.exports = factory();
	else if(typeof define === 'function' && define.amd)           // AMD
		define([], factory);
	else if(typeof exports === 'object')                          // CommonJS
		exports["lib"] = factory();
	else                                                          // root
		root["lib"] = factory();
})(window /* or global */, ...);

如果library欄位沒有設定,它的JS輸出格式會像是這樣:

(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object') // CommonJS
		module.exports = factory();
	else if(typeof define === 'function' && define.amd)           // AMD
		define([], factory);
	else {                                                        // root
		var a = factory();
		for(var i in a) (typeof exports === 'object' ? exports : root)[i] = a[i];
	}
})(window /* or global */, ...);

在Webpack中用Vue.js

Vue.js是一個非常方便、可在前端或後端運作的框架兼模板引擎,但是它被Webpack打包後,會出現問題,導致無法正常使用。

現在Node.js專案中有以下幾個檔案。

import Vue from 'vue';

export function init() {
    new Vue({
        el: '#main',
        data: {
            message: 'Hello, Webpack!'
        },
        mounted: function () {
            alert('Mounted!');
        }
    });
}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>Greeting</title>
        <script src="./js/bundle.min.js"></script>
    </head>
    <body>
        <div id="main">
            {{message}}
        </div>

        <script>
            init();
        </script>
    </body>
</html>
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');

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

module.exports = {
    output: {
        filename: './js/bundle.min.js'
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            template: './views/index.html',
            filename: './index.html',
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true
            },
            inject: false
        })
    ],
    module: {
        rules: [
            {
                test: /\.js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env']
                    }
                }
            }
        ]
    },
    optimization: {
        minimizer: [
            new TerserPlugin(
                {
                    cache: true,
                    parallel: true,
                    terserOptions: {
                        output: {
                            comments: false,
                        }
                    }
                }
            )
        ]
    }
};

記得用以下指令安裝所需的套件:

npm i -D webpack webpack-cli clean-webpack-plugin babel-loader @babel/core @babel/register @babel/preset-env terser-webpack-plugin html-webpack-plugin

也記得要安裝Vue.js:

npm i --save vue

在執行npm run build之後,用網頁瀏覽器開啟dist/index.html。我們預期會看到一個對話框,上面寫著Mounted!,同時網頁上也會顯示Hello, Webpack。然而,實際上我們確實看到該對話框,但是網頁卻是一片空白。此時查看網頁瀏覽器的主控台,並不會發現相關的錯誤訊息。

這個問題的解決方法很簡單,只要把原本的import 'vue',改成以下這樣就好了。

import Vue from 'vue/dist/vue.esm';

...

好像是因為原先那樣的引用方式,只會引用到Vue.js的Runtime,導致HTML並沒有處理到可以被網頁瀏覽器顯示出來的地步,所以要改用Standalone版本的Vue.js。其實除了vue.esm能正常使用之外,vue.common也是可以的。

不過這樣做只能保證自己做的模組是沒問題的,如果有用到別人的模組,可能還是會遭遇同樣的狀況。因此我們可以利用Webpack提供的假名(alias)機制,將所有引用的vue都替換為vue/dist/vue.esm.jswebpack.config.js修改如下:

module.exports = {
    ...

    resolve: {
        alias: {
            'vue$': 'vue/dist/vue.esm.js'
        }
    }

    ...
};

用Webpack打包Font Awesome

Font Awesome是非常多人用的Icon Font,但是它在Webpack下使用時有個地方需要注意,否則無法正常打包。

現在Node.js專案中有以下幾個檔案。

import './index.scss';
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/solid';
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Apple</title>
    <link href="./css/bundle.min.css" rel="stylesheet">
</head>
<body>
    <i class="fas fa-apple-alt fa-10x"></i>
</body>
</html>
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

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

module.exports = {
    output: {
        filename: './js/bundle.min.js',
        libraryTarget: 'umd'
    },
    plugins: [
        new CleanWebpackPlugin(),
        new HtmlWebpackPlugin({
            template: './views/index.html',
            filename: './index.html',
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true
            },
            inject: false
        }),
        new MiniCssExtractPlugin({
            filename: './css/bundle.min.css',
        })
    ],
    module: {
        rules: [
            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader
                    },
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: function () {
                                return [
                                    require('autoprefixer'),
                                    require('cssnano')({preset: ['default', {discardComments: {removeAll: true}}]})
                                ];
                            }
                        }
                    },
                    'sass-loader'
                ]
            },
            {
                test: /\.(eot|woff|woff2|[ot]tf)$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './fonts/',
                        publicPath: '../fonts/'
                    }
                }
            },
            {
                test: /.*font.*\.svg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: './fonts/',
                        publicPath: '../fonts/'
                    }
                }
            }
        ]
    }
};

記得用以下指令安裝所需的套件:

npm i -D webpack webpack-cli file-loader clean-webpack-plugin sass-loader node-sass postcss-loader autoprefixer cssnano css-loader mini-css-extract-plugin html-webpack-plugin

也記得要安裝Font Awesome:

npm i -D @fortawesome/fontawesome-free

但在執行npm run build時,會出現如下的錯誤訊息:

ModuleNotFoundError: Module not found: Error: Can't resolve '../webfonts/fa-solid-900.eot'

這個是因為Sass/SCSS在使用@import關鍵字引用其它Sass/SCSS檔案(不包含CSS檔案)時,貌似是直接拿目標檔案的內容來取代掉那行@import關鍵字,所以如果目標檔案的內容有用到相對路徑就會出問題。解決的方法有兩種,第一種就是改成去@importCSS檔案(如果有的話),如下:

@import '~@fortawesome/fontawesome-free/css/fontawesome.css';
@import '~@fortawesome/fontawesome-free/css/solid.css';

第二種就是去設定Sass/SCSS中儲存著路徑前綴的變數,以Font Awesome來說,這個變數為$fa-font-path。如下:

$fa-font-path: "~@fortawesome/fontawesome-free/webfonts/";
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/solid';

改寫之後npm run build就可以正常被執行。用網頁瀏覽器開啟dist/index.html,就可以看到大大的蘋果圖示。