🧨 大家好,我是 Smooth,一名大二的 SCAU 前端er
🏆 本篇文章会带你入门 Webpack 并对基本配置以及进阶配置做比较通俗易懂的介绍!
🙌 如文章有误,恳请评论区指正,谢谢!
自定义 Loader 内容于 2021/02/25 更新
自定义 Plugin 内容于 2021/02/26 更新
Webpack
本教程包管理方式统一使用 npm
进行讲解
学习背景
由于在平时使用 vue、react 框架进行项目开发时,vue-cli 和 create-react-app 脚手架已经为你默认配置了 webpack 的常用参数,所以没有额外需求不用另外配置 webpack。
但在一次项目开发中,突然自己想给项目增加一个优化打包速度和体积的需求,所以就开始学习起了 webpack,逐渐明白了这打包工具的强大之处,在查看了脚手架给我们默认配置好的 webpack 配置文件后,也明白了脚手架这种东西的便捷之处。
当然,系统学习后 webpack,你也可以做到去阉割脚手架的 webpack 配置文件用不到的一些配置选项,并增加一些你需要的配置,例如优化打包体积、提升打包速度等等。
此篇文章便为你进行系统地讲解 webpack 的基本使用
PS:对于 webpack 的配置文件,vue-cli 可以通过修改 vue.config.js 进行配置修改,create-react-app 需要通过 craco 覆盖,或 eject 进行暴露。
webpack介绍
webpack
是什么
bundler:模块打包工具
webpack
作用
对项目进行打包,明确项目入口,文件层次结构,翻译代码(将代码翻译成浏览器认识的代码,例如import/export)
webpack
环境配置
webpack
安装前提:已安装 node
(node安装在此不做赘述),用指令 node -v
和 npm -v
来测试node安装有没成功
npm install webpack webpack-cli --save-dev // 推荐,--save-dev结尾(或直接一个 -D),该项目内安装
npm install webpack webpack-cli -g // 不推荐,-g结尾,全局安装(如果两个项目用的两个webpack版本,会造成版本冲突)
安装后查询版本:
webpack -v:查找全局的 webpack 版本,非 -g 全局安装是找不到的
npx webpack -v:查找该项目下的 webpack 版本
其他指令:
npm init -y:初始化 npm 仓库,-y 后缀意思是创建package.json文件时默认所有选项都为yes
npm info webpack:查询 webpack 有哪些版本号
npm install webpack@版本号 webpack-cli -D:安装指定版本号的webpack
npx webpack;进行打包
webpack-cli
和 webpack
区别:
webpack-cli
能让我们在命令行运行webpack
相关指令,例如 webpack
, npx webpack
等等
webpack
配置文件
默认配置文件:webpack.config.js
const path = require('path');
module.exports = {
mode: "production", // 环境,默认 production 即生产环境,打包出来的文件经过压缩(可以不写),development没压缩
entry: 'index.js', // 入口文件(要写路径)
output: { // 出口位置
filename: 'bundle.js', // 出口文件名
path: path.resolve(__dirname, 'bundle'), // 出口文件打包到哪个文件夹下,参数(绝对路径根目录下,文件名)
}
}
如果想让 webpack
按其他配置文件规则进行打包,比如叫做 webpackconfig.js
npx webpack --config webpackconfig.js
小问题:
为什么使用react
、vue
框架打包项目文件时不是输入 npx webpack
而是输入 npm start/npm run dev
等等?
原因:更改 package.json
文件里的 scripts 脚本指令(该文件:项目的说明,包括所需依赖、可运行脚本、项目名、版本号等等)
{
"scripts": {
"bundle": "webpack" // 运行 npm run 脚本名,相当于运行 原始指令,即 `npm run bundle -> webpack`
}
}
回顾:
webpack index.js // 全局安装 webpack 后,单独对这个js文件进行打包
npx webpack index.js // 局部(项目内)安装 webpack 后,单独对这个js文件进行打包
npm run bundle -> webpack // 运行脚本,进行 webpack 打包,先在项目内查找webpack进行打包,没有再全局----前两者融合
后面开始用 npm run bundle
代替 webpack
进行打包
Webpack 基本概念
webpack
的 Concepts
板块
Loader
Loader 是什么?
由于 webpack 默认只认识、支持打包js文件,想要拓展其能力进行打包 css文件、图片文件等等,需要安装 Loader 进行拓展
Loader 的使用
在 webpack.config.js
的配置文件中进行配置
在文件中新增 module
字段,module
中新增 rules
的数组,进行一系列规则的配置,每个规则对象有两个字段
test
:匹配所有以 xxx 为后缀的文件的打包,用正则表达式进行匹配
use
:指明要使用的 loader 名称 ,且要对该 loader 进行安装
拓展,use
还有 options
可选择字段,name
指明打包后的文件命名,[name].[ext]
代表打包后和打包前 命名
和 后缀
一样
{
module: {
rules: [
{
test: /.jpg$/,
use: {
loader: 'file-loader',
options: {
// placeholder 占位符
name: '[name].[ext]'
}
}
}
]
}
}
在项目根目录下使用 npm install loader名字
或 yarn add loader名字
进行所需 loader 的安装
常用 Loader 推荐
babel-loader
、style-loader
、css-loader
、less-loader
、sass-loader
、postcss-loader
、url-loader
、file-loader
等等
图片(Images)
打包图片文件
图片静态资源,所以都对应 file-loader
,且一般项目中这些静态资源被放到 images
文件夹,通过 use
字段配置额外参数
{
module: {
rules: [
{
test: /.(jpg|png|gif)$/,
use: {
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'images/' // 匹配到上面后缀的文件时,都打包到的的文件夹路径
}
}
}
]
}
}
当然,对于 file-loader
,url-loader
会更具拓展性
推荐用 url-loader
进行替换,因为可以设置limit
参数,当图片大于对应字节大小,会打包到指定文件夹目录,若小于,则会生成base64
(不会打包图片到文件夹下,而是生成 base64
到 output
的js
文件里 )
{
module: {
rules: [
{
test: /.(jpg|png|gif)$/,
use: {
loader: 'url-loader',
options: {
name: '[name].[ext]',
outputPath: 'images/', // 匹配到上面后缀的文件时,都打包到该文件夹路径
limit: 2048 // 指定大小
}
}
}
]
}
}
样式(CSS)
打包样式文件
需要 css-loader
和 style-loader
,在 use
字段进行配置
说明:设置 css 样式后,挂载到 style 的属性上,所以要两个
{
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader']
}
]
}
}
对于 scss
文件,除了上面两个 loader 以外,还要在 use 中额外配置 sass-loader
,然后安装两个文件
npm install sass-loader node-sass webpack --save-dev
注意事项:
use
中的 loader
数组,是有打包顺序的,按从右到左,从上到下,即 scss,要先 style,然后 css,最后sass,从右到左
use: ['style-loader', 'css-loader', 'sass-loader']
postcss.loader
对于样式,如果老版本的浏览器可能需要兼容,即在 css 属性中加 -webkit
等前缀,可以通过 postcss.loader
实现,在上面例子在后面加上这个 loader 并进行下载后,新建一个 postcss.config.js
文件进行该 loader 的配置即可
module.exports = {
plugins: [
require('autoprefixer')
]
}
样式拓展
如何让 webpack 识别 less 文件内再引入的 less 文件,并进行打包?
如何模块化导出和使用样式?(css in js)
{
module: {
rules: [
{
test: /.scss$/,
use: ['style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2, // 允许less文件内引入less文件
modules: true // 允许模块化导入导出使用css,css in js 同理
}
},
'sass-loader,
'postcss-loader'
]
}
]
}
}
字体(Fonts)
打包字体文件(借助iconfont)
从 iconfont 网站下载对应图标的字体文件并压缩到目录后,会发现由于下载的 iconfont.css
文件内部又引入了 eot、ttf、svg
文件,webpack无法识别,引入需给这三个后缀的文件再配置打包规则,用 file-loader
即可
{
module: {
rules: [
{
test: /.scss$/,
use: ['style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2, // 允许less文件内引入less文件
modules: true // 允许模块化导入导出使用css,css in js 同理
}
},
'sass-loade,
'postcss-loader'
]
},
{ // 配置这个规则即可
test: /.(eot|ttf|svg)$/,
use: {
loader: 'file-loader'
}
}
]
}
}
自定义 Loader
首先明确最基本的,编写 Loader
其实就是编写一个函数并暴露出去给 Webpack
使用
例如编写一个 replaceLoader
,作用是当遇到某个字符时替换成其他字符,例如遇到 hello
字符串时,替换成 hi
// 在根目录的 loaders 文件夹下的 replaceLoader.js 即路径:'./loaders/replaceLoader.js'
module.exports = function(source) {
return source.replace('hello', 'hi');
}
这样,一个简易的 Loader 就写好啦
注意
暴露的函数不能写成箭头函数,即不能写成如下:
// replaceLoader.js
module.exports = (source) => {
return source.replace('hello', 'hi');
}
由于箭头函数没有 this
指针,而 Webpack
在使用 Loader
时会做些变更,绑定一些方法到 this
上,所以会没法调用原本属于 this
的一些方法了。
例如:获取传入 Loader
的参数是通过 this.query
获取
当然,要用你自定义的 Loader,除了上面的编写 Loader 外,还需要对他进行使用,在 Webpack
配置文件进行相关配置
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
module: {
rules: [{
test: /.js/,
use: [
path.resolve(__dirname, './loaders/replaceLoader.js') // 这里要书写该 js 文件的路径
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
如果你想往你的自定义 Loader 传入一些参数,传参的方式如下:
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
module: {
rules: [{
test: /.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js'), // 这里要书写该 js 文件的路径
options: {
name: 'hi'
}
}
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
这样,Webpack
在进行打包时,会将 {name: 'hi'}
参数传入 replaceLoader.js
在 replaceLoader.js
中,参数的接收形式如下:
// replaceLoader.js
module.exports = (source) => {
return source.replace('hello', this.query.name);
}
这样,原项目所有 js 文件中的 hello
字符串都被替换成了 hi
这样,一个简易的 Loader
就完成啦
更多
loader-utils
但有时往自定义 Loader 传参时会比较诡异,例如上述例子,传入的明明是一个对象,但可能变成只有一个字符串 ,此时就需要用到 loader-utils
模块,对传入的参数进行分析,解析成正确的内容
使用方法
先运行 npm install loader-utils --save-dev
安装,然后
// replaceLoader.js
const loaderUtils = require('loader-utils'); // 引入该模块
module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
return source.replace('hello', options.name);
}
callback()
有时,除了用自定义 Loader 对原项目做出更改以外,如果启用了 sourceMap
,还希望 sourceMap
对应的映射也发生更改,
由于该函数只返回了项目内容的更改,而没返回 sourceMap
的更改,所以要用 callback
做一些配置
this.callback(
err: Error | null,
content: string | Buffer,
sourceMap?: SourceMap,
meta?: any
)
通过该函数进行回调,可以返回除了项目内容更改外,还可以返回 sourceMap 、错误、meta 的更改
由于,我只需要返回项目内容以及 sourceMap
的更改,所以配置示例如下:
// replaceLoader.js
const loaderUtils = require('loader-utils'); // 引入该模块
module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
const result = source.replace('hello', options.name);
this.callback(null, result, source);
}
async()
自定义 Loader 中有时会有异步操作,例如设置延时器1s后再进行打包(方便摸鱼),那如果直接 setTimeout(),设置一个延时器再返回肯定是不行的,会报错无返回内容,因为正常来说是不允许在延时器中返回内容的。
我们可以通过 async() 来解决,如下:
// replaceLoader.js
const loaderUtils = require('loader-utils'); // 引入该模块
module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
const callback = this.async();
setTimeout(() => {
const result = source.replace('hello', options.name);
callback(null, result); // 参数同上面的 callback()
}, 1000);
}
可以看出,其实 async()
跟 callback()
很类似,只不过用于异步返回而已
同时自定义多个 Loader
例如想实现一个需求:打包后项目先是将项目中的所有字符串 hello
替换成 hi
,再把 hi
替换成 Wow
那么就要编写两个 Loader,第一个将 hello
替换成 hi
,第二个将 hi
替换成 Wow
第一个 replaceLoader.js
// replaceLoader.js
const loaderUtils = require('loader-utils'); // 引入该模块
module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
const callback = this.async();
setTimeout(() => {
const result = source.replace('hello', options.name);
callback(null, result); // 参数同上面的 callback()
}, 1000);
}
第二个 replaceLoader2.js
// replaceLoader2.js
module.exports = function(source) {
return source.replace('hi', 'wow');
}
同时对 webpack.config.js
进行配置
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
module: {
rules: [{
test: /.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader2.js')
},
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js') // 这里要书写该 js 文件的路径,
options: {
name: 'hi'
}
},
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
要注意的地方
由于前面提到 Loader
执行顺序是从下到上,从右到左,所以要将第一个写在下面,第二个写在上面
Loader 引入转换成官方的引入方式
在上面的例子中,引入 loader
时,方式都是
loader: path.resolve(__dirname, './loaders/replaceLoader2.js')
太长了,太麻烦了,不美观,想更换成官方的引入方式,该怎么做呢
loader: 'replaceLoader2'
Webpack
配置文件中配置 resolveLoader
字段
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
resolveLoader: {
modules: ['node_modules', './loaders']
},
module: {
rules: [{
test: /.js/,
use: [
{
loader: 'replaceLoader2'
},
{
loader: 'replaceLoader', // 这里要书写该 js 文件的路径,
options: {
name: 'hi'
}
},
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
参数意思:如果在 node_modules
文件夹下没找到配置的 Loader,那么就会进入同级目录下的 loaders
文件夹进行查找
更多 Loader 的设计思考
推荐一些自定义的实用的 loader
- 全局异常监控,思路:给所有函数外面包裹
try{} catch(err) {console.log(err)}
语句 - style-loader
module.exports = function(source) {
const style = `
let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style)
`
return style;
}
Plugins
-
使用插件让打包更快捷、多样化
-
在打包的某个生命周期,插件会帮助你做一些事情
下面介绍几个常用插件
html-webpack-plugin
作用:由于 webpack 默认打包不会生成 index.html 文件, htmlWebpackPlugin
会在打包结束后,自动生成一个html文件,并把打包生成的js自动引入到这个html文件中
插件运行生命周期:打包之后
参数:对象
template
指定一个模板,打包生成的 html 文件根据这个模板来生成,比如会多生成一个 <div id="root"></div>
new HtmlWebpackPlugin({
template: 'index.html'
})
clean-webpack-plugin
作用:打包前删除某个目录下的所有内容,主要用于删除之前的打包内容,防止重复
插件运行生命周期:打包之前
参数:数组形式
[‘要删除的文件夹名’]
new HtmlWebpackPlugin(['dist'])
自定义一个 Plugin
首先明确最基本的,编写 plugin
其实就是编写一个类并暴露出去给 Webpack
在打包的某个生命周期进行相关操作。
例如编写一个 copyright-webpack-plugin
// copyright-webpack-plugin.js 我定义该文件位于根目录的 plugins 文件夹下
class CopyrightWebpackPlugin {
constructor() {
console.log('插件被使用了')
}
apply(compiler) {
}
}
module.exports = CopyrightWebpackPlugin;
webpack.config.js
// webpack.config.js
const path = require('path');
const CopyRightWebpackPlugin = require('./plugins/copyright-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
plugins: [
new CopyRightWebpackPlugin()
],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
这样,一个简易的 Plugin
就完成啦
更多
往插件里传参
在 Webpack 配置文件创建插件实例时,同时传入参数就行
plugins: [
new CopyRightWebpackPlugin({
name: 'Smoothzjc'
})
],
这样,就可以在类的构造函数中接收到该参数了
class CopyrightWebpackPlugin {
constructor(options) {
console.log('我是', options.name)
}
apply(compiler) {
}
}
module.exports = CopyrightWebpackPlugin;
不同生命周期
前面我有提到,在打包的某个生命周期,插件会帮助你做一些事情,
所以我们可以在 Webpack
打包的不同生命周期时,写一些想让 Webpack 帮我们做的事
常用生命周期:
emit
异步钩子,打包完成准备将打包内容放到生成目录前,即打包完成的最后时刻compile
同步钩子,准备进行打包前
下面示例的一些参数解释:
compiler
配置的所有内容,包括打包相关的内容
compilation
本次打包的所有内容
如果你想在打包完成前新加一个文件到打包目录下,可以配置 compilation
的 assets
属性
相关代码运行在 apply
属性中
class CopyrightWebpackPlugin {
constructor(options) {
console.log('我是', options.name)
}
apply(compiler) {
// 同步钩子 compile
compiler.hooks.compile.tap('CopyrightWebpackPlugin', () => {
console.log('同步钩子 compile 生效');
})
// 异步钩子 emit
compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
compilation.assets['copyright.txt'] = {
// 文件内容配置在 source 属性中,该例子意思是文件内容是一个函数,且返回值是如下
source: funciton() {
return 'copyright write by Smoothzjc'
},
// 该文件大小
size: function() {
return 28
}
}
// 由于异步钩子,所以要运行回调函数
cb();
})
}
}
module.exports = CopyrightWebpackPlugin;
编写插件时进行调试
大部分调试工具都是基于 node 编写,在此我举个例子,如何在编写 plugin
时使用调试工具进行 debug
- 先添加脚本指令,通过
node
运行调试工具
// package.json
{
"scripts": {
"debug": node --inspect --inspect-brk node_modules/webpack/bin/webpack.js,
"build": "webpack"
}
}
- 在需要调试的地方打断点
class CopyrightWebpackPlugin {
constructor(options) {
console.log('我是', options.name)
}
apply(compiler) {
compiler.hooks.compile.tap('CopyrightWebpackPlugin', () => {
console.log('compiler');
})
compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
debugger; // 在此处打断点
compilation.assets['copyright.txt'] = {
// 文件内容配置在 source 属性中,该例子意思是文件内容是一个函数,且返回值是如下
source: funciton() {
return 'copyright write by Smoothzjc'
},
// 该文件大小
size: function() {
return 28
}
}
// 由于异步钩子,所以要运行回调函数
cb();
})
}
}
module.exports = CopyrightWebpackPlugin;
-
控制台运行
debug
指令npm run debug
后 -
打开浏览器按 F12 打开控制台,可以在开发者工具左上角看到 node 图标,点击即可进入 webpack 打包时经过的一些页面
- 可以将鼠标放上想查看的变量的属性
- 或在右边的
Watch
属性输入想查看的属性名称进行查看
Entry
打包的入口文件,并指定打包后生成的 js文件名
参数:字符串 或 一个对象,默认生成的文件名是 main
,即 生成的文件名:'入口文件路径'
entry: './src/index.js'
或
entry: {
main: './src/index.js'
}
同时上面跟下面的等价
同时也可以打包成多个 js 文件,即多入口
entry:{
main: './src/index.js',
sub: './src/index.js'
}
Output
输出js文件名
参数:
filename
最后打包出来的 js 文件名,可以直接bundle.js
指定,也可以[name].js
根据 entry 指定的名字,也可以[hash].js
根据 entry 指定的哈希值chunkFilename
通过异步引入的文件的文件名
path
打包后所处文件夹和路径
publicPath
给打包出来的 js 文件的 src 引入路径都加个前缀,一般用于 cdn 配置
publicPath 详解
例如:
index.html
中引入 js 板块的代码
<script type="text/javascript" src="main.js"></script>
如果想将打包后的js 文件都放到 cdn上,减少打包后体积(此时该js就不用放在打包后的文件夹中了),例如
<script type="text/javascript" src="http://cdn.com.cn/main.js"></script>
可配置成如下
publicPath: 'http://cdn.com.cn'
output配置示例
output: {
publicPath: 'http://cdn.com.cn',
filename: '[name].js',
chunkFilename: '[name].chunk.js',
path: path.resolve(__dirname, 'dist')
}
SourceMap
打包后的文件是否开启映射关系,他知道打包后文件与打包前源代码文件的代码映射
例如:知道 dist 目录下 main.js 文件96行报错,实际上对应的是 src 目录下 index.js 文件中的第一行
通常不用开启,默认 none
关闭,因为开启后会减缓打包速度和增大打包体积
参数
devtool: '参数'
'none' // 不开启source-map
'source-map' // 开启source-map进行映射
'inline-source-map' // 开启source-map进行映射的前提下,精确到哪一行哪一列
'cheap-source-map' // 开启source-map进行映射的前提下,只精确到哪一行
'eval' // 通过 eval 开启映射,效率最快,但不全面
推荐:
开发环境:devtool: 'cheap-module-eval-source-map'
生产环境(线上环境):devtool: 'cheap-module-source-map'
生产环境一般不用配置 devtool,但如果想报错时快速定位错误,可开启,建议使用上面推荐的参数
mode: 'development'
是开发环境
mode: 'production'
是生产环境
更多其他参数查看下表
SourceMap 配置示例
devtool: 'cheap-module-source-map'
WebpackDevServer
开启一个本地web服务器,可提高开发效率
webpack
指令(一般直接配置第二个脚本就行)
1. webpack --watch 保存后自动重新打包
2. webpack-dev-server 启动一个web服务器,并将对应目录资源进行打开,对应目录资源修改后保存会重新进行打包,且自动对网页进行刷新
我们下载的每个项目都经过两条指令(安装依赖 + 打包运行),下面以 create-react-app 脚手架生成的 react 项目为例
npm install
npm run start
第二步,其实就是运行 WebpackDevServer
,你会发现,start 后会直接打开浏览器,且每次保存后都会自动重新打包、重新刷新网页。
WebpackDevServer
隐藏特性:打包后的资源不会生成一个 dist 文件夹,而是将打包后资源放在电脑内存中,能有效提高打包速度
参数
contentBase
将哪个目录下的文件放到 web 服务器上进行打开open
是否在打包时同时打开浏览器访问项目对应预览 urlport
端口号proxy
设置代理
WebpackDevServer 配置示例
webpackDevServer: {
contentBase: './dist',
open: true,
port: 8080,
proxy: {
'api': 'xxxxx'
}
}
拓展内容
其实相当于自己手写一个 webpack-dev-server,但人家官方已经帮我们写好一个各配置项都齐全的一个了,自己不用手写了,只是带大家进行拓展,理解一下 webpack-dev-server 背后的源码是如何搭配 node 实现的
在 node 中使用 webpack
查看官方文档 的 Node.js API
板块
在命令行中使用 webpack
查看官方文档 的 Command Line Interface
板块
Hot Module Replacement
热模块更新 HMR
当内容发生更改时,只有更改的那部分发生变化,其他已加载的部分不会重新加载(例如修改css样式,只有对应样式更改,js不会改变)
参数
hot
开启热模块更新hotOnly
设置为 true 后,无论热更新是否开启,都禁用webpackDevServer
的保存后自动刷新浏览器功能
也是配置到 webpackDevServer
里
HMR
配置示例
const webpack = require('webpack');
devServer: {
hot: true,
hotOnly: true
}
plugins: [
new webpack.HotModuleReplacementPlugin()
]
上面是让 HMR
生效,下面是对 HMR
进行使用
// 例子:当 number.js 发生更改时,会调用函数
import number from './number';
number();
if(module.hot) {
module.hot.accept('./number', () => {
number();
})
}
但上面的 使用 一般不用写,因为其实很多地方都已经写好了,内置了 HMR
组件,例如css的话 css-loader
里面给你写好了,vue 的话 vue-loader
里写好了,react 的话 babel-preset
写好了。
如果你要引入比较冷门的数据文件,没有内置 HMR
,就需要写。
使用 Babel 处理 ES6 语法
babel-loader
、@babel/preset-env
、@babel/polyfill
- babel-loader 的配置选项可以单独写进
.babelrc
文件里 - 除了将 ES6 转换成 ES5 还不够,有些低版本浏览器还需要将 Promise、Array.map 注入额外代码,需要引入
@babel/polyfill
@babel/preset-env 的参数
useBuiltIns: 'usage' // 对于使用的代码,才转译成 ES5 并打包至 dist 文件夹
targets: 该代码运行环境,根据环境来判定是否要做 ES6 的转化
{
chrome: '67' // 谷歌浏览器版本大于67,对 ES6 能直接正常编译,所以没必要做 ES5 的转换了
}
使用示例:
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
// 以下演示两种方案
presets: [["@babel/preset-env", {
targets: {
edge: '17',
firefox: '60',
chrome: '67',
safari: '11.1'
},
useBuiltIns: 'usage'
}]]
// 以下是生成第三方库或源组件,不希望 babel 污染时才使用,可替换上面的 presets
plugins: [["babel/plugin-transform-runtime", {
"corejs": 2,
"helpers": true,
"regenerator": true,
"useESModules": false
}]]
}
}
]
}
如果你想在 react 使用 babel
实现对 React 框架代码的打包
下载 @babel/preset-react
.babelrc 文件
{
presets: [
[
"babel/preset-env", {
targets: {
chrome: "67",
},
useBuiltIns: "usage"
}
],
"@babel/preset-react"
]
}
Webpack 高级概念
webpack
的 Guides
板块
Tree Shaking
只会对引入进行使用的代码进行打包,没引入进行使用的代码不会打包(可减少代码体积),webpack 2.0 之后默认开启该功能
- 只支持 ES Module(静态引入)
- 不支持 Common JS(动态引入)
import { } from '' // ESM 支持
const xxx = require('') // Common JS 不支持
development(开发环境) 默认不打开 Tree Shaking
因为如果开发环境进行调试时,如果每次重新编译打包后的代码都进行了 Tree Shaking
,那就会让 debug 时代码行数对不上,不利于调试
如果你想开发环境打开
// package.json
{
"sideEffects": false 或 数组
}
如果是数组,则配置你不想哪些代码进行 Tree Shaking
例如:如果不想 css 文件进行 Tree Shaking,则
{
"sideEffects": ["*.css"]
}
Development 和 Production 模式的区分打包
通常来说,两个环境的 webpack 配置文件不会有变化,但如果非得区分,可以不同文件形式
webpack.dev.js
根据名字可知,是 development(开发环境)
webpack.proud.js
根据名字可知,是 production(生产环境)
打包脚本也要更改,如果区别开
// package.json
{
"scripts": {
"dev-build": webpack --config webpack.dev.js, // 相对路径
或
"proud-build": webpack --config webpack.proud.js, // 相对路径
}
}
Webpack 和 Code Splitting
为什么要进行代码分割?
如果用户一个页面要加载的 js 文件很大,足足有2MB,那么用户每次访问这个页面,都要加载完2MB的资源页面才能正常显示,但其中可能有很多代码块是当前页面不需要使用的,那么将没使用的代码分割成其他 js 文件,当要使用的时候再进行加载、当页面变更时只有那部分进行重新加载,这样可以大大加快页面加载速度。
即通过配置进行合理的代码分割,能让文件结构更清晰,项目运行更快,比如如果用到 lodash 库,就分割出来。
下面通过一个例子进行解释:
假设现在有 main.js(2MB),里面含有 lodash.js(1MB)
1. 该种方式
首次访问页面时,加载 main.js(2MB)
当页面业务逻辑发生变化时,又要重新加载2MB内容
2. 将 lodash.js 抽离出来,即现在是 main.js(1MB) 和 lodash.js(1MB)
由于浏览器的并行机制,首次访问页面时,并行渲染两个1MB的文件是要比只渲染一个2MB的文件要快的。
其次,当页面业务逻辑发生变化时,只要重新加载 main.js(1MB) 即可。
同时,如果对于两个文件,如果都有用到某个模块,如果两个文件各自写一次这个模块,就会有重复,此时如果将这个公共模块抽离出来,两个文件分别去引用他,那么就会减少一次该模块的撰写(减少包体积)。
即对代码进行合理分割,还可以加快首屏加载速度,加快重新打包速度(包体积减少)
代码分割:通俗解释就是将一坨代码分割成多个 js 文件
代码分割自己可以手动,例如我们平时的抽离公共组件,但为什么现在 webpack
几乎跟 Code Splitting
绑定在一起了呢?
因为 webpack
中有一个插件 SplitChunksPlugin
,会让代码分割变得非常简单, 这也是 webpack
的一个强大的竞争力点
// webpack.config.js
{
optimization: {
splitChunks: {
chunks: 'all'
}
}
}
总结一下
Webpack
进行 Code Splitting
有两种方式
1. 通过配置插件 SplitChunksPlugin
```
// webpack.config.js
{
optimization: {
splitChunks: {
chunks: 'all'
}
}
}
```
然后编写同步代码
```
import _ from 'lodash';
// 编写业务代码
```
2. 通过异步地动态引入
function getComponent() {
return import('lodash').then(({ default: _ }) => {
let element = document.createElement('div');
element.innerHTML = _.join(['zjc', 'handsome'], '-');
return element;
})
}
getComponent().then(element => {
document.body.appendChild(element);
})
当然,想要支持异步地动态引入某个模块,需要先下载 babel-plugin-dynamic-import-webpack
然后
// .babelrc
{
presets: [
[
"@babel/preset-env", {
targets: {
chrome: "67"
},
useBuiltIns: 'usage'
}
],
"@babel/preset-react"
],
plugins: ["dynamic-import-webpack"]
}
SplitChunksPlugin
该板块会对该插件配置参数进行详解
重要作用是可以减少包体积,缓存组中的 reuseExistingChunk
属性:开启true后,如果要分割进该组的模块在之前已经被缓存到了某个组内,那就不会再缓存
配置示例
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'all', // 只对哪些代码进行分割(all 的话全部都,async 的话只对异步代码进行分割)
minSize: 30000, // 要做代码分割的引入库的最小大小,即30000代表如果你引入的库大小超过30KB才做代码分割
minRemainingSize: 0,
minChunks: 1, // 一个库被引入至少多少次才做代码分割
maxAsyncRequests: 5, // 同时分割的库数
maxInitialRequests: 3, // 最多能分割出多少个 js 文件
automaticNameDelimiter: '~', // 代码分割出来的 js 文件名 和下面的组名 用什么符号进行连接
name: 'true' // 当为 true 时,下面组的 filename 属性才会生效
enforceSizeThreshold: 50000,
// 缓存组,当打包同步代码时,除了走完上面的设置流程,还会额外再走进下面的组设置,即代码分割进下面符合要求的各组
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/, // 只有 node_modules 里面的库被引入时,才做代码分割到 vendor 组
priority: -10, // 优先级,越大优先级越高
reuseExistingChunk: true,
filename: 'vendors.js', // vendors 组的代码分割都分割到 filename 文件内
name: 'vendors' // 生成 vendors.chunk.js,该属性和上面的 filename 写一个就行
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true, // 开启true后,如果要分割进该组的模块在之前已经被缓存到了某个组内,那就不会再缓存
},
},
},
},
};
更多配置项请看官方文档
Lazy Loading
通过异步地动态引入某个模块,通常在路由配置页面,对引入的组件进行懒加载
function getComponent() {
return import('lodash').then(({ default: _ }) => {
let element = document.createElement('div');
element.innerHTML = _.join(['zjc', 'handsome'], '-');
return element;
})
}
getComponent().then(element => {
document.body.appendChild(element);
})
打包分析
应用 webpack
官方工具 Bundle Analysis
Preloading、Prefetching
Preloading
懒加载,当进行某个事件时,才会引入某个组件,例如点击某个元素时,才会 import
引入组件
PreFetching
预加载,当主页面的核心功能和交互都加载完成后,如果网络空闲,那么就会预先加载某个组件,这样在某个时刻引入该组件时,就能一下子打开
实现预加载:
webpack
搭配魔法注释,在引入的路径前加上 webpackPrefetch: xxx
document.addEventListener('click', () => {
import(/* webpackPrefetch: true */ './click.js').then((func) => {
func()
})
})
这也是 webpack
最为推荐的首屏加载优化手段,异步引入 + 预加载
CSS 文件的代码分割
前面的 Code splitting
都是针对 js 的,将 js 文件进行代码分割,而打包出来的 css 都在 js 文件里
如果想 CSS 文件也代码分割出来,可以使用 MiniCssExtractPlugin
插件,该插件由于依赖热更新,所以只能运行在线上打包环境中
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
plugins: [ new MiniCssExtractPlugin() ],
module: {
rules: [
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
};
且默认将引入的各 css 文件合并到同一个 css 文件里
如果你想代码分割出来的 css 文件做代码压缩,重复属性合并到一起,可以使用 OptimizeCSSAssetsPlugin
插件
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
new OptimizeCSSAssetsPlugin({})
]
},
plugins: [ new MiniCssExtractPlugin() ],
module: {
rules: [
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
}
如果想多入口引入的 css文件也合并在一起,同样需要用到代码分割
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
styles: {
name: 'styles', // 都打包进 styles.css 的文件里
test: /.css$/,
chunks: 'all',
enforce: true // 无视默认参数,如果你在代码分割时设置过一些参数,当你对css文件进行代码分割时可以无视
}
}
},
plugins: [ new MiniCssExtractPlugin() ],
module: {
rules: [
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
}
}
Webpack 与浏览器缓存(Cache)
当浏览器加载过某项资源时会在本地进行缓存记忆,这样当用户下次再重新访问该页面加载该资源时,浏览器可以根据缓存过的文件快速加载该资源,直到该文件名发生改变,浏览器才知道该文件发生改变,需要重新渲染。
浏览器该特性的作用
可以加快加载速度,当该页面某个部分发生改变时,可以只重新渲染改变的部分,做到局部渲染
问题:如何保证每次打包时只有做了更改的文件的文件名发生更改,没做更改的文件的文件名不变?
如果在 webpack
配置文件中的 output
属性设置为如下
output: {
filename: '[name].js',
chunkFilename: '[name].chunk.js'
}
如果对项目中文件做了更改,而文件名没变,打包的 filename 没变,由于浏览器已经加载过该文件,缓存了这个文件名,当你该文件发生更改而文件名没改变时,浏览器不会重新渲染,为了让每次文件更改后文件名都发生改变,可以使用哈希值命名
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].chunk.js'
}
让 webpack
根据文件内容创建对应独立的一个哈希值,同时在文件内容发生改变时,由于哈希值也会改变,所以文件名也会改变
拓展
在老版本 webpack
中(webpack 4.0 以下),如果在每次运行 npm run build
进行打包时,发现即使文件没变更,每次重新打包他们的哈希值都会变,可以通过配置 runtimeChunk
解决
optimization: {
runtimeChunk: {
name: 'runtime'
}
}
// 同时打包后会多生成一个 runtime.hash.js 的文件,hash每次都不一样
runtimeChunk 原理
假设我通过 webpack
打包出来有两个 js文件,A文件是业务逻辑相关代码,B文件是作代码分割时的库代码(例如 lodash),由于业务逻辑中有引入库的操作,所以他们之间会有关联,而这个关联的相关代码同时存在于A和B文件(这种关联我们一般称之为 manifest
),而在每次打包时,manifest
内置的包和包的关系、js和js文件的嵌套关系会发生微小改变,所以即使A和B文件没做更改时,打包出来的哈希值还是会发生变化。
通过配置 runtimeChunk
,可以将这些 manifest
相关的代码抽离出来单独放在 runtimeChunk
中,因此每次重新打包,改变的只有runtime.hash.js
,A文件只有业务逻辑,B文件只有库文件,A和B文件内都不会有任何 manifest
的代码了,这样A和B文件都不会发生改变了,因此哈希值就不会变了
Shimming
打包兼容,自动引入
在你页面使用到某个库,但没进行引入时,webpack
打包后的代码会帮你自动、"偷偷"进行引入
plugins: [
new webpack.ProvidePlugin({
$: 'jquery', // 当使用 $ 时,会自动在那个页面引入 jquery 库
_: 'lodash', // 当使用 _ 时,会自动在那个页面引入 lodash 库
_join: ['lodash', 'join'] // 当输入 _join 时,会引入 lodash库的 join 方法
})
]
更多
环境变量的使用
对于开发环境和生产环境,可能有时真的需要单独写不同的 webpack
配置文件进行配置
而单独写,肯定有许多属性是重复的,又不想多写,怎么办呢?
例如A和B文件是两个环境中不同的配置参数,而C文件是共同的配置文件,那么开发环境打包时希望按照 A+C 的打包规则,生产环境打包时希望按照 B+C的打包规则
可以通过 webpack-merge
配置 开发环境 和 生产环境 的 不同配置文件
// webpack.common.js
const merge = require('webpack-merge');
const devConfig = require('./webpack.dev.js'); // 假设有这个文件,且导出的是开发环境的一些配置参数
const prodConfig = require('./webpack.prod.js'); // 假设有这个文件,且导出的是生产环境的一些配置参数
const commonConfig = {
// 这里放开发和生产环境共有的一些配置参数
}
module.exports = (env) => {
// 如果 env 参数存在,且传进来了 production 属性,说明是生产环境
if(env && env.production) {
return merge(commonConfig, prodConfig);
} else {
return merge(commonConfig, devConfig);
}
}
同时 package.json
文件中修改配置
{
scripts: {
"dev": "webpack-dev-server --config webpack.common.js",
"build": "webpack --env.production --config webpack.common.js" // 通过--env.production 传递参数进文件,执行线上环境的打包配置文件
}
}
当然,脚本配置时,向配置文件传入参数也可以如下方式:
"build": "webpack --env production --config webpack.common.js"
同时,webpack.common.js
参数判断时也要改成
module.exports = (env, production) => {
// 如果 env 参数存在,且传进来了 production 属性,说明是生产环境
if(env && production) {
return merge(commonConfig, prodConfig);
} else {
return merge(commonConfig, devConfig);
}
}
🎁 谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。
🎁 我是 Smoothzjc,如果觉得写得可以的话,请点个赞吧❤
🎁 我也会在今后努力产出更多好文。
🎁 感兴趣的小伙伴也可以关注我的公众号:Smooth前端成长记录,公众号同步更新
写作不易,「点赞」+「收藏」+「转发」 谢谢支持❤
往期推荐
《都2022年了还不考虑来学React Hook吗?6k字带你从入门到吃透》
《Github + hexo 实现自己的个人博客、配置主题(超详细)》
《React实战:使用Antd+EMOJIALL 实现emoji表情符号的输入》