webpack 打包
模塊化開發為我們解決了很多問題,使得代碼組織管理非常的方便,但是又帶來了新的問題,ES Module 存在環境兼容問題,劃分的文件太多,就會導致網絡請求頻繁,不能保證所有資源的模塊化
如果能我們享受模塊化帶來的開發優勢,又能不必擔心生產環境的存在這些問題,于是就有了 webpack, rollup, Parcel 等工具
webpack 模塊化不等于 js ES modele 模塊,相對來講是前端的模塊化處理方案,更加宏觀
- 快速上手
$ yarn init --yes
$ yarn add webpack webpack-cli -D
$ yarn webpack --version
$ yarn webpack // 默認打包src/index.js // 最終存放到dist/main.js
- webpack 配置文件
在項目根目錄添加 webpack.config.js
const path = require('path');
module.exports = {
entry: './src/main.js', // 入口文件
output: {
filename: 'bundle.js', // 輸出文件名
path: path.join(__dirname, 'output'), // 輸出文件路徑(絕對路徑)
},
};
- 工作模式
webpack4 新增了工作模式的用法,大大簡化了配置的復雜程度;三種工作模式 mode: production development none
$ webpack --mode none
$ webpack --mode production // 默認模式
$ webpack --mode development
或者采用配置的方式
const path = require('path');
module.exports = {
// 這個屬性有三種取值,分別是 production、development 和 none。
// 1. 生產模式下,Webpack 會自動優化打包結果;
// 2. 開發模式下,Webpack 會自動優化打包速度,添加一些調試過程中的輔助;
// 3. None 模式下,Webpack 就是運行最原始的打包,不做任何額外處理;
mode: 'development',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
};
-
資源模塊加載
webpack 內部的 loader 只能處理 js 文件,其他文件我們需要配置對應的 loader 才可以完成打包,否則會報錯。
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.css',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.css$/,
// css-loader作用就是將css代碼轉化為js模塊
// style-loader作用就是將cssloader轉化的結果追加到頁面
use: ['style-loader', 'css-loader'],
},
],
},
};
- 導入資源模塊
入口文件為 js 文件,根據代碼的需要動態導入其他資源,由 javascript 驅動整個前端應用
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.css',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
};
// main.js
import './main.css';
-
文件資源加載器
- file-loader
經過 file-loader 處理后,將文件資源放到我們打包目錄的根目錄。返回文件資源的訪問路徑,通過 import 就可以拿到文件資源的路徑。webpack 默認認為文件資源放在網站的根目錄下
會發起文件請求
- file-loader
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/',
},
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /.png$/,
use: 'file-loader',
},
],
},
};
// main.js
import createHeading from './heading.js';
import './main.css';
import iconURL from './icon.png';
// 經過file-loader處理后,將圖片放到我們打包目錄的根目錄。返回圖片的訪問路徑,通過import就可以拿到圖片的路徑。webpack默認認為圖片放在網站的根目錄下
const heading = createHeading();
document.body.append(heading);
const img = new Image();
img.src = iconURL;
document.body.append(img);
-
url-loader
將文件資源轉化為 Data Url, 最終返回這個 Data Url,不單獨生成資源文件,直接嵌入到 bundle.js 中
當資源文件過大時,導致 base64 邊長,打包的 bundle.js 體積過大-
Data URLs
直接表示文件內容,使用這種 Url 不會發起 Http 請求data:[<mediatype>][;base64],<data> // 協議 + 媒體類型以及編碼+ 文件內容(圖片會被轉化為base64)
-
最佳實踐:小文件使用 Data URLs, 減少請求次數。大文件單獨提取,避免 bundle.js 過大,加載時間過長
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /.png$/,
use: {
// 必須同時安裝file-loader,當超過limit設置的值,url-loader會自動讓file-loader處理
loader: 'url-loader',
options: {
limit: 10 * 1024, // 10 KB
},
},
},
];
}
-
常用 loader 分類
編譯轉化類型
文件操作類型
代碼質量檢查
-
處理 ES6+新特性
webpack 只是打包工具 默認處理代碼中的 export 和 import,但對其他 ES6+新特性不做處理,需要 babel-loader
$ yarn add babel-loader @babel/core @babel/preset-env -D
// babel 只是一個js的轉換平臺。基于平臺通過不同的插件實現轉化
{
"test": /.js$/,
"use": {
"loader": "babel-loader",
"options": {
"presets": ["@babel/preset-env"]
}
}
}
-
模塊加載方式
webpack 兼容多種標準的模塊加載方式- ES Module
- CommonJs
- AMD
- import('XXX.css')
- @import ()
- @import url()
- html 中的 img 的 src 屬性
- background 屬性的 url
- a 標簽的 herf 屬性
...
-
webpack 核心工作原理
首先設置入口文件,webpack 會根據配置找到入口文件(如果不設置默認 src 下面的 index.js 文件)作為我們的打包入口
根據代碼中出現的 import 或者 require 解析推斷出這個文件所依賴的其他資源模塊
然后分別延伸解析每一個資源模塊對應的依賴,形成一個整個項目當中所有用的資源文件的依賴樹
然后遞歸這個依賴樹,找到每個節點對應的資源文件,根據配置文件的 rules 屬性找到當前模塊的加載器(loader),然后交給加載器加載這個模塊
最終將執行完成的結果放到 output 對應的 bundle.js 中
在整個過程中,會通過 webpack 提供的鉤子函數(生命周期函數)加載對應的任務。這個任務我們也成 plugins
webpack 開發一個 loader
原則: 對同一文件所用到的 loader 執行完成后, 最終必須返回 javascropt 代碼,也就是處理當前資源的最后的 loader 必須是返回 javascript 代碼
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/',
},
module: {
rules: [
{
test: /.md$/,
// 將 md 轉化為 html
use: ['html-loader', './markdown-loader'],
},
],
},
};
// main.js
import about from './about.md';
console.log(about);
// markdown-loader.js
const marked = require('marked');
module.exports = source => {
// source為加載進來的資源內容
const html = marked(source);
// 如果不交給下個loader處理
// return `module.exports = "${html}"`
// return `export default ${JSON.stringify(html)}`
// 如果交給下個loader處理
// 返回 html 字符串交給下一個 loader 處理
return html;
};
-
常用插件 Plugin
clean-webpack-plugin
每次打包前先清除 webpack 輸出目錄HtmlWebpackPlugin
每次打包的文件自動生成 html 文件,自動引入打包結果
plugins: [
new webpack.ProgressPlugin(),
new CleanWebpackPlugin(),
// 不額外添加模板的使用
new HtmlWebpackPlugin({
title: 'glh', // 設置標題
meta: {
// 設置meta標簽
viewport: 'width=device-width',
},
// ...
}),
];
// 添加模板,讓HtmlWebpackPlugin根據模板生成
new HtmlWebpackPlugin({
title: 'glh', // 設置標題
meta: {
// 設置meta標簽
viewport: 'width=device-width',
},
template: './public/index.html',
templateParameters: {
// 自定義變量
BASE_URL: './',
},
// ...
});
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
properly without JavaScript enabled. Please enable it to
continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
// 用于生成index.html
new HtmlWebpackPlugin({
template: './public/index.html',
// ...
});
// 用于生成about.html
new HtmlWebpackPlugin({
filename: 'about.html',
// ...
});
copy-webpack-plugin
對一些公共資源文件直接復制到打包目錄中。比如 public/favicon.ico
new CopyWebpackPlugin({
patterns: [{ from: 'public/favicon.ico', to: '.' }],
});
我們一般在使用插件的時候掌握一些經常用的就可以。后面根據需求再去提煉關鍵詞,搜索自己想用的插件,當然也可以自己寫。插件的約定名稱一般都是 XXX-webpack-plugin,比如我們想要壓縮圖片就可以找 imagemin-webpack-plugin
- 實現一個自定義 plugin
首先要明白:
- Plugin 其實就是通過在生命周期的鉤子中掛載函數實現擴展。類似于我們 React 中的聲明周期。
webpack 在工作的過程中給每一個環節都埋下了鉤子,我們只需要在對應的鉤子下掛載任務就可以輕松的擴展 webpack 的能力
自定義的 Plugin 其實就是一個函數,或者包含 apply 的方法的對象
apply 方法接受一個 compiler 對象參數,這個參數包含我們整個構建過程中的所有配置信息,通過這個對象我們可以注冊鉤子函數,通過 tap 方法注冊任務
tap 方法又接受兩個參數,一個是插件名稱,一個是當前次打包執行的上下文
- 和 loader 區別:loader 是專注實現資源模塊加載轉化
Plugin 是解決處理資源加載轉化之外的的一些自動化工作
相比于 Loader,Plugin 的能力范圍更寬
因為 Loader 只是在加載模塊的范圍工作,而插件的工作范圍可以觸及到 webpack 的每一個環節
class MyPlugin {
apply(compiler) {
console.log('MyPlugin 啟動');
// 這里要做的事情就是在emit鉤子上掛載一個任務,這個任務幫我們去除打包后沒有必要的注釋(mode=none情況下)其他鉤子可參考官網
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation => 可以理解為此次打包的上下文
for (const name in compilation.assets) {
// console.log(name)
// console.log(compilation.assets[name].source())
if (name.endsWith('.js')) {
const contents = compilation.assets[name].source();
const withoutComments = contents.replace(/\/\*\*+\*\//g, '');
compilation.assets[name] = {
source: () => withoutComments,
size: () => withoutComments.length,
};
}
}
});
}
}
plugins: [new MyPlugin()];
- 增強 webpack 的開發體驗
// 不使用Webpack Dev Server情況下,自動監聽打包文件的變化
$ yarn webpack --watch
$ http-server -c-1 dist //or $ browser-sync dist --file "**/*"
以上方式效率太低,文件不斷的被讀寫操作,有待優化
-
Webpack Dev Server
編寫源代碼=> webpack 打包=> 運行應用=> 刷新瀏覽器
我們可以借助 Webpack Dev Server 來提升開發體驗,更接近生產環境的運行狀態,同時也可以設置 proxy,對于錯誤我們可以使用 souceMap 來快速定位源代碼問題
$ yarn add webpack-dev-server -D
$ yarm webpack-dev-server --open
webpack-dev-server 并不會將打包結果放到磁盤中,暫時存放到內存中,從臨時內存中讀取內容發送給瀏覽器,從而大大提高了效率
- webpackDevServer 的靜態資源訪問
devServer: {
contentBase: './public', //也可以指定數組標識多個目錄
}
- 代理 proxy
代理方式適用于后端沒有配置 cors 的情況
如果我們的項目最終上線前后端代碼符合同源策略,也就沒必要設置 cors 了,這個時候可以通過本地服務器配置代理的方式實現跨域請求
devServer: {
proxy: {
'/api': {
// http://localhost:8080/api/users -> https://api.github.com/api/users
target: 'https://api.github.com',
// http://localhost:8080/api/users -> https://api.github.com/users
pathRewrite: {
'^/api': '' // 根據后端接口文件路勁因情況而定,這里只是用github舉例說明
},
// 不能使用 localhost:8080 作為請求 GitHub 的主機名
changeOrigin: true
}
}
}
// main.js;
// 跨域請求,雖然 GitHub 支持 CORS,但是不是每個服務端都應該支持。
// fetch('https://api.github.com/users')
fetch('/api/users') // http://localhost:8080/api/users
.then(res => res.json())
.then(data => {
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item.login;
ul.append(li);
});
});
-
sourceMap
由于編寫的代碼和運行的代碼不一致,sourceMap 幫我們定位源代碼錯誤
webpack 提供了 12 中 sourceMap 方式。每種方式的效果和效率不同,效果最好的,效率最差,效果最差的,效率最高,因此我們只需要實際開發中符合需求的最佳實踐
cheap: 定位到行,不定位列
eval: 定位到文件
module: 定位 loader 處理前的源代碼
inline: 把 sourcemap 嵌入到打包文件中,不額外生成對應的.map 文件
hidden: 會有錯誤信息,但是不是源文件。開發第三方包的時候可以用
nosources: 看不到源代碼,但是會有行列信息,保護在生產環境中源代碼不被暴露
devtool: // 開發環境 'cheap-module-eval-source-map',
// 生產環境 'none',
// 如果對自己上線代碼沒有信心 'nosources-source-map'
const HtmlWebpackPlugin = require('html-webpack-plugin');
const allModes = [
'eval',
'cheap-eval-source-map',
'cheap-module-eval-source-map',
'eval-source-map',
'cheap-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'inline-cheap-module-source-map',
'source-map',
'inline-source-map',
'hidden-source-map',
'nosources-source-map',
];
module.exports = allModes.map(item => {
return {
devtool: item,
mode: 'none',
entry: './src/main.js',
output: {
filename: `js/${item}.js`,
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: `${item}.html`,
}),
],
};
});
- 熱更新(HMR)代替自動刷新
自動刷新導致頁面狀態丟失
熱更新就是在頁面不跟新的情況下,只將修改的模塊實時替換到應用中
$ yarn webpack-dev-server --hot
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
filename: 'js/bundle.js',
},
devtool: 'source-map',
devServer: {
hot: true,
// hotOnly: true // 只使用 HMR,不會 fallback 到 live reloading
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
// ...
],
};
默認的 HMR 開啟后還需要我們手動去處理熱更新的邏輯。當然在 css 文件中由于 cssloader 中已經幫我們處理了,所以我們可以看到修改 css 可以出發熱跟新
編寫的 js 模塊由于代碼太過靈活,如果沒有框架的約束,wabpack 很難實現通用的熱更新
- HMR API
import createEditor from './editor';
import background from './better.png';
import './global.css';
const editor = createEditor();
document.body.appendChild(editor);
const img = new Image();
img.src = background;
document.body.appendChild(img);
// ============ 以下用于處理 HMR,與業務代碼無關 ============
// console.log(createEditor)
if (module.hot) {
let lastEditor = editor;
// 處理js模塊的熱更新
module.hot.accept('./editor', () => {
// console.log('editor 模塊更新了,需要這里手動處理熱替換邏輯')
// console.log(createEditor)
const value = lastEditor.innerHTML;
document.body.removeChild(lastEditor);
const newEditor = createEditor();
// 解決文本框狀態丟失
newEditor.innerHTML = value;
document.body.appendChild(newEditor);
lastEditor = newEditor;
});
// 處理img熱更新
module.hot.accept('./better.png', () => {
img.src = background;
console.log(background);
});
}
以上例子 只是說明 webpack 沒辦法提供通用方案。實現一個熱更新原理就是利用 module.hot,HotModuleReplacementPluginApi 提供的這個。大部分框架中都集成了 HMR。
- 不同環境的配置文件
// 函數方式配置
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = (env, argv) => {
const config = {
// ...
};
if (env === 'production') {
config.mode = 'production';
config.devtool = false;
config.plugins = [
...config.plugins,
new CleanWebpackPlugin(),
new CopyWebpackPlugin(['public']),
];
}
return config;
};
文件劃分的配置
// webpack.common.js
module.exports = {};
// webpack.dev.js
const common = require('./webpack.common');
const merge = require('webpack-merge'); // 安裝webpack-merge合并配置
module.exports = merge(common, {
mode: 'development',
devtool: 'cheap-eval-module-source-map',
devServer: {
hot: true,
contentBase: 'public',
},
plugins: [new webpack.HotModuleReplacementPlugin()],
});
// webpack.prod.js
const merge = require('webpack-merge');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'production',
plugins: [new CleanWebpackPlugin(), new CopyWebpackPlugin(['public'])],
});
$ yarn webpack --config webpack.prod.js
$ yarn webpack-dev-server --config webpack.dev.js
- DefinePlugin
為代碼注入全局成員
默認注入 process.evn.NODE_ENV 常量
plugins: [
new webpack.DefinePlugin({
// 值要求的是一個代碼片段
API_BASE_URL: JSON.stringify('https://api.example.com'),
}),
];
- Tree-shaking
將未引用代碼去除掉 生產環境下自動開啟
在其他模式下開啟需要:
optimization: {
// 模塊只導出被使用的成員
usedExports: true,
// 盡可能合并每一個模塊到一個函數中
concatenateModules: true, // scope Hoisting
// 壓縮輸出結果
minimize: true
}
Tree-shaking && babel
由于 Tree-shaking 是基于 ESModule 實現的,但是 舊版本 babel 中如果用到 preset-env 的插件集合的時候會默認開啟轉化 ESModule 的導入導出語法為 Commonjs 的規范。所以導致 Tree-shaking 失效,新版本已默認關閉sideEffects 新特新
標識代碼是否有副作用,為 Tree-shaking 提供更大的壓縮空間
// webpack.config.js
optimization: {
sideEffects: true; // 開啟sideEffects功能
}
// package.json
"siedEffects": false // 標識代碼是否有副作用
副作用需要我們手動添加并且謹慎使用,一般用在開發第三方包中,當我們的代碼有副作用,但是卻配置了以上兩個屬性,就會導致程序報錯。
// package.json 配置有副作用的文件,這樣webpack在打包的過程中就不會忽略這些
"siedEffects" :[
"./src/extend.js",
"*/css"
]
-
Code Splitting
打包成一個文件導致體積過大,加載時間過長。
應用啟動的首屏并不是所有模塊都工作的
所以我們需要分包,按需加載- 多入口打包
適用于多頁面應用
entry: { index: './src/index.js', album: './src/album.js' }, output: { filename: '[name].bundle.js' }, optimization: { splitChunks: { // 自動提取所有公共模塊到單獨 bundle chunks: 'all' } }, plugins: [ new HtmlWebpackPlugin({ title: '首頁', template: './src/index.html', filename: 'index.html', chunks: ['index'] // 對不同的頁面指定不同的打包js文件 }), new HtmlWebpackPlugin({ title: '其他頁面', template: './src/album.html', filename: 'album.html', chunks: ['album'] }) ]
- 動態導入 import()
適用于單頁面應用
在 react 或者 vue 中一般都是通過路由映射組件實現動態加載
webpack 會根據 import()把對應的模塊拆分到不同的輸出文件,根據加載的需要執行不同的 js 文件
// import posts from './posts/posts' // import album from './album/album' const render = () => { const hash = window.location.hash || '#posts' const mainElement = document.querySelector('.main') mainElement.innerHTML = '' if (hash === '#posts') { // mainElement.appendChild(posts()) import(/_ webpackChunkName: 'components' _/'./posts/posts').then(({ default: posts }) => { mainElement.appendChild(posts()) }) } else if (hash === '#album') { // mainElement.appendChild(album()) import(/_ webpackChunkName: 'components' _/'./album/album').then(({ default: album }) => { mainElement.appendChild(album()) }) } } render() window.addEventListener('hashchange', render)
- 多入口打包
魔法注釋
通過在 import 語句中加注釋的方式為 webpack 提供打包后的名稱如果設置一樣則打包到一個文件
if (hash === '#posts') {
// mainElement.appendChild(posts())
import(/* webpackChunkName: 'components' */ './posts/posts').then(
({ default: posts }) => {
mainElement.appendChild(posts());
}
);
} else if (hash === '#album') {
// mainElement.appendChild(album())
import(/* webpackChunkName: 'components' */ './album/album').then(
({ default: album }) => {
mainElement.appendChild(album());
}
);
}
- MiniCssExtractPlugin
提取 css 到單個文件
需要考慮 css 大小,如果很少寫 css 那么還是采用 stypeLoader 注入到頁面的 style 標簽中
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 將樣式通過 style 標簽注入
MiniCssExtractPlugin.loader, // 將樣式通過Link標簽方式注入
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
- webpack 內部提供的生產環境的壓縮只是針對 JS 代碼的。如果想要壓縮其他形式資源,需要單獨安裝對應的插件
optimization: {
minimize: [
// 要使用其他壓縮,這里要把默認的js壓縮的插件也安裝進來,是因為webpack會覆蓋了 optimization原有的默認配置
// 這里配置的壓縮都只會在生產環境起作用,符合我們的預期,不用再去放到webpack.prod.js或者根據環境變量判斷
new TreserWebpackPlugin(),
// 這里以壓縮css為例,其他的參見官網
new OptimizeCssAssetsWebpackPlugin()]
}
- 輸出文件名稱 Hash
一般我們部署前端資源文件的時候,都會開啟靜態資源緩存,避免每次都請求資源,整體應用的響應速度就會大幅度提升,不過也會有問題,當我們緩存時間設置過長,我們的應用更新過后,瀏覽器并不會及時更新。這就需要我們在生產環境中需要給文件添加 Hash 值,全新的文件名就是全新的請求,也就避免了上述問題
hash: 只要內容修改,所有文件 hash 都會跟新
contenthash: 文件級別的 hash,當前修改的文件以及被引用的文件 hash 會被動跟新
chunk: 當文件內容改變,只修改當前同類的 hash 值
output: {
filename: '[name]-[contenthash:8].bundle.js'
},
還有一些其他的配置項比如 preformance target externals resolve other option 我們只需要查閱官方文檔即可,另外還需要多理解 manifest 和 runtime 這樣的 webpack 概念