webpack静态模块化打包工具。无论是前端工程化,还是高级前端岗位都是绕不开的话题之一,面试经常问到构建优化的的方案,自定义loader,自定义plugin等。无论是进阶,还是面试高级岗位,在这里你都可以找到答案。webpack简单来说就是各种令人眼花缭乱的配置,笔者也是感同身受,学了很快就会忘记,所以才有了这篇文章。采用费曼学习法,用简洁的思想和简单的语言向大家表达出来,帮助大家也是帮助自己,本文的学习资料大部分来自于官方文档。本文将持续迭代,收藏===学会~
pnpm init
# 安装依赖
pnpm i webpack webpack-cli -D代码仓库:https://github.com/GetWebHB/webpack-advanced
- 每一个小的章节,都会存放在对应的文件夹中(例:
0.start),文件夹会存放对应的配置文件,源代码,产物。打包命令如下所示,npm run build
// src/0.start/package.json
"scripts": {
"build": "webpack",
},scripts脚本,本质上就只在.bin文件夹下寻找 webpack, 即npm run build等同于npx webpack,默认会去存在package.json(即根目录查找配置文件webpack.config.js) ,它的filename可以更改,--config filename即可。只要修改了配置文件,就需要重启服务
目录结构
├── README.MD
├── package-lock.json
├── package.json
└── src
├── 0.start
│ ├── build
│ │ └── bundle.js
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── src
│ │ └── main.js
│ └── webpack.config.js
src下是每个小节的代码,cd当前的小节,执行pnpm install / npm run build即可
本节代码见:0.start
// main.js
function sayHi() {
console.log('hi ice 24')
}
sayHi()
export { sayHi }
// webpack.config.js
const { resolve } = require('path')
module.exports = {
entry: './src/main.js',
output: {
path: resolve(__dirname, 'build'),
filename: 'bundle.js',
},
}- 简单走读一下,
cjs的方式导出了一个对象
entry代表入口,默认情况下相对路径为package.json存在的目录output代表出口,即产物打包后的位置,同样是绝对路径,打包到build的文件夹下,文件名为bundle.jsnpm run build, 即打包产物,就会发现该目录下出现了产物
本节代码见:1.mode_devtool
上一小节中,我们学习到了entry,output这两项配置,这一章节中,我们学习mode和devtool
概述:告诉 webpack 使用相应模式的内置优化
当我们执行npm run build的时候,webpack会有这一串警告,说我们没有设置mode
那什么是模式(即
mode),从提示看说我们可以设置为developmentorproduction
传送门:https://webpack.js.org/configuration/mode/
mode 也是最重要的优化,
webpack都会帮我们做好。接下来我们来一一了解对应模式展示的不同行为
mode='none' | 'development' | 'production'(default)
// main.js
const mes = 'hi ice 24'
function sayHi() {
console.log(mes)
}
sayHi()
export { sayHi }产物分析
- 我们大致扫一眼即可
我们平常使用的cli, vue/cli(维护阶段),create-react-app等,反正所有底层使用webpack的,npm run serve / npm run start这种在本地开启服务的,采用的策略都是使用development,主打的就是一个快,不需要通过一些plugin,例如terser(后面会讲)丑化压缩代码
产物分析
从注释中我们可以得知,它使用
eval函数可以在浏览器的开发工具中创建一个单独的源文件,在或者说devtool:false的时候就会移除(source map),那么创建一个单独的文件可以干嘛呢?可以映射到代码报错的位置,这也是devtool配置项的作用,我们后面会详细探讨。
概述:是否生成,控制如何生成 source map(源码映射),不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。
默认值
dev: evalprod: none
传送门:https://webpack.docschina.org/configuration/devtool/
这幅图比较重要,介绍了该配置项的性能怎样,是否使用于
production中,以及构建的速度如何
- 在
mode模式为development中,我们看见了eval函数,可以映射出代码的错误位置信息,即配置为mode: "eval",现在让我们来深入探讨一下devtool
就拿mode:prod来说,我们发现代码是已经被丑化过,编译后的产物,如果在测试阶段,代码发生了错误压根不知道代码出错在哪里,那我们如何debug呢?这正是source map的作用,编译后的产物 ->(映射)源代码的位置
// main.js
const mes = 'hi ice 24'
function sayHi() {
console.log(mes)
}
console.log(age) // age is not defined
sayHi()
export { sayHi }
// bundle.js
;(() => {
'use strict'
console.log(age), console.log('hi ice 24')
})()跑到浏览器上(测试阶段),我们可以看到错误信息,但是却看不到代码详细出错在第几行,这在一个庞大的项目中,是非常致命的(即devtool: false),不开启source map
使用eval函数可以在浏览器的开发工具中创建一个单独的source map
当我们配置改为它,我们先看下产物
会多出来一个.map 的文件,即源码映射文件,最后一行代表着引用哪个.map 文件,接下来我们在到浏览器下看下行为。
竟然神奇的映射出来了源代码的位置(第几行,甚至第几个字符),非常的神奇是吧
- version:3,从之前 1,2 的版本构建出来的 map 文件有点大,随着不断的迭代构建出来的
.map文件也越来小 - file:映射的源文件(转换后的源文件)
- mappings:记录位置信息的字符串(VLQ 编码)
- sources:源的路径
- sourcesContent:源代码的内容
- names:转换前的所有变量名和属性名
- sourceRoot:映射目录的位置,为根目录
- prod:
none(默认) | false - test:
source-map - development:
source-map
本节代码见:2.babel
babel 你可能不太了解(因为cli全部帮我们做好了,配置presets),但是它现在是前端工程化必不缺少的一部分,它的本质就是一个
编译器,把 A 源代码转换为 B 源代码。更通俗的说:把ES6+的代码转换为ES5的代码,可以适配版本更低的浏览器
graph LR
ES6+-- babel --->ES5
- 解析阶段
- 词法分析,语法分析,生成 ast 树(抽象语法树)
- 转换阶段
- 转换为新的 ast 树
- 生成阶段
基础使用,我们不再讲解,直接看官网即可,还是比较简单的,它可以在终端直接使用,因为提供了对应的CLI工具
// main.js ES6代码
let mes = 'hi ice'
console.log(mes.toUpperCase())
console.log(mes.slice(0, 2))
const double = [1, 2, 3].map((num) => num * 2)
console.log(double) // [2,4,6]安装开发时依赖,插件作用分别是
- 转换箭头函数
- 转换块级作用域
- 以及 babel-loader
@babel/core不需要安装,因为loader中存在关联会被下载下来
pnpm i @babel/plugin-transform-arrow-functions @babel/plugin-transform-block-scoping babel-loader -D走读一下配置,module中rules,匹配js文件,采用loader进行转换,
options里可以写babel的配置,也可以单独抽成一个独立的文件(babel.config.js)
// webpack.config.js
const { resolve } = require('path')
module.exports = {
mode: 'production',
entry: './src/main.js',
output: {
path: resolve(__dirname, 'build'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: {
// ...一些配置
},
},
],
},
}使用plugins进行源代码代码转换
// babel.config.js
module.exports = {
plugins: ['@babel/plugin-transform-block-scoping', '@babel/plugin-transform-arrow-functions'],
}执行npm run build查看打包的产物就会发现已经转换好了
那么当我们高级语法有很多很多,比如promise async await等,我们难道一个一个去安装插件吗,这样显得太繁琐了,所以这个时候preset(预设)就登场了,提前把那些高级特性全部帮我们设置好
pnpm i @babel/preset-env -D// babel.config.js
module.exports = {
presets: ['@babel/preset-env'],
}接下来,在执行npm run build,效果一样,但是已经帮我们设置好了预设,高级语法都会被打包成低版本的代码,那提前帮我们安装了哪些插件呢?
详见:https://babel.docschina.org/docs/babel-preset-env
@babel/preset-env 其中的env即是根据环境查询兼容性(browserslist)后面我们会讲解到
polyfill(补丁),前面我们说到preset-env,可以把我们使用的高级语法,打包成更多浏览器适配的语法,但是对于某种API,不存在的情况它是无能为力的。比如replaceAll ES2021提出的,而polyfill就会帮我注入对应的API
未使用 polyfill
我们能非常直观的观察到,该
replaceAll,直接被构建出来了,但是在低版本的浏览器上肯定是没有这个API的,就会出现类似的错误Uncaught TypeError: mes.replaceAll is not a function,笔者在实际生产中(微信浏览器上),遇到了类似的bug,我们应该如何解决呢?
前置知识
pnpm i core-js regenerator-runtime注意不是开发时依赖,因为它实际要被我们注入到代码当中,其中这两个包代表用于模拟完整的 ES2015+ 环境
我们可以根据实际需要直接引入对应的API即可,好比上方的replaceAll,我们知道它是较新的语法,直接从core-js引入即可
// main.js
import 'core-js/es/string/replace-all'进行打包,然后我们再次在分析下产物,对应的API就被注入到我们的产物当中,就实现了polyfill
// babel.config.js
const path = require('path')
module.exports = {
presets: [
[
'@babel/preset-env',
{
corejs: 3,
useBuiltIns: 'usage',
modules: 'cjs',
},
],
],
}useBuiltIns: usage | entry | false(default)
usage就是你有使用哪些,entry包含node_modules的第三方库使用的,根据实际情况选择即可,但是一般使用usage,因为这些polyfill都会被实际注入到代码中会影响构建产物的大小
corejs:3 代表第三个版本
modules默认是auto,但实际在我的Mac上,会报错不支持cjs的问题,所以我改了下,有可能在windows不需要配置,这点还没测试过。如果你使用了高级特性,但是代码没有polyfill,请看下个章节browserslist,在那里你会找到答案(把市场占有率调低一些比如 > 0.1%)
本节代码见:2.babel
在谈起浏览器的兼容性,browserslist一定是前端必不可少的工具,早期无论是处理css(添加浏览器前缀),还是ES6+ -> ES5。好比,我们针对的用户都是一些大学生,普遍这些用户电脑上的浏览器都是较新的,那这些浏览器本身就支持ES6+的语法,我们就没有必要去转换为ES5的代码,我们来简单介绍一下它
pnpm i browserslist -D
我们在根目录下新建文件.browserslistrc
// .browserslistrc
> 1% //市场占有率 > 1%
last 2 versions // 最后两个版本
not dead // 还在维护的此时,我们执行npx run browserslist
在控制台打印出来了,适配的浏览器版本。这里的版本代表的是区间
chrome109 - chrome 120,那么疑问就来了,它是怎么知道要适配哪些浏览器呢?其实是上方的配置文件在影响
postcss / babel它们兼容性的都是通过browserslist工具,然后browserslist是通过can i use网站,查询适配的
graph LR
postcss/babel-- browserslist --->caniuse
本节代码见:3.build_react
前面我们讲过可以通过babel打包js语法,
而react使用的是jsx代码。jsx其实是js代码的扩展,加了一些特定的语法而已
- 以前:
jsx通过babel打包成react.createElement,所以说jsx其实是react.createElement的语法糖 v18后,本质是从react/jsx-runtime引入的jsx
我们了解到这里即可,因为如果没有
jsx语法,我们写嵌套的结构简直是噩梦。
直观感受下Count组件,所以才需要jsx语法,更接近html让我们更容易上手
pnpm i react react-dom
//Counter.jsx
import React, { memo, useState } from 'react'
const Counter = memo(() => {
const [count, setCount] = useState(0)
return (
<div className="count">
<button onClick={() => setCount(count + 1)}>+1</button>
<span style={{ padding: '0 8px' }}>{count}</span>
<button onClick={() => setCount(count - 1)}>-1</button>
</div>
)
})
export default Counter
// App.js
import React from 'react'
import { createRoot } from '../node_modules/react-dom/client.js'
import Counter from './components/Counter.jsx'
const root = createRoot(document.getElementById('root'))
root.render(<Counter />)就是一个简单的计数器代码,但是笔者遇到一个比较奇怪的问题,就是默认引入react-dom/client.js的时候,没有向上查找(node_modules),所以这里写了相对路径,效果如下
暂时没定位出来哪里的问题,如果知道的同学可以指点一下, 接下来我们应用就就好了,我们执行
build
错误:不支持 jsx
报错信息:告诉我们需要一个合适的
loader来处理jsx类型的文件,因为我们之前使用loader,rules规则中并没有匹配它,webpack不知道如何去解析它
解决办法
// webpack.config.js
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
},
],
},正则?代表 1 个或者 0 个,告诉它使用babel进行处理,然而它也需要许多插件对jsx代码进行支持,因为plugin过于繁琐,我能直接使用预设即可
pnpm i @babel/preset-react -Dmodule.exports = {
presets: ['@babel/preset-env', '@babel/preset-react'],
}再次执行npm run build我们就发现代码已经打包成功,,但是这里还差最后一步,我们在编写jsx代码的时候,是要把react应用挂载在#root上的,但是我们现在并没有html文件。当然我们也可以手动创建,但是这样太过于繁琐。我们可以使用另外一个插件,直接把产物挂载到html上即可
pnpm i html-webpack-plugin -D
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React App</title>
</head>
<body>
<div id="root"></div>
</body>
</html>// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
// ...
plugins: [
new HtmlWebpackPlugin({
template: './index.html',
}),
],
}build以后就发现已经构建完毕,我们利用live server开启一个本地服务预览,后面我们还会讲解开启一个本地服务,热更新等,构建一个小型的开发环境
一个简单的计数器React应用,我们就构建好啦~
本小节见4.webpack_dev_server
上一小节中,我们说到打包react应用,但是需要我们手动进行build,但是在实际开发中,肯定不可能这样,每次修改一次代码,就build一下,所以我们要像一些常规的cli一样,开启一个本地服务进行开发
安装
pnpm i webpack-dev-server -D// package.json
"scripts": {
"start": "webpack server",
},启动
npm run start这样就开启了一个服务了,端口在 8080,我们直接打开这个地址即可,同时也会进行热更新
我们启动的这个服务,不像build,它是实实在在的文件,而我们开启的本地服务内容都是在内存当中的,其中打包的静态资源,一般都需要存放到public文件夹中。
错误演练
从上面内容,我们可以得知,
avatar.jpg请求的地址,正是我们开启服务的地址,但是这个地址的内容都是存在于内存当中,即使我们在目录下存放了图片文件,该服务不知道从哪里去寻找
解决办法
- 我们修改一下
avatar的存在目录即可,把它修正到public文件夹中即可
const Profile = memo(() => {
return (
<div>
avatar1: <img src="../../avatar.jpg" /> <br />
avatar2: <img src="/avatar.jpg" />
</div>
)
})avatar1是走的相对路径,avatar2,/avatar代表的从根目录进行查找,最后它两都是从8080端口下访问资源,那为什么是public文件夹呢,不是abc呢
- default(public)
- 它的默认值为
public所以在这个文件夹下的资源,可以被访问到,如果想要改成其他文件夹,直接修改配置即可
如果是一个数组,那么它们两个文件夹都会起作用devServer: { static: ['public', 'abc'], },
- 它的默认值为
- 自动打开浏览器窗口
- 修改端口号
在我们日常开发中,经常会遇到跨域的问题,解决方案有很多种
- 生产阶段
- 通过
nginx进行反向代理来解决跨域问题 - 后端开启 CORS
- Access-Control-Allow-Origin: *
- 通过
- 开发阶段
- 后端开启 CORS
- 利用
devServer开启Proxy,本文重点讲解,其他大家自行了解
本节代码见:koa
我们采用Koa编写
pnpm i koa koa-routerconst Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const userRouter = new Router({ prefix: '/users' })
userRouter.get('/', (ctx, next) => {
const users = [
{
name: 'ice',
age: 24,
},
{
name: 'panda',
age: 23,
},
]
ctx.body = users
})
app.use(userRouter.routes())
app.listen(3000, () => {
console.log('服务启动成功')
})import React, { memo, useEffect, useState } from 'react'
import axios from 'axios'
const Users = memo(() => {
const [list, setList] = useState([])
useEffect(() => {
axios.get('http://localhost:3000/users').then((res) => {
setList(res.data)
})
}, [])
return (
<div>
<h4>Users:</h4>
<ul>
{list.map(({ name, age }) => {
return (
<li key={name}>
{name}-{age}
</li>
)
})}
</ul>
</div>
)
})
export default Users当我们访问浏览器就会看到这样的错误
这正是跨域的错误,因为我们的前端服务开启在
8080端口上,而后端的api服务开启在3000上,我们不能进行访问,这个是浏览器的限制,违背了同源策略,那么我们如何解决这个问题呢?
- 后端解决开启
CORS
ctx.set('Access-Control-Allow-Origin', '*')- 利用
proxy开启代理
// webpack.config.js
proxy: {
'/api': {
target: 'http://localhost:3000',
pathRewrite: {
'^/api': '',
},
},
},axios.get('/api/users').then((res) => {
setList(res.data)
})当我们以/api开头的,就会被代理到proxy中,而它是使用的http-proxy-middleware,是它开启的服务去我们3000端口请求数据,拿到数据以后在通过它返回给客户端这样就完成了代理,而pathRewrite是正则匹配,把以/api开头的,我们给它替换为空地址,最后拼接而成的就是后端api的地址
// before
// /api/users
// after
// http://localhost:3000/usersproxy 原理:中间人模式
flowchart LR
前端 --> proxy --> 后端api
后端api --> proxy --> 前端
改变host的来源,有些服务器会根据host来源来判断,如果不是同一个 host 访问就给他屏蔽掉,防止爬虫。
在koa服务器中,我们打印出它的header,就会发现请求的origin地址,也就是我们的前端地址8080。如果想要改变它的源,配置如下就可以解决
proxy: {
'/api': {
// ...
changeOrigin: true,
},
},因为正常情况下,我服务器在3000端口下,那么请求的host也应该在3000,这个最主要看后端是否有限制
前端路由,都是一个url -> components,我们会在url上增加前端路由,然后渲染对应的组件,但是在浏览器中则会代表请求对应的资源,一般为index.html文件,刷新网页就会报一个404找不到文件的错误
那么我们如何解决呢?我们刷新网页的时候在给它重定下index.html文件即可
devServer: {
historyApiFallback: true,
},本节代码见:5.entry_points
我们前面就配置了entry,指向的main.js,当执行build,webpack就会从它出发,把有关联的代码形成一个依赖图(图结构),打包构建,而之前的mode中,我们只配置了单入口,其实是可以设置多入口起点的,不过日常使用的较少(vite/create react app都是单入口),我们了解即可
// src/stu.js
console.log('stu')
// src/teacher.js
console.log('teacher')此时,我们的src文件中,有两个文件,它们彼此并没有依赖,我们设置给他们两个都设置为入口文件
// webpack.config.js
module.exports = {
// ...
entry: {
stu: './src/stu.js',
teacher: './src/teacher.js',
},
output: {
filename: 'bundle.js',
path: resolve(__dirname, 'build'),
},
}entry为对象的写法,我们可以配置多个入口,此时我们执行build,会发现如下错误,有多个chunk名字相同产生了冲突,它不知道如何打包了,其实学到这里,大家肯定知道应该要设置output(即出口配置)
output: {
filename: '[name]_bundle.js',
path: resolve(__dirname, 'build'),
},现在执行build就可以发现打包成功了,其中[name]写法为placeholder(占位符语法)
如下图所示,我们在日常开发中,会有一些包会在很多文件中使用,比如dayjs在stu teacher文件中均有用到
// src/stu.js
import dayjs from 'dayjs'
console.log('stu')
console.log(dayjs().format('YYYY-MM-DD HH:mm:ss'))
// src/teacher.js
import dayjs from 'dayjs'
console.log('teacher')
console.log(dayjs().format('YYYY-MM-DD HH:mm:ss'))然后我们进行build观察如下产物,会发现dayjs竟然会被打包两次(即第10行)
那么我们针对如上的场景如何优化呢?能不能把
库文件单独打包到一个文件里面,然后让这两个文件应用这两个包呢?
entry: {
stu: {
import: './src/stu.js',
dependOn: 'shared',
},
teacher: {
import: './src/teacher.js',
dependOn: 'shared',
},
shared: ['dayjs'],
},我们在另外增加一个入口,让stu & teacher去引入这个需要共享的第三方包,这样就可以实现,我们再次观察产物,就可以看见实现了对shared包进行了引入
本节代码见:6.dynamic_import
其实动态导入大家应该都用过,就是路由懒加载。Vue 中利用import函数导入组件或者React中的React.lazy的函数引入的组件。何为路由懒加载?最主要的就是映射关系, path: component,有history/hash两种模式,监听路由的变化,动态加载component
那么当浏览器首次渲染的时候,只需要下载对应js文件即可(不需要全部下载),有利于提高首屏渲染速度。当浏览器闲置的时候下载,或者使用到的时候在下载即可(路由变化)
// main.js
const btnEl1 = document.createElement('button')
const btnEl2 = document.createElement('button')
btnEl1.textContent = '加载stu'
btnEl1.addEventListener('click', () => {
import('./stu').then((res) => {
console.log(res)
})
})
btnEl2.textContent = '加载tea'
btnEl2.addEventListener('click', () => {
import('./teacher').then((res) => {
console.log(res)
})
})
document.body.append(btnEl1, btnEl2)当我们执行build的时候,就会发现stu和tea是打成单独的包的,通过某种行为,动态的加载
我们前面进行了动态导入,但是仔细看它的名字src_stu_js.bundle,是根据它的目录+filename 生成的,但是实际cli中,都会是对应的页面名称,此时我们就需要用到魔法注释了
btnEl2.addEventListener('click', () => {
import(/* webpackChunkName: 'teacher' */ './teacher').then((res) => {
console.log(res)
})
})- 预获取(prefetch) :将来某些导航下可能需要的资源
- 预加载(preload):当前导航下可能需要的资源
- 预加载的
chunk会在父chunk加载时并行开始加载,预获取chunk则会在父chunk加载结束后开始加载 - 预加载
chunk具有中等优先级,而预获取chunk则在浏览器闲置时下载
增加:/* webpackPrefetch: true */
我们可以从浏览器看到,
teacher的chunk会在父chunk加载完,就立即获取,随后当我们触发动态导入操作的时候,它会从cache中获取文件
preload不正确地使用 webpackPreload 会有损性能,请谨慎使用,笔者就不再演示
本节代码见:7.optimization
在我们实际构建的产物,往往第三方包(即vendors)和我们自己编写的代码都是分开打包的,一般会构建为两个文件
// main.js
import dayjs from 'dayjs'
console.log(dayjs('YYYY-MM-DD HH:mm:ss'))
console.log('ice')这是我们不处理构建的产物,无论是第三方库,还是我们编写的代码都在bundle.js文件,现在看起来无伤大雅,但是依赖多了,随着bundle文件越来越大,那么意味着下载的也慢,首屏渲染速度也会下降。所以,我们才要把他们分开存放
我们走读一下配置,其中all代表,可在同步与异步(异步:动态导入)之间共享chunk,minSize代表生成chunk的最小体积,如果比这个1kb还小的话,就直接不分开打包,
defaultVendors设置产物的名称
//webpack.config.js
const { resolve } = require('path')
const htmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 1 * 1024,
// 自定义分包
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
filename: 'vendors.bundle.js',
},
},
},
},
}接下来我们来查看下产物,我们编写的代码,在bundle.js中,而第三方的在vendors中
当我们不设置output输出的文件名且在development模式下,那么它将使用绝对路径进行命名,如下图所示
但是它的文件名生成算法大有来头,配置即
chunkIds,dev环境采用的是named,而prod的阶段采用的是deterministic(在不同的编译中不变的短数id。有益于长期缓存。在生产模式中会默认开启。)
编译结果如下图所示:
本节代码见:8.cdn
我们构建的产物,最后是要被部署到服务器上的,但是对于这种物理设备会存在地域的限制,往往服务器离用户近的越近的,相对来说访问的速度越快,所以才有了 CDN,把静态资源部署到 CDN 服务器上,也是性能优化的策略之一
一般把index.html直接部署到服务器上,而那些资源js/css等,应该存放在 CDN 上,选择也还是存在于服务器上,那么怎么修改呢?
// webpack.config.js
module.exports = {
output: {
publicPath: 'https://cdn.com', // 示例地址
},
}构建打包效果如下:
对于CDN服务器来说,往往比普通的服务器更贵,小型公司选择性的可能性不大,而对于那种第三包库而说都会有开源的CDN平台,bootcdn,众多知名的库都会托管在这里。
比如,我们想要dayjs使用 CDN 进行加速,而不是打包在我们的源代码中
首先进行排除,externals 中key:value代表不同的意义,key是引入包的名字(from x),而value是cdn库的链接导出的名字
// webpack.config.js
module.exports = {
externals: {
dayjs: 'dayjs',
},
}我们知道,从浏览器的渲染原理可以得知css是可以并行加载的,不会阻塞后面代码的执行,dom+css解析完成形成一个render tree在经过layout paint最后显示到浏览器上,而在不配置css单独打包,会打包到js代码里,会增加js包的大小,影响首屏渲染的速度
// webpack.config.js
module: {
rules: [{ test: /\.css$/, use: ['style-loader', 'css-loader'] }],
},/* style.css */
body {
background: skyblue;
}// main.js
import './style.css'
console.log('ice')我们执行build,会发现css代码是打包到js代码当中的,通过style-loader把css插入到head当中
pnpm i mini-css-extract-plugin -D// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
module: {
rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name]_[hash:6].css',
}),
],
}使用mini-css-extract-plugin提取css,把css提取成单个文件,我们执行build在观察产物,资源就被我们提取出来了
不使用丑化,对于一些换行符,空格之类的它实际会占代码体积的,我们可以把它进行丑化,删除一些无用的空格和换行
使用之后
// webpack.config.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
module.exports = {
mode: 'development',
optimization: {
minimize: true,
minimizer: [new CssMinimizerPlugin()],
},
}我们就可以清晰的看见css被丑化了,minimize代表启动minimizer,而在prod模式下,默认为true
本节代码见:10.terser
terser 工具,在mode为prod环境的时候,会自动应用。那它是什么呢?它用于丑化js代码,什么是丑化呢?我们平常使用的第三方库都会有以min.js结尾的就是丑化之后的代码。而它由mangler and compressor两部分组成
比如:dayjs.min.js,如下图所示
那为什么需要把它丑化呢?对于我们开发的时候,我们都需要做到“见名知意”,但是对于用户来说,它无需关系变量名,存不存在换行等,因为这样可以大大减少代码的体积,更快的在
http中进行传输,更快的传输给用户,加载首评渲染速度等一系列好处。
本节代码见:1.CLI
pnpm i terser -D// main.js
const sum = (n1, n2) => n1 + n2
function bar() {
console.log('bar')
}// package.json
"scripts": {
"build": "terser main.js -o mini.min.js -m --toplevel",
}我们简单走读一下配置
terser main.js编译该文件-o输出为mini.min.js-mmangle(乱砍)把变量名砍断,变量名简写即getUserName ->缩短为一个字符 x--toplevel顶层作用域的名称进行 压缩/砍断
...更多配置
我们执行npm run build,观察一下产物信息,发现代码就被我丑化了
本节代码见:2.webpack
前面我们有说到过,当我们模式为prod的时候,会自动使用terser-webpack-plugin这个插件,而我们可以进行自定义,一般我们使用默认即可,我们首先来看下prod模式下,打包后的产物,它是正常进行压缩的
接下来,我们来设置一些配置,对它了解的更加深刻
// webpack.config.js
optimization: {
minimize: true,
minimizer: [
new terserWebpackPlugin({
extractComments: false, // 提取注释
terserOptions: {
mangle: true, // 字母缩写
compress: {
// 未使用的不打包
unused: false,
},
},
}),
],
},minimize使用minimizer自定义的terser或者默认的TerserWebpackPluginextractComments是否把注释提取为单独的文件mangle字母缩写unused未使用的包,不打包
本节代码见:11.webpack_merge_config
至今为止,我们已经学了很多的配置项了,但是我们的模式是混乱的,有些应该存在于prod环境下,而有的应该在dev模式下,现在我们就进行抽取
我们要区分mode,首先我们要让webpack.config.js中,知道我们执行build处于prod模式,而start处于dev模式下
// package.json
{
"scripts": {
"start": "webpack server --config ./config/index.config.js --env development",
"build": "webpack --config ./config/index.config.js --env production"
},
}我们执行对应的命令,--env传入现处的模式,那我们在哪里接受呢?其实我们写的webpack.config.js不仅可以接受一个对象,还可以接受一个函数,webpack就会执行这个函数,并把env的环境传递给我们
module.exports = function (env) {
// const isProd = env.production
}- 执行
start/build区分对应的环境 - 把文件分离出来,把对应的配置写到对应的
mode下- index.config.js
- dev.config.js
- prod.config.js
- 利用
webpack-merge进行组合
源代码
// index.config.js
const { resolve } = require('path')
const { merge } = require('webpack-merge')
const htmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const prodWebpackConfig = require('./prod.config')
const devWebpackConfig = require('./dev.config')
const getCommonConfig = (isProd) => {
return {
entry: './src/main.js',
output: {
filename: 'js/[hash:6]_bundle.js',
path: resolve(__dirname, '../build'),
clean: true,
},
module: {
rules: [{ test: /\.css$/, use: [isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'] }],
},
plugins: [
new htmlWebpackPlugin({
template: './index.html',
}),
],
}
}
module.exports = function (env) {
const isProd = env.production
const config = isProd ? prodWebpackConfig : devWebpackConfig
return merge(getCommonConfig(isProd), config)
}
// dev.config.js
module.exports = {
mode: 'development',
devServer: {
open: true,
port: 3000,
compress: true,
static: ['public', 'abc'],
proxy: {
'/api': {
target: 'http://localhost:3000',
pathRewrite: {
'^/api': '',
},
changeOrigin: true,
},
},
historyApiFallback: true,
},
}
// prod.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'production',
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name]_[hash:6].css',
}),
],
optimization: {
minimizer: [new CssMinimizerPlugin(), new TerserWebpackPlugin()],
},
}本节代码见:12.tree_shaking
“摇树”,把代码中的dead code不打包在产物中,我们前面说到的terser可以实现部分tree shaking的效果,即顶层作用域中没使用到的"函数",变量等。为了实现演示效果,我们把prod.config.js的mode改为dev模式,避免应用了一些插件影响我们。tree shaking依赖于ES Module的静态语法分析,要使用ES Module进行导出
如下图所示,我们其中的sum&info都是没有使用过的
当我们没有使用
terser,就会发现,我们构建的产物中,还会存在dead code这些死的代码,这样会增加代码的体积
当我们使用
terser就会把没使用过的代码(即顶层作用域)去除,为什么说是顶层呢?模块就不可以吗?
// service/getUserName.js
function getUserName() {
return 'ice'
}
export default getUserName
// main.js
import './style.css'
import { getUserName } from './service/getUserName'
const sum = (a, b) => a + b
const info = { name: 'ice', age: 24 }我们执行build观察产物,会发现并没有被删除掉,是因为terser并不知道这个模块有没有被使用,需要结合usedExports
usedExports 做了什么事情?
首先我们为了查看效果,先不要使用terser,避免干扰到我们,build观察一下产物
optimization: {
usedExports: true,
},应用usedExports后,我们在进行build,就会多了一行魔法注释,在结合terser,terser就可以大胆的将这个模块移除了
我们放开
terser再次build导出却没有使用的代码就被移除了
它会跳过整个模块/文件,直接查看该文件是否有副作用
// package.json
"sideEffects": false,import './style.css'
import { getUserName } from './service/getUserName'
const sum = (a, b) => a + b
const info = { name: 'ice', age: 24 }
console.log(sum, info)function getUserName() {
return 'ice'
}
// 增加副作用代码
window.info = {
name: getUserName(),
age: 20,
}
export default getUserName如果为sideEffects为不保留副作用代码,那么没有使用到的代码模块(文件)将直接被删除,但是这些副作用代码有可能会被用到,那么代码就会报错
style.css也不会被打包
// package.json
"sideEffects": ["*.css", "getUserName.js"],把需要保留的副作用模块,写入到数组中即可
本节代码见:13.scope_hoisting
概念
早期 webpack 中模块都会被包裹在单独的函数闭包中,这些包裹的函数使js在浏览器中执行速度变慢,相比像rollupjs就会把所有的模块作用域“提升”合并到一个闭包中,这就称之为“作用域提升”
前面我们有提到,当mode为生产环境的时候默认,会给我们默认使用许多“优化手段”,提升作用域也是应用的一个plugin,我们改为dev来测试一下这个 plugin(即ModuleConcatenationPlugin)
// sub.js
const sub = (a, b) => a - b
export default sub
// sum.js
const sum = (a, b) => a + b
export default sumimport sub from './utils/sub'
import sum from './utils/sum'
console.log(sub(10, 5))
console.log(sum(10, 5))我们观察一下构建的产物,可以非常的清晰看见他们是存在一个单独的作用域中,这样浏览器执行的速度变慢
// webpack.config.js
plugins: [
new webpack.optimize.ModuleConcatenationPlugin(),
],我们进行build再次观察产物,就会发现他们的作用域被“提升”了,或者说被合并了,处于同一个作用域(即 IIFE,立即执行函数)
本节代码见:14.gzip
前面我们说到了terser,它可以把源代码进行丑化,但是这种“丑化”已经达到了压缩的最大程度。基本上不能再从源代码上优化,但是gzip格式可以很大程度的减少源文件的大小,可以更快的在网络上传输,加快首屏渲染速度,而在webpack的mode为prod的模式下并没有应用这个插件,这是性能优化的手段之一
客户端和服务端达成了共识,当遇到gzip文件,就自动进行解压
// webpack.config.js
plugins: [
new CompressionPlugin({
test: /\.(css|js)$/,
minRatio: 0.8,
algorithm: 'gzip',
}),
]简单走读一下配置
test匹配以.css .js结尾的- 最小压缩比例要达到
0.8这个也是默认值
我们进行build,观察一下结果,就会发现打包之后的产物多了.gz文件
本节代码见:15.speed_measure
这个插件主要作用于测量构建速度的,可以根据测量,来优化我们的构建速度
// before
const webpackConfig = {
plugins: [new MyPlugin(), new MyOtherPlugin()],
}// after
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
const webpackConfig = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
})效果如下:
比如我们看
babel-loader的消耗时间较长,就可以使用一些优化手段,exclude排除一些第三方的库,库一般都会进行向下兼容,不需要使用babel
{ test: /\.js$/, use: 'babel-loader', exclude: /node_modules/ }本代码详见:16.bundle_analyzer
对我们构建的产物进行分析,可以分析包的大小,比如dayjs包,如果占用项目大小较大,我们就可以考虑自己封装一个,从而减少包的代码体积
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
plugins: [
new BundleAnalyzerPlugin(),
],本代码详见:17.custom_loader
前面我们有使用过style-loader babel-loader等,那么现在我们就在自定义loader,让我们对loader了解的更加透彻
// loaders
// ice-loader1.js
module.exports = function (content) {
console.log('loader1:', content)
return content
}
// ice-loader2.js
module.exports = function (content) {
console.log('loader2:', content)
return content
}
// webpack.config.js
module.exports = {
resolveLoader: {
modules: ['node_modules', 'loaders'],
},
module: {
rules: [{ test: /.js$/, use: ['ice-loader1.js', 'ice-loader2.js'] }],
},
}简单走读代码:定义了两个loader文件,即(loader1/loader2),build遇到.js结尾匹配loader文件,而loader的应用是从前往后,即loader2先执行,然后loader1执行
我们在匹配规则中,有匹配两个loader,现在要实现一个场景,loader2中会传递一些数据给loader1进行处理
// sync_loaders
// loader2.js
module.exports = function (content) {
console.log('loader2:', content)
return content + 'ice'
}
// loader1.js
module.exports = function (content) {
console.log('loader1:', content)
return content
}当我们进行build,先匹配loader2,然后把匹配的结果加上ice,传递给下一个loader,即loader1
module.exports = function (content) {
console.log('loader2:', content)
const callback = this.async()
setTimeout(() => {
callback(null, content + 'ice')
}, 3000)
}在同步的基础上this.async(),把参数通过回调函数的形式传递给下一个loader,其中第一个实参为error,第二个为返回值
接受参数
// params_loaders
module.exports = function (content) {
const params = this.getOptions()
console.log(params)
return content
}
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'params-loader',
options: {
name: 'ice',
age: 20,
},
},
},
],
},
}校验参数
// validate-loader.js
const { validate } = require('schema-utils')
const schema = require('./schema.json')
module.exports = function (content) {
const options = this.getOptions()
validate(schema, options)
return content
}
// schema.json
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "必须是字符串类型"
},
"age": {
"type": "number",
"description": "必须是数字类型"
}
}
}