❝浮躁背后的本质是自卑感 ❞
大家好,我是「柒八九」。
今天,我们继续「前端面试」的知识点。我们来谈谈关于「前端工程化」的相关知识点和具体的算法。
该系列的文章,大部分都是前面文章的知识点汇总,如果想具体了解相关内容,请移步相关系列,进行探讨。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
好了,天不早了,干点正事哇。
❝
Source Map
「推荐阅读指数」 ⭐️⭐️⭐️⭐️Webpack
打包过程 「推荐阅读指数」 ⭐️⭐️⭐️Webpack Loader
vs Plugin
「推荐阅读指数」 ⭐️⭐️⭐️⭐️❞
❝脚手架作为一种创建项目「初始文件」的工具被广泛地应用于「新项目」或者「迭代初始阶段」 ❞
使用工具替代人工操作能够避免人为失误引起的低级错误,同时结合整体前端工程化方案,快速生成功能「模块配置」、「自动安装依赖」等,降低了时间成本。
现在主流的前端脚手架工具有两类:
名称 | 模板框架 | 多选项生产 | 支持自定义模板 | 特点 |
---|---|---|---|---|
Create-React-App | React | 否 | 是 | React 官方维护 |
Vue CLI | Vue | 是 | 是 | Vue官方维护 |
Facebook
官方提供的 React
开发工具集,create-react-app
用于选择脚手架「创建项目」react-scripts
则作为所创建项目中的「运行时依赖包」,提供了封装后的项目「启动、编译、测试」等基础工具react-app-rewired
:利用config-overrides.js
文件来对 webpack
配置进行扩展customize-cra
:利用react-app-rewired
的配置文件config-overrides.js
对webpack
配置进行修改@vue/cli
@vue/cli-service
@vue/cli-plugin
在实际开发中,我们可以通过创建脚手架对应的模板对项目进行「定制化处理」。
定制化模板可以「弥补」官方提供基础工具集不满足特定需求的场景。
定制化有如下优点(但有不仅限这些优点)
webpack
配置优化,来提升开发环境的效率和生产环境的性能等我们简单的为CRA
配置一个最简单的模板。
作为一个最简化的 CRA 模板,模板中包含如下必要文件
README.md
:用于在 npm
仓库中显示的「模板说明」package.json
:用于描述模板本身的「元信息」, 例如名称、运行脚本、依赖包名和版本等template.json
:用于描述基于模板创建的项目中的 package.json
信息template
目录:用于「复制」到创建后的项目中,gitignore
在复制后重命名为 .gitignore
,「public/index.html和src/index 为运行 react-scripts 的必要文件」对应的目录结构如下:
cra-template-[template-name]/
README.md (for npm)
template.json
package.json
template/
README.md (for projects created from this template)
gitignore
public/
index.html
src/
index.js (or index.tsx)
在使用时,还是需要将模板通过 npm link
命令「映射」到全局依赖中或发布到 npm
仓库中。
然后执行创建项目的命令
npx create-react-app [app-name] --template [template-name]
❝脚手架的「功能和本质」 功能是「创建项目初始文件」,本质是「方案的封装」 ❞
Source Map
:「映射」转换后的代码与源代码之间的关系
一段转换后的代码,通过转换过程中生成的 Source Map
文件就可以「逆向解析」得到对应的源代码。
Source Map
是一个 JSON
格式的文件,这个 JSON
里面记录的就是「转换后和转换前」代码之间的映射关系。
在编译器(Babel/SWC
)编译处理的过程中,在生成产物代码的同时,也生成产物代码中被转换的部分与源代码中相应部分的「映射关系表」。
有了完整的映射表,就可以通过 Chrome
控制台中的"Enable Javascript source map"来实现调试时的显示与定位源代码功能。
Source map的格式
{
version : 3,
file: "out.js",
sourceRoot : "",
sources: ["foo.js", "bar.js"],
names: ["src", "maps", "are", "fun"],
mappings: "AAgBC,SAAQ,CAAEA"
}
这里额外对mappings
字段做一个介绍
❝这是一个很长的字符串,它分成「三层」
VLQ
编码表示,「代表该位置对应的转换前的源码位置」
❞
mappings:"AAAAA,BBBBB;CCCCC"
:转换后的源码分成「两行」,第一行有两个位置,第二行有一个位置。
❝一般我们会在「转换后的代码中」通过「添加一行注释」的方式来去「引入 Source Map 文件」 ❞
对于同一个源文件,根据不同的目标,可以生成不同效果的 Source Map
。在开发环境和生产环境下,对于 source map
功能的期望也有所不同。
Source Map
/生成的文件大小/访问方式是否会对页面性能造成影响,最后才考虑质量和构建速度由于,现在在打包领域,Webpack
还是一个绕不过去的大山,所以,在了解到基础的知识点后,需要将知识配合实际项目进行分析和学习。
根据不同规则,实际上 Webpack
是从三种「插件」中选择其一作为 source map
的处理插件。
EvalDevToolModulePlugin
EvalSourceMapDevToolPlugin
SourceMapDevToolPlugin
❝插件中有
eval
字段的,模块产物是通过eval()
封装的,只有SourceMapDevToolPlugin
(AKA:SMDP
)生成.map
文件 ❞
分别从「质量」/「构建速度」/「包大小和生成方式」三个角度来分析
生成的 source map
的质量分为 「5 个级别」,对应的「调试便捷性」依次降低。
不同环境下关注的速度也不同
devServer
,「再次构建的速度」对我们的效率影响远大于初次构建的速度在「开发环境」下我们并不需要关注这些因素。
利用EvalSourceMapDevToolPlugin
对开发环境提速。
在开发阶段,调试的是开发的业务代码部分,而非依赖的第三方模块部分,所以在生成 source map
的时候如果可以「排除第三方模块」的部分,而只生成业务代码的 source map
,无疑能进一步提升构建的速度。
webpack.config.js
...
//devtool: 'eval-source-map',
devtool: false,
plugins: [
new webpack.EvalSourceMapDevToolPlugin({
exclude: /node_modules/,
module: true,
columns: false
})
],
...
将 devtool
设为 false
,就是丢弃webpack
或者CRA
的默认配置,而是直接使用 EvalSourceMapDevToolPlugin
,通过传入 module: true
和 column:false
,达到和预设 eval-cheap-module-source-map
一样的质量。但是,我们传入 exclude
参数,「排除第三方依赖包的 source map 生成」。进一步提升了构建的速度。
❝在控制台的「网络面板」中通常看不到
source map
文件的请求,其原因是出于安全考虑Chrome
「隐藏」了source map
的请求 ❞
我们可以通过一些抓包工具进行文件的抓取
Chrome
自带的 chrome://net-export/
whistle
WireShark
既然能通过抓包工具进行Source Map
文件的捕获,那么在前面我们就说了,「Source Map:映射转换后的代码与源代码之间的关系」,那么我们反其道而行,我们是不是可以通过Source Map
来反向推出源代码。
这里推荐一个库shuji
。可以通过x.map
(x可以是js
也可以是css
),反编译出源代码。
❝本质上,
webpack
是一个现代JavaScript
应用程序的「静态」模块打包器module bundler 当webpack
处理应用程序时,它会「递归」地构建一个依赖关系图dependency graph,其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个bundle
。 ❞
❝「一切」都从
entry
对象开始 ❞❝「模块是文件的升级版」。 模块,一旦创建和构建,除了「源代码」,还包含很多有意义的信息,如:
❞
「同时entry
对象中的每一项都可以被认为是模块树中的根模块」。「模块树」是因为根模块可能需要一些其他的模块,这些模块可能需要其他的模块,等等。「所有这些模块树都被储存在 ModuleGraph
中」。
「webpack是建立在很多插件之上的」
「webpack的可扩展性是通过hook
实现的」。例如,你可以在 ModuleGraph
建立后,当一个新的资源asset被生成时,在模块即将被「建立前」(运行加载器和解析源代码),添加自定义逻辑。大多数时候,hook
是「根据它们的目的分组的」,对于任何定义好的目的,都有一个插件plugin。
❝
entry
对象中的每一项,都会有一个 EntryPlugin
实例,其中创建了一个 EntryDependency
。EntryDependency
「保存了模块的请求」(即文件的路径),并且通过映射到一个模块工厂,即 NormalModuleFactory
,「提供了一种使该请求有用的方法」。EntryPlugin
实例,在新创建的 EntryDependency
的帮助下,将「创建一个模块树」。模块树是建立在模块和它们的依赖关系之上的,这些模块也可以有依赖关系。❞
❝「模块是一个文件的升级版」。 一个模块,一旦创建和构建,除了「源代码」,还包含很多有意义的信息,如:
❞
❝「一个Chunk封装了一个或多个模块」 一般情况下,
entry
文件(一个entry
文件=entry
对象的一个项目)的数量与所产生的Chunk
的数量「成正比」。
entry
对象可能只有一个项目,而结果块的数量可能大于1。的确,对于每一个entry
项目,在dist
目录中都会有一个相应的chunk
❞
❝但也可能是「隐式创建」其他的
chunk
,例如在使用import()
函数时。但不管它是如何被创建的,每个chunk
在dist
目录下「都会有一个对应的文件」。 ❞
❝「一个ChunkGroup包含一个或多个chunks」 一个
ChunkGroup
可以是另一个ChunkGroup
的父或子。
import()
函数,就会有一个ChunkGroup被创建」,它的父级是一个「现有的」 ChunkGroup
,即包括使用import()
函数的文件(即模块)的那个。❞
「微前端」是一套用于「组织大型前端应用的指导规范」。是受后端「微服务」启发而发展而来。
Tree-Shaking
-有了依赖关系后,可以通过依赖分析,去掉一些没用的代码Code-Splitting
- 这些模块根据功能和类型拆分到不同的分组(chunk)里,然后生成不同的文件,然后将变更频繁和几乎不动的模块划分到不同的chunk
,并封装到特定文件中,针对几乎不会变更的资源和模块,则可以利用浏览器缓存进行资源的优化处理Lazy-Load
:生成的代码,拥有自己的runtime
,这样可以实现模块的 lazy load
,也就是把Code-Splitting
分出来的 chunk,在运行时「动态加载」loader
是「文件加载器」,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中plugin
赋予了 webpack
各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是「解决 loader 无法实现的其他事」loader
运行在「打包文件之前」plugins
在整个编译周期都起作用对于 loader
,实质是一个「转换器」,将A文件进行编译形成B文件,操作的是文件,比如将A.scss
或A.less
转变为B.css
,「单纯的文件转换」过程。
在 Webpack
运行的生命周期中会广播出许多事件,Plugin
可以监听这些事件,在合适的时机通过Webpack提供的 API改变输出结果。
Webpack
工作流程中最核心的两个模块
Compiler
Compilation
它们都扩展自 Tapable
类,用于实现工作流程中的生命周期划分,以便在不同的生命周期节点上注册和调用插件,其中所暴露出来的生命周期节点称为Hook
(俗称钩子)。
「构建器实例」的生命周期可以分为 3 个阶段
environment
compiler
实例且执行了配置内定义的插件的 apply
方法后触发afterEnvironment
compiler
实例且执行了配置内定义的插件的 apply
方法后触发entryOption
EntryOptions
插件afterPlugins
afterResolvers
resolver
配置后触发normalModuleFactory
contextModuleFactory
beforeRun
run
beforeCompile
compile
thisCompilation
make
」shouldEmit、emit、assetEmitted、afterEmit
failed、done
「构建过程实例」的生命周期分为两个阶段:
❝真正影响整个构建效率的是
Compilation
实例的处理过程
❞
要「提升编译阶段的构建效率」,大致可以分为三个方向
需要一类插件,来帮助我们统计项目构建过程中在编译阶段的耗时情况
speed-measure-webpack-plugin
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
const webpackConfig = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
});
找出对产物包体积影响最大的包的构成,从而找到那些冗余的、可以被优化的依赖项。不仅能减小最后的包体积大小,也能提升构建模块时的效率webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
❝编译模块阶段所耗的时间是从单个入口点开始,编译每个模块的时间的总和 ❞
IgnorePlugin
(国际化包)DllPlugin
Externals
有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块
典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包
Webpack 提供的 IgnorePlugin
,即可在「构建模块时」直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积。
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
});
require
语句都将被忽略除了 moment
包以外,其他一些带有「国际化模块」的依赖包,都可以应用这一优化方式。
「减少执行模块的方式是按需引入」,一般适用于「工具类库」性质的依赖包的优化
典型例子是 lodash
依赖包
优化处理
antd
,antd-mobil
,lodash
babel-plugin-lodash
babel-plugin-import
{
"plugins": [["import",{
"libraryName": "lodash",
"libraryDirectory": "",
"camel2DashComponentName": false, // default: true
}]]
}
Tree Shaking
,这一特性也能减少产物包的体积,但是 Tree Shaking
需要相应导入的依赖包使用 ES6
模块化,而 lodash
还是基于 CommonJS
,需要替换为 lodash-es
才能生效
Tree Shaking
是在优化阶段生效,Tree Shaking
并不能减少模块编译阶段的构建时间。
它的核心思想是将项目依赖的框架等模块「单独构建打包」,与普通构建流程区分开。
事先把常用但又构建时间长的代码提前打包好(例如 react
、react-dom
),取个名字叫 dll
。后面再打包的时候就跳过原来的未打包代码,直接用 dll
。这样一来,构建时间就会缩短,提高 webpack
打包速度。
两个配置文件
module.exports = {
entry: {
vendor: ['react', 'react-dom'],
},
output: {
filename: '[name].dll.js',
path: path.join(__dirname, 'dll'),
publicPath: '/dll',
library: '[name]_dll',
},
plugins: [
new webpack.DllPlugin({
context: __dirname,
name: '[name]_dll',
path: path.join(__dirname, 'dll' + '/[name]_manifest.json'),
}),
],
}
new webpack.DllPlugin
- 生成manifest.json
文件,供DllReferencePlugin
指向依赖模块位置 - 将公共模块 react/react-dom
抽离到项目中dll文件下
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./dll/vendor_manifest.json'),
}),
],
new webpack.DllReferencePlugin
manifest.json
文件,寻找依赖模块❝webpack 4 有着比 dll 更好的打包性能,所以在最新版的cra中已经将dll剔除。 ❞
Webpack 配置中的 externals
和 DllPlugin
解决的是同一类问题。「将依赖的框架等模块从构建过程中移除」。
Externals
和 DllPlugin
区别
externals
更简单DllPlugin
需要独立的配置文件DllPlugin
包含了依赖包的独立构建流程,而 externals
配置中不包含依赖框架的生成方式,通常使用已传入 CDN 的依赖包externals
配置的依赖包需要单独指定依赖模块的加载方式:全局对象、CommonJS
、AMD
等DllPlugin
无须更改,而 externals
则会将子模块打入项目包中使用范例
module.exports = {
//...
externals: [
{
// String
react: 'react',
// Object
lodash: {
commonjs: 'lodash',
amd: 'lodash',
root: '_', // indicates global variable
},
// [string]
subtract: ['./math', 'subtract'],
},
// Function
function ({ context, request }, callback) {
if (/^yourregex$/.test(request)) {
return callback(null, 'commonjs ' + request);
}
callback();
},
// Regex
/^(jquery|\$)$/i,
],
};
在保持构建模块数量不变的情况下,提升单个模块构建的速度。
常用的方式有
include/exclude
noParse
Source Map
通过减少构建「单个模块」时的一些处理逻辑来提升速度
Webpack -loader
配置中的 include/exclude
,是常用的优化特定模块构建速度的方式之一
include
的用途是只对符合条件的模块使用指定 Loader
进行转换处理exclude
则相反,不对特定条件的模块使用该 Loader
例如不使用 babel-loader 处理 node_modules 中的模块 使用范例
module.exports = {
......
module: {
rules: [
{
test: /\.js$/,
include: /src/
exclude: /node_modules/,
use: ['babel-loader'],
},
],
},
}
「注意点」
include/exclude
排除的模块,并非不进行编译,而是使用 Webpack
「默认的 js 模块编译器进行编译」loader
中的 include
与 exclude
配置存在冲突的情况下,优先使用 exclude
的配置,而忽略冲突的 include
部分的配置Webpack 配置中的 module.noParse
则是在 include/exclude
的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间
使用范例
module.exports = {
......
module: {
noParse: /jquery|lodash/,
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
],
},
}
对于生产环境的代码构建而言,会根据项目实际情况判断是否开启 Source Map
在开启 Source Map
的情况下,「优先选择与源文件分离的类型」 --例如 "source-map"
Webpack 中编译 TS 有两种方式
ts-loader
babel-loader
在使用 ts-loader
时,由于 ts-loader
默认在「编译前进行类型检查,因此编译时间往往比较慢」
通过加上配置项 transpileOnly: true
,可以在编译时忽略类型检查
module.exports = {
......
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
},
],
},
}
babel-loader
则需要单独安装 @babel/preset-typescript
来支持编译 TS,配合 ForkTsCheckerWebpackPlugin
使用类型检查功能
module.exports = {
......
module: {
rules: [
{
test: /\.ts$/,
use: ['babel-loader'],
},
],
},
plugins: [
new TSCheckerPlugin({
typescript: {
diagnosticOptions: {
semantic: true,
syntactic: true,
},
},
}),
],
}
Webpack
中的 resolve
配置制定的是在「构建时指定查找模块文件的规则」
resolve.modules
resolve.extensions
resolve.mainFields
package.json
中主文件的属性名resolve.symlinks
这些规则「在处理每个模块时都会有所应用」,因此尽管对小型项目的构建速度来说影响不大,对于大型的模块众多的项目而言,「使用默认配置和增加了大量无效范围后,构建时长的变化」。
「并行构建」的方案早在 Webpack 2
时代已经出现,适用于大项目。使用方式
两种工具的本质作用相同,都作用于模块编译的 Loader
上,「用于在特定 Loader 的编译过程中」。
开启多进程的方式加速编译
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve('src'),
use: [
'thread-loader',
’babel-loader‘
],
},
],
},
};
并发构建的第二种场景是「针对与多配置构建」。
Webpack
的配置文件可以是「一个包含多个子配置对象的数组」,在执行这类多配置构建时,默认「串行执行」
var path = require('path');
module.exports = [
{
entry: './pageA.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'pageA.bundle.js'
}
},
{
entry: './pageB.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'pageB.bundle.js'
}
}];
通过 parallel-webpack
,就能「实现相关配置的并行处理」
"build:parallel": "parallel-webpack --config webpack.parallel.config.js"
Webpack
构建流程中的第二个阶段,也就是从代码优化到生成产物阶段的效率提升问题
优化阶段可以分为两个不同的方向
一般在项目的优化阶段,主要耗时的任务有两个
ChunkAssets
Chunk
信息生成 Chunk 的产物代码Webpack
引擎内部的模块中处理Webpack 4 中内置了 TerserWebpackPlugin
作为默认的 JS 压缩工具--基于 Terser
。之前的版本,需要单独引入,早期主要使用的是 UglifyJSWebpackPlugin
-- 基于 UglifyJS
。两者在压缩效率与质量方面差别不大,但 Terser
整体上略胜一筹
Terser
原本是 Fork
自 uglify-es
的项目,其绝大部分的 API 和参数都与 uglify-es 和 uglify-js@3 兼容。
以 Terser 为例来分析其中的优化方向
npm install terser-webpack-plugin --save-dev
TerserWebpackPlugin
中,对执行效率产生影响的配置主要分为 3 个方面
Cache
选项Parallel
选项terserOptions
选项Terser
工具中的 minify
选项集合compress
和mangle
选项compress
参数的作用mangle
参数的作用案例使用
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
cache: false,
terserOptions: {
compress: false,
mangle: false,
},
}),
],
},
};
压缩代码是在 optimizeChunkAssets 阶段
CSS 同样有3种压缩工具可供选择
OptimizeCSSAssetsPlugin
OptimizeCSSNanoPlugin
CssMinimizerWebpackPlugin
Webpack
社区新发布的 CSS 压缩插件它们都基于 cssnano
实现,「压缩质量方面」没有什么差别。
在压缩效率方面,最新发布的 MiniCssExtractPlugin
,它支持缓存和多进程,「默认开启多进程」。这是另外两个工具不具备的。
对于 CSS
文件的打包,一般我们会使用 style-loader
进行处理,这种处理方式最终的打包结果就是 CSS
代码会内嵌到 JS 代码中
MiniCssExtractPlugin
是一个可以将 CSS
代码从打包结果中提取出来的插件。
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
将这个插件添加到配置对象的 plugins
数组中,使用 MiniCssExtractPlugin
中提供的 loader
去替换掉 style-loader
,以此来「捕获到所有的样式」。打包过后,样式就会存放在独立的文件中,直接通过 link
标签引入页面
使用了 MiniCssExtractPlugin
过后,样式就被提取到单独的 CSS 文件中了,「样式文件并没有被压缩」。Webpack
「内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件」。
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
optimization: {
minimizer: [
new CssMinimizerPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
文档中的这个插件并不是配置在 plugins
数组中的,而是添加到了 optimization
对象中的 minimizer
属性中。
❝「如果我们配置到
plugins
属性中,那么这个插件在任何情况下都会工作,而配置到 minimizer 中,就只会在 minimize 特性开启时才工作」 ---optimization.minimize: true
❞
原本可以自动压缩的 JS,现在却不能压缩了,因为设置了 minimizer
。Webpack
认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉。「必须手动再添加回来」
内置的 JS 压缩插件叫作 terser-webpack-plugin
,手动添加这个模块到 minimizer
配置当中。
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserWebpackPlugin(),
new CssMinimizerPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
❝通过对本环节的处理减少后续环节处理内容,以便提升后续环节的工作效率 ❞
Code Splitting
Tree Shaking
Scope Hoisting
(作用域提升)sideEffects
Code Splitting--通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle
中
Webpack 实现分包的方式主要有两种
ES Modules
的动态导入(Dynamic Imports
)特性,「按需加载模块」多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是:一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中
├── dist
├── src
│ ├── common
│ │ ├── fetch.js
│ │ └── global.css
│ ├── album.css
│ ├── album.html
│ ├── album.js
│ ├── index.css
│ ├── index.html
│ └── index.js
├── package.json
└── webpack.config.js
有两个页面,分别是 index
和 album
// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
// ... 其他配置
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html'
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html'
})
]
}
entry
属性中只会配置一个打包入口。entry
「定义成一个对象」。entry
是定义为对象而不是数组,如果是数组的话就是把多个文件打包到一起,还是一个入口。[name]
这种占位符来输出动态的文件名 - [name]
最终会被替换为入口的名称html-webpack-plugin
- 分别为 index
和 album
页面生成了对应的 HTML 文件输出 HTML 的插件,默认这个插件会自动注入所有的打包结果。如果需要指定所使用的 bundle
,通过 HtmlWebpackPlugin
的 chunks
属性来设置
「每个打包入口都会形成一个独立的 chunk(块)」
// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
// ... 其他配置
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index'] // 指定使用 index.bundle.js
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album'] // 指定使用 album.bundle.js
})
]
}
需要把这些公共的模块提取到一个单独的 bundle 中
优化配置中开启 splitChunks
功能
// ./webpack.config.js
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all'
}
}
// ... 其他配置
}
将它设置为 all
,表示所有公共模块都可以被提取
Code Splitting
更常见的实现方式还是「结合 ES Modules 的动态导入特性,从而实现按需加载」。
一般我们常说的按需加载指的是加载数据或者加载图片,这里所说的按需加载,指的是「在应用运行过程中,需要某个资源模块时,才去加载这个模块」。
Webpack
中支持使用动态导入的方式实现模块的按需加载,而且「所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包」
├── src
│ ├── album
│ │ ├── album.css
│ │ └── album.js
│ ├── common
│ │ ├── fetch.js
│ │ └── global.css
│ ├── posts
│ │ ├── posts.css
│ │ └── posts.js
│ ├── index.html
│ └── index.js
├── package.json
└── webpack.config.js
文章列表对应的是这里的 posts
组件,而相册列表对应的是 album
组件
在打包入口(index.js)中同时导入了这两个模块,然后根据页面锚点的变化决定显示哪个组件
// ./src/index.js
// import posts from './posts/posts'
// import album from './album/album'
const update = () => {
const hash = window.location.hash || '#posts'
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
// mainElement.appendChild(posts())
import('./posts/posts').then(({ default: posts }) => {
mainElement.appendChild(posts())
})
} else if (hash === '#album') {
// mainElement.appendChild(album())
import('./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
}
}
window.addEventListener('hashchange', update)
update()
为了动态导入模块,可以将 import
关键字作为函数调用。当以这种方式使用时,import
函数返回一个 Promise
对象.
import
函数导入指定路径Promise
Promise
的 then
方法中能够拿到模块对象由于这里的 posts 和 album 模块是「以默认成员导出,需要解构模块对象中的 default」,先拿到导出成员,然后再正常使用这个导出成员。
import('./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
默认通过动态导入产生的 bundle
文件,它的 name
就是一个序号。如果需要给这些 bundle
命名的话,就可以使用 Webpack 所特有的魔法注释去实现
import(/* webpackChunkName: 'posts' */'./posts/posts')
.then(({ default: posts }) => {
mainElement.appendChild(posts())
})
所谓魔法注释,就是在 import
函数的「形式参数位置,添加一个行内注释」,注释有一个特定的格式---webpackChunkName:’xxx‘
,就可以给分包的 chunk
起名字
如果 chunkName
相同的话,那相同的 chunkName
最终就会被打包到一起,借助这个特点,就可以根据自己的实际情况,灵活组织动态加载的模块了。
Tree-shaking
最早是 Rollup
中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。使用 Webpack
生产模式打包的优化过程中,自动开启这个功能 --- npx webpack --mode=production
配置对象中添加一个 optimization
属性,该属性用来「集中配置 Webpack 内置优化功能」,它的值也是一个对象,在 optimization
对象中先开启一个 usedExports
选项,表示在输出结果中只导出外部使用了的成员
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true
}
}
对于未引用代码,如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码.
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 压缩输出结果
minimize: true
}
}
Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能
usedExports
minimize
把代码看成一棵大树
usedExports
的作用就是标记树上哪些是枯树枝、枯树叶minimize
的作用就是负责把枯树枝、枯树叶摇下来Tree-shaking
实现的前提是 ES Modules
,最终交给 Webpack
打包的代码,「必须是使用 ES Modules
的方式来组织的模块化」
Webpack 在打包所有的模块代码之前
Loader
处理Loader
处理的结果打包到一起为了更好的兼容性,会选择使用 babel-loader
去转换我们源代码中的一些 ECMAScript
的新特性,Babel
在转换 JS 代码时,很有可能处理掉代码中的 ES Modules
部分,把它们转换成 CommonJS
的方式。
babel-loader
(低版本)我们为 Babel
配置的都是一个 preset
(预设插件集合),而不是某些具体的插件。
目前市面上使用最多的 @babel/preset-env
,这个预设里面就有转换 ES Modules
的插件。使用这个预设时,代码中的 ES Modules
部分就会被转换成 CommonJS
方式。Webpack 再去打包时,拿到的就是以 CommonJS
方式组织的代码了,所以 Tree-shaking
不能生效
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env']
]
}
}
}
]
},
optimization: {
usedExports: true
}
}
「自动帮我们关闭了对 ES Modules 转换的插件」,经过 babel-loader
处理后的代码默认仍然是 ES Modules
。那 Webpack
最终打包得到的还是 ES Modules
代码。Tree-shaking
自然也就可以正常工作了
最新版本的 babel-loader
并不会导致 Tree-shaking
失效,确保babel-loader
能使用Tree-shaking
。最简单的办法就是在配置中将 @babel/preset-env
的 modules
属性设置为 false
。确保不会转换 ES Modules
,也就确保了 Tree-shaking
的前提
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { modules: 'false' }]
]
}
}
}
]
},
optimization: {
usedExports: true
}
}
Webpack 3.0 中添加的一个特性,使用 concatenateModules
选项继续优化输出
普通打包只是将一个模块最终放入一个单独的函数中,如果模块很多,就意味着在输出结果中会有很多的模块函数。concatenateModules
配置的作用,尽可能将所有模块合并到一起输出到一个函数中,既提升了运行效率,又减少了代码的体积。
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 尽可能合并每一个模块到一个函数中
concatenateModules: true,
}
}
bundle.js
中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中
Webpack 4 中新增了一个 sideEffects
特性,允许「通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间」。
模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情,特性一般只有去开发一个 npm 模块时才会用到。
Tree-shaking
只能移除没有用到的代码成员,而想要「完整移除没有用到的模块」,那就需要开启 sideEffects
特性了,在 optimization
中开启 sideEffects
特性
// ./webpack.config.js
module.exports = {
mode: 'none',
optimization: {
sideEffects: true
}
}
这个特性在 production
模式下同样会自动开启
利用缓存数据来「加速构建过程」的处理。
在「初次构建」的压缩代码过程中,就将这一阶段的结果写入了缓存目录(node_modules/.cache/插件名/
)中有缓存。
当「再次构建」进行到压缩代码阶段时,即可对比读取已有缓存。
「编译过程的耗时点主要在使用不同加载器(Loader)来编译模块的过程」
Babel-loader
是绝大部分项目中会使用到的 JS/JSX/TS
编译器
与缓存相关的设置主要有
cacheDirectory
./node_modules/.cache/babel-loader/
false
,即不开启缓存」true
时开启缓存并使用默认缓存目录cacheIdentifier
Babel
相关依赖包的版本babelrc
配置文件的内容cacheCompression
false
的情况下将跳过压缩和解压的过程,从而提升这一阶段的速度在编译过程中利用缓存的第二种方式是使用 --- Cache-loader
在使用时,需要「将 cache-loader
添加到对构建效率影响较大的 Loader
(如 babel-loader 等)之前」
module: {
rules: [
{
test: /\.js$/,
use: ['cache-loader', 'babel-loader'],
},
],
}
「使用 cache-loader
后,比使用 babel-loader
的开启缓存选项后的构建时间更短」
主要原因是 babel-loader
中的缓存信息较少,而 cache-loader
中存储的 Buffer 形式的数据处理效率更高。
生成 ChunkAsset
时的缓存优化
在 Webpack 4 中,生成 ChunkAsset
过程中的缓存优化是受限制的:
watch
模式下cache
时(development 模式下自动开启),才能在这一阶段执行缓存的逻辑在 Webpack 4 中,缓存插件是「基于内存」的,只有在 watch
模式下才能在内存中获取到相应的缓存数据对象
对于 JS 的压缩TerserWebpackPlugin
/UglifyJSPlugin
都是支持缓存设置的。
对于 CSS 的压缩,目前最新发布的 CSSMinimizerWebpackPlugin
支持且「默认开启缓存」,其他的插件如 OptimizeCSSAssetsPlugin
和 OptimizeCSSNanoPlugin
目前还不支持使用缓存
「如何最大程度地让缓存命中,成为我们选择缓存方案后首先要考虑的」
缓存标识符发生变化导致的缓存失效,支持缓存的 Loader
和插件中,会根据一些「固定字段的值加上所处理的模块或 Chunk 的数据 hash 值来生成对应缓存的标识符」。一旦其中的值发生变化,对应缓存标识符就会发生改变,意味着对应工具中,所有之前的缓存都将失效。需要尽可能少地变更会影响到缓存标识符生成的字段
优化打包阶段的缓存失效,尽管在模块编译阶段每个模块是单独执行编译的。但是当进入到代码压缩环节时,各模块已经被组织到了相关联的 Chunk
中,「N个模块最后只生成了一个 Chunk」。任何一个模块发生变化都会导致整个 Chunk
的内容发生变化,而使之前保存的缓存失效。
尽可能地「把那些不变的处理成本高昂的模块打入单独的 Chunk 中」,Webpack
中的分包配置——splitChunks
。使用 splitChunks
「优化缓存利用率」。
好处
CI/CD 中的缓存目录问题
自动化集成的系统中,项目的构建空间会在每次构建执行完毕后,立即回收清理。在这种情况下,「默认的项目构建缓存目录(node_mo dules/.cache)将无法留存」。导致即使项目中开启了缓存设置,也无法享受缓存的便利性,反而因为需要写入缓存文件而浪费额外的时间
如果需要使用缓存,则需要根据对应平台的规范,「将缓存设置到公共缓存目录下」
❝缓存的便利性本质在于用磁盘空间换取构建时间,需要考虑对缓存区域的定期清理 ❞
「分享是一种态度」。
「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」