浅谈Webpack与应用优化实践✨
哈喽,我是前端JLong
😄 最近看完webpack
的一些文章和课程,总结分享一下笔记,希望jym有所收获。
起源
上图是来自Webpack官网的宣传图,可以看出,左侧是一大堆不同格式的资源文件(模块依赖),经过中间那个六棱形转换后,变成了具备统一格式的静态资源文件。
这个六棱形即——Webpack
,而这张图也简单明了的阐述了Webpack的主要作用——静态模块打包
什么是Webpack?
webpack是一个用于构建现代JavaScript应用程序的静态模块打包工具
,它能够以一种一致且开放
的处理方式,加载应用中的所有资源文件,如图片、CSS、视频、字体文件等,并将其合并打包成浏览器兼容
的Web资源文件。
在我看来,一致且开放
非常好的总结的Webpack的主要特性,那么Webpack具备怎样的特性
?
- 统一资源构建(一切皆模块)
其他一些模块打包器如Gulp、Grunt、RequireJs、Browserify等都缺乏兼容处理所有资源,只能应对不同资源,做不同的特化处理逻辑,且不同类型文件之间无法信息互通
,而Webpack能很好做到这一点。忽略具体资源类型之间的差异,将所有代码/非代码文件都统一看做Module-模块对象
,以相同的加载、解析、依赖管理、优化、合并流程实现打包,并借助Loader、Plugin两种开发接口将资源差异处理逻辑转交由社区实现,实现统一资源构建
模型。这样做的好处有:
- 所有资源都是Module,所以可以用同一套代码实现诸多特性,包括:代码压缩、Hot Module Replacement热更新、缓存等。
- 打包时,资源之间信息互换方便,例如HTML导入Base64格式的图片,放在以前是需要做处理逻辑的,不然也没办识别。
- 借助Loader、Plugin,Webpack几乎可以用任意方式处理任意类型的资源,例如可以用Less、Stylus、Sass等预编译CSS代码。
可以用下面不同国家插座转换图来理解:
其他模块打包器就像图中的转换器,不同型号需要不同转换器,即不同资源需要不同处理逻辑,而Webpack能直接
统一一个模块格式标准
,即全世界通用一个插座型号,做一层处理将不同型号转成通用型号或者不需要额外处理就能直接通电。
- 开放性
Webpack具备极强开放性,体现在能够轻易接入一系列工程化工具,例如TypeScript、CoffeScript、Babel一类的JavaScript编译工具;或者Less、Sass、Stylus、PostCss等CSS预处理器;或者Jest、Karma等测试框架等等。基于此,Webpack成为了现代前端工程化的基石。
为什么需要Webpack?
Web是一个极其复杂的构建系统,它能够融合多种工程化工具,将开发阶段的应用代码编译、打包成适合网络分发、客户端运行的应用产物,同时其开放性的特点,让整个社区环境活跃且庞大,光自己内置都有上百种配置项,更何况社区实现的数千种Loader、Plugin组件。
所以为什么我们需要这种非常复杂的构建工具? 答案是:“大人,时代变了”
在最早的计算机时代,我们只能用原生 JavaScript(ES5)、CSS、HTML 方式编写页面代码,
开发环境和生产环境通用一套代码
,开发与运行效率低下;其次,过往可没有现在开箱即用的脚手架,页面的图片、代码、CSS 等资源都能且只能通过
img
、script
、link
等标签插入到页面中,在精细处理上耗费我们太多精力。
直到 2009年 Node 与 RequireJS 打破僵局,让我们在代码被放到浏览器运行起来之前,有机会做一些预处理工作 —— 开发与生产环境隔离管理
实现方案。
再往后,计算机世界陆续出现许多解决具体问题的工程化工具,如Babel
(转译JS语法)、TypeScript
(编译时类型检测)、CoffeeScript
(ES6出来就不怎么用了,社区也没什么人维护) 等,以及一些如 Less、Sass、Stylus
的预处理工具,为页面样式开发提供丰富的语言特性,如嵌套、继承等。一定程度上很好的弥补浏览器、语言、规范本身的设计缺陷,解放人工,让我们可以更高效专注于业务代码中。
持续到目前为止,互联网依旧处于高速发展的阶段,各种工具如春笋不断冒出,前端不再局限于单调页面的开发,大前端时代来临,边界被扩宽,页面我能干,后台我也行,微服务,小程序,app我也可以,Unity 3D游戏肝一下我。但随之而来的问题是我们该如何管理这些工具?这时候我们就需要一套足够开放,能融合诸多工程化工具,彻底抹平开发与生产环境差异的一体化工程方案
,而Webpack可以很好的处理这一问题。
核心概念
Entry(入口)
指示webpack以哪个文件为入口起点开始打包,分析构建内部依赖图
Output(输出)
指示webpack打包后的资源bundles输出到哪里去,以及如何命名
Loader(文件加载器)
- webpack只能理解JavaScript和JSON文件,这是webpack开箱即用的自带能力;loader让webpack能够处理其他类型的文件,并将它们转换为有效模块,以供应用程序使用,以及被添加到依赖图中;
- 计算机世界文件资源格式太多,Webpack不可能一一穷举,部分处理工作开放出去由第三方处理势在必行。目前Webpack内部只需实现标准JavaScript代码解析/处理,其他文件资源解析逻辑将由第三方补充。
通常以mapping
函数形式,接收原始代码内容,返回翻译结果;
大多数 Loader 要做的事情就是将 source 转译为另一种形式的 output。
module.exports = function(source) {
// 代码计算
return modifySource
}
- 流程
在Webpack进入构建阶段后,首先会通过IO接口读取文件内容,之后调用LoaderRunner
并将文件内容以source参数形式传递到Loader数组,source数据在Loader数组内可能会经过若干次形态转换,最终以标准JavaScript
代码提交给Webpack主流程,以此实现内容翻译功能。
- 常见Loader
file-loader:把文件输出到一个文件夹中,在代码中通过相对 URL 去引用输出的文件
url-loader:和 file-loader 类似,但是能在文件很小的情况下以 base64 的方式把文件内容注入到代码中去
source-map-loader:加载额外的 Source Map 文件,以方便断点调试
image-loader:加载并且压缩图片文件
babel-loader:把 ES6 转换成 ES5
css-loader:加载 CSS,支持模块化、压缩、文件导入等特性
style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS。
eslint-loader:通过 ESLint 检查 JavaScript 代码
......
Plugins (插件)
Webpack对外提供了Loader与Plugin两种扩展方式,其中Loader职责比较单一,而Plugin则功能强大,借助Webpack数量庞大的Hook,几乎能改写Webpack所有特性,执行范围更广的任务,从打包优化和压缩,一直到重新定义环境中的变量等
- 总体流程
在Webpack运行过程中,随着构建流程的推进会触发各个钩子回调,并传入上下文参数(例如tap等方法回调函数中的compilation对象),插件可以通过调用上下文接口、修改上下文状态等方式“篡改”构建代码,从而将扩展代码插入到Webpack构建流程中。
- 触发时机: 各构建流程暴露出来的Hook就是很好的触发时机
Webpack内部几个核心对象:
complier: 全局构建管理器,负责管理配置信息、Loader、Plugin等 compilation: 单次构建过程的管理器,负责遍历模块,执行编译操作,当watch=true,重新创建compilation. 此外还有Module、Resolver、Parser、Generator等关键类型,也都相应暴露了许多Hook。
- 从形态上,插件通常是一个
apply
函数的类,Webpack 在启动时会调用插件对象的 apply 函数,并以参数方式传递核心对象 compiler ,以此为起点,插件内可以注册 compiler 对象及其子对象的钩子(Hook)回调,例如:
class SomePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("SomePlugin", (compilation) => {
compilation.addModule(/* ... */);
});
}
}
示例中的 compiler
为 Hook 挂载的对象;thisCompilation
为 Hook 名称;后面调用的 tap
为调用方式,支持 tap/tapAsync/tapPromise 等;
Hook和调用方式繁多,可以用到再去查找,熟悉常见的plugin及其应用,深入了解建议还是找常见的几个看看源码,因为社区太多了
- 常见Plugin
HotModuleReplacementPlugin: 模块热更新插件,Webpack自带
html-webpack-plugin: 生成 html 文件
mini-css-extract-plugin: 将 CSS 提取为独立的文件的插件,对每个包含 css 的 js 文件都会创建一个 CSS 文件,支持按需加载 css 和 sourceMap
define-plugin:定义环境变量
commons-chunk-plugin:提取公共代码
uglifyjs-webpack-plugin:通过UglifyES压缩ES6代码
Mode
模式(Mode)指示 webpack 使用相应模式的配置,即上文提到的开发环境和生产环境隔离,不同mode对应不同配置,能够进行有效环境区分,提高性能。
构建流程
可以总结为4个步骤:
- 输入:从文件系统配置文件和 Shell 语句中读取与合并最终参数,根据参数初始化
Compiler
对象,加载所有配置的插件,执行对象的run
方法开始执行编译。 - 模块递归处理:根据
entry
找到所有入口文件,调用Loader转译Module内容,并将结果转换为AST
(抽象语法树),从中分析出模块依赖关系,进一步递归模块处理过程,直到所有依赖文件都处理完毕。 - 后处理:所有模块递归处理完毕后开始执行后处理,包括模块合并、注入运行时、产物优化等,最终输出Chunk集合。
- 输出:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的
Chunk
,再把每个 Chunk 转换成一个单独的文件加入到输出列表,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
在以上过程中,Webpack 会在特定的时间点广播出特定的事件钩子
,Plugin在监听到感兴趣的事件后会执行特定的逻辑,并且Plugin可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
应用
处理CSS资源
原生Webpack并不能识别CSS语法,假如不做额外配置直接导入.css文件,会导致编译失败。所以需要借助一些Loader来实现转译打包。
常见Loader和Plugin:
- css-loader: 该Loader会将CSS等价翻译形如:module.exports = "${css}" 的JavaScript代码,使得Webpack能够如同处理JS代码一样解析CSS内容与资源依赖。包含:CSS到JS转译(主要)、依赖解析、Sourcemap、css-in-module等,基于这些能力,Webpack才能像处理JS模块一样处理CSS模块代码。
- style-loader: 该Loader将在产物中注入一系列runtime代码,这些代码会将CSS内容注入到页面的标签,使得样式生效。
- mini-css-extract-plugin: 该插件会将CSS代码抽离到单独的.css文件,并将文件通过标签方式插入到页面中
三个组件各司其职:css-loader让Webpack能够正确理解CSS代码、分析资源依赖;style-loader、mini-css-extract-plugin则通过适当方式将CSS插入到页面,对页面样式产生影响。
- 经css-loader处理后,CSS代码会转译为等价JS字符串,但这些字符串还不会对页面样式产生影响,还需要:
- 开发环境: 使用style-loader将样式代码注入到页面的
- 生产环境:使用mini-css-extract-plugin将样式代码抽离到单独产物文件,并以标签方式引入到页面中
即
npm i -D style-loader css-loader
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader']
}
]
}
}
注意
: 注意保持 style-loader 在前,css-loader 在后,Webpack执行Loader是基于compose
(函数式编程)方式实现的,所以是从后往前,即css-loader -> style-loader
优化点
经过style-loader + css-loader处理后,样式代码最终会被写入Bundle文件,并在运行时通过style标签注入到页面。这种将JS、CSS代码合并进同一个产物文件的方式有几个问题:
- JS、CSS资源无法并行加载,从而降低页面性能
- 资源缓存颗粒变大,JS、CSS任意一种变更都会致使缓存失效
因此,生产环境通常会用mini-css-extract-plugin插件替代style-loader,将样式代码抽离成单独CSS文件
npm i -D mini-css-extract-plugin
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HTMLWebpackPlugin = require('html-webpack-plugin')
module.exports = {
module: {
rules: [{
test: '/\.css$/',
use: [
// 根据运行环境判断使用哪个loader
(process.env.NODE_ENV === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader),
'css-loader'
]
}]
},
plugins: [
new MiniCssExtractPlugin(),
new HTMLWebpackPlugin()
]
}
注意
:
- mini-css-extract-plugin 库同时提供 Loader、Plugin 组件,需要同时使用
- mini-css-extract-plugin 不能与 style-loader 混用,否则报错,所以上述示例中第 9 行需要判断 process.env.NODE_ENV 环境变量决定使用那个 Loader
- mini-css-extract-plugin 需要与 html-webpack-plugin 同时使用,才能将产物路径以 link 标签方式插入到 html 中
style-loader与mini-css-extract-plugin区别
- style-loader主要用于开发环境,会在javascript主程序中注入runtime代码,将CSS抽取注入HTML的,支持hmr,但由于混合在js中,代码颗粒度大,无论js或css变动都会导致缓存失效,并且内嵌css代码无法异步并行加载,导致页面卡顿。
- mini-css-extract-plugin主要用于生产环境,将CSS打包成单个独立文件,不支持hmr,然后以
<link>
的形式引入页面,是并行加载资源,这个插件必须与html-webpack-plugin同时使用才能生效。
预处理器Less、Sass、Stylus等同理,就不多说了。
处理图像
原生 Webpack 4 只能处理标准 JavaScript 模块,因此需要借助 Loader —— 例如 file-loader、url-loader、raw-loader
等完成图像加载操作,实践中我们通常需要按资源类型选择适当加载器
- file-loader: 将图片引用转换成url语句,经过转换后生成图片文件,并在代码中插入图片的url地址
- url-loader: 两种表现,小于阈值转为base64编码,大于阈值调用file-loader进行加载
- raw-loader: 不做任何转译,简单的将文件内容弄复制到产品中,适用SVG场景。
上述 file-loader、url-loader、raw-loader 都并不局限于处理图片,它们还可以被用于加载任意类型的多媒体或文本文件,使用频率极高,几乎已经成为标配组件!所以 Webpack5 直接内置了这些能力,开箱即可使用。
环境治理策略
在现代前端工程化实践中,通常需要将同一个应用项目部署在不同环境(如生产环境、开发环境、测试环境)中,以满足项目参与各方的不同需求。这就要求我们能根据部署环境需求,对同一份代码执行各有侧重的打包策略,例如:
- 开发环境需要使用 webpack-dev-server 实现 Hot Module Replacement;
- 测试环境需要带上完整的 Soucemap 内容,以帮助更好地定位问题;
- 生产环境需要尽可能打包出更快、更小、更好的应用代码,确保用户体验。
拿常用脚手架vue-cli配置流程举例:
- 首先在项目根目录下(与package.json同级)新建三个".env"文件
如上,三个".env"文件后缀名为development、production、test,分别对应为开发环境、生产环境和测试环境
- 配置package.json文件
在 vue-cli-service 命令后加上对应".env"文件名字。配置完成后,当我们运行npm run xxx命令时会执行对应的".env"文件。从而实现环境变量配置功能。
- 使用配置的环境变量 我们可以通过对不同环境变量适配不同配置
注意
:.env.xxx文件接受键值对形式参数,但只要有NODE_ENV,BASE_URL和以VUE_APP_开头的变量将会通过webpack.DefinePlugin静态嵌入客户端,其他一些命名是无效的。
还有许多应用,例如热更新等,用到就查即可
优化
使用最新稳定版本的Webpack与Node
看起来算是句废话,但是确实非常实用,也是性价比最高的优化手段之一,每个版本的迭代会带来许多新特性和对性能的优化提升,特别是Webpack团队还特别重视优化方面。可能现在苦恼的优化点,需要绕几个圈实现,下版本就开箱即用也不一定,所以紧跟稳定版本
就对了。
图像优化
- 图像压缩:减少网络传输流量, 推荐
image-webpack-loader
- 雪碧图:减少 HTTP 请求次数,将许多细小图片合并成一张大图,从而将多次请求合并成一次请求,结合CSS
background-position
控制显示视图位置达到效果,仅使用于那种同屏需要请求许多张图片的场景。 - 响应式图片:根据客户端设备情况下发适当分辨率的图片,有助于减少网络流量;推荐webpack-spritesmith
- CDN:缓存资源,中转站,有效提升传输效率。
- 等等。
注意
:雪碧图曾经是一种使用广泛的性能优化技术,但 HTTP2 实现 TCP 多路复用之后,雪碧图的优化效果已经微乎其微 —— 甚至是反优化
。
SplitChunk分包优化
Webpack 默认会将尽可能多的模块代码打包在一起,优点是能减少最终页面的 HTTP 请求数,但缺点也很明显:
-
页面初始代码包过大,导致资源冗余,影响首屏渲染性能
-
无法有效应用浏览器缓存,特别对于 NPM 包这类变动较少的代码,业务代码哪怕改了一行都会导致 NPM 包缓存失效.
为此,从webpack v4 开始就提供了开箱即用的SplitChunksPlugin
,专门用于根据产物包的体积、引用次数等做分包优化,规避上述问题,特别适合生产环境使用。
代码压缩
顾名思义,代码压缩是指基于代码保证功能逻辑完整性的前提下,对JavaScript、CSS、HTML代码一些非必要细节进行压缩,如备注、空格、变量名压缩等,减少代码体积,进而达到降低请求网络传输量,减少首屏渲染,缩短打包解包时间等优化;
目前社区提供丰富的代码压缩插件,比较常用的如:
- terser-webpack-plugin:用于压缩 ES6 代码的插件;
- css-minimizer-webpack-plugin:用于压缩 CSS 代码的插件;
- html-minifier-terser:用于压缩 HTML 代码的插件;
- comporession-webpack-plugin:webpack gzip,压缩一些体积大的js、css等文件
拿vue-cli配置Gzip举例
npm i compression-webpack-plugin --save-dev
// vue.config.js
const CompressionPlugin = require('comporession-webpack-plugin')
module.exports = {
configureWebpack: (config) => {
if(process.env.NODE_ENV === 'productiono') { // 生产环境
// 开启js、css等压缩 gzip
config.plugins.push(
new CompressionPlugin({
filename: '[path].gz[query]',
algorithm: 'gzip',
test: /.js$|.css$|.html$/,
threshold: 10240,
minRatio: 0.8
})
)
}
}
}
Gzip 压缩原理是基于压缩算法Deflate
,使用LZ77
算法压缩重复字符,对结果使用 Huffman Coding
(最优二叉树哈夫曼树)算法给字符重新编码,确保出现频率越高的字符占用的字节越少:
LZ77算法:通过滑动窗口(sliding-window compression)实现对重复字符的压缩,例如下面:
KFCVNWKFCVN50 => KFCVNW(6,5)50
// 一个串由重复串存在时,可以通过以上形式代表,6表示往前第6位开始,5代表重复串的长度
理论上文本代码重复率越大,压缩率也高,通常能帮我们减少响应 70% 左右的大小。
客户端是否有必要配置?
一般有必要。常见gzip是服务端的工作,但压缩代码是需要牺牲服务端CPU等性能开销的,并不是毫无代价;假如存在大量的压缩需求,服务器可能出现周转不过来的情况,那客户端请求还是需要等待;所以客户端是可以根据业务场景适当配置gzip为服务端分压。
说到代码压缩,容易和代码混淆混淆,怎么区分?
代码混淆 = 代码压缩 + ...(其他方法)
代码压缩是代码混淆的一种实现方式,但不是全部,代码压缩侧重减少代码体积达到目的,代码混淆侧重于可读性降低,同时是有可能反过来增加代码体积的,例如常用到的OLLVM(针对LLVM的代码混淆工具)流程平坦化。
使用lazyCompilation实现按需编译
Webpack 5.17.0引入的新特性,主要实现Entry或者异步引用模块按需编译,很实用的方法,类似vite,极大提升冷启速度。不足之处是目前还处于实验阶段,推荐开发环境启用。
// webpack.config.js
module.exports = {
// ...
experiments: {
lazyCompilation: true,
},
};
通过exclude、include限制Loader执行范围
Loader执行涉及密集的CPU计算,如AST树的来回转换,所以可以通过exclude、include等配置限制Loader的执行范围,减少不必要的操作,一般是排除node_module包。
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: ["babel-loader", "eslint-loader"]
}
]
}
}
动态加载(懒加载)
Webpack默认对Entry下所有模块进行统一打包,一些不会影响到首屏渲染的代码也会被打包,这也导致页面初始化花费更多时间,进而影响首屏渲染;动态加载用到的模块是个可选优化方案,但是要注意没必要每个小模块都动态加载,这回导致反优化情况出现。
常见动态加载方案例如Vue的前端路由懒加载:
import { createRouter, createWebHashHistroy } from 'vue-router'
const Home = () => import (./Home.vue)
const Foo = () => import(/* webpackChunkName: "sub-pages" */ "./Foo.vue")
const routes = [
{ path: '/foo', name: 'Foo', component: Foo },
{ path: '/', name: 'Home', component: Home }
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
通过import()语句动态导入,使得仅当前页面切换到相应路由时才会加载对应组件代码。
通过webpackChunkName (魔法注释)指定异步模块名称,相同名称的模块能够打包到一起,这让我们能够有效的控制最终产物数量。
正确使用[hash]占位符,优化HTTP资源缓存效率
通过调整静态资源js、css等产物文件的名称(通过 Hash)与内容(通过 Code Splitting),最终可以让Webpack更适配浏览器的静态资源缓存(主要是强缓存
),也能及时更新发版内容。
常见Hash占位符
- [fullhash]:整个项目的内容 Hash 值,项目中任意模块变化都会产生新的 fullhash;
- [chunkhash]:产物对应 Chunk 的 Hash,Chunk 中任意模块变化都会产生新的 chunkhash;
- [contenthash]:产物内容 Hash 值,仅当产物内容发生变化时才会产生新的 contenthash,因此实用性较高。
module.exports = {
// ...
entry: { index: "./src/index.js", foo: "./src/foo.js" },
output: {
filename: "[name]-[contenthash].js",
path: path.resolve(__dirname, "dist"),
},
plugins: [new MiniCssExtractPlugin({ filename: "[name]-[contenthash].css" })],
};
可以看到打包产物带上了唯一hash值,只要内容不发生变化,hash也不会发生变化,有效提高网络性能。
# HTTP Response header
Cache-Control: max-age=31536000
// max-age代表缓存有效期,默认单位秒,当内容文件不变,有效期内再次请求文件直接返回本地缓存
发版时候hash发生变化,则会重新请求资源
hash也是常见发版时热更新实现基础,通过插件如hash-webpack-plugin、build-hash-webpack-plugin
等插件打包版本Hash,通过websocket监听hash变化实现热更新。
thread-loader开启多进程打包
Webpack默认是以单进程串行对资源文件执行一系列操作,导致打包效率低下,cpu利用率也低,目前社区是有提供一些工具支持多进程打包的,例如thread-loader
npm install -D thread-loader
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ["thread-loader", "babel-loader", "eslint-loader"],
},
],
},
};
// 放首位,确保优先执行
// vue-cli内置有
module.export = {
parallel: true
}
实测效果还是很明显的
注意:
这可能是个反优化
,thread-loader开启耗费时间大概600ms,多线程之间的通信也会消耗时间,假如是比较小的项目,反而会增加打包时间;建议在项目比较庞大,因JavaScript积累到一定量,babel-loader编译速度开始下滑情况,开启多进程。
参考
结语
-
本文浅谈Webpack,事实上目前流行的脚手架如Vue-cli,内置完善的Webpack配置体系,并不需要过多的额外优化,所以最好根据业务场景决定添加,避免出现
反优化
情况。 -
相比Vite、WMR、Snowpack 等新一代
Unbundle
工具,目前公司还是以Webpack为主,未来可能会逐渐过渡,但Webpack依旧具备竞争力,特别是Webpack5也出现了持久化缓存、lazyCompilation
等特性有望追平Vite这些Unbundle方案性能,所以是值得学习的。 -
由于庞大的社区,不可能每一个Loader,每一个Plugin都学习,了解基本配置,找几个常用的深入即可,其他用到再去找。
✨分享不易,点赞鼓励🤞
转载自:https://juejin.cn/post/7187378439182090299