先有问题再有答案
bundle是什么 为什么需要打包?
资源体积对首屏性能有什么影响?
bundle资源越小 性能越好嘛?
如何优化资源体积?
性能优化还有哪些方案?
打包&bundle
为什么要打包:
转译(Transpiling) :开发中经常使用一些新的语言特性,这些语法浏览器可能并不支持,打包的过程中,通常会使用Babel等工具把新的JavaScript语法转译成大多数浏览器能够理解的语法,确保代码在大部分环境中都能正常运行。
资源整合:打包工具会把CSS、图片资源等进行整合,打包成一个或者多个文件,减少HTTP请求的数量。
性能优化:打包工具在打包的过程中,会进行很多优化,例如代码压缩、删除无用代码(Tree Shaking)、代码分割等,这些都可以极大地减小Bundle的体积,提高页面的加载速度。
bundle
在Web开发中,我们编写的多个JavaScript文件,经过打包工具像Webpack、Rollup处理后,最终形成一个或多个包含了所有JavaScript代码的文件,这个文件就是Bundle
。
优化思路
网络下载
bundle的体积越大,需要下载的数据就越多,这就需要更多的时间和更大的带宽。相应地,如果Web bundle的体积越小,需要下载的数据就越少,那么页面的加载速度就更快,用户的等待时间也就更短。
在HTTP/1.1协议下, 因为每一个额外的JavaScript文件都需要额外的HTTP请求,这会产生额外的延迟。如果拆分得过细,会导致大量的HTTP请求,这也会影响加载性能。
在HTTP/2中,由于支持了多路复用,可以同时传输多个请求或响应,而不用等待上一次请求或响应完成。这一点让文件的拆分和组织更有优势,因为不需要像HTTP/1.1那样担心多个文件会造成多个HTTP请求带来过多的延迟。
但是,这并不表示bundle就应该越小越好,因为仍然存在一些因素需要考虑:
- 资源中的代码重复:如果bundle分割得过细,可能会导致一些重复的代码被多次下载和执行,尤其是如果几个bundle中都使用到了同一部分的公共代码。
- 浏览器解析和编译开销:每一个额外的bundle都需要浏览器进行单独的解析和编译,如果bundle拆分得过细,各个小的bundle的解析、编译开销加起来可能会比一个相对大一些的bundle要大。
解析执行
JavaScript文件需要被浏览器解析和执行,这是一个需要消耗时间的过程。JavaScript文件越大,解析和执行的时间就越长。反之,如果JavaScript文件体积越小,解析和执行就会更快。
JavaScript解析执行除了和包体积有关,还和具体的业务代码有关系,如果有同步执行的耗时任务
,例如在首屏的代码中 执行了1000w次循环计算,因为js单线程的原因,那么必然会导致js执行时间过长,进而导致页面首屏变慢。
关于这部分的优化方案可以参考 浏览器:帧&事件循环 & js性能优化:时间切片分帧,webworker并行, requestidlecallback空闲执行,延迟执行~ 这里不做讨论。
总结
对bundle资源的优化 收益主要来源于下载
和解析执行
这两个关键节点,我们需要综合考虑,保持bundle的大小适中,尽量避免过大或过小的bundle。找到 平衡点 达到 收益最大化.
一图胜千文
代码分割(Code Splitting)
介绍
通过将代码分割成多个较小的包,只加载用户当前所需要的功能代码,减少了首屏需要加载的JavaScript代码量,从而降低了首屏的加载时间。
应用
项目打包一般是使用webpack来做的, 想在项目中使用代码分割的功能,就不得不提到webpack的 split-chunks-plugin 插件。
splitChunks: {
cacheGroups: {
defaultVendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
}
},
上面是vue2默认的 split-chunks-plugin配置项。 简单分析下:
-
defaultVendors
: 这是一个特定的缓存组,用于处理来自node_modules
目录的第三方库模块。name
: 指定生成的 chunk 名称。在这个例子中,所有被这个缓存组匹配的模块将被打包到名为chunk-vendors.js
的文件中。test
: 使用正则表达式来匹配应该被这个缓存组包含的模块路径。这里[\/]node_modules[\/]
是一个正则表达式,用于匹配所有在node_modules
目录下的文件路径。priority
: 缓存组的优先级。数字越小,优先级越高。在这个例子中,-10
表示defaultVendors
缓存组的优先级较低,这意味着如果有多个缓存组规则匹配同一个模块,将优先考虑其他优先级更高的缓存组。chunks
: 指定这个缓存组应该应用到哪种类型的 chunks 上。这里'initial'
表示只对初始 chunks 进行分割,不包括按需加载的 chunks。
-
common
: 这是另一个缓存组,用于处理应用程序中共享的模块。name
: 指定生成的 chunk 名称。这里所有被这个缓存组匹配的模块将被打包到名为chunk-common.js
的文件中。minChunks
: 表示模块至少需要被多少个 chunks 引用才会被分割出来。在这个例子中,2
表示一个模块至少需要被两个 chunks 共享才会被提取到chunk-common.js
。priority
: 缓存组的优先级。-20
表示common
缓存组的优先级更低,如果有多个缓存组规则匹配同一个模块,common
将不会是首选。chunks
: 同样指定应用到初始 chunks 上。reuseExistingChunk
: 当设置为true
时,如果一个模块已经被分割到一个 chunk 中,并且这个 chunk 符合当前缓存组的规则,那么 webpack 将重用这个 chunk 而不是创建一个新的 chunk。
经过上面的配置vue2在打包后可能会输出三个文件,业务的main.js
,node_module里的chunk-vendors.js
,共享的chunk-common.js
.
缓存&hash
一般通过contenthash作为文件名的一部分,做资源缓存的依据。
output: {
path: '/dist',
filename: 'js/[name].[contenthash:8].js',
publicPath: '/',
chunkFilename: 'js/[name].[contenthash:8].js'
}
通过hash + http协议Cache-Control
共同完成。
摇树(Tree Shaking)
介绍
通过在编译时根据ast静态分析死区代码
,删除项目中未引用的代码,进一步减小了打包后的代码体积,降低了首屏的加载时间。
应用
Tree Shaking生效的条件:
- 使用 ES6 模块语法:Tree Shaking只能用于ES6的模块语法,包括 import 和 export。因为这种静态的模块结构可以在编译阶段确定哪些模块会被使用,哪些不会。这让Tree Shaking成为可能。而像CommonJS那样的动态模块系统就无法进行Tree Shaking。
- 关闭模块转换功能:如果你使用了Babel这样的编译工具,需要确保关闭了它们的模块转换功能。否则,Babel可能会把你的ES6模块语法转换为无法进行Tree Shaking的CommonJS模块。
- 在生产模式下打包:Webpack默认只在生产模式下进行Tree Shaking,因此在进行打包操作时需要设置mode为production。这样做的原因是,开发模式下更关注构建速度和调试,而在生产模式下,Webpack会使用额外的插件,例如UglifyJS,TerserPlugin来摇掉那些没有被引用的代码,
所以实际上提供shake功能的是压缩插件
。 - 设置"sideEffects" :标识代码没有副作用,帮助编译器更高效的删除无用代码,即使不加也可以达到tree shake的功能 只是这个属性对tree shake 有更好的提效。不过需要注意,如果项目中有引入且执行的模块本身就是副作用(比如一些polyfill),那么需要在sideEffects中将其排除在外,以防止被错误地去除。
按需引入
Tree Shaking本身就是一种按需引入方式,但是是基于es6的模块方案来实现的。对于有些库会将代码打包为es5的老代码以支持更多的低版本。对于这种情况 还有另一种方式。
es5可以通过将文件打包成多个独立文件,然后在使用时指定具体的模块,以达到按需引入的作用。
单文件 + 相对路径 + babel-plugin-component(路径转换)
动态引入(Dynamic Imports)
介绍:
通过按需加载的方式,只有当特定功能被用到的时候才去加载对应的模块,这样也能有效减小首屏加载的代码量,提升了首屏的加载速度。
应用
const router = new Router({
mode: 'hash',
routes: [
{
path: '/',
name: 'main',
component: Main,
},
{
path: '/list',
name: 'list',
component: () => import(/* webpackChunkName:"Dynamic-test1" */ '../../component/index.vue'),
},
],
});
代码压缩(minification)
minimizer: [
{
options: {
test: /\.m?js(\?.*)?$/i,
chunkFilter: () => true,
warningsFilter: () => true,
extractComments: false,
sourceMap: true,
cache: true,
cacheKeys: defaultCacheKeys => defaultCacheKeys,
parallel: true,
include: undefined,
exclude: undefined,
minify: undefined,
terserOptions: {
compress: {
arrows: false,
collapse_vars: false,
comparisons: false,
computed_props: false,
hoist_funs: false,
hoist_props: false,
hoist_vars: false,
inline: false,
loops: false,
negate_iife: false,
properties: false,
reduce_funcs: false,
reduce_vars: false,
switches: false,
toplevel: false,
typeofs: false,
booleans: true,
if_return: true,
sequences: true,
unused: true,
conditionals: true,
dead_code: true,
evaluate: true,
drop_console: true,
drop_debugger: true
},
mangle: {
safari10: true
}
}
}
}
]
使用 webpack 内置的压缩工具来压缩所有的 JavaScript 文件,启用了多线程和缓存来提高压缩效率,同时生成 source map 以便调试。
压缩选项中启用了一些基本的压缩策略,同时,配置中删除了控制台日志和调试器代码,以减少最终打包文件的大小。