深入淺出 webpack 之基礎配置篇
前言
前端工程化經歷過很多優秀的工具,例如 Grunt
、Gulp
、webpack
、rollup
等等,每種工具都有自己適用的場景,而現今應用最為廣泛的當屬 webpack
打包了,因此學習好 webpack
也成為一個優秀前端的必備技能。
由於 webpack
技術棧比較複雜,因此作者打算分兩篇文章進行講解:
- 基礎應用篇:講解各種基礎配置;
- 高階應用篇:講解
webpack
優化以及原理。
[注] 本文是基於 webpack 4.x
版本
webpack 是什麼
webpack 是模組打包工具。
webpack
可以在不進行任何配置的情況下打包如下程式碼:
[注] 不進行任何配置時,webpack 會使用預設配置。
// 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
mkdir webpackDemo // 建立資料夾
cd webpackDemo // 進入資料夾
npm init -y // 初始化package.json
npm install webpack webpack-cli -D // 開發環境安裝 webpack 以及 webpack-cli
通過這樣安裝之後,我們就可以在專案中使用 webpack
打包第一個檔案
webpack.config.js
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
建立檔案進行簡單打包:
src/moduleA.js
const moduleA = function () {
return "moduleA"
}
export default moduleA;
--------------------------------
src/index.js
import moduleA from "./moduleA";
console.log(moduleA());
修改 package.json
的 scripts
,增加一條命令:
"scripts": {
"build": "webpack --config webpack.config.js"
}
執行 npm run build
命令
打包後的 bundle.js 原始碼分析
原始碼經過簡化,只把核心部分展示出來,方便理解:
(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"
對應的執行函式
(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
來仔細看看裡面是什麼內容,簡化後代碼如下:
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"
程式碼會輸出什麼:
const moduleA = function () {
return "moduleA"
}
__webpack_exports__["default"] = (moduleA);
再回頭看看上面的程式碼就相當於:
console.log(Object(function () {
return "moduleA"
})());
最後執行列印了 "moduleA"
通過這段原始碼的分析可以看出:
- 打包之後的模組,都是通過
eval
函式進行執行的; - 通過呼叫入口函式
./src/index.js
然後遞迴的去把所有模組找到,由於遞迴會進行重複計算,因此__webpack_require__
函式中有一個快取物件installedModules
。
loader
我們知道 webpack
可以打包 JavaScript
模組,而且也早就聽說 webpack
還可以打包圖片、字型以及 css
,這個時候就需要 loader
來幫助我們識別這些檔案了。
[注意] 碰到檔案不能識別記得找 loader 。
打包圖片檔案
修改配置檔案:webpack.config.js
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
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
。
打包樣式檔案
{
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
module.exports = {
plugins: [
require('autoprefixer')
]
};
打包解析:
- 當
webpack
遇到xx.scss
樣式檔案是; - 依次呼叫
postcss-loader
自動增加廠商字首-webket -moz
; - 呼叫
sass-loader
把scss
檔案轉換成css
檔案; - 呼叫
css-loader
處理css
檔案,其中importLoaders:2
,是scss
檔案中引入了其它scss
檔案,需要重複呼叫sass-loader
postcss-loader
的配置項; - 最後呼叫
style-loader
把前面編譯好的css
檔案內容以<style>...</style>
形式插入到頁面中。
[注意] loader的執行順序是陣列後到前的執行順序。
打包字型檔案
{
test: /\.(woff|woff2|eot|ttf|otf)$/, // 打包字型檔案
use: ['file-loader'] // 把字型檔案移動到dist目錄下
}
plugins
plugins
可以在 webpack
執行到某個時刻幫你做一些事情,相當於 webpack
在某一個生命週期,呼叫外掛做一些輔助的事情。
html-webpack-plugin
作用:
會在打包結束後,自動生成一個 HTML
檔案(也可通過模板生成),並把打包生成的 js
檔案自動引入到 HTML
檔案中。
使用:
const HtmlWebpackPlugin = require('html-webpack-plugin');
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html' // 使用模板檔案
})
]
clean-webpack-plugin
作用:
每次輸出打包結果時,先自動刪除 output
配置的資料夾
使用:
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
plugins: [
...
new CleanWebpackPlugin() // 使用這個外掛在每次生成dist目錄前,先刪除dist目錄
]
source map
在開發過程中有一個功能是很重要的,那就是錯誤除錯,我們在編寫程式碼過程中出現了錯誤,編譯後的包如果提示不友好,將會嚴重影響我們的開發效率。而通過配置 source map
就可以幫助我們解決這個問題。
示例:
修改:src/index.js
,增加一行錯誤的程式碼
console.log(a);
由於mode: 'development'
開發模式是預設會開啟 source map
功能的,我們先關閉它。
devtool: 'none' // 關閉 source map 配置
執行打包來看下控制檯的報錯資訊:
錯誤堆疊資訊,竟然給的是打包之後的 bundle
檔案中的資訊,但其實我們在開發過程中的檔案結構並不是這樣的,因此我們需要它能指明我們是在 index.js
中的多少行發生錯誤了,這樣我們就可以快速的定位到問題。
我們去掉 devtool:'none'
這行配置,再執行打包:
此時它就把我們在開發中的具體錯誤在堆疊中輸出了,這就是 source map
的功能。
總結下:source map
它是一個對映關係,它知道 dist
目錄下 bundle.js
檔案對應的實際開發檔案中的具體行列。
webpackDevServer
每次修改完程式碼之後都要手動去執行編譯命令,這顯然是不科學的,我們希望是每次寫完程式碼,webpack
會進行自動編譯,webpackDevServer
就可以幫助我們。
增加配置:
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
中增加一條命令:
"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
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
作用:
- 通過
watch mode
監聽資源的變更然後自動打包,本質上是呼叫compiler
物件上的watch
方法; - 使用記憶體檔案系統編譯速度快
compiler.outputFileSystem = new MemoryFileSystem()
;
package.json
增加一條命令:
"scripts": {
"server": "node server.js"
},
執行命令 npm run server
啟動我們自定義的服務,瀏覽器中輸入 http://localhost:3000/
檢視效果。
熱更新 Hot Moudule Replacement(HMR)
模組熱更新功能會在應用程式執行過程中,替換、新增或刪除模組,而無需重新載入整個頁面。
HMR 配置
const webpack = require('webpack');
module.exports = {
devServer: {
contentBase: './dist',
open: true,
port: 8081,
hot: true // 熱更新配置
},
plugins:[
new webpack.HotModuleReplacementPlugin() // 增加熱更新外掛
]
}
手動編寫 HMR 程式碼
在編寫程式碼時經常會發現熱更新失效,那是因為相應的 loader
沒有去實現熱更新,我們看看如何簡單實現一個熱更新。
import moduleA from "./moduleA";
if (module.hot) {
module.hot.accept('./moduleA.js', function() {
console.log("moduleA 支援熱更新拉");
console.log(moduleA());
})
}
程式碼解釋:
我們引人自己編寫的一個普通 ES6
語法模組,假如我們想要實現熱更新就必須手動監聽相關檔案,然後當接收到更新回撥時,主動呼叫。
還記得上面講 webpack
打包後的原始碼分析嗎,webpack
給模組都建立了一個 module
物件,當你開啟模組熱更新時,在初始化 module
物件時增加了(原始碼經過刪減):
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、安裝相關包
npm install babel-loader @babel/core @babel/preset-env @babel/polyfill -D
2、修改配置 webpack.config.json
還記得文章上面說過,碰到不認識的檔案型別的編譯問題要求助 loader
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
const arr = [
new Promise(()=>{}),
new Promise(()=>{})
];
function handleArr(){
arr.map((item)=>{
console.log(item);
});
}
export default handleArr;
修改檔案 src/index.js
import moduleES6 from "./moduleES6";
moduleES6();
執行打包後的原始檔(簡化後):
"./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 檔案
配置
安裝相關依賴包
npm install @babel/preset-react -D
npm install react react-dom
webpack.config.js
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
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)=><p>num: {item}</p>)
)
}
}
ReactDom.render(<App />, document.getElementById('root'));
執行打包命令 yarn build
可以正確打包並且顯示正常介面。
隨著專案的複雜度增加,babel
的配置也隨之變的複雜,因此我們需要把 babel
相關的配置提取成一個單獨的檔案進行配置方便管理,也就是我們工程目錄下的 .babelrc
檔案。
.babelrc
{
"presets":[
["@babel/preset-env",{ "useBuiltIns": "usage" }],
["@babel/preset-react"]
]
}
[注意] babel-laoder
執行 presets
配置順序是陣列的後到前,與同時使用多個 loader
的執行順序是一樣的。
也就是把 webpack.config.js
中的 babel-loader
中的 options
物件提取成一個單獨檔案。
通過編譯記錄,我們可以發現一個問題就是打包後的 bundle.js
檔案足足有 1M
大,那是因為 react
以及 react-dom
都被打包進來了。
Tree Shaking
Tree shaking
的本質是消除無用的 JavaScript
程式碼。
import { forEach } from "lodash"
forEach([1,2,3],(item)=>{
console.log(item);
})
在專案中引入了 lodash
庫,只使用了其中的 forEach
方法,在 jquery
時代我們只能引入整個 lodash
檔案。但通過 import
引入則支援 Tree Shaking
,下面讓我們一起來配置它。
webpack.config.js
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
。
打包後依然有 72kb
大小,顯然 Tree Shaking
失敗了,這是為什麼呢?
Tree Shaing
執行的前提是:必須是使用 import
export
ESModule
語法的類庫才能被 Tree Shaking
, lodash
也提供了相應的庫給我們使用 lodash-es
。
修改業務程式碼: src/index.js
import { forEach } from "lodash-es";
forEach([1,2,3],(item)=>{
console.log(item);
})
再次執行打包:
打包後的大小隻有 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
{
"sideEffects": false
}
"sideEffects": false
表示webpack
它可以安全地刪除未用到的export
;"sideEffects": ["*.css"]
表示*.css
的引入不做Tree Shaking
處理。
為什麼需要對 css
做處理呢?因為我們經常這樣引入全域性 css
:
import "xxx.css"
如果不進行特殊的配置, Tree Shaking
會誤認為 xxx.css
只匯入了,但是沒有使用,因此相關 css
程式碼會被刪除,當配置為 ["*.css"]
時,會排除所有 css
檔案不做 Tree Shaking
處理。
Code Splitting
程式碼分割,顧名思義就是把打包好的程式碼的進行分割。
看一個場景:
import { forEach } from "lodash-es";
forEach([1,2,3],(item)=>{
console.log(item);
})
執行打包命令:
我們發現 lodash
也被打包進 bundle.js
了。
在實際開發中,我們可能會使用多種類庫共同工作,如果都打包到 bundle.js
中,那麼這個檔案勢必會非常大!
還有另外一個問題就是,我們打包的靜態檔案都會新增相應的 hash
值,如下配置:
output: {
filename: '[hash]_bundle.js', // 打包出的檔案類似:07b62441b18e3aaa6c93_bundle.js
path: path.resolve(__dirname,'dist')
}
這麼做的目的想必大家都知道,就是瀏覽器會對同一個名字的靜態資源進行快取,假設我們不新增 hash
值,但是線上又發現了 bug
,我再次打包後把靜態資源更新到伺服器後,使用者的瀏覽器由於有快取是不會立馬顯示最新效果的,而需要手動去清空快取。
一般外部引入的類庫檔案是不會改變的,而我們的業務程式碼是會經常變動的。我們把會變動的和不變動的程式碼都打包到一起,顯然是不合理的,至少會造成當我們重新打包後,使用者需要載入全部的程式碼。
假如我們做了程式碼分割再配合瀏覽器的快取機制,使用者網站只需要載入更新後的業務程式碼,而類庫的程式碼則不需要重新載入。
以上就是我們需要做程式碼分割的理由。接下來我們看看 webpack
中可以如何進行程式碼分割配置。
SplitChunksPlugin
它的配置應該算是 webpack
外掛中比較複雜的配置了而且又非常重要,因此本文會詳細講解它的核心配置的含義。
我們有以下兩種方式引入一個第三方模組:
同步方式:
import { forEach } from "lodash";
import $ from "jquery";
$(function () {
forEach([1,2,3],(item)=>{
console.log(item);
})
});
非同步方式:
import("lodash").then(({default:_})=>{
console.log(_.join(["a","b"],"-"));
})
SplitChunksPlugin
外掛已經提供了一套開箱即用的預設配置,讓我們可以快速對以上兩種模組引入方式進行程式碼分割打包優化。下面我們來分析下它的預設配置的意思:
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
。
快取策略是這樣配置的:
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 // 會去檢查迴圈引用,避免打包一些無用的模組進來
}
}
同步模組打包:
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
中設定的字尾。
非同步模組打包:
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
檔案
我們來看看具體配置:
...
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
掉。
"sideEffects": ["*.css"] // 所有 .css 的檔案都不進行 tree shaking
配置好後執行打包命令發現可以單獨分離出 css
檔案並且 css
檔案是經過壓縮的。
配置 CSS cacheGroups
splitChunks: {
...
cacheGroups: {
...
styles: {
name: 'styles',
test: /\.css$/,
chunks: 'all',
enforce: true,
},
...
}
}
增加一條 styles
策略,這樣打包輸出的名字為 styles.css
就是這條策略的名字 enforce: true
表示 styles
策略忽略 splitChunks
的其它引數配置。
模組懶載入
模組懶載入不需要我們再去做 webpack
的配置,而是使用 ES6
提供的 import()
語法來支援
我們來對比下面兩段業務程式碼:
document.addEventListener("click",()=>{
import(/* webpackChunkName: "lodash" */"lodash").then(({default:_})=>{
console.log(_.join(["a","b"],"-"));
});
},false);
import { join } from "lodash";
document.addEventListener("click",()=>{
console.log(_.join(["a","b"],"-"));
},false);
第一段程式碼表示:點選 document
後去非同步載入 lodash
模組,當模組載入成功後輸出一個字串。
第二段程式碼表示:進入介面先載入 lodash
模組,當點選頁面後輸出字串。
顯然第一段程式碼是一個非同步載入方式,如果使用者沒有去點選頁面就不必要去載入相應的模組,節省資源。這就是非同步載入,只需要 webpack
配置 babel
支援 ES6
語法即可。
Preload and Prefetch
我們看一張這樣的業務場景:
使用者點選登入,彈出登入框。這個登入彈框模組其實沒有必要在最開始就載入完成的,因為它是屬於互動性質的內容,是必須在使用者看到首頁後才會進行的動作,也就意味著這個模組可以在首頁載入完成之後再去載入。
如何去實現這一的效果呢?瀏覽器給我們提供了資源載入的方式:
<link rel="preload" href="loginModule.js" as="script">
<link rel="prefetch" href="loginModule.js" as="script">
preload
會以並行方式開始載入;prefetch
會在首頁模組載入完成之後,再去載入。
實現這樣的效果我們並不需要對 webpack
的配置做任何改動,依然是利用 ES6
提供的 import()
語法配合魔法註釋來實現。
document.addEventListener("click",()=>{
import(/* webpackPrefetch: true */ /* webpackChunkName: "lodash" */ "lodash").then(({default:_})=>{
console.log(_.join(["a","b"],"-"));
});
},false);
執行打包命令後檢視瀏覽器控制檯:
檔案快取策略
我們打包的靜態資原始檔是要釋出到伺服器上的,例如靜態資源名字為 main.js
,此時如果線上有一個 bug
,我們肯定是立即修復,然後立即打包並把靜態資源更新到伺服器上,如果不更改檔名,由於瀏覽器的快取問題,使用者是沒有辦法立馬看到效果的,因此我們可以給檔名新增 hash
的配置
output: { // 出口檔案
publicPath:"",
filename: '[name].[hash].js',
chunkFilename:'[name].[hash].js',
path: path.resolve(__dirname,'dist')
},
業務程式碼:
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
檔案
它們公用同一個 hash
值,此時當我修改了業務程式碼:
$(function () {
forEach([1,2,3,4,5,6],(item)=>{
console.log(item);
})
});
業務程式碼變了,但是我們引入的第三方庫是沒有任何改變的,當再次執行打包,兩類檔案的 hash
值都改變了,此時我們部署到伺服器,使用者瀏覽器的確可以重新載入並且立馬看到效果,但是使用者不應該重新載入第三庫的程式碼呀,這些可是沒有變化的。此時我們就應該使用 webpack
提供的 [contenthash]
配置,它代表的意思是,只有內容改變的模組檔案 hash
值會變化,內容不改變的檔案 hash
值保持原樣
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
配置時,會分檔案進行配置的,因為生產環境和開發環境差異還是非常大的。
配置檔案分離思路:
- 提取一個公共配置,例如
js
處理,css
處理,圖片等資源的處理,在開發環境和生產環境都是一樣的; - 單獨配置一個開發環境和生產環境配置,然後通過
webpack-merge
合併公共配置:
const webpack = require('webpack');
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common,{
mode: 'development',
...
});
配置全域性變數
plugins: [
...
new webpack.ProvidePlugin({
$:"jquery",
_:"loadsh"
})
]
配置好了 $
與 _
的全域性變數後,我們在後續編寫模組時可以不需要引入而直接使用:
export function ui (){
$('body').css('background','green');
}
使用環境變數
package.json
"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
module.exports = (env)=>{
console.log(env); // {development:true} || {production:true}
if(env && env.production){
return merge(commonConfig,prodConfig);
}else{
return merge(commonConfig,devConfig);
}
}
小結
通過本文的學習並且自己能動手實踐一遍的話,相信對於 webpack
的基礎配置會有一個更加全面的瞭解,併為之後學習如何優化以及 webpack
原理打好堅實的基礎。
喜歡本文請點個贊把~