--- tags: 前端 --- # Webpack打包範例 - 我全包了 ## 寫網頁這麼簡單,為什麼沒事還要打包 不管是用傳統vallina JS,還是最新最潮es6~es10的語法,都有語法編譯、相容性、檔案優化的問題,您可能聽過pug(前身jade), sass, scss, babel, polyfill,這些東西可以讓寫網頁變得簡潔有力、易於快速修改、解決跨瀏覽器的問題,甚至可以避免讓辛苦寫好的code輕易被盜用,然而這些語法要個別去下指令,編譯成可執行的html, css, js檔案,實在是花太多功夫了;所以出現了前端打包工具,從pipeline風格的gulp、架構化的webpack,標榜零設定的parcel或snowpack,同時這個作法也逐漸流行起來,以下介紹目前最廣泛使用的webpack。 ## 啥是webpack ![](https://i.imgur.com/gzG9Gdl.png) 取自 https://webpack.js.org/ ,如上圖所示,我們的目標是要優化雜亂的檔案 過去處理一個個檔案就要一個個的去打指令,gulp的pipe功能讓一個指令可以輪流觸發這些動作;而webpack更是將這些處理模式有個架構,長得像這樣 | entry | module | plugins | output | |-|-|-|-| | 進入點 | 各類型檔案的前置處理 | 額外插件的後製 | 輸出點 | :::info **這個架構看起來夠直覺,讓我們先新增一個資料夾叫webpack-test來試試** ```bash= mkdir webpack-test cd webpack-test npm init -y npm install --save-dev webpack webpack-cli ``` **新增webpack.config.js**,讓webpack知道檔案在哪裡、要怎麼吃以及要吐到哪邊 ```javascript= const path = require('path'); // 用來確保webpack不會吐在奇怪的地方 module.exports = { entry: './src/index.js', // 這個檔案import了所有css和js module: { //放一些正規表達式和模組名稱(記得先安裝) rules: [] }, plugins: { //放一些類別(class) }, output: { // 吐出來的檔案預設在dist path: path.resolve(__dirname, 'dist'), filename: '[name].min.js' // 預設吐出來後把[name]換成main } } ``` **修改package.json的scripts** (要注意新版的webpack不設定mode會跳出警告) ```json= { "name": "webpack-test", "version": "1.0.0", "description": "", "main": "index.js", "devDependencies": { "webpack": "^4.29.6", "webpack-cli": "^3.2.3", }, "scripts": { "build": "webpack --mode production --config webpack.config.js", }, "author": "chuboy", "license": "ISC", } ``` ::: :::info **把專案丟到src資料夾,讓資料夾長這樣** * webpack-test * node_modules/ * src/ * css/ * bootstrap.css * index.html * index.js * package-lock.json * package.json * webpack.config.js ::: :::info **輸入`npm run build`開始打包,然後dist資料夾就變出來了** * webpack-test * dist/ * main.js ::: :::danger 咦怎麼只有吐出main.js,不是說webpack很方便嗎,怎麼感覺有點複雜? ::: :::info 沒關係只是因為我們還沒有加入module和plugins,它是有點小複雜,就讓我們繼續看下去。 ::: ## 可不可以邊寫邊自動打包 :::danger 剛剛如果每次改檔案都要再打一次`npm run build`太麻煩了吧,有沒有像vscode的[LiveServer](https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer)插件那種好東西,每次按ctrl+S都可以自動更新? ::: :::info 答案是有的,它叫做webpack-live-server **修改package.json** `npm i -D webpack-live-server` ```json= { "devDependencies": { ..., "webpack-dev-server": "^3.2.1" } "scripts": { "start": "webpack-dev-server --mode development --config webpack.config.js", "build": "webpack --mode production --config webpack.config.js", }, } ``` **修改webpack.config.js** ```javascript= module.exports = { ..., devServer: { contentBase: path.join(__dirname, 'dist'), compress: true, port: 9000 } } ``` **終端機** `npm run start` ```bash= i 「wds」: Project is running at http://localhost:9000/ i 「wds」: webpack output is served from / i 「wds」: Content not from webpack is served from C:\Users\ChuBoy\Desktop\webpack-test\dist i 「wdm」: Hash: 8b2111a5fca1e0ccf7b2 ``` ctrl+左鍵點擊http://localhost:9000/ 就可以進入網頁 而HTML處理最複雜,以下分別按照js>css>html說明如何處理 ::: ## JS & Babel > 隨著Javascript的語法快速更新,es6, es7的新潮語法讓寫code更具易讀性,像是寫個類別 ```javascript= class fileHandler{ constructor(inputElement){ this.inputElement = inputElement } async readFile(file){ // async一定得放在function外且回傳Promise const reader = new FileReader() return new Promise((resolve,reject)=>{ reader.onload = ()=>{ resolve(reader.result) } reader.readAsArrayBuffer(file) }) } getBlob(file){ const blob = await this.readFile(file) // 異步執行完才會換下一行 return blob } } ``` :::info 很可惜的是,這些好用的新語法,IE瀏覽器幾乎一概看不懂,因此為了要把語法都編譯成舊版瀏覽器可讀的格式,babel是最為流行的解決方案 **修改webpack.config.js** `npm i -D @babel/core @babel/preset-env babel-loader` ```javascript= module.exports = { ..., module: { rules: [ //webpack會用正規表達式去找這個檔案,/.js$/代表檔名尾端是.js { test: /.js$/, use: ['babel-loader'] } ] } } ``` ::: :::danger 等等,打包出來的東西根本只是複製貼上,沒有變啊 ::: :::info 喔因為剛剛還沒加進babel設定檔,所以要把use改成物件並增加options.presets **修改webpack.config.js** ```javascript= module.exports = { ..., module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'], plugins: [ ['@babel/plugin-proposal-decorators', { legacy: true }], ] } } }, ] } } ``` 附帶一提,webpack本身不會清掉dist裡面的舊檔案,也許是怕會刪錯東西,因此要加個插件 `npm i -D clean-webpack-plugin` ```javascript= const CleanWebpackPulgin = require('clean-webpack-plugin') module.exports = { ..., plugins: [ new CleanWebpackPlugin(), ] } ``` ::: :::danger 我在IE上測試網頁還是怪怪的耶,有時候甚至一片空白。 ::: :::info 除了語法問題,原生API也不是每個瀏覽器都通用,這時候最強工具Polyfill就要出馬,能讓不支援API的瀏覽器也能正常瀏覽(但功能還是不一定會有)。 `npm i -D @babel/polyfill` ```javascript= module.exports = { entry: ['@babel/polyfill','./src/index.js'], } ``` 再附帶一提,如果想要讓打包後的js不讓別人輕易盜取,可用terser-webpack-plugin(webpack第五版已經內建) `npm i -D terser-webpack-plugin` ```javascript= const TerserPlugin = require('terser-webpack-plugin') module.exports = { plugins: [ new TerserPlugin(), ] } ``` ::: :::danger 如果我的HTML長這樣,要保留這些js檔有解嗎? ```html <body> ... <script src="js/skrollr.js"/> <script src="js/textFx.js"/> <script src="js/chart.js"/> </body> ``` ::: :::info 這種狀況就要改entry的設定,讓webpack一次吐一堆js檔,其中output的[name]值會對應到entry的自定義的命名。 ```javascript= module.exports = { entry: { skrollr: './src/js/skrollr.js', textFx: './src/js/textFx.js', chart: './src/js/chart.js' }, ..., output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js' } } ``` ::: ## Css/Sass/Scss 新語法有一個我覺得比較不直觀的地方,就是加入css要在js中設定檔案位置,然後從html中移除 `<link href="css/bootstrap.css" type="stylesheet">`,而且需要特定的loader與plugin來編譯,最後webpack才會自動加入`<link>`標籤,導入方式如下 :::info **修改./src/index.js** ```javascript= import 'css/bootstrap.css' import 'css/index.css' import Chart from 'chart.js' ... ``` **修改webpack.config.js** `npm i -D css-loader mini-css-extract-plugin` ```javascript= const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.exports = { module: { rules: [ { test: /\.css$/, use: [ { loader: MiniCssExtractPlugin.loader }, 'css-loader' ] }, ] }, plugins: [ new MiniCssExtractPlugin({filename: '[name].css'}) ] } ``` ::: :::danger 可是瑞凡,我用SASS ::: :::info 這還不簡單 **修改webpack.config.js** `npm i -D node-sass sass-loader` ```javascript= module.exports = { module: { rules: [ { test: /\.css$/, use: [ { loader: MiniCssExtractPlugin.loader }, 'css-loader','sass-loader' ] }, ] }, } ``` 如果要優化css的話,use的陣列再加一項css的前處理器 `npm i -D postcss-loader autoprefixer cssnano` ```javascript= { loader: 'postcss-loader', options: { plugins: ()=>([ require('autoprefixer'), // 自動添加 -webkit- -moz- -ms- 這類東西 require('cssnano') // css最小化 ]) } ``` ::: ## Html/Pug :::info html因為會包含靜態檔案、css、js,也因此處理的時候會有非常多情況與選擇性。 `npm i -D html-webpack-plugin` ```javascript= const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { ..., plugins: [ new HtmlWebpackPlugin({ template: './src/index.html', //輸入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 }, }), ] } ``` ::: :::danger 筆者不是很愛用PUG嗎? ::: :::info `npm i -D html-loader pug-html-loader` ```javascript= module.exports = { module: { rules: [ { test: /\.pug$/, use: ['html-loader','pug-html-loader'] }, ] }, plugins: [ new HtmlWebpackPlugin({ template: './src/index.pug', //改成pug filename: 'index.html', //output name }), ] } ``` ::: :::danger 為什麼我的html中 <input type="file" onchange="handleFile(event.target.files)"/> handleFile這個變數會不見 ::: :::info 因為打包後會重新更改變數名稱,很可惜沒有一個套件可以自動解決,其中一個用硬派解法是在entry的js中將函式定義為window的全域變數。 ```javascript= function handleFile(){...} // 將其定義為window的函數 window.handleFile = handleFile ``` ::: :::danger 那靜態檔案怎麼處理? ::: :::info 可以設定資料夾位置,將同一類型的靜態檔案通通丟在一起 `npm i -D file-loader` ```javascript= module.exports = { module: { rules: [ { test: /\.(png|svg|jpg|gif)$/, use: { loader:'file-loader', options: { name: '[name].[ext]', outputPath: './assets/', publicPath: './assets/' } } }, ] }, } ``` ::: ## 完整範例 **package.json** ```json= { "name": "webpack-demo", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "yarn && webpack serve --mode development --config webpack.config.js --progress", "build": "yarn && webpack --mode production --config webpack.config.js --progress", }, "dependencies": { "react": "^17.0.1", "react-dom": "^17.0.1", "react-router-dom": "^5.2.0" }, "devDependencies": { "@babel/core": "^7.0.0-0", "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/plugin-proposal-decorators": "^7.10.5", "@babel/preset-env": "^7.13.5", "@babel/preset-react": "^7.12.13", "babel-loader": "^8.2.2", "babel-plugin-transform-react-pug": "^7.0.1", "core-js": "^3.12.0", "css-loader": "^5.2.1", "cssnano": "^4.1.10", "dotenv": "^8.2.0", "file-loader": "^6.2.0", "glob": "^7.1.6", "html-webpack-plugin": "^5.3.1", "process": "^0.11.10", "regenerator-runtime": "^0.13.7", "style-loader": "^2.0.0", "webpack": "^5.33.2", "webpack-cli": "^4.6.0", "webpack-dev-server": "^3.11.2" } "author": "chuboy", "license": "ISC", } ``` **webpack.config.js** ```javascript= const fs = require('fs'); const path = require('path'); const glob = require('glob').sync; require('dotenv').config(); const webpack = require('webpack'); const ESLintPlugin = require('eslint-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; class PostBuildPlugin { // 後處理插件 constructor(fn) { this.handler = fn; } apply(compiler) { if (compiler.hooks) compiler.hooks.done.tap('webpack-arbitrary-code', this.handler); else compiler.plugin('done', this.handler); } } // webpack設定 module.exports = function (env, argv = {}) { // 取得.env設定 const mode = argv.mode || 'development'; const BASE_URL = process.env.BASE_URL || '/DSICWEBAP'; const PUBLIC_URL = process.env.PUBLIC_URL || '/resources/react'; const WEBAP_PATH = process.env.WEBAP_PATH || path.join(__dirname, 'build'); const WEBPACK_PORT = process.env.WEBPACK_PORT || 9000; const WEBAP_URL = process.env.WEBAP_URL || 'http://localhost:8080'; const ANALYZE = process.env.ANALYZE || false; // 靜態檔位置,尾端要加個斜線,否則js路徑會少個斜線 const PUBLIC_PATH = mode == 'production' ? BASE_URL + PUBLIC_URL + '/' : ''; const moveFile = (dir) => ({ loader: 'file-loader', options: { name: '[name].[ext]', outputPath: `./${dir}/`, publicPath: `${PUBLIC_PATH}${dir}/`, }, }); return { // 使用polyfill才能支援async entry: ['./src/index.js'], // webpack5必須加這行才能編譯成es2015,但target必須是web才會在開發時自動刷新 target: mode == 'production' ? ['web', 'es5'] : 'web', module: { rules: [ { test: /\.js$/, exclude: /node_modules/, // 所有的起始點babel-loader use: [ { loader: 'babel-loader', options: { // JS編譯設定 presets: [['@babel/preset-env', { targets: { ie: '11' }, useBuiltIns: 'usage', corejs: 3 }], ['@babel/preset-react']], // JS轉換插件(二維陣列表示) plugins: [ ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties'], ['babel-plugin-transform-react-pug'], ], }, }, ], }, { test: /\.(sa|sc|c)ss$/, // 必須透過style-loader才會引入html裡 use: ['style-loader', 'css-loader'], }, { test: /\.(png|svg|jpg|gif|ico)$/, use: moveFile('assets'), }, { test: /\.(eot|woff|woff2|[ot]tf)$/, use: moveFile('fonts'), }, ], }, plugins: [ // 使src裡面的東西也能吃process.env,注意這邊要加JSON.stringify,不然編譯出來的字串會沒有引號 new webpack.DefinePlugin({ 'process.env.BASE_URL': JSON.stringify(BASE_URL), 'process.env.NODE_ENV': JSON.stringify(mode), }), // 需要「process」套件,Webpack5強制網頁必須加這行 new webpack.ProvidePlugin({ process: 'process/browser' }), // Webpack只編譯js檔,包裝index.js的html得另外使用套件來設定 new HtmlWebpackPlugin({ favicon: './src/assets/favicon.ico', template: './src/index.html', filename: 'index.html', minify: { removeComments: true, minifyCss: true }, }), // Linting工具 // new ESLintPlugin({ extensions: ['js', 'jsx', 'ts', 'tsx'] }), // 後處理,從[PUBLIC_URL]搬到WEB-INF資料夾裡 new PostBuildPlugin(() => { if (mode == 'production') { // 移動index.html let from = path.join(WEBAP_PATH, PUBLIC_URL, 'index.html'); let to = path.join(WEBAP_PATH, 'WEB-INF', 'index.html'); if (!fs.existsSync(path.dirname(to))) fs.mkdirSync(path.dirname(to)); fs.copyFileSync(from, to); console.log(`搬檔 ${from} -> ${to}`); // 移除LICENSE.txt let license = glob(path.join(WEBAP_PATH, PUBLIC_URL, '**/*.LICENSE.txt')); for (let txt of license) fs.unlinkSync(txt); // rmSync在node.js 14版才被加入 } }), // 靜態檔大小分析套件 new BundleAnalyzerPlugin({ analyzerMode: ANALYZE ? 'server' : 'disabled', openAnalyzer: mode == 'production', }), ], resolve: { // 避免crpyto-js跳出警告 fallback: { crypto: false }, // 將import的絕對路徑轉換為相對路徑,如import * from 'store/app'->./src/store/app.js alias: Object.fromEntries( fs .readdirSync('src') .filter((dir) => !dir.includes('.')) .map((dir) => [dir, path.resolve(__dirname, 'src', dir)]) ), }, output: { // 打包後存放的位置 path: path.join(WEBAP_PATH, PUBLIC_URL), // 伺服器裡客製靜態檔的路由 publicPath: mode == 'production' ? PUBLIC_PATH : '', // JS主檔案名稱,若設[name].[ext]會變成main.js filename: 'js/[name].js', }, devtool: 'inline-source-map', // 避免瀏覽器跳出檔案過大的黃底警告 devServer: { // API重新導向,避開跨域請求CORS的限制 proxy: { [BASE_URL + '/api']: { target: WEBAP_URL, secure: false, changeOrigin: true }, [BASE_URL + '/file']: { target: WEBAP_URL, secure: false, changeOrigin: true }, [BASE_URL + '/oet']: { target: 'http://localhost:3000', secure: false, changeOrigin: true }, }, // 開發站台自動導向導回首頁(否則會出現404) historyApiFallback: { index: BASE_URL }, // 允許所以傳輸的資源用gzip進行壓縮 compress: true, // 自動開啟網頁 open: true, // 開發站台port port: WEBPACK_PORT, }, }; }; ``` P.S.感謝[Magic Len大大的好文](https://magiclen.org/webpack/),讓我的學習之路有解。