1. 程式人生 > >深入淺出 webpack 之基礎配置篇

深入淺出 webpack 之基礎配置篇

## 前言 前端工程化經歷過很多優秀的工具,例如 `Grunt`、`Gulp`、`webpack`、`rollup` 等等,每種工具都有自己適用的場景,而現今應用最為廣泛的當屬 `webpack` 打包了,因此學習好 `webpack` 也成為一個優秀前端的必備技能。 由於 `webpack` 技術棧比較複雜,因此作者打算分兩篇文章進行講解: 1. 基礎應用篇:講解各種基礎配置; 1. 高階應用篇:講解 `webpack` 優化以及原理。 [注] 本文是基於 `webpack 4.x` 版本 ## webpack 是什麼 > webpack 是模組打包工具。 `webpack` 可以在不進行任何配置的情況下打包如下程式碼: **[注] 不進行任何配置時,webpack 會使用預設配置。** ```javascript // moduleA.js function ModuleA(){ this.a = "a"; this.b = "b"; } export default ModuleA // index.js import ModuleA from "./moduleA.js"; const module = new ModuleA(); ``` 我們知道瀏覽器是不認識 `import` 語法的,直接在瀏覽器中執行這樣的程式碼會報錯。我們就可以藉助 `webpack` 來打包這樣的程式碼,賦予 `JavaScript` 模組化的能力。 最初版本的 `webpack` 只能打包 `JavaScript` 程式碼,隨著發展 `css` 檔案,圖片檔案,字型檔案都可以被 `webpack` 打包。 本文將主要講解 `webpack` 是如何打包這些資源的,屬於比較基礎的文章主要是為了後面講解效能優化和原理做鋪墊,如果已經對 `webpack` 比較熟悉的同學可以跳過本文。 ## 初始化安裝 webpack ```javascript mkdir webpackDemo // 建立資料夾 cd webpackDemo // 進入資料夾 npm init -y // 初始化package.json npm install webpack webpack-cli -D // 開發環境安裝 webpack 以及 webpack-cli ``` 通過這樣安裝之後,我們就可以在專案中使用 `webpack` 命令了。 ## 打包第一個檔案 `webpack.config.js` ```javascript const path = require('path'); module.exports = { mode: 'development', // {1} entry: { // {2} main:'./src/index.js' }, output: { // {3} publicPath:"", // 所有dist檔案新增統一的字首地址,例如釋出到cdn的域名就在這裡統一新增 filename: 'bundle.js', path: path.resolve(__dirname,'dist') } } ``` 程式碼分析: - {1} `mode` 打包模式是開發環境還是生成環境, `development | production` - {2} `entry` 入口檔案為 `index.js` - {3} `output` 輸出到 `path` 配置的 `dist` 資料夾下,輸出的檔名為 `filename` 配置的 `bundle.js` 建立檔案進行簡單打包: ```javascript src/moduleA.js const moduleA = function () { return "moduleA" } export default moduleA; -------------------------------- src/index.js import moduleA from "./moduleA"; console.log(moduleA()); ``` 修改 `package.json`  的 `scripts`,增加一條命令: ```javascript "scripts": { "build": "webpack --config webpack.config.js" } ``` 執行 `npm run build` 命令 ![](//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2fcc7fce5f8342419d03a3a72e4b9570~tplv-k3u1fbpfcp-zoom-1.image#align=left&display=inline&height=229&margin=%5Bobject%20Object%5D&originHeight=229&originWidth=802&status=done&style=none&width=802#align=left&display=inline&height=229&margin=%5Bobject%20Object%5D&originHeight=229&originWidth=802&status=done&style=none&width=802) ## 打包後的 bundle.js 原始碼分析 原始碼經過簡化,只把核心部分展示出來,方便理解: ```javascript (function(modules) { var installedModules = {}; function __webpack_require__(moduleId) { // 快取檔案 if(installedModules[moduleId]) { return installedModules[moduleId].exports; } // 初始化 moudle,並且也在快取中存入一份 var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; // 執行 "./src/index.js" 對應的函式體 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // 標記"./src/index.js"該模組以及載入 module.l = true; // 返回已經載入成功的模組 return module.exports; } // 匿名函式開始執行的位置,並且預設路徑就是入口檔案 return __webpack_require__(__webpack_require__.s = "./src/index.js"); }) // 傳入匿名執行函式體的module物件,包含"./src/index.js","./src/moduleA.js" // 以及它們對應要執行的函式體 ({ "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\");\n\n\nconsole.log(Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__[\"default\"])());\n\n\n//# sourceURL=webpack:///./src/index.js?"); }), "./src/moduleA.js": (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\nconst moduleA = function () {\n return \"moduleA\"\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (moduleA);\n\n\n//# sourceURL=webpack:///./src/moduleA.js?"); }) }); ``` 再來看看`"./src/index.js"` 對應的執行函式 ```javascript (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\");\n\n\nconsole.log(Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__[\"default\"])());\n\n\n//# sourceURL=webpack:///./src/index.js?"); }) ``` 你會發現就是一個 `eval` 執行方法。 我們拆開 `eval` 來仔細看看裡面是什麼內容,簡化後代碼如下: ```javascript var moduleA = __webpack_require__("./src/moduleA.js"); console.log(Object(moduleA["default"])()); ``` 上面原始碼中其實已經呼叫了 `__webpack_require__(__webpack_require__.s = "./src/index.js");` ,然後 `"./src/index.js"` 又遞迴呼叫了去獲取 `"./src/moduleA.js"` 的輸出物件。 我們看看 `"./src/moduleA.js"` 程式碼會輸出什麼: ```javascript const moduleA = function () { return "moduleA" } __webpack_exports__["default"] = (moduleA); ``` 再回頭看看上面的程式碼就相當於: ```javascript console.log(Object(function () { return "moduleA" })()); ``` 最後執行列印了 `"moduleA"` 通過這段原始碼的分析可以看出: 1. 打包之後的模組,都是通過 `eval` 函式進行執行的; 1. 通過呼叫入口函式 `./src/index.js` 然後遞迴的去把所有模組找到,由於遞迴會進行重複計算,因此 `__webpack_require__` 函式中有一個快取物件 `installedModules`。 ## loader 我們知道 `webpack` 可以打包 `JavaScript` 模組,而且也早就聽說 `webpack` 還可以打包圖片、字型以及 `css`,這個時候就需要 `loader` 來幫助我們識別這些檔案了。 **[注意] 碰到檔案不能識別記得找 loader 。** ### 打包圖片檔案 修改配置檔案:`webpack.config.js` ```javascript const path = require('path'); module.exports = { mode: 'development', entry: { main:'./src/index.js' }, output: { filename: 'bundle.js', path: path.resolve(__dirname,'dist') }, module:{ rules:[ { test:/\.(png|svg|jpg|gif)$/, use:{ loader: 'url-loader', options: { name: '[name]_[hash].[ext]', outputPath:"images", // 打包該資源到 images 資料夾下 limit: 2048 // 如果圖片的大小,小於2048KB則時輸出base64,否則輸出圖片 } } } ] } } ``` 修改:`src/index.js` ```javascript import moduleA from "./moduleA"; import header from "./header.jpg"; function insertImg(){ const imageElement = new Image(); imageElement.src = `dist/${header}`; document.body.appendChild(imageElement); } insertImg(); ``` 執行打包後,發現可以正常打包,並且 `dist` 目錄下也多出了一個圖片檔案。 我們簡單分析: `webpack` 本身其實只認識 `JavaScript` 模組的,當碰到圖片檔案時便會去 `module` 的配置 `rules` 中找,發現 `test:/\.(png|svg|jpg|gif)$/` ,正則匹配到圖片檔案字尾時就使用 `url-loader`  進行處理,如果圖片小於 `2048KB` (這個可以設定成任意值,主要看專案)就輸出 `base64` 。 ### 打包樣式檔案 ```javascript { test:/\.scss$/, // 正則匹配到.scss樣式檔案 use:[ 'style-loader', // 把得到的CSS內容插入到HTML中 { loader: 'css-loader', options: { importLoaders: 2, // scss中再次import scss檔案,也同樣執行 sass-loader 和 postcss-loader modules: true // 啟用 css module } }, 'sass-loader', // 解析 scss 檔案成 css 檔案 'postcss-loader'// 自動增加廠商字首 -webket -moz,使用它還需要建立postcss.config.js配置檔案 ] } ``` `postcss.config.js` ```javascript module.exports = { plugins: [ require('autoprefixer') ] }; ``` 打包解析: 1. 當 `webpack` 遇到 `xx.scss` 樣式檔案是; 1. 依次呼叫 `postcss-loader` 自動增加廠商字首 `-webket -moz` ; 1. 呼叫 `sass-loader` 把 `scss` 檔案轉換成 `css` 檔案; 1. 呼叫 `css-loader` 處理 `css` 檔案,其中 `importLoaders:2` ,是 `scss` 檔案中引入了其它 `scss` 檔案,需要重複呼叫 `sass-loader` `postcss-loader` 的配置項; 1. 最後呼叫 `style-loader` 把前面編譯好的 `css` 檔案內容以 `` 形式插入到頁面中。 **[注意] loader的執行順序是陣列後到前的執行順序。** ### 打包字型檔案 ```javascript { test: /\.(woff|woff2|eot|ttf|otf)$/, // 打包字型檔案 use: ['file-loader'] // 把字型檔案移動到dist目錄下 } ``` ### plugins `plugins` 可以在 `webpack` 執行到某個時刻幫你做一些事情,相當於 `webpack` 在某一個生命週期,呼叫外掛做一些輔助的事情。 #### html-webpack-plugin 作用: 會在打包結束後,自動生成一個 `HTML` 檔案(也可通過模板生成),並把打包生成的 `js` 檔案自動引入到 `HTML` 檔案中。 使用: ```javascript const HtmlWebpackPlugin = require('html-webpack-plugin'); plugins: [ new HtmlWebpackPlugin({ template: 'src/index.html' // 使用模板檔案 }) ] ``` #### clean-webpack-plugin 作用: 每次輸出打包結果時,先自動刪除 `output` 配置的資料夾 使用: ```javascript const { CleanWebpackPlugin } = require('clean-webpack-plugin'); plugins: [ ... new CleanWebpackPlugin() // 使用這個外掛在每次生成dist目錄前,先刪除dist目錄 ] ``` ### source map 在開發過程中有一個功能是很重要的,那就是錯誤除錯,我們在編寫程式碼過程中出現了錯誤,編譯後的包如果提示不友好,將會嚴重影響我們的開發效率。而通過配置 `source map` 就可以幫助我們解決這個問題。 示例: 修改:`src/index.js`,增加一行錯誤的程式碼 ```javascript console.log(a); ``` 由於`mode: 'development'` 開發模式是預設會開啟 `source map` 功能的,我們先關閉它。 ```javascript devtool: 'none' // 關閉 source map 配置 ``` 執行打包來看下控制檯的報錯資訊: ![](//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b67936152c854f91a5bc4493ea92dd4c~tplv-k3u1fbpfcp-zoom-1.image#align=left&display=inline&height=71&margin=%5Bobject%20Object%5D&originHeight=71&originWidth=1430&status=done&style=none&width=1430#align=left&display=inline&height=71&margin=%5Bobject%20Object%5D&originHeight=71&originWidth=1430&status=done&style=none&width=1430) 錯誤堆疊資訊,竟然給的是打包之後的 `bundle` 檔案中的資訊,但其實我們在開發過程中的檔案結構並不是這樣的,因此我們需要它能指明我們是在 `index.js` 中的多少行發生錯誤了,這樣我們就可以快速的定位到問題。 我們去掉 `devtool:'none'` 這行配置,再執行打包: ![](//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bbed819cf62a44a9a0878d24f619daa5~tplv-k3u1fbpfcp-zoom-1.image#align=left&display=inline&height=91&margin=%5Bobject%20Object%5D&originHeight=91&originWidth=1433&status=done&style=none&width=1433#align=left&display=inline&height=91&margin=%5Bobject%20Object%5D&originHeight=91&originWidth=1433&status=done&style=none&width=1433) 此時它就把我們在開發中的具體錯誤在堆疊中輸出了,這就是 `source map` 的功能。 總結下:`source map` 它是一個對映關係,它知道 `dist` 目錄下 `bundle.js` 檔案對應的實際開發檔案中的具體行列。 ### webpackDevServer 每次修改完程式碼之後都要手動去執行編譯命令,這顯然是不科學的,我們希望是每次寫完程式碼,`webpack` 會進行自動編譯,`webpackDevServer` 就可以幫助我們。 增加配置: ```javascript devServer: { contentBase: './dist', // 伺服器啟動根目錄設定為dist open: true, // 自動開啟瀏覽器 port: 8081, // 配置服務啟動埠,預設是8080 proxy:{ '/api': 'http://www.baidu.com' // 當開發環境時傳送/api請求時都會代理到http://www.baidu.com host下 } }, ``` 它相當於幫助我們開啟了一個 `web` 服務,並監聽了 `src` 下檔案,當檔案有變動時,自動幫助我們進行重新執行 `webpack` 編譯。 我們在 `package.json` 中增加一條命令: ```javascript "scripts": { "start": "webpack-dev-server" }, ``` 現在我們執行  `npm start`  命令後,可以看到控制檯開始實行監聽模式了,此時我們任意更改業務程式碼,都會觸發 `webpack` 重新編譯。 ### webpack-dev-server 實現請求代理 在前後端分離的專案中進行前端開發時,想必每個同學都會碰到一個棘手的問題就是請求跨域。一般在生產環境下我們通過 `nginx` 進行代理,那麼開發環境下我們一般如何處理呢,答案非常簡單,配置`webpack-dev-server` 也可以輕易實現 ``` devServer: { ... proxy:{ '/api': 'http://www.baidu.com' // 當開發環境時傳送/api請求時都會代理到http://www.baidu.com host下 } } ``` `proxy` 的配置項非常豐富具體可以參考文件,我們只需要記住,它可以提供代理伺服器的功能給我們。 ### 手動實現簡單版 webpack-dev-server 專案根目錄下增加:`server.js` 載入包: `npm install express webpack-dev-middleware -D` ```javascript const express = require('express'); const app = express(); const webpack = require('webpack'); const webpackDevMiddleware = require('webpack-dev-middleware'); const config = require('./webpack.config.js'); // 引入webpack配置檔案 const compiler = webpack(config); // webpack 編譯執行時 // 告訴 express 使用 webpack-dev-middleware, // 以及將 webpack.config.js 配置檔案作為基礎配置 app.use(webpackDevMiddleware(compiler, {})); // 監聽埠 app.listen(3000,()=>{ console.log('程式已啟動在3000埠'); }); ``` `webpack-dev-middleware` 作用: 1. 通過 `watch mode` 監聽資源的變更然後自動打包,本質上是呼叫 `compiler` 物件上的 `watch` 方法; 1. 使用記憶體檔案系統編譯速度快 `compiler.outputFileSystem = new MemoryFileSystem()` ; `package.json` 增加一條命令: ```javascript "scripts": { "server": "node server.js" }, ``` 執行命令 `npm run server`  啟動我們自定義的服務,瀏覽器中輸入 `http://localhost:3000/`  檢視效果。 ### 熱更新 Hot Moudule Replacement(HMR) 模組熱更新功能會在應用程式執行過程中,替換、新增或刪除模組,而無需重新載入整個頁面。 #### HMR 配置 ```javascript const webpack = require('webpack'); module.exports = { devServer: { contentBase: './dist', open: true, port: 8081, hot: true // 熱更新配置 }, plugins:[ new webpack.HotModuleReplacementPlugin() // 增加熱更新外掛 ] } ``` #### 手動編寫 HMR 程式碼 在編寫程式碼時經常會發現熱更新失效,那是因為相應的 `loader` 沒有去實現熱更新,我們看看如何簡單實現一個熱更新。 ```javascript import moduleA from "./moduleA"; if (module.hot) { module.hot.accept('./moduleA.js', function() { console.log("moduleA 支援熱更新拉"); console.log(moduleA()); }) } ``` 程式碼解釋: 我們引人自己編寫的一個普通 `ES6` 語法模組,假如我們想要實現熱更新就必須手動監聽相關檔案,然後當接收到更新回撥時,主動呼叫。 還記得上面講 `webpack` 打包後的原始碼分析嗎,`webpack` 給模組都建立了一個 `module` 物件,當你開啟模組熱更新時,在初始化 `module` 物件時增加了(原始碼經過刪減): ```javascript function hotCreateModule(moduleId) { var hot = { active: true, accept: function(dep, callback){ if (dep === undefined) hot._selfAccepted = true; else if (typeof dep === "function") hot._selfAccepted = dep; else if (typeof dep === "object") for (var i = 0; i < dep.length; i++) hot._acceptedDependencies[dep[i]] = callback || function() {}; else hot._acceptedDependencies[dep] = callback || function() {}; } } } ``` `module` 物件中儲存了監聽檔案路徑和回撥函式的依賴表,當監聽的模組發生變更後,會去主動呼叫相關的回撥函式,實現手動熱更新。 [注意] 所有編寫的業務模組,最終都會被 `webpack` 轉換成 `module` 物件進行管理,如果開啟熱更新,那麼 `module` 就會去增加 `hot` 相關屬性。這些屬性構成了 `webpack` 編譯執行時物件。 ### 編譯 ES6 顯然大家都知道必須要使用 `babel` 來支援了,我們具體看看如何配置 #### 配置 1、安裝相關包 ```javascript npm install babel-loader @babel/core @babel/preset-env @babel/polyfill -D ``` 2、修改配置 `webpack.config.json` 還記得文章上面說過,碰到不認識的檔案型別的編譯問題要求助 `loader` ```javascript module:{ rules:[ { test: /\.js$/, // 正則匹配js檔案 exclude: /node_modules/, // 排除 node_modules 資料夾 loader: "babel-loader", // 使用 babel-loader options:{ presets:[ [ "@babel/preset-env", // {1} { useBuiltIns: "usage" } // {2} ] ] } } ] } ``` `babel` 配置解析: - {1} `babel presets` 是一組外掛的集合,它的作用是轉換 `ES6+` 的新語法,但是一些新 `API` 它不會處理的 - `Promise`  `Generator` 是新語法 - `Array.prototype.map` 方法是新 `API` ,`babel` 是不會轉換這個語法的,因此需要藉助 `polyfill` 處理 - {2} `useBuiltIns` 的配置是處理 `@babel/polyfill` 如何載入的,它有3個值 `false` `entry` `usage` - `false`: 不對 `polyfills`做任何操作; - `entry`: 根據 `target`中瀏覽器版本的支援,將`polyfills`拆分引入,僅引入有瀏覽器不支援的 `polyfill` - `usage`:檢測程式碼中`ES6/7/8`等的使用情況,僅僅載入程式碼中用到的`polyfills` #### 演示 新建檔案 `src/moduleES6.js` ```javascript const arr = [ new Promise(()=>
{}), new Promise(()=>{}) ]; function handleArr(){ arr.map((item)=>{ console.log(item); }); } export default handleArr; ``` 修改檔案 `src/index.js` ```javascript import moduleES6 from "./moduleES6"; moduleES6(); ``` 執行打包後的原始檔(簡化後): ```javascript "./node_modules/core-js/modules/es6.array.map.js": (function(module, exports, __webpack_require__) { "use strict"; var $export = __webpack_require__("./node_modules/core-js/modules/_export.js"); var $map = __webpack_require__("./node_modules/core-js/modules/_array-methods.js")(1); $export($export.P + $export.F * !__webpack_require__(/*! ./_strict-method */ "./node_modules/core-js/modules/_strict-method.js")([].map, true), 'Array', { map: function map(callbackfn) { return $map(this, callbackfn, arguments[1]); } }); ``` 看程式碼就應該能明白了 `polyfill` 相當於是使用 `ES5` 的語法重新實現了 `map` 方法來相容低版本瀏覽器。 而 `polyfill` 實現了 `ES6+` 所有的語法,十分龐大,我們不可能全部引入,因此才會有這個配置 `useBuiltIns: "usage"` 只加載使用的語法。 ### 編譯 React 檔案 #### 配置 安裝相關依賴包 ```javascript npm install @babel/preset-react -D npm install react react-dom ``` `webpack.config.js` ```javascript module:{ rules:[ { test: /\.js$/, // 正則匹配js檔案 exclude: /node_modules/, // 排除 node_modules 資料夾 loader: "babel-loader", // 使用 babel-loader options:{ presets:[ [ "@babel/preset-env", { useBuiltIns: "usage" } ], ["@babel/preset-react"] ] } } ] } ``` 直接在 `presets` 配置中增加一個 `["@babel/preset-react"]`  配置即可, 那麼這個 `preset` 就會幫助我們把 `React` 中 `JSX` 語法轉換成 `React.createElement` 這樣的語法。 #### 演示 修改檔案:`src/index.js` ```javascript import React,{Component} from 'react'; import ReactDom from 'react-dom'; class App extends Component{ render(){ const arr = [1,2,3,4]; return ( arr.map((item)=>

num: {item}

) ) } } ReactDom.render(, document.getElementById('root')); ``` 執行打包命令 `yarn build` 可以正確打包並且顯示正常介面。 隨著專案的複雜度增加,`babel` 的配置也隨之變的複雜,因此我們需要把 `babel` 相關的配置提取成一個單獨的檔案進行配置方便管理,也就是我們工程目錄下的 `.babelrc` 檔案。 #### .babelrc ```javascript { "presets":[ ["@babel/preset-env",{ "useBuiltIns": "usage" }], ["@babel/preset-react"] ] } ``` [注意] `babel-laoder` 執行 `presets` 配置順序是陣列的後到前,與同時使用多個 `loader` 的執行順序是一樣的。 也就是把 `webpack.config.js`  中的 `babel-loader` 中的 `options` 物件提取成一個單獨檔案。 ![](//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0beb180570ad4535a143ffac0a49076e~tplv-k3u1fbpfcp-zoom-1.image#align=left&display=inline&height=177&margin=%5Bobject%20Object%5D&originHeight=177&originWidth=1226&status=done&style=none&width=1226#align=left&display=inline&height=177&margin=%5Bobject%20Object%5D&originHeight=177&originWidth=1226&status=done&style=none&width=1226) 通過編譯記錄,我們可以發現一個問題就是打包後的 `bundle.js`  檔案足足有 `1M` 大,那是因為 `react` 以及 `react-dom` 都被打包進來了。 ## Tree Shaking `Tree shaking` 的本質是消除無用的 `JavaScript` 程式碼。 ```javascript import { forEach } from "lodash" forEach([1,2,3],(item)=>
{ console.log(item); }) ``` 在專案中引入了 `lodash` 庫,只使用了其中的 `forEach` 方法,在 `jquery` 時代我們只能引入整個 `lodash` 檔案。但通過 `import` 引入則支援 `Tree Shaking` ,下面讓我們一起來配置它。 `webpack.config.js` ```javascript const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { mode: 'production', devServer: { contentBase: './dist', // 伺服器啟動根目錄設定為dist open: true, // 自動開啟瀏覽器 port: 8081, // 配置服務啟動埠,預設是8080 }, entry: { // 入口檔案 main:'./src/index.js' }, output: { // 出口檔案 publicPath:"", filename: 'bundle.js', path: path.resolve(__dirname,'dist') }, module:{ // loader 配置 rules:[ { test: /\.js$/, // 正則匹配js檔案 exclude: /node_modules/, // 排除 node_modules 資料夾 loader: "babel-loader", // 使用 babel-loader } ] }, plugins: [ new HtmlWebpackPlugin({ template: 'src/index.html' // 使用模板檔案 }), new CleanWebpackPlugin() ] } ``` 只需要配置 `mode: 'production'` 生成環境下會預設 `Tree Shaking` 。 ![](https://cdn.nlark.com/yuque/0/2020/png/335529/1605323797265-23924339-91a2-44d8-b261-3cf522f175db.png#align=left&display=inline&height=324&margin=%5Bobject%20Object%5D&originHeight=324&originWidth=749&size=0&status=done&style=none&width=749) 打包後依然有 `72kb` 大小,顯然 `Tree Shaking` 失敗了,這是為什麼呢? `Tree Shaing` 執行的前提是:必須是使用 `import` `export`  `ESModule`  語法的類庫才能被 `Tree Shaking` , `lodash` 也提供了相應的庫給我們使用 `lodash-es` 。 修改業務程式碼: `src/index.js` ```javascript import { forEach } from "lodash-es"; forEach([1,2,3],(item)=>{ console.log(item); }) ``` 再次執行打包: ![](https://cdn.nlark.com/yuque/0/2020/png/335529/1605323797281-749c3a61-2fbe-40aa-981a-4112dbd494f6.png#align=left&display=inline&height=352&margin=%5Bobject%20Object%5D&originHeight=352&originWidth=707&size=0&status=done&style=none&width=707) 打包後的大小隻有 `5.55Kb` ,說明 `Tree Shaking` 生效了。 ### 為什麼要 ESModule 前面說了必須要使用 `ES6` 提供的模組化語法才可以實現 `Tree Shaking` ,使用 `CommonJs` 的語法能實現 `Tree Shaking` 嗎?答案肯定是不能的。 `CommonJS` 模組是執行時載入,`ES6` 模組是編譯時輸出介面。 `ES6` 模組不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成。所謂靜態分析就是不執行程式碼,從字面量上對程式碼進行分析。 拿上面程式碼分析:我們引入了 `lodash-es` 中的 `forEach` ,在靜態分析階段就可以知道,我們只使用了 `forEach` 一個函式,因此沒有必要把整個 `lodash-es` 中所有的函式都引入,這個時候就剔除了那些沒有用的程式碼,只保留 `forEach` 。 ### sideEffects 在配置 `Tree Shaking` 時必須要配置 `sideEffects` package.json ```javascript { "sideEffects": false } ``` - `"sideEffects": false` 表示 `webpack` 它可以安全地刪除未用到的 `export`; - `"sideEffects": ["*.css"]` 表示 `*.css` 的引入不做 `Tree Shaking` 處理。 為什麼需要對 `css` 做處理呢?因為我們經常這樣引入全域性 `css`: ```javascript import "xxx.css" ``` 如果不進行特殊的配置, `Tree Shaking` 會誤認為 `xxx.css` 只匯入了,但是沒有使用,因此相關 `css` 程式碼會被刪除,當配置為 `["*.css"]` 時,會排除所有 `css` 檔案不做 `Tree Shaking` 處理。 ## Code Splitting 程式碼分割,顧名思義就是把打包好的程式碼的進行分割。 看一個場景: ```javascript import { forEach } from "lodash-es"; forEach([1,2,3],(item)=>{ console.log(item); }) ``` 執行打包命令: ![](https://cdn.nlark.com/yuque/0/2020/png/335529/1605323797272-a4a76e73-5ba4-425f-ab70-80f7aa6f8b57.png#align=left&display=inline&height=199&margin=%5Bobject%20Object%5D&originHeight=199&originWidth=953&size=0&status=done&style=none&width=953) 我們發現 `lodash` 也被打包進 `bundle.js` 了。 在實際開發中,我們可能會使用多種類庫共同工作,如果都打包到 `bundle.js` 中,那麼這個檔案勢必會非常大! 還有另外一個問題就是,我們打包的靜態檔案都會新增相應的 `hash` 值,如下配置: ```javascript output: { filename: '[hash]_bundle.js', // 打包出的檔案類似:07b62441b18e3aaa6c93_bundle.js path: path.resolve(__dirname,'dist') } ``` 這麼做的目的想必大家都知道,就是瀏覽器會對同一個名字的靜態資源進行快取,假設我們不新增 `hash` 值,但是線上又發現了 `bug`,我再次打包後把靜態資源更新到伺服器後,使用者的瀏覽器由於有快取是不會立馬顯示最新效果的,而需要手動去清空快取。 一般外部引入的類庫檔案是不會改變的,而我們的業務程式碼是會經常變動的。我們把會變動的和不變動的程式碼都打包到一起,顯然是不合理的,至少會造成當我們重新打包後,使用者需要載入全部的程式碼。 假如我們做了程式碼分割再配合瀏覽器的快取機制,使用者網站只需要載入更新後的業務程式碼,而類庫的程式碼則不需要重新載入。 以上就是我們需要做程式碼分割的理由。接下來我們看看 `webpack` 中可以如何進行程式碼分割配置。 ### SplitChunksPlugin 它的配置應該算是 `webpack` 外掛中比較複雜的配置了而且又非常重要,因此本文會詳細講解它的核心配置的含義。 我們有以下兩種方式引入一個第三方模組: 同步方式: ```javascript import { forEach } from "lodash"; import $ from "jquery"; $(function () { forEach([1,2,3],(item)=>{ console.log(item); }) }); ``` 非同步方式: ```javascript import("lodash").then(({default:_})=>{ console.log(_.join(["a","b"],"-")); }) ``` `SplitChunksPlugin` 外掛已經提供了一套開箱即用的預設配置,讓我們可以快速對以上兩種模組引入方式進行程式碼分割打包優化。下面我們來分析下它的預設配置的意思: ```javascript optimization: { splitChunks: { chunks: 'async', minSize: 30000, minRemainingSize: 0, maxSize: 0, minChunks: 1, maxAsyncRequests: 6, maxInitialRequests: 4, automaticNameDelimiter: '~', cacheGroups: { defaultVendors: { test: /[\\/]node_modules[\\/]/, priority: -10 }, default: { minChunks: 2, priority: -20, reuseExistingChunk: true } } } } ``` #### chunks - `async` 非同步模組生效 - `initial` 同步模組生效 - `all` 非同步同步都生效 #### minSize `chunk` 檔案最小打包尺碼,例如這裡預設設定是 `30000 kb` ,假設我們要打包的庫小於 `30000kb` 則不會進行分模組打包。 #### maxSize 最大打包尺寸,假設 `lodash` 為 `1MB` ,這裡設定為 `500KB` ,`webpack` 會嘗試把 `lodash` 拆分成2個檔案,但其實 `lodash` 這種類庫是不好做拆分的,所以最終結果是一樣的,只會打出一個包。 #### minChunks 一個模組被用了多少次才對它進行程式碼分割。 #### maxAsyncRequests 最多載入的 `chunk` 數量 #### maxInitialRequests 入口檔案做程式碼分割的最大數量 #### automaticNameDelimiter 檔名的連線符 #### name 設定為 `true` 時,`cacheGroups` 中的 `filename` 才能生效 #### cacheGroups 快取組,該物件裡面的 `defaultVendors` 與 `default` 相當於兩條模組快取陣列。 一般是同步引入的模組,命中該快取策略就把該模組 `push` 到該陣列中,最後合併輸出一個 `chunk`。 快取策略是這樣配置的: ```javascript cacheGroups: { vendors: { chunks: 'initial', // 只針對同步模組 test: /[\\/]node_modules[\\/]/, // 對同步程式碼進行打包時,會先判斷是否在node_modules下面 priority: -10, // 打包一個模組有可能既符合vendors的規則也符合default的規則,這個時候根據priority的來判斷選擇哪個值越大優先順序越高 filename: '[name].chunk.js' // 輸出的檔名 }, default: { minChunks: 2, // 當模組被使用了兩次 priority: -20, // 表示許可權值 reuseExistingChunk: true // 會去檢查迴圈引用,避免打包一些無用的模組進來 } } ``` 同步模組打包: ```javascript import { forEach } from "lodash"; import $ from "jquery"; $(function () { forEach([1,2,3],(item)=>{ console.log(item); }) }); ``` 分析: - `lodash` 模組命中 `vendors` 策略,推入 `vendors` 策略快取組中; - `jquery` 模組同樣命中 `vendors` 策略,推入 `vendors` 策略快取組中; - 沒有其它模組了,因此合併輸出一個檔名字 `vendors~main.chunk.js` ,其中 `verdors` 是策略的名字,`~` 波浪線是 `automaticNameDelimiter` 的配置, `main` 是 `entry` 入口檔案的名字, `chunk.js` 是 `filename` 中設定的字尾。 非同步模組打包: ```javascript import(/* webpackChunkName: "lodash" */"lodash").then(({default:_})=>{ console.log(_.join(["a","b"],"-")); }) import(/* webpackChunkName: "jquery" */"jquery").then(({default:$})=>{ $(function () { console.log("jquery 已經載入完成"); }) }) ``` 分析: - 首先模組為了滿足懶載入需求會根據魔法註釋 `webpackChunkName` 打包成單獨的模組如 `jquery.bundle.js` 和 `lodash.bundle.js` - 同樣它會去 `cacheGroups` 中查詢是否匹配相應的策略,此時發現 `vendors` 匹配不了, `default` 策略可以匹配,但是 `default` 中有一個配置是 `reuseExistingChunk: true` 表示會去已經打包好模組中查詢,如果已經被打包了就輸出。把它改為 `false` 後,則會把 `jquery.bundle.js` 根據策略重新命名為 `default~jquery.bundle.js` 由於它是非同步載入的,首頁兩個模組不會被合併,分別輸出。 `cacheGroups` 中的策略可以根據專案自行新增,因此而且 `webpack` 提供了各種回撥方法使得配置更加靈活。 ## CSS 檔案程式碼分割 隨著專案的增大 `css` 檔案是非常的多,如果都打包到 `js` 中,勢必是的 `js` 檔案過於臃腫,影響載入速度,因此我們要把 `css` 分離打包。 - `MiniCssExtractPlugin` 它會幫助我們建立一個新的 `css` 檔案 - `OptimizeCSSAssetsPlugin` 它會幫助我們合併壓縮 `css` 檔案 我們來看看具體配置: ```javascript ... const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); module.exports = { mode: 'production', // 只有在production的模式下,才會去執行minimizer裡面的配置 optimization: { ... minimizer: [ new OptimizeCSSAssetsPlugin({}) ] }, module:{ rules:[ { test:/\.css$/, use:[ MiniCssExtractPlugin.loader, // 這裡要使用 MiniCssExtractPlugin 提供的 loader 'css-loader' ] } ] }, plugins: [ ... new MiniCssExtractPlugin({ // 在外掛中初始化MiniCssExtractPlugin,並且配置好獨立出來的CSS檔案的命名規則 filename: "[name].css", chunkFilename: "[id].chunk.css" }) ] } ``` 還有一個重點要記得,就是生產環境下,我們預設開啟了 `Tree Shaking` ,因此需要配置 `package.json` 中的 `sideEffects` ,否則 css 檔案會被 `Tree Shaking` 掉。 ```javascript "sideEffects": ["*.css"] // 所有 .css 的檔案都不進行 tree shaking ``` 配置好後執行打包命令發現可以單獨分離出 `css` 檔案並且 `css` 檔案是經過壓縮的。 ### 配置 CSS cacheGroups ```javascript splitChunks: { ... cacheGroups: { ... styles: { name: 'styles', test: /\.css$/, chunks: 'all', enforce: true, }, ... } } ``` 增加一條 `styles` 策略,這樣打包輸出的名字為 `styles.css` 就是這條策略的名字 `enforce: true`  表示 `styles` 策略忽略 `splitChunks` 的其它引數配置。 ## 模組懶載入 模組懶載入不需要我們再去做 `webpack` 的配置,而是使用 `ES6` 提供的 `import()` 語法來支援 我們來對比下面兩段業務程式碼: ```javascript document.addEventListener("click",()=>{ import(/* webpackChunkName: "lodash" */"lodash").then(({default:_})=>{ console.log(_.join(["a","b"],"-")); }); },false); ``` ```javascript import { join } from "lodash"; document.addEventListener("click",()=>{ console.log(_.join(["a","b"],"-")); },false); ``` 第一段程式碼表示:點選 `document` 後去非同步載入 `lodash` 模組,當模組載入成功後輸出一個字串。 第二段程式碼表示:進入介面先載入 `lodash` 模組,當點選頁面後輸出字串。 顯然第一段程式碼是一個非同步載入方式,如果使用者沒有去點選頁面就不必要去載入相應的模組,節省資源。這就是非同步載入,只需要 `webpack` 配置 `babel` 支援 `ES6` 語法即可。 ## Preload and Prefetch 我們看一張這樣的業務場景: ![](https://cdn.nlark.com/yuque/0/2020/png/335529/1605323797289-273f960f-6079-4930-9259-fc8bd3d3f8c9.png#align=left&display=inline&height=530&margin=%5Bobject%20Object%5D&originHeight=530&originWidth=1420&size=0&status=done&style=none&width=1420) 使用者點選登入,彈出登入框。這個登入彈框模組其實沒有必要在最開始就載入完成的,因為它是屬於互動性質的內容,是必須在使用者看到首頁後才會進行的動作,也就意味著這個模組可以在首頁載入完成之後再去載入。 如何去實現這一的效果呢?瀏覽器給我們提供了資源載入的方式: ```javascript ``` - `preload` 會以並行方式開始載入; - `prefetch` 會在首頁模組載入完成之後,再去載入。 實現這樣的效果我們並不需要對 `webpack` 的配置做任何改動,依然是利用 `ES6` 提供的 `import()` 語法配合魔法註釋來實現。 ```javascript document.addEventListener("click",()=>{ import(/* webpackPrefetch: true */ /* webpackChunkName: "lodash" */ "lodash").then(({default:_})=>{ console.log(_.join(["a","b"],"-")); }); },false); ``` 執行打包命令後檢視瀏覽器控制檯: ![](https://cdn.nlark.com/yuque/0/2020/png/335529/1605323797292-8e9c66c7-a29e-488d-9b99-41c2e0fa2533.png#align=left&display=inline&height=261&margin=%5Bobject%20Object%5D&originHeight=261&originWidth=1043&size=0&status=done&style=none&width=1043) ## 檔案快取策略 我們打包的靜態資原始檔是要釋出到伺服器上的,例如靜態資源名字為 `main.js`  ,此時如果線上有一個 `bug`,我們肯定是立即修復,然後立即打包並把靜態資源更新到伺服器上,如果不更改檔名,由於瀏覽器的快取問題,使用者是沒有辦法立馬看到效果的,因此我們可以給檔名新增 `hash` 的配置 ```javascript output: { // 出口檔案 publicPath:"", filename: '[name].[hash].js', chunkFilename:'[name].[hash].js', path: path.resolve(__dirname,'dist') }, ``` 業務程式碼: ```javascript import { forEach } from "lodash"; import $ from "jquery"; $(function () { forEach([1,2,3],(item)=>{ console.log(item); }) }); ``` 打包之後會輸出兩個 `ja` 檔案 - `main.[hash].js` 的入口檔案 - `vendors~main.[hash].js` 的 `chunk` 檔案 ![](https://cdn.nlark.com/yuque/0/2020/png/335529/1605323797339-e11b177f-726a-4b40-9b64-987dac4aee5f.png#align=left&display=inline&height=88&margin=%5Bobject%20Object%5D&originHeight=88&originWidth=330&size=0&status=done&style=none&width=330) 它們公用同一個 `hash` 值,此時當我修改了業務程式碼: ```javascript $(function () { forEach([1,2,3,4,5,6],(item)=>{ console.log(item); }) }); ``` 業務程式碼變了,但是我們引入的第三方庫是沒有任何改變的,當再次執行打包,兩類檔案的 `hash` 值都改變了,此時我們部署到伺服器,使用者瀏覽器的確可以重新載入並且立馬看到效果,但是使用者不應該重新載入第三庫的程式碼呀,這些可是沒有變化的。此時我們就應該使用 `webpack` 提供的 `[contenthash]` 配置,它代表的意思是,只有內容改變的模組檔案 `hash` 值會變化,內容不改變的檔案 `hash` 值保持原樣 ```javascript output: { publicPath:"", filename: '[name].[contenthash].js', chunkFilename:'[name].[contenthash].js', path: path.resolve(__dirname,'dist') } ``` ## 開發環境與生成環境 在 `webpack` 配置中提供了 `mode` 屬性配置開發環境與生產環境,我們來總結這兩個環境它們在工程配置上有什麼區別: | 功能 \\ 環境 | Develoment(開發) | Production(生產) | | :---: | --- | --- | | 程式碼壓縮 | 不壓縮(方便除錯) | 壓縮(減小程式碼體積) | | Tree Shaking | 預設不開啟 | 預設開啟 | | Source Map | cheap-module-eval-source-map | cheap-module-source-map | | webpackDevServer(本地服務) | 需要開啟 | 不需要 | | HMR(熱更新) | 需要配置 | 不需要 | 正常我們去編寫 `webpack` 配置時,會分檔案進行配置的,因為生產環境和開發環境差異還是非常大的。 配置檔案分離思路: 1. 提取一個公共配置,例如 `js` 處理,`css` 處理,圖片等資源的處理,在開發環境和生產環境都是一樣的; 1. 單獨配置一個開發環境和生產環境配置,然後通過 `webpack-merge` 合併公共配置: ```javascript const webpack = require('webpack'); const merge = require('webpack-merge'); const common = require('./webpack.common.js'); module.exports = merge(common,{ mode: 'development', ... }); ``` ## 配置全域性變數 ```javascript plugins: [ ... new webpack.ProvidePlugin({ $:"jquery", _:"loadsh" }) ] ``` 配置好了 `$` 與 `_` 的全域性變數後,我們在後續編寫模組時可以不需要引入而直接使用: ```javascript export function ui (){ $('body').css('background','green'); } ``` ## 使用環境變數 `package.json` ```javascript "scripts": { "dev-build": "webpack --env.development --config webpack.common.js", "dev": "webpack-dev-server --env.development --config webpack.common.js", "build": "webpack --env.production --config webpack.common.js" }, ``` 增加了: `--env.development` 、 `--env.production` 。 `webpack.common.js` ```javascript module.exports = (env)=>{ console.log(env); // {development:true} || {production:true} if(env && env.production){ return merge(commonConfig,prodConfig); }else{ return merge(commonConfig,devConfig); } } ``` [檢視具體配置程式碼](https://github.com/shiyou00/webpack-end/blob/master/lesson_16/webpack.common.js) ## 小結 通過本文的學習並且自己能動手實踐一遍的話,相信對於 `webpack` 的基礎配置會有一個更加全面的瞭解,併為之後學習如何優化以及 `webpack` 原理打好堅實的基礎。 [本文所有程式碼託管地址](https://github.com/shiyou00/webpack-end) 喜歡本文請點個