深入淺出 webpack 之基礎配置篇
阿新 • • 發佈:2020-11-14
## 前言
前端工程化經歷過很多優秀的工具,例如 `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)=> {
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)
喜歡本文請點個
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)=>