一、SPA首屏为什么加载慢?
SPA
首屏加载慢可能有以下原因:
- JavaScript文件过大:SPA通常有很多 JavaScript 文件,如果这些文件的大小过大或加载速度慢,就会导致首屏加载缓慢。可以通过代码分割和打包、使用CDN等方式来优化加载速度。
- 数据请求过多或数据请求太慢:SPA通过 AJAX 或 Fetch 等方式从后端获取数据,如果数据请求过多或数据请求太慢,也会导致首屏加载缓慢。可以通过减少数据请求、使用数据缓存、优化数据接口等方式来优化数据请求速度。
- 大量图片加载慢:如果首屏需要加载大量图片,而这些图片大小过大或加载速度慢,也会导致首屏加载缓慢。可以通过图片压缩、使用图片懒加载等方式来优化图片加载速度。
- 过多的渲染和重绘操作:如果在首屏加载时进行大量的渲染和重绘操作,也会导致首屏加载缓慢。可以通过尽可能少的DOM操作、使用CSS3动画代替JS动画等方式来优化渲染和重绘操作。
- 网络问题:网络问题也会影响SPA首屏加载速度,比如网络延迟、丢包等。可以通过使用CDN、使用HTTP/2等方式来优化网络问题。
二、为什么要做性能优化?
性能优化是为了提高网页的加载速度和相应速度,给用户带来更好的体验和用户满意度,同时还能减少服务器的负载压力,以此来提升程序的稳定性,具体有以下几个因素:
- 提高用户体验
- 增加页面访问量
- 提高搜索引擎排名
- 减少服务器压力
- 节约成本
- 提高用户留存率
- 从企业角度看,优化能够减少页面请求数或者减小请求所占带宽,能够节省可观的资源成本,最终提高收益转化。
三、常见性能优化指标&指标
目标会影响我们在过程中的决策
指标则用来度量我们的目标
指标
- 首屏加载时间First Contentful Paint(FCP) :首次内容绘制时间,指浏览器首次绘制页面中至少一个文本、图像、非白色背景色的
canvas/svg
元素等的时间,代表页面首屏加载的时间点。 - 首次绘制时间First Paint(FP) :首次绘制时间,指浏览器首次在屏幕上渲染像素的时间,代表页面开始渲染的时间点。
- 最大内容绘制时间Largest Contentful Paint(LCP) :最大内容绘制时间,指页面上最大的可见元素(文本、图像、视频等)绘制完成的时间,代表用户视觉上感知到页面加载完成的时间点。
- 用户可交互时间Time to Interactive(TTI) :可交互时间,指页面加载完成并且用户能够与页面进行交互的时间,代表用户可以开始操作页面的时间点。
- 页面总阻塞时间Total Blocking Time (TBT) :页面上出现阻塞的时间,指在页面变得完全交互之前,用户与页面上的元素交互时出现阻塞的时间。TBT应该尽可能小,通常应该在300毫秒以内。
- 搜索引擎优化Search Engine Optimization (SEO) :网站在搜索引擎中的排名和可见性。评分范围从0到100,100分表示网站符合所有SEO最佳实践。
目标
首先我们需要确定目标,根据场景和项目复杂度不同,制定的目标也不同,比如希望比竞品快20%
这里我定下的目标是
- 正常网速下,2s内加载完成
- 弱网下,30s内加载完成
除此之外还有符合标准258
原则、GOOGLE
团队建议
258原则
- 2:页面的加载时间应该控制在2秒以内,这是用户能够接受的最短时间。
- 5:页面的加载时间在5秒以内,用户对页面加载速度的不满意度开始上升。
- 8:页面的加载时间超过8秒,用户的流失率将急剧增加,用户很可能会放弃访问该页面。
四、分析工具
Network
优化前Network
从Network上我们发现主要问题在3.2M的chunk-vendor.js上
- 体积太大,下载慢
- 阻塞了其他资源下载
Lighthouse
优化前Lighthouse
Performance
dist目录分析
- 整体体积太大,近5M
- 出现了若干不应出现的静态资源,比如页面上没引用到SVG图标、应该被内联的小图等
- 部分图片资源较大,最大的达到仅400KB
Webpack Bundle分析
优化前Bundle
从webpack bundle可以看出,问题着实不少
- 未剔除项目模板用到的冗余依赖,比如g2、quill、wangEditor、mock等
- 一些没用到的Ant-design组件由于全局注册也一并打包了进去
- 项目中只用到几个Ant-Design/icons,但却被全量引入
- moment和moment-timezone重复,且体积较大
- core-js体积较大
- 打包策略不合理,导致chunk-vendor太大
五、性能优化方式有哪些
1. 代码优化
HTML&CSS
- 减少
DOM
数量,减轻浏览器渲染计算负担。 - 使用异步和延迟加载
js
文件,避免js
文件阻塞页面渲染 - 压缩
HTML、CSS
代码体积,删除不要的代码,合并CSS
文件,减少HTTP
请求次数和请求大小。 - 减少
CSS
选择器的复杂程度,复杂度与阿高浏览器解析时间越长。 - 避免使用
CSS
表达式在javascript
代码中 - 使用
css
渲染合成层( gpu渲染 )如transform
、opacity
、will-change
等,提高页面相应速度减少卡顿现象。 - 动画使用
CSS3
过渡,减少动画复杂度,还可以使用硬件加速。
减少回流与重绘
1. 回流与重绘的概念及触发条件
(1)回流
当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流。
下面这些操作会导致回流:
- 页面的首次渲染
- 浏览器的窗口大小发生变化
- 元素的内容发生变化
- 元素的尺寸或者位置发生变化
- 元素的字体大小发生变化
- 激活CSS伪类
- 查询某些属性或者调用某些方法
- 添加或者删除可见的DOM元素
在触发回流(重排)的时候,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:
- 全局范围:从根节点开始,对整个渲染树进行重新布局
- 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局
(2)重绘
当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘。
下面这些操作会导致回流:
- color、background 相关属性:background-color、background-image 等
- outline 相关属性:outline-color、outline-width 、text-decoration
- border-radius、visibility、box-shadow
注意: 当触发回流时,一定会触发重绘,但是重绘不一定会引发回流。
2. 如何避免回流与重绘?
减少回流与重绘的措施:
- 操作DOM时,尽量在低层级的DOM节点进行操作
- 不要使用
table
布局, 一个小的改动可能会使整个table
进行重新布局 - 使用CSS的表达式
- 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
- 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
- 避免频繁操作DOM,可以创建一个文档片段
documentFragment
,在它上面应用所有DOM操作,最后再把它添加到文档中 - 将元素先设置
display: none
,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。 - 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制。
浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列
浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。
上面,将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。
3. 如何优化动画?
对于如何优化动画,我们知道,一般情况下,动画需要频繁的操作DOM,就就会导致页面的性能问题,我们可以将动画的position
属性设置为absolute
或者fixed
,将动画脱离文档流,这样他的回流就不会影响到页面了。
4. documentFragment 是什么?用它跟直接操作 DOM 的区别是什么?
MDN中对documentFragment
的解释:
DocumentFragment,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的 Document使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。
当我们把一个 DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。在频繁的DOM操作时,我们就可以将DOM元素插入DocumentFragment,之后一次性的将所有的子孙节点插入文档中。和直接操作DOM相比,将DocumentFragment 节点插入DOM树时,不会触发页面的重绘,这样就大大提高了页面的性能。
JS
- 减少
DOM
操作数量 - 避免使用
with
语句、eval
函数,避免引擎难以优化。 - 尽量使用原生方法,执行效率高。
- 将
js
文件放到文件页面底部,避免阻塞页面渲染 - 使用事件委托,减少事件绑定次数。
- 合理使用缓存,避免重复请求数据。
js的6种加载方式
1)正常模式
xml
代码解读
复制代码
<script src="index.js"></script>
这种情况下 JS 会阻塞 dom 渲染,浏览器必须等待 index.js 加载和执行完成后才能去做其它事情
2)async 模式
xml
代码解读
复制代码
<script async src="index.js"></script>
async 模式下,它的加载是异步的,JS 不会阻塞 DOM 的渲染,async 加载是无顺序的,当它加载结束,JS 会立即执行
使用场景:若该 JS 资源与 DOM 元素没有依赖关系,也不会产生其他资源所需要的数据时,可以使用async 模式,比如埋点统计
3)defer 模式
xml
代码解读
复制代码
<script defer src="index.js"></script>
defer 模式下,JS 的加载也是异步的,defer 资源会在 DOMContentLoaded
执行之前,并且 defer 是有顺序的加载
如果有多个设置了 defer 的 script 标签存在,则会按照引入的前后顺序执行,即便是后面的 script 资源先返回
所以 defer 可以用来控制 JS 文件的执行顺序,比如 element-ui.js 和 vue.js,因为 element-ui.js 依赖于 vue,所以必须先引入 vue.js,再引入 element-ui.js
xml
代码解读
复制代码
<script defer src="vue.js"></script>
<script defer src="element-ui.js"></script>
defer 使用场景:一般情况下都可以使用 defer,特别是需要控制资源加载顺序时
4)module 模式
xml
代码解读
复制代码
<script type="module">import { a } from './a.js'</script>
在主流的现代浏览器中,script 标签的属性可以加上 type="module"
,浏览器会对其内部的 import 引用发起 HTTP 请求,获取模块内容。这时 script 的行为会像是 defer 一样,在后台下载,并且等待 DOM 解析
Vite 就是利用浏览器支持原生的 es module
模块,开发时跳过打包的过程,提升编译效率
5) preload
ini
代码解读
复制代码
<link rel="preload" as="script" href="index.js">
link 标签的 preload 属性:用于提前加载一些需要的依赖,这些资源会优先加载(如下图红框)
vue2 项目打包生成的 index.html 文件,会自动给首页所需要的资源,全部添加 preload,实现关键资源的提前加载
preload 特点:
1)preload 加载的资源是在浏览器渲染机制之前进行处理的,并且不会阻塞 onload 事件;
2)preload 加载的 JS 脚本其加载和执行的过程是分离的,即 preload 会预加载相应的脚本代码,待到需要时自行调用;
6)prefetch
ini
代码解读
复制代码
<link rel="prefetch" as="script" href="index.js">
prefetch 是利用浏览器的空闲时间,加载页面将来可能用到的资源的一种机制;通常可以用于加载其他页面(非首页)所需要的资源,以便加快后续页面的打开速度
prefetch 特点:
1)pretch 加载的资源可以获取非当前页面所需要的资源,并且将其放入缓存至少5分钟(无论资源是否可以缓存)
2)当页面跳转时,未完成的 prefetch 请求不会被中断
加载方式总结
async、defer 是 script 标签的专属属性,对于网页中的其他资源,可以通过 link 的 preload、prefetch 属性来预加载
如今现代框架已经将 preload、prefetch 添加到打包流程中了,通过灵活的配置,去使用这些预加载功能,同时我们也可以审时度势地向 script 标签添加 async、defer 属性去处理资源,这样可以显著提升性能
requestAnimationFrame 制作动画
requestAnimationFrame
是浏览器专门为动画提供的 API,它的刷新频率与显示器的频率保持一致,使用该 api 可以解决用 setTimeout/setInterval 制作动画卡顿的情况,核心目标是提升流畅度、降低性能消耗
使用:告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
平滑移动的方块案例:
// 动画状态管理
const state = {
box: document.getElementById('box'),
isAnimating: false,
lastTime: 0,
speed: 200, // 像素/秒
maxX: 350 // 最大移动距离
};
// 时间差计算函数(确保动画速度稳定)
function getDeltaTime(currentTime) {
const delta = currentTime - (state.lastTime || currentTime);
state.lastTime = currentTime;
return delta / 1000; // 转换为秒
}
// 动画主函数
function animate(timestamp) {
if (!state.isAnimating) return;
// 计算时间差(秒)
const delta = getDeltaTime(timestamp);
// 计算新位置(速度 * 时间差)
const currentX = parseFloat(state.box.style.left) || 0;
const newX = currentX + state.speed * delta;
// 边界检测
if (newX >= state.maxX) {
state.isAnimating = false;
state.box.style.left = `${state.maxX}px`;
return;
}
// 使用 transform 替代 left(触发GPU加速)
state.box.style.transform = `translateX(${newX}px)`;
// 递归调用下一帧
requestAnimationFrame(animate);
}
// 页面可见性监听(优化资源)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
state.isAnimating = false;
} else {
// 恢复时重置时间戳,避免跳跃
state.lastTime = 0;
state.isAnimating = true;
requestAnimationFrame(animate);
}
});
// 启动动画(点击容器触发)
document.querySelector('.container').addEventListener('click', () => {
if (!state.isAnimating) {
state.isAnimating = true;
state.lastTime = 0; // 重置时间戳
requestAnimationFrame(animate);
}
});
// 窗口关闭时取消动画(防内存泄漏)
window.addEventListener('beforeunload', () => {
state.isAnimating = false;
});
setTimeout/setInterval、requestAnimationFrame 三者的区别:
特性 | requestAnimationFrame | setTimeout/setInterval |
---|---|---|
引擎 | JS引擎 | GUI引擎 |
触发时机 | 与浏览器重绘周期同步(通常 60Hz,约 16.6ms / 帧) | 固定时间间隔(受 JS 任务队列阻塞影响) |
性能优化 | 页面不可见时自动暂停,节省资源 | 持续执行,可能导致电量浪费 |
帧率自适应 | 自动匹配屏幕刷新率(如 120Hz 屏提升至 120 帧 / 秒) | 固定时间间隔,可能丢帧或过度渲染 (是宏任务,根据事件轮询机制,其他任务会阻塞或延迟js任务的执行,会出现定时器不准的情况) |
浏览器优化 | 合并 DOM 操作,减少重排 / 重绘 | 需手动控制 DOM 操作频率 |
性能对比:在 Chrome DevTools 的Performance面板中:
-
使用
setInterval
时,帧率波动大(40-60 帧 / 秒),CPU 占用 15%-25%。 -
使用优化的
requestAnimationFrame
后,帧率稳定在 60 帧 / 秒,CPU 占用降至 5%-8%。
通过以上策略,可显著提升动画流畅度并降低性能消耗,适用于游戏、数据可视化、交互组件等场景。
扩展:requestIdleCallback
RequestIdleCallback 简单的说,判断一帧有空闲时间,则去执行某个任务。目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。故RequestIdleCallback 定位处理的是: 不重要且不紧急的任务。
缺点
这是一个实验中的功能 此功能某些浏览器尚在开发中,请参考浏览器兼容性表格以得到在不同浏览器中适合使用的前缀。由于该功能对应的标准文档可能被重新修订,所以在未来版本的浏览器中该功能的语法和行为可能随之改变。
实验过程实验结论: requestIdleCallback FPS只有20ms,正常情况下渲染一帧时长控制在16.67ms (1s / 60 = 16.67ms)。该时间是高于页面流畅的诉求。
有人认为 RequestIdleCallback 主要用来处理不重要且不紧急的任务,因为React渲染内容,并非是不重要且不紧急。不仅该api兼容一般,帧渲染能力一般,也不太符合渲染诉求,故React 团队自行实现
react-fiber blog.csdn.net/web20220509…
节流与防抖
1. 节流
节流是一种常用的性能优化技术,它可以限制函数的执行频率,避免过多的重复操作,提升页面的响应速度。
函数在 n 秒内只执行一次,如果多次触发,则忽略执行。
应用场景:
- 拖拽场景
- scroll场景
- 窗口大小调整
2. 防抖
防抖函数可以将多次高频率触发的函数执行合并成一次,并在指定的时间间隔后执行一次。通常在处理输入框、滚动等事件时使用,避免频繁触发事件导致页面卡顿等问题。还可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求
函数在 n 秒后再执行,如果 n 秒内被触发,重新计时,保证最后一次触发事件 n 秒后才执行。
应用场景:
- 输入框搜索
- 表单提交按钮:防⽌多次提交按钮,只执⾏最后提交的⼀次
- 文本器保存
- 服务端验证场景:表单验证需要服务端配合,只执⾏⼀段连续的输⼊事件的最后⼀次,还有搜索联想词功能类似⽣存环境请⽤lodash.debounce
3. 实现节流函数和防抖函数
函数节流的实现:
javascript
体验AI代码助手
代码解读
复制代码
// 时间戳版
function throttle(fn, delay) {
var preTime = Date.now();
return function() {
var context = this,
args = [...arguments],
nowTime = Date.now();
// 如果两次时间间隔超过了指定时间,则执行函数。
if (nowTime - preTime >= delay) {
preTime = Date.now();
return fn.apply(context, args);
}
};
}
// 定时器版
function throttle (fun, wait){
let timeout = null
return function(){
let context = this
let args = [...arguments]
if(!timeout){
timeout = setTimeout(() => {
fun.apply(context, args)
timeout = null
}, wait)
}
}
}
函数防抖的实现:
javascript
体验AI代码助手
代码解读
复制代码
function debounce(fn, wait) {
var timer = null;
return function() {
var context = this,
args = [...arguments];
// 如果此时存在定时器的话,则取消之前的定时器重新记时
if (timer) {
clearTimeout(timer);
timer = null;
}
// 设置定时器,使事件间隔指定事件后执行
timer = setTimeout(() => {
fn.apply(context, args);
}, wait);
};
}
Vue
- 合理使用
watch
和computed
,数据变化就会执行,避免使用太多,减少不必要的开销 - 合理使用组件,提高代码可维护性的同事也会降低代码组件的耦合性
- 使用路由懒加载,在需要的时候才会进行加载,避免一次性加载太多路由,导致页面阻塞
- 使用
Vuex
缓存数据 - 合理使用
mixins
,抽离公共代码封装成模块,避免重复代码。 - 合理使用
v-if
、v-show
v-for
不要和v-if
一起使用,v-for
的优先级会比v-if
高v-for
中不要用index
做key
,要保证key
的唯一性- 使用异步组件,避免一次性加载太多组件
- 避免使用
v-html
,存在安全问风险和性能问题,可以使用v-text
- 使用
keep-alive
缓存组件,避免组件重复加载
2.加载优化
图片的优化
1)不用图片
很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
平常大部分性能优化工作都集中在 JS 方面,但图片也是页面上非常重要的部分
特别是对于移动端来说,完全没有必要去加载原图,浪费带宽。如何去压缩图片,让图片更快的展示出来,有很多优化工作可以做
淘宝首页的图片资源都很小:
2)图片的动态裁剪
对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。
很多云服务,比如阿里云或七牛云,都提供了图片的动态裁剪功能,效果很棒,确实是钱没有白花
只需在图片的url地址上动态添加参数,就可以得到你所需要的尺寸大小,比如:http://7xkv1q.com1.z0.glb.clouddn.com/grape.jpg?imageView2/1/w/200/h/200
图片瘦身前后对比:
- 原图:
1.8M
- 裁剪后:
12.8KB
经过动态裁剪后的图片,加载速度会有非常明显的提升
3)图片的懒加载
图片懒加载也叫延迟加载,只加载当前屏幕的图片,可视区域外的图片不会进行加载,只有当屏幕滚动的时候才加载。
特点:
- 提高网页加载速度
- 减少后台服务器压力
- 提升用户体验
原理
- 将图片地址存储到
data-xxx
属性上 - 判断图片是否在可视区域
- 如果在,就设置图片
src
- 绑定
scroll
监听事件
对于一些图片量比较大的首页,用户打开页面后,只需要呈现出在屏幕可视区域内的图片,当用户滑动页面时,再去加载出现在屏幕内的图片,以优化图片的加载效果
这里以 vue-lazyload
插件为例
javascript
代码解读
复制代码
// 安装
npm install vue-lazyload
// main.js 注册
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload)
// 配置项
Vue.use(VueLazyload, {
preLoad: 1.3,
error: 'dist/error.png', // 图片加载失败时的占位图
loading: 'dist/loading.gif', // 图片加载中时的占位图
attempt: 1
})
// 通过 v-lazy 指令使用
<ul>
<li v-for="img in list">
<img v-lazy="img.src" :key="img.src" >
</li>
</ul>
4)使用字体图标
字体图标是页面使用小图标的不二选择,最常用的就是 iconfont
字体图标的优点:
1)轻量级:一个图标字体要比一系列的图像要小。一旦字体加载了,图标就会马上渲染出来,减少了 http 请求
2)灵活性:可以随意的改变颜色、产生阴影、透明效果、旋转等
3)兼容性:几乎支持所有的浏览器,请放心使用
5)小图使用 base64 格式(图片转 base64 格式)
将小图片转换为 base64 编码字符串,并写入 HTML 或者 CSS 中,减少 http 请求
转 base64 格式的优缺点:
1)它处理的往往是非常小的图片,因为 Base64 编码后,图片大小会膨胀为原文件的 4/3,如果对大图也使用 Base64 编码,后者的体积会明显增加,即便减少了 http 请求,也无法弥补这庞大的体积带来的性能开销,得不偿失
2)在传输非常小的图片的时候,Base64 带来的文件体积膨胀、以及浏览器解析 Base64 的时间开销,与它节省掉的 http 请求开销相比,可以忽略不计,这时候才能真正体现出它在性能方面的优势
项目可以使用 url-loader
将图片转 base64:
css
代码解读
复制代码
// 安装
npm install url-loader --save-dev
// 配置
module.exports = {
module: {
rules: [{
test: /.(png|jpg|gif)$/i,
use: [{
loader: 'url-loader',
options: {
// 小于 10kb 的图片转化为 base64
limit: 1024 * 10
}
}]
}]
}
};
6)雪碧图
将多个图标文件整合到一张图片中,减少请求
7)选择正确的图片格式
* 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
* 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
* 照片使用 JPEG
常见的图片格式及使用场景
(1)BMP,是无损的、既支持索引色也支持直接色的点阵图。这种图片格式几乎没有对数据进行压缩,所以BMP格式的图片通常是较大的文件。
(2)GIF是无损的、采用索引色的点阵图。采用LZW压缩算法进行编码。文件小,是GIF格式的优点,同时,GIF格式还具有支持动画以及透明的优点。但是GIF格式仅支持8bit的索引色,所以GIF格式适用于对色彩要求不高同时需要文件体积较小的场景。
(3)JPEG是有损的、采用直接色的点阵图。JPEG的图片的优点是采用了直接色,得益于更丰富的色彩,JPEG非常适合用来存储照片,与GIF相比,JPEG不适合用来存储企业Logo、线框类的图。因为有损压缩会导致图片模糊,而直接色的选用,又会导致图片文件较GIF更大。
(4)PNG-8是无损的、使用索引色的点阵图。PNG是一种比较新的图片格式,PNG-8是非常好的GIF格式替代者,在可能的情况下,应该尽可能的使用PNG-8而不是GIF,因为在相同的图片效果下,PNG-8具有更小的文件体积。除此之外,PNG-8还支持透明度的调节,而GIF并不支持。除非需要动画的支持,否则没有理由使用GIF而不是PNG-8。
(5)PNG-24是无损的、使用直接色的点阵图。PNG-24的优点在于它压缩了图片的数据,使得同样效果的图片,PNG-24格式的文件大小要比BMP小得多。当然,PNG24的图片还是要比JPEG、GIF、PNG-8大得多。
(6)SVG是无损的矢量图。SVG是矢量图意味着SVG图片由直线和曲线以及绘制它们的方法组成。当放大SVG图片时,看到的还是线和曲线,而不会出现像素点。这意味着SVG图片在放大时,不会失真,所以它非常适合用来绘制Logo、Icon等。
(7)WebP是谷歌开发的一种新图片格式,WebP是同时支持有损和无损压缩的、使用直接色的点阵图。从名字就可以看出来它是为Web而生的,什么叫为Web而生呢?就是说相同质量的图片,WebP具有更小的文件体积。现在网站上充满了大量的图片,如果能够降低每一个图片的文件大小,那么将大大减少浏览器和服务器之间的数据传输量,进而降低访问延迟,提升访问体验。目前只有Chrome浏览器和Opera浏览器支持WebP格式,兼容性不太好。
-
在无损压缩的情况下,相同质量的WebP图片,文件大小要比PNG小26%;
-
在有损压缩的情况下,具有相同图片精度的WebP图片,文件大小要比JPEG小25%~34%;
-
WebP图片格式支持图片透明度,一个无损压缩的WebP图片,如果要支持透明度只需要22%的格外文件大小。
<img src="loading.gif" data-src="pic.png"> <img src="loading.gif" data-src="pic.png">
懒加载
1. 懒加载的概念
懒加载也叫做延迟加载、按需加载,指的是在长网页中延迟加载图片数据,是一种较好的网页性能优化的方式。在比较长的网页或应用中,如果图片很多,所有的图片都被加载出来,而用户只能看到可视窗口的那一部分图片数据,这样就浪费了性能。
如果使用图片的懒加载就可以解决以上问题。在滚动屏幕之前,可视化区域之外的图片不会进行加载,在滚动屏幕时才加载。这样使得网页的加载速度更快,减少了服务器的负载。懒加载适用于图片较多,页面列表较长(长列表)的场景中。
2. 懒加载的特点
- 减少无用资源的加载:使用懒加载明显减少了服务器的压力和流量,同时也减小了浏览器的负担。
- 提升用户体验: 如果同时加载较多图片,可能需要等待的时间较长,这样影响了用户体验,而使用懒加载就能大大的提高用户体验。
- 防止加载过多图片而影响其他资源文件的加载 :会影响网站应用的正常使用。
3. 懒加载的实现原理
图片的加载是由src
引起的,当对src
赋值时,浏览器就会请求图片资源。根据这个原理,我们使用HTML5 的data-xxx
属性来储存图片的路径,在需要加载图片的时候,将data-xxx
中图片的路径赋值给src
,这样就实现了图片的按需加载,即懒加载。
注意:data-xxx
中的xxx
可以自定义,这里我们使用data-src
来定义。
懒加载的实现重点在于确定用户需要加载哪张图片,在浏览器中,可视区域内的资源就是用户需要的资源。所以当图片出现在可视区域时,获取图片的真实地址并赋值给图片即可。
使用原生JavaScript实现懒加载:
知识点:
(1)window.innerHeight
是浏览器可视区的高度
(2)document.body.scrollTop || document.documentElement.scrollTop
是浏览器滚动的过的距离
(3)imgs.offsetTop
是元素顶部距离文档顶部的高度(包括滚动条的距离)
(4)图片加载条件:img.offsetTop < window.innerHeight + document.body.scrollTop;
图示: 代码实现:
javascript
体验AI代码助手
代码解读
复制代码
<div class="container">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
问题一:如何判断图片是否已经进入可视区域范围内?
方案一:获取元素的相对浏览器可是区域的位置:getBoundingClientRect()
这个方案首先需要获取两个高度:浏览器窗口高度(可视区域高度)和元素距离浏览器窗口顶部的高度
(1)获取浏览器窗口高度(可视区域高度)
浏览器窗口高度通过 document.documentElement.clientHeight
这个 API
来获取,如下图所示浏览器常用高度的示意图,从图中,很清晰的可以看到clientHeight
的高度
(2)获取元素距离浏览器窗口顶部的高度
获取元素距离可视区域顶部的高度需要通过getBoundingClientRect()
API 来实现,getBoundingClientRect()
获取的是 DOM 元素相对于窗口的坐标集合,集合中有多个属性,其中的 top
属性就是当前元素元素距离窗口可视区域顶部的距离,如下图所示
通过这两个高度判断的方法,实现方案也就有了,通过监听并计算 当前可视区域的高度 - 元素距离可视区域顶部的高度 ,当这个高度差大于 0 时说明图片已经进入可视区域,此时可以开始加载图片。
js
体验AI代码助手
代码解读
复制代码
// 获取所有图片标签
const imgs = document.getElementsByTagName("img");
// 获取可视区域的高度
const viewHight = document.documentElement.clientHeight;
// 统计当前加载到了哪张照片,避免每一次都从第一张照片开始检查
let num = 0;
function lazyload() {
for (let i = num; i < imgs.length; i++) {
const item = imgs[i]
// 可视区域高度减去元素顶部距离可视区域顶部的高度,如果差值大于 0 说明元素展示
let distance = viewHight - item.getBoundingClientRect().top;
if (distance >= 0) {
// 展示真实图片
item.src = item.getAttribute("data-src");
num = i + 1;
}
}
}
// 监听 scroll 事件,实际项目中需要进行**节流优化**
window.addEventListener("scroll", lazyload, false);
lazyload();
注意: 以上代码只为说明图片懒加载的实现思路,应该明白这样会存在较大的性能问题,因为 scroll
事件会在很短的时间内触发很多次,会严重影响页面性能,为了提高网页性能,因此需要一个节流函数来控制函数的多次触发,在一段时间内只执行一次回调
方案二:异步观察目标元素:Intersection Observer
Intersection Observer
(交叉观察器)是一个现代的 JavaScript API,用于监测页面上元素与视口(可见区域)之间的交叉状态。它可以轻松地实现一些与元素可见性相关的功能,如图片懒加载、无限滚动、响应式布局等。
Intersection Observer
API 的核心概念是观察器(Observer
)和目标元素(Target
)。观察器用于监听目标元素与视口之间的交叉信息,并在交叉状态发生变化时执行相应的回调函数。
使用 Intersection Observer
API 的步骤:
- 创建观察器实例: 使用
new IntersectionObserver(callback, options)
创建一个观察器实例。callback
是一个回调函数,用于处理交叉状态的变化;options
是观察器的配置参数,可以设置用于判断交叉状态的阈值、根节点等。 - 指定目标元素: 使用观察器实例的
observe(target)
方法,将要观察的目标元素添加进观察器。目标元素可以是单个元素,也可以是一个节点列表。 - 处理交叉状态变化: 当被观察的目标元素与视口发生交叉状态变化时,观察器会执行指定的回调函数。回调函数会接收一个参数,即包含交叉信息的观察器实例数组(一般只有一个实例)。通过这些交叉信息,可以获取目标元素与视口之间的交叉比例、交叉区域的位置等。
- 解除观察: 使用观察器实例的
unobserve(target)
方法,可以取消对特定目标元素的观察。当不再需要观察某个元素或者页面销毁时,应及时解除观察,以避免资源的浪费。
Intersection Observer
API 的优势在于它可以提供更好的性能和效率,减少了手动监听滚动事件并计算元素位置的复杂性。它使用浏览器原生的算法来判断交叉状态,能够在性能友好的情况下,准确、高效地处理元素的可见性变化。
总而言之,Intersection Observe
r API 可以简化元素可见性的监测工作,让开发者可以更方便地实现一些与元素可见性相关的功能,提升用户体验。
js
体验AI代码助手
代码解读
复制代码
const io = new IntersectionObserver((entries) => {
entries.forEach(item => {
// 当前元素可见时
if(item.isIntersecting) {
item.target.src = item.target.dataset.src // 替换 src
io.unobserve(item.target) // 停止观察当前元素,避免不可见时再次调用 callback 函数
}
})
})
const imgs = document.querySelectorAll('[data-src]')
// 监听所有图片元素
imgs.forEach(item => {
io.observe(item)
})
问题二:图片进入可视区域后触发加载图片
对于这个问题,实现思路也很简单,需要用到 DOM
元素的 dataset
属性,所有以 data-
开头的属性都可以用做自定义属性,所以我们可以定义一个 data-src
属性存放需要加载的图片链接,src
属性使用 loading
占位图片,当需要加载图片的时候,把 src
的链接更换为 data-src
的链接即可
js
体验AI代码助手
代码解读
复制代码
<img data-src="需要加载的图片链接" src="图片占位loading图片地址">
4. 懒加载与预加载的区别
这两种方式都是提高网页性能的方式,两者主要区别是一个是提前加载,一个是迟缓甚至不加载。懒加载对服务器前端有一定的缓解压力作用,预加载则会增加服务器前端压力。
- 懒加载也叫延迟加载,指的是在长网页中延迟加载图片的时机,当用户需要访问时,再去加载,这样可以提高网站的首屏加载速度,提升用户的体验,并且可以减少服务器的压力。它适用于图片很多,页面很长的电商网站的场景。懒加载的实现原理是,将页面上的图片的 src 属性设置为空字符串,将图片的真实路径保存在一个自定义属性中,当页面滚动的时候,进行判断,如果图片进入页面可视区域内,则从自定义属性中取出真实路径赋值给图片的 src 属性,以此来实现图片的延迟加载。
- 预加载指的是将所需的资源提前请求加载到本地,这样后面在需要用到时就直接从缓存取资源。 通过预加载能够减少用户的等待时间,提高用户的体验。预加载常用于网页中的重要组件、CSS样式文件、JavaScript脚本等。我了解的预加载的最常用的方式是使用 js 中的 image 对象,通过为 image 对象来设置 scr 属性,来实现图片的预加载。
懒加载和预加载都是通过对资源加载时机进行调整,以提高页面性能和用户体验。懒加载将资源的加载延迟到需要的时候,避免了一次性加载大量资源造成的性能问题;而预加载则是提前加载资源,确保用户在需要时能够快速获取到所需的内容。两者可以结合使用,根据具体场景和需求来选择合适的优化策略。
路由懒加载
SPA 项目,一个路由对应一个页面,如果不做处理,项目打包后,会把所有页面打包成一个文件,当用户打开首页时,会一次性加载所有的资源,造成首页加载很慢,降低用户体验
列一个实际项目的打包详情:
-
app.js 初始体积:
1175 KB
-
app.css 初始体积:
274 KB
将路由全部改成懒加载
js
代码解读
复制代码
// 通过webpackChunkName设置分割后代码块的名字
const Home = () => import(/* webpackChunkName: "home" */ "@/views/home/index.vue");
const MetricGroup = () => import(/* webpackChunkName: "metricGroup" */ "@/views/metricGroup/index.vue");
…………
const routes = [
{
path: "/",
name: "home",
component: Home
},
{
path: "/metricGroup",
name: "metricGroup",
component: MetricGroup
},
…………
]
重新打包后,首页资源拆分为 app.js 和 home.js,以及对应的 css 文件
-
app.js:
244 KB
、 home.js:35KB
-
app.css:
67 KB
、home.css:15KB
通过路由懒加载,该项目的首页资源压缩约 52%
路由懒加载的原理
懒加载前提的实现:ES6的动态地加载模块——import()
调用 import() 之处,被作为分离的模块起点,意思是,被请求的模块和它引用的所有子模块,会分离到一个单独的 chunk 中
——摘自《webpack——模块方法》的import()小节
要实现懒加载,就得先将进行懒加载的子模块分离出来,打包成一个单独的文件
webpackChunkName 作用是 webpack 在打包的时候,对异步引入的库代码(lodash)进行代码分割时,设置代码块的名字。webpack 会将任何一个异步模块与相同的块名称组合到相同的异步块中
组件懒加载
除了路由的懒加载外,组件的懒加载在很多场景下也有重要的作用
举个🌰:
home 页面 和 about 页面,都引入了 dialogInfo 弹框组件,该弹框不是一进入页面就加载,而是需要用户手动触发后才展示出来
home 页面示例:
js
代码解读
复制代码
<template>
<div class="homeView">
<p>home 页面</p>
<el-button @click="dialogVisible = !dialogVisible">打开弹框</el-button>
<dialogInfo v-if="dialogVisible" />
</div>
</template>
<script>
import dialogInfo from '@/components/dialogInfo';
export default {
name: 'homeView',
components: {
dialogInfo
}
}
</script>
项目打包后,发现 home.js 和 about.js 均包括了该弹框组件的代码(在 dist 文件中搜索dialogInfo弹框组件)
当用户打开 home 页时,会一次性加载该页面所有的资源,我们期望的是用户触发按钮后,再加载该弹框组件的资源
这种场景下,就很适合用懒加载的方式引入
弹框组件懒加载:
xml
代码解读
复制代码
<script>
const dialogInfo = () => import(/* webpackChunkName: "dialogInfo" */ '@/components/dialogInfo');
export default {
name: 'homeView',
components: {
dialogInfo
}
}
</script>
重新打包后,home.js 和 about.js 中没有了弹框组件的代码,该组件被独立打包成 dialogInfo.js,当用户点击按钮时,才会去加载 dialogInfo.js 和 dialogInfo.css
最终,使用组件路由懒后,该项目的首页资源进一步减少约 11%
组件懒加载的使用场景
有时资源拆分的过细也不好,可能会造成浏览器 http 请求的增多
总结出三种适合组件懒加载的场景:
1)该页面的 JS 文件体积大,导致页面打开慢,可以通过组件懒加载进行资源拆分,利用浏览器并行下载资源,提升下载速度(比如首页)
2)该组件不是一进入页面就展示,需要一定条件下才触发(比如弹框组件)
3)该组件复用性高,很多页面都有引入,利用组件懒加载抽离出该组件,一方面可以很好利用缓存,同时也可以减少页面的 JS 文件大小(比如表格组件、图形组件等)
骨架屏优化白屏时长
使用骨架屏,可以缩短白屏时间,提升用户体验。国内大多数的主流网站都使用了骨架屏,特别是手机端的项目
SPA 单页应用,无论 vue 还是 react,最初的 html 都是空白的,需要通过加载 JS 将内容挂载到根节点上,这套机制的副作用:会造成长时间的白屏
常见的骨架屏插件就是基于这种原理,在项目打包时将骨架屏的内容直接放到 html 文件的根节点中
使用骨架屏插件,打包后的 html 文件(根节点内部为骨架屏):
同一项目,对比使用骨架屏前后的 FP 白屏时间:
- 无骨架屏:白屏时间
1063ms
- 有骨架屏:白屏时间
144ms
骨架屏确实是优化白屏的不二选择,白屏时间缩短了 86%
骨架屏插件
这里以 vue-skeleton-webpack-plugin
插件为例,该插件的亮点是可以给不同的页面设置不同的骨架屏,这点确实很酷
1)安装
css
代码解读
复制代码
npm i vue-skeleton-webpack-plugin
2)vue.config.js 配置
js
代码解读
复制代码
// 骨架屏
const SkeletonWebpackPlugin = require("vue-skeleton-webpack-plugin");
module.exports = {
configureWebpack: {
plugins: [
new SkeletonWebpackPlugin({
// 实例化插件对象
webpackConfig: {
entry: {
app: path.join(__dirname, './src/skeleton.js') // 引入骨架屏入口文件
}
},
minimize: true, // SPA 下是否需要压缩注入 HTML 的 JS 代码
quiet: true, // 在服务端渲染时是否需要输出信息到控制台
router: {
mode: 'hash', // 路由模式
routes: [
// 不同页面可以配置不同骨架屏
// 对应路径所需要的骨架屏组件id,id的定义在入口文件内
{ path: /^/home(?:/)?/i, skeletonId: 'homeSkeleton' },
{ path: /^/detail(?:/)?/i, skeletonId: 'detailSkeleton' }
]
}
})
]
}
}
3)新建 skeleton.js 入口文件
arduino
代码解读
复制代码
// skeleton.js
import Vue from "vue";
// 引入对应的骨架屏页面
import homeSkeleton from "./views/homeSkeleton";
import detailSkeleton from "./views/detailSkeleton";
export default new Vue({
components: {
homeSkeleton,
detailSkeleton,
},
template: `
<div>
<homeSkeleton id="homeSkeleton" style="display:none;" />
<detailSkeleton id="detailSkeleton" style="display:none;" />
</div>
`,
});
长列表虚拟滚动
首页中不乏有需要渲染长列表的场景,当渲染条数过多时,所需要的渲染时间会很长,滚动时还会造成页面卡顿,整体体验非常不好
虚拟滚动——指的是只渲染可视区域的列表项,非可见区域的不渲染,在滚动时动态更新可视区域,该方案在优化大量数据渲染时效果是很明显的
虚拟滚动图例:
虚拟滚动基本原理:
计算出 totalHeight 列表总高度,并在触发时滚动事件时根据 scrollTop 值不断更新 startIndex 以及 endIndex ,以此从列表数据 listData 中截取对应元素
虚拟滚动性能对比:
-
在不使用虚拟滚动的情况下,渲染10万个文本节点:
-
使用虚拟滚动的情况后:
使用虚拟滚动使性能提升了 78%
一、方案核心原理
虚拟列表的核心是 “可视区域渲染”,通过以下 3 层逻辑实现性能优化:
- 空间换时间:仅渲染视口内的少量 DOM 节点(约 10-20 个),而非全部数据(如 10000 条)。
- 动态占位:通过计算总高度模拟完整列表的滚动条,用户滚动时仅更新可视区域内容。
- 节点复用:维护固定数量的 DOM 节点池,避免频繁创建 / 销毁节点带来的性能损耗。
二、完整实现步骤(附代码)
以下为支持动态高度的虚拟列表实现,包含 HTML 结构、CSS 样式、JavaScript 逻辑及关键注释:
1. HTML 结构
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高性能虚拟列表</title>
<style>
/* 容器样式 */
.virtual-list-container {
width: 100%;
height: 600px; /* 视口高度 */
overflow-y: auto;
border: 1px solid #e5e7eb;
position: relative; /* 用于绝对定位子元素 */
}
/* 占位元素(模拟总高度) */
.virtual-list-placeholder {
width: 100%;
position: absolute;
top: 0;
left: 0;
}
/* 列表项样式 */
.list-item {
width: 100%;
padding: 16px;
border-bottom: 1px solid #f3f4f6;
box-sizing: border-box;
/* GPU加速 */
will-change: transform;
transform: translateZ(0);
/* 动态高度时内容自然撑开 */
min-height: 80px;
}
</style>
</head>
<body>
<div class="virtual-list-container" id="container"></div>
<script src="virtual-list.js"></script>
</body>
</html>
2. JavaScript 核心逻辑(virtual-list.js)
javascript
class VirtualList {
/**
* 初始化虚拟列表
* @param {HTMLElement} container 列表容器
* @param {Object} options 配置项
* @param {Array} options.data 列表数据
* @param {Number} options.avgHeight 初始平均高度(用于动态高度预占位)
*/
constructor(container, options) {
this.container = container;
this.data = options.data || [];
this.avgHeight = options.avgHeight || 100; // 初始平均高度(可根据业务调整)
this.heightCache = new Map(); // 缓存 { index: height },记录每个项的实际高度
this.nodesPool = []; // 节点池(复用的DOM节点)
this.visibleCount = 0; // 视口内可见项数量(动态计算)
this.startIdx = 0; // 当前可见起始索引
this.endIdx = 0; // 当前可见结束索引
this.scrollTop = 0; // 容器滚动偏移量
this.isRendering = false; // 渲染锁(防重复渲染)
// 初始化DOM结构
this.placeholder = document.createElement('div');
this.placeholder.className = 'virtual-list-placeholder';
this.container.appendChild(this.placeholder);
// 绑定滚动事件(带节流优化)
this.container.addEventListener('scroll', this.throttle(this.handleScroll, 16));
// 首次渲染
this.calculateVisibleRange();
this.render();
}
/**
* 计算可见区间(起始/结束索引)
*/
calculateVisibleRange() {
// 获取容器视口高度和滚动偏移
const viewportHeight = this.container.clientHeight;
this.scrollTop = this.container.scrollTop;
// 动态计算可见项数量(向上取整+2个缓冲项)
this.visibleCount = Math.ceil(viewportHeight / this.avgHeight) + 2;
// 计算起始索引(找到第一个在视口内的项)
let currentTop = 0;
this.startIdx = 0;
for (let i = 0; i < this.data.length; i++) {
const height = this.heightCache.get(i) || this.avgHeight;
if (currentTop + height > this.scrollTop) {
this.startIdx = i;
break;
}
currentTop += height;
}
// 计算结束索引(起始+可见项数量,不超过数据长度)
this.endIdx = Math.min(this.startIdx + this.visibleCount, this.data.length);
}
/**
* 渲染可见项(复用节点池)
*/
render() {
if (this.isRendering) return; // 防止重复渲染
this.isRendering = true;
// 更新占位元素高度(总高度)
const totalHeight = this.data.reduce((sum, _, index) => {
return sum + (this.heightCache.get(index) || this.avgHeight);
}, 0);
this.placeholder.style.height = `${totalHeight}px`;
// 计算当前可见项的起始偏移量(用于定位)
const startOffset = this.data.slice(0, this.startIdx).reduce((sum, _, index) => {
return sum + (this.heightCache.get(index) || this.avgHeight);
}, 0);
// 复用节点池或创建新节点
for (let i = 0; i < this.visibleCount; i++) {
const dataIdx = this.startIdx + i;
let node = this.nodesPool[i];
// 节点不存在则创建(仅首次)
if (!node) {
node = document.createElement('div');
node.className = 'list-item';
this.container.appendChild(node);
this.nodesPool.push(node);
}
// 超出数据范围时隐藏节点
if (dataIdx >= this.data.length) {
node.style.visibility = 'hidden';
node.style.transform = 'translateY(0)';
continue;
}
// 显示节点并更新内容
node.style.visibility = 'visible';
node.textContent = `Item ${dataIdx + 1}: ${this.data[dataIdx]}`;
// 动态计算节点实际高度并缓存(渲染后执行)
requestAnimationFrame(() => {
const height = node.offsetHeight;
if (!this.heightCache.has(dataIdx)) {
this.heightCache.set(dataIdx, height);
// 高度变化后重新计算总高度(可能影响滚动位置)
this.placeholder.style.height = `${this.data.reduce((sum, _, index) => {
return sum + (this.heightCache.get(index) || this.avgHeight);
}, 0)}px`;
}
});
// 定位节点到正确位置(基于起始偏移量)
const nodeTop = startOffset + (i === 0 ? 0 : (this.heightCache.get(this.startIdx + i - 1) || this.avgHeight));
node.style.transform = `translateY(${nodeTop}px)`;
}
this.isRendering = false;
}
/**
* 处理滚动事件(带节流优化)
*/
handleScroll = () => {
this.calculateVisibleRange();
this.render();
}
/**
* 节流函数(限制滚动处理频率)
* @param {Function} fn 要节流的函数
* @param {Number} delay 延迟时间(ms)
*/
throttle(fn, delay) {
let lastTime = 0;
return (...args) => {
const now = Date.now();
if (now - lastTime > delay) {
fn.apply(this, args);
lastTime = now;
}
};
}
/**
* 更新数据(外部调用)
* @param {Array} newData 新数据
*/
updateData(newData) {
this.data = newData;
this.heightCache.clear(); // 清空高度缓存(数据变化后需重新计算)
this.calculateVisibleRange();
this.render();
}
}
// 使用示例
const data = Array.from({ length: 10000 }, (_, index) => `内容${index + 1}(动态高度示例:随机高度${Math.floor(80 + Math.random() * 120)}px)`);
const container = document.getElementById('container');
const virtualList = new VirtualList(container, {
data: data,
avgHeight: 100 // 初始平均高度(根据业务调整)
});
三、关键步骤详细解释
1. 容器与占位元素
- 容器:设置固定高度(如 600px)并开启滚动(
overflow-y: auto
),作为视口限制可见区域。 - 占位元素:绝对定位在容器内,高度等于所有列表项的总高度(通过
heightCache
计算)。作用是模拟完整列表的滚动条,使用户能正常滚动。
2. 可见区间计算
- 滚动偏移量:通过
container.scrollTop
获取用户当前滚动的垂直距离。 - 起始索引(startIdx) :遍历
heightCache
,找到第一个顶部位置超过滚动偏移量的列表项索引。 - 结束索引(endIdx) :起始索引 + 可见项数量(视口高度 / 平均高度 + 2 个缓冲项),确保滚动时内容衔接流畅。
3. 节点复用与动态高度处理
- 节点池(nodesPool) :维护固定数量的 DOM 节点(约 10-20 个),滚动时仅更新节点内容和位置,而非创建新节点。
- 高度缓存(heightCache) :记录每个列表项的实际高度(通过
offsetHeight
获取),避免重复计算。未渲染项使用平均高度占位,渲染后更新缓存。
4. 滚动优化
- 节流(throttle) :限制滚动事件处理频率(每 16ms 一次,匹配 60Hz 屏幕),避免主线程阻塞。
- requestAnimationFrame:在浏览器重绘前更新 DOM,确保渲染与屏幕刷新率同步,避免丢帧。
5. 动态数据更新
- updateData 方法:外部调用时清空高度缓存(数据变化后高度可能改变),重新计算可见区间并渲染。
四、性能验证与测试
在 Chrome DevTools 中验证:
- Elements 面板:检查 DOM 节点数量(始终为
visibleCount
,约 10-20 个)。 - Performance 面板:录制滚动操作,观察帧率(稳定在 55-60fps)和 CPU 占用(主线程占用 < 10%)。
- Memory 面板:确认无内存泄漏(节点池大小固定,无大量未释放的 DOM 节点)。
五、适用场景与扩展建议
适用场景
- 社交平台动态列表(如微博、朋友圈)。
- 电商商品长列表(如淘宝商品详情页)。
- 数据表格(如 Excel 式大表格)。
扩展建议
-
固定高度优化:若列表项高度固定,可跳过
heightCache
计算,直接通过startIdx * itemHeight
定位,性能更优。 -
平滑滚动:添加滚动动画(如
scroll-behavior: smooth
),提升用户体验。 -
虚拟列表 + 懒加载:结合图片懒加载(
IntersectionObserver
),进一步降低初始加载负担。
通过此方案,可在 10000 条数据下实现60fps 流畅滚动,DOM 节点数仅 10-20 个,CPU 占用 < 10%,显著提升长列表性能。
虚拟滚动插件
虚拟滚动的插件有很多,比如 vue-virtual-scroller、vue-virtual-scroll-list、react-tiny-virtual-list、react-virtualized 等
这里简单介绍 vue-virtual-scroller 的使用
js
代码解读
复制代码
// 安装插件
npm install vue-virtual-scroller
// main.js
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
Vue.use(VueVirtualScroller)
// 使用
<template>
<RecycleScroller
class="scroller"
:items="list"
:item-size="32"
key-field="id"
v-slot="{ item }">
<div class="user"> {{ item.name }} </div>
</RecycleScroller>
</template>
该插件主要有 RecycleScroller.vue、DynamicScroller.vue 这两个组件,其中 RecycleScroller 需要 item 的高度为静态的,也就是列表每个 item 的高度都是一致的,而 DynamicScroller 可以兼容 item 的高度为动态的情况
3.传输优化
-
使用
HTTP/2
-
减少、合并
HTTP
请求,通过合并CSS、JS
文件、精灵图等方式减少请求数量。 -
压缩文件, 开启
nginx
,Gzip
对静态资源压缩 -
使用
HTTP
缓存,如强缓存、协商缓存 -
使用
CDN
,将网站资源分布到各地服务器上,减少访问延迟
CDN
1. CDN的概念
CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性及低成本的网络内容传递给用户。
典型的CDN系统由下面三个部分组成:
- 分发服务系统: 最基本的工作单元就是Cache设备,cache(边缘cache)负责直接响应最终用户的访问请求,把缓存在本地的内容快速地提供给用户。同时cache还负责与源站点进行内容同步,把更新的内容以及本地没有的内容从源站点获取并保存在本地。Cache设备的数量、规模、总服务能力是衡量一个CDN系统服务能力的最基本的指标。
- 负载均衡系统: 主要功能是负责对所有发起服务请求的用户进行访问调度,确定提供给用户的最终实际访问地址。两级调度体系分为全局负载均衡(GSLB)和本地负载均衡(SLB)。全局负载均衡主要根据用户就近性原则,通过对每个服务节点进行“最优”判断,确定向用户提供服务的cache的物理位置。本地负载均衡主要负责节点内部的设备负载均衡
- 运营管理系统: 运营管理系统分为运营管理和网络管理子系统,负责处理业务层面的与外界系统交互所必须的收集、整理、交付工作,包含客户管理、产品管理、计费管理、统计分析等功能。
2. CDN的作用
1. 加速内容加载
-
原理:将内容缓存到离用户最近的边缘服务器,减少数据传输距离和延迟。
- 静态资源加速:图片、CSS、JavaScript、HTML 页面等静态内容直接由边缘节点提供,无需回源服务器。
- 动态内容加速:通过缓存动态生成的内容(如 API 响应、用户个性化数据)或实时优化传输路径,减少延迟。
-
优势:
- 用户访问速度显著提升(尤其对跨地域用户)。
- 减少源站服务器的响应压力。
2. 提升网站稳定性
- 负载均衡:通过分布式节点分散用户请求,避免单点过载。
- 冗余备份:多个边缘节点提供内容,即使部分节点故障,仍可通过其他节点响应请求。
- 高并发支持:应对突发流量(如电商大促、新闻热点),保障服务可用性。
3. 安全防护
- DDoS 攻击防御:通过分布式节点吸收恶意流量,过滤异常请求。
- CC 攻击防护:限制单位时间内的请求频率,防止资源耗尽。
- 数据加密:支持 HTTPS 加密传输,保护内容安全。
- 隐藏源站 IP:用户无法直接访问源服务器,降低被攻击风险。
4. 全球覆盖与多运营商支持
- 节点分布:CDN 在全球部署大量边缘节点(如 Azure CDN、腾讯云 CDN),覆盖不同国家/地区和运营商网络。
- 智能路由:通过 DNS 重定向或 Anycast 技术,将用户请求导向最优节点(如地理位置最近、网络状况最佳的节点)。
- 解决南北互通问题:国内用户跨运营商访问(如电信用户访问联通服务器)时,CDN 可消除网络瓶颈。
5. 成本优化
- 降低带宽成本:缓存命中率高的内容无需回源服务器,减少源站带宽消耗。
- 减少服务器资源投入:通过分担流量压力,企业可减少自建服务器和数据中心的投入。
- 按需扩展:CDN 服务按实际使用量计费,灵活应对业务波动。
6. 支持多种内容类型
- 静态内容:图片、文档、脚本等(如网页资源)。
- 动态内容:实时生成的页面、API 数据(如电商商品详情页)。
- 视频流媒体:直播、点播视频(如 Netflix、抖音)。
- 大文件下载:软件安装包、游戏更新包(如 Steam 下载)。
典型应用场景
- 电商网站:加速商品页面加载,提升用户转化率。
- 新闻平台:快速分发热点新闻,应对高并发访问。
- 视频平台:优化视频播放流畅度,减少卡顿。
- 在线游戏:降低延迟,提升玩家体验。
- 跨国企业:为全球用户提供一致的访问速度和服务质量。
对 SEO 的影响
- 正面:CDN 提升页面加载速度,间接优化搜索引擎排名(如 Google 将加载速度作为排名因素之一)。
- 潜在问题:需确保 CDN 配置正确(如回源功能正常),避免因缓存未更新导致搜索引擎抓取旧内容。
总结
CDN 是现代互联网基础设施的核心组件,通过分布式缓存、智能路由和负载均衡,解决了传统网络中的延迟高、带宽瓶颈和安全威胁等问题,广泛应用于各类需要高效内容分发的场景。
3. CDN的原理
CDN和DNS有着密不可分的联系,先来看一下DNS的解析域名过程,在浏览器输入 www.test.com 的解析过程如下: (1) 检查浏览器缓存 (2)检查操作系统缓存,常见的如hosts文件 (3)检查路由器缓存 (4)如果前几步都没没找到,会向ISP(网络服务提供商)的LDNS服务器查询 (5)如果LDNS服务器没找到,会向根域名服务器(Root Server)请求解析,分为以下几步:
- 根服务器返回顶级域名(TLD)服务器如
.com
,.cn
,.org
等的地址,该例子中会返回.com
的地址 - 接着向顶级域名服务器发送请求,然后会返回次级域名(SLD)服务器的地址,本例子会返回
.test
的地址 - 接着向次级域名服务器发送请求,然后会返回通过域名查询到的目标IP,本例子会返回
www.test.com
的地址 - Local DNS Server会缓存结果,并返回给用户,缓存在系统中
CDN的工作原理:
(1)用户未使用CDN缓存资源的过程:
- 浏览器通过DNS对域名进行解析(就是上面的DNS解析过程),依次得到此域名对应的IP地址
- 浏览器根据得到的IP地址,向域名的服务主机发送数据请求
- 服务器向浏览器返回响应数据
(2)用户使用CDN缓存资源的过程:
- 对于点击的数据的URL,经过本地DNS系统的解析,发现该URL对应的是一个CDN专用的DNS服务器,DNS系统就会将域名解析权交给CNAME指向的CDN专用的DNS服务器。
- CND专用DNS服务器将CND的全局负载均衡设备IP地址返回给用户
- 用户向CDN的全局负载均衡设备发起数据请求
- CDN的全局负载均衡设备根据用户的IP地址,以及用户请求的内容URL,选择一台用户所属区域的区域负载均衡设备,告诉用户向这台设备发起请求
- 区域负载均衡设备选择一台合适的缓存服务器来提供服务,将该缓存服务器的IP地址返回给全局负载均衡设备
- 全局负载均衡设备把服务器的IP地址返回给用户
- 用户向该缓存服务器发起请求,缓存服务器响应用户的请求,将用户所需内容发送至用户终端。
如果缓存服务器没有用户想要的内容,那么缓存服务器就会向它的上一级缓存服务器请求内容,以此类推,直到获取到需要的资源。最后如果还是没有,就会回到自己的服务器去获取资源。
CNAME(意为:别名):在域名解析中,实际上解析出来的指定域名对应的IP地址,或者该域名的一个CNAME,然后再根据这个CNAME来查找对应的IP地址。
4. CDN的使用场景
- **使用第三方的CDN服务:**如果想要开源一些项目,可以使用第三方的CDN服务
- **使用CDN进行静态资源的缓存:**将自己网站的静态资源放在CDN上,比如js、css、图片等。可以将整个项目放在CDN上,完成一键部署。
- **直播传送:**直播本质上是使用流媒体进行传送,CDN也是支持流媒体传送的,所以直播完全可以使用CDN来提高访问速度。CDN在处理流媒体的时候与处理普通静态文件有所不同,普通文件如果在边缘节点没有找到的话,就会去上一层接着寻找,但是流媒体本身数据量就非常大,如果使用回源的方式,必然会带来性能问题,所以流媒体一般采用的都是主动推送的方式来进行。
托管至OSS、COS
OSS,对象存储
海量,安全,低成本,高可靠的云存储服务。可以通过简单的REST接口,在任何时间、任何地点上传和下载数据,也可以使用WEB页面对数据进行管理。
OSS的特点:
- 稳定,服务可用性高,多重备份保障数据安全
- 安全,多层次安全防护,防DDoS
- 大规模,高性能,从容应对高并发
另外,OSS还提供一些方便的服务
- 图片处理,支持压缩、裁剪、水印、格式转换等
- 传输加速,优化传输链路和协议策略实现高速传输
这里推荐直接购买阿里家的OSS,OSS虽然也有传输加速服务,但对于静态热点文件的下载加速场景还是需要CDN加速
prefetch 和 preload
preload 是一个新的 Web 标准,在页面生命周期中提前加载你指定的资源,同时确保在浏览器的主要渲染机制启动之前。
具体使用如下:
html
体验AI代码助手
代码解读
复制代码
<scirpt rel="preload" as="script" href="/afu_spa/activity315/assets/js/index-5a2f07e3.js" />
<scirpt rel="prefetch" as="script" href="/afu_spa/activity315/assets/js/index-5a2f07e3.js" />
注意:preload 紧挨着 title 放,使其最早介入。
prefetch 是提示浏览器,用户在下次导航时可能会使用的资源(HTML,JS,CSS或者图片等),因此浏览器为了提升性能可以提前加载、缓存资源。prefetch 的加载优先级相对较低,浏览器在空闲的时候才会在后台加载。用法与 preload 类似,将 rel 的值替换成 prefetch 即可。
preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源,而 prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源。所以建议:对于当前页面很有必要的资源使用 preload,对于可能在将来的页面中使用的资源使用 prefetch。
注意:用 preload 和 prefetch 情况下,如果资源不能被缓存,那么都有可能浪费一部分带宽,请慎用。非首页的资源建议不用 preload,prefetch 作为加载下一屏数据来用。
dns-prefetch 和 preconnect
dns-prefetch
DNS 请求需要的带宽非常小,但延迟较高,这点特别是在手机网络上比较明显。预读取 DNS 能让延迟明显减少一些(尤其是移动网络下)。为了帮助浏览器对某些域名进行预解析,你可以在页面的html标签中添加 dns-prefetch 告诉浏览器对指定域名预解析。
dns-prefetch 是一项使浏览器主动去执行域名解析的功能。dns-prefetch 应该尽量的放在网页的前面,推荐放在后面。具体使用方法如下:
ini
体验AI代码助手
代码解读
复制代码
<link rel="dns-prefetch" href="//*.com">
洗车项目中有体现:
注意:dns-prefetch需慎用,推荐首屏加载资源添加DNS Prefetch
preconnect
和 DNS prefetch 类似,preconnect 不仅会解析 DNS,还会建立 TCP 握手连接和 TLS 协议(如果是https的话)。用法如下:
preconnect
允许浏览器在 HTTP 请求实际发送到服务器之前建立早期连接。可以预先启动 DNS 查找、TCP 握手和 TLS 协商等连接,从而消除这些连接的往返延迟并为用户节省时间。
ini
体验AI代码助手
代码解读
复制代码
<link rel="preconnect" href="//*.com.cn" />
开启HTTP2
HTTP2是HTTP协议的第二个版本,相较于HTTP1 速度更快、延迟更低,功能更多。 目前来看兼容性方面也算过得去,在国内有超过50%的覆盖率。
通常浏览器在传输时并发请求数是有限制的,超过限制的请求需要排队,以往我们通过域名分片、资源合并来避开这一限制,而使用HTTP2协议后,其可以在一个TCP连接分帧处理多个请求(多路复用),不受此限制。(其余的头部压缩等等也带来了一定性能提升)
如果网站支持HTTPS,请一并开启HTTP2,成本低收益高,对于请求多的页面提升很大,尤其是在网速不佳时
Nginx开启HTTP2(>V1.95)
- 调整Nginx配置
arduino
体验AI代码助手
代码解读
复制代码
// nginx.conf
listen 443 http2;
- 重启Nginx
arduino
体验AI代码助手
代码解读
复制代码
nginx -s stop && nginx
- 验证效果
HTTP2开启后
多路复用避开了资源并发限制,但资源太多的情况,也会造成浏览器性能损失(Chrome进程间通信与资源数量相关)
http缓存
当我们发起GET请求,请求还未发出时,浏览器会首先检查 是否有缓存 ,如果 存在缓存 ,是否为强缓存,强缓存不需要发送请求到服务器,直接取浏览器本地缓存即可,如果不存在强缓存或者强缓存失效,则走协商缓存。
如何检查是否存在强缓存?
强缓存由 Expires、Cache-Control 和 Pragma(优先级依次递增) 3 个 Header 属性共同来控制
Expires: Expires是在HTTP/1.0时期提出的,它的值是一个 HTTP 日期,在浏览器发起请求时,会根据系统时间和 Expires 的值进行比较,如果系统时间超过了 Expires 的值,缓存失效。它的缺陷也很明显:由于和系统时间进行比较,所以当系统时间和服务器时间不一致的时候,会有缓存有效期不准的问题。(优先级最低)
Cache-Control: Cache-Control 是 HTTP/1.1 中新增的属性。(优先级中等)常用的属性值如有: max-age:单位是秒,缓存时间计算的方式是距离发起的时间的秒数,超过间隔的秒数缓存失效 no-cache:不使用强缓存,需要与服务器验证缓存是否新鲜 no-store:禁止使用缓存(包括协商缓存),每次都向服务器请求最新的资源 private:专用于个人的缓存,中间代理、CDN 等不能缓存此响应 public:响应可以被中间代理、CDN 等缓存 must-revalidate:在缓存过期前可以使用,过期后必须向服务器验证
Pragma: Pragma 只有一个属性值,就是 no-cache ,效果和 Cache-Control 中的 no-cache 一致,不使用强缓存,需要与服务器验证缓存是否新鲜。(优先级最高)
协商缓存
协商缓存分为两种:ETag/If-None-Match 和 Last-Modified/If-Modified-Since。
ETag/If-None-Match:
ETag 是服务器根据当前文件的内容,给文件生成的唯一标识,只要里面的内容有改动,这个值就会变。服务器通过响应头把这个值给浏览器。 浏览器接收到 ETag 的值,会在下次请求时,将这个值作为 If-None-Match 这个字段的内容,并放到请求头中,然后发给服务器。 服务器接收到 If-None-Match 后,会跟服务器上该资源的 ETag 进行比对:
如果两者不一样,说明要更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
否则返回304,告诉浏览器直接用本地缓存。
Last-Modified/If-Modified-Since:
在浏览器第一次给服务器发送请求后,服务器会在响应头中加上 Last-Modified(最后修改时间) 这个字段。浏览器接收到后,会在下次请求时,将这个值作为 If-Modified-Since 这个字段的内容,并放到请求头中,然后发给服务器。服务器拿到请求头中的 If-Modified-Since 的字段后,其实会和这个服务器中该资源的最后修改时间对比:
如果请求头中的这个值小于最后修改时间,说明是时候更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
否则返回304,告诉浏览器直接用本地缓存。
两者的区别
在精准度上,ETag优于Last-Modified。优于 ETag 是按照内容给资源上标识,因此能准确感知资源的变化。而 Last-Modified 就不一样了,它在一些特殊的情况并不能准确感知资源变化,主要有两种情况:
编辑了资源文件,但是文件内容并没有更改,这样也会造成缓存失效。Last-Modified 能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的 Last-Modified 并没有体现出修改了。
在性能上,Last-Modified 优于 ETag,因为 Last-Modified 仅仅只是记录一个时间点,而 Etag 需要根据文件的具体内容生成哈希值。
如果两种方式都支持的话,服务器会优先考虑ETag。
Gzip压缩传输
Gzip压缩是一种强力压缩手段,针对文本文件时通常能减少2/3的体积。
HTTP协议中用头部字段Accept-Encoding
和 Content-Encoding
对「采用何种编码格式传输正文」进行了协定,请求头的Accept-Encoding
会列出客户端支持的编码格式。当响应头的 Content-Encoding
指定了gzip时,浏览器则会进行对应解压
一般浏览器都支持gzip,所以Accept-Encoding
也会自动带上gzip
,所以我们需要让资源服务器在Content-Encoding
指定gzip,并返回gzip文件
Nginx配置Gzip
bash
体验AI代码助手
代码解读
复制代码
#开启和关闭gzip模式
gzip on;
#gizp压缩起点,文件大于1k才进行压缩
gzip_min_length 1k;
# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间
gzip_comp_level 6;
# 进行压缩的文件类型。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript ;
# nginx对于静态文件的处理模块,开启后会寻找以.gz结尾的文件,直接返回,不会占用cpu进行压缩,如果找不到则不进行压缩
gzip_static on
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# 设置gzip压缩针对的HTTP协议版本
gzip_http_version 1.1;
构建时生成gzip文件
虽然上面配置后Nginx已经会在响应请求时进行压缩并返回Gzip了,但是压缩操作本身是会占用服务器的CPU和时间的,压缩等级越高开销越大,所以我们通常会一并上传gzip文件,让服务器直接返回压缩后文件
javascript
体验AI代码助手
代码解读
复制代码
// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin')
// gzip压缩处理
chainWebpack: (config) => {
if(isProd) {
config.plugin('compression-webpack-plugin')
.use(new CompressionPlugin({
test: /.js$|.html$|.css$/, // 匹配文件名
threshold: 10240, // 对超过10k的数据压缩
deleteOriginalAssets: false // 不删除源文件
}))
}
}
- 插件的默认压缩等级是9,最高级的压缩
- 图片文件不建议使用gzip压缩,效果较差
Web Worker 优化长任务
由于浏览器 GUI 渲染线程与 JS 引擎线程是互斥的关系,当页面中有很多长任务时,会造成页面 UI 阻塞,出现界面卡顿、掉帧等情况
查看页面的长任务:
打开控制台,选择 Performance 工具,点击 Start 按钮,展开 Main 选项,会发现有很多红色的三角,这些就属于长任务(长任务:执行时间超过50ms的任务)
测试实验:
如果直接把下面这段代码直接丢到主线程中,计算过程中页面一直处于卡死状态,无法操作
js
代码解读
复制代码
let sum = 0;
for (let i = 0; i < 200000; i++) {
for (let i = 0; i < 10000; i++) {
sum += Math.random()
}
}
使用 Web Worker 执行上述代码时,计算过程中页面正常可操作、无卡顿
js
代码解读
复制代码
// worker.js
onmessage = function (e) {
// onmessage获取传入的初始值
let sum = e.data;
for (let i = 0; i < 200000; i++) {
for (let i = 0; i < 10000; i++) {
sum += Math.random()
}
}
// 将计算的结果传递出去
postMessage(sum);
}
Web Worker 具体的使用与案例,详情见 一文彻底了解Web Worker,十万、百万条数据都是弟弟🔥
Web Worker 的通信时长
并不是执行时间超过 50ms 的任务,就可以使用 Web Worker,还要先考虑通信时长
的问题
假如一个运算执行时长为 100ms,但是通信时长为 300ms, 用了 Web Worker可能会更慢
比如新建一个 web worker, 浏览器会加载对应的 worker.js 资源,下图中的 Time 是这个资源的通信时长(也叫加载时长)
当任务的运算时长 - 通信时长 > 50ms,推荐使用Web Worker
原理
Web Worker 是浏览器提供的多线程编程机制,允许 JavaScript 在后台线程运行复杂计算,避免阻塞主线程(负责 UI 渲染和用户交互)。以下从核心原理、通信机制、运行限制、典型场景四个维度详细解析。
1)核心原理:浏览器的多线程架构
JavaScript 语言本身是单线程的(源于设计初衷:避免多线程操作 DOM 导致的竞态问题),但浏览器底层是多进程 / 多线程的。Web Worker 的本质是在浏览器进程中创建一个独立的工作线程,与主线程并行运行,通过消息传递实现数据交互。
1. 浏览器的线程分工
线程类型 | 职责 | 与 Web Worker 的关系 |
---|---|---|
主线程 | UI 渲染、事件处理、JavaScript 执行 | 负责创建 / 终止 Worker,传递消息 |
工作线程 | Web Worker 运行环境 | 执行复杂计算,不访问 DOM |
其他线程 | 网络请求、文件 I/O、定时器等 | 与 Worker 无直接交互 |
2. Web Worker 的执行流程
- 主线程创建 Worker:通过
new Worker('worker.js')
加载外部脚本,浏览器创建独立工作线程。 - 脚本执行:
worker.js
在工作线程中运行,无法访问主线程的 DOM 或全局变量。 - 消息通信:主线程与 Worker 通过
postMessage
发送消息,数据通过结构化克隆传递(非共享内存)。 - 资源释放:调用
worker.terminate()
终止 Worker,或 Worker 自身调用close()
退出。
2)通信机制:消息传递与数据共享
Web Worker 与主线程的通信基于事件驱动模型,核心是 postMessage
和 onmessage
方法。
1. 消息传递的本质
- 结构化克隆算法:传递的消息会被序列化为二进制数据,在接收端反序列化。支持对象、数组、Blob 等类型,但无法传递函数、DOM 节点或循环引用对象。
- 零拷贝优化:对于
ArrayBuffer
等二进制数据,可通过转移所有权(Transferable
)避免复制,提升性能(如postMessage(buffer, [buffer])
)。
2. 通信示例(主线程与 Worker)
主线程代码(main.js) :
// 创建 Worker,加载 worker.js
const worker = new Worker('worker.js');
// 监听 Worker 发送的消息
worker.onmessage = (e) => {
console.log('主线程收到:', e.data); // 输出:计算结果:12345
};
// 向 Worker 发送消息(传递数据)
worker.postMessage({ type: 'compute', data: 12345 });
Worker 线程代码(worker.js) :
// 监听主线程发送的消息
self.onmessage = (e) => {
const { type, data } = e.data;
if (type === 'compute') {
const result = doHeavyCalculation(data); // 复杂计算(不阻塞主线程)
// 向主线程发送结果
self.postMessage(`计算结果:${result}`);
}
};
// 模拟复杂计算
function doHeavyCalculation(num) {
let sum = 0;
for (let i = 0; i < 1e7; i++) {
sum += num * Math.random();
}
return sum.toFixed(2);
}
3)运行限制:沙盒环境与安全边界
Web Worker 被设计为沙盒化的执行环境,禁止访问可能干扰主线程的资源,核心限制如下:
1. 禁止访问的对象
- DOM 相关:
document
、window
、documentElement
等(避免多线程操作 DOM 的竞态问题)。 - 主线程全局变量:无法直接访问主线程的
var/let/const
变量或函数。 - 部分 BOM 对象:如
parent
(跨窗口通信)、top
(顶层窗口)等。
2. 允许访问的资源
- 网络请求:支持
XMLHttpRequest
、fetch
(但响应的responseType
不能是document
)。 - 存储:支持
IndexedDB
、localStorage
(但localStorage
是同步的,可能阻塞 Worker)。 - 定时器:
setTimeout
、setInterval
可用(与主线程独立)。 - 嵌套 Worker:Worker 可以通过
new Worker()
创建子 Worker(需注意资源消耗)。
4)典型场景:适合与不适合的场景
适合使用 Web Worker 的场景
场景类型 | 示例 | 优势 |
---|---|---|
大量数据计算 | 金融风控模型、图像像素处理、大数据排序 | 避免主线程卡顿,保持 UI 流畅 |
复杂算法执行 | 机器学习推理、物理模拟(如碰撞检测) | 利用多线程并行加速计算 |
日志与监控 | 收集用户行为数据并异步上传 | 不阻塞用户交互 |
数据预加载 / 预处理 | 解析 CSV/JSON 文件、转换图片格式 | 提前处理数据,提升主流程响应速度 |
不适合使用 Web Worker 的场景
- 需要频繁操作 DOM:如实时图表更新(需通过消息通知主线程更新)。
- 轻量级计算:创建 Worker 的开销(约几毫秒)可能超过计算本身的耗时。
- 严格实时性任务:消息传递存在延迟(约几毫秒),无法保证绝对实时。
4)扩展:特殊类型的 Worker
除了通用的专用 Worker(Dedicated Worker) ,浏览器还支持以下特殊类型:
1. 共享 Worker(Shared Worker)
- 特点:可被多个同源窗口 / 标签页共享,通过
SharedWorker
创建,通信需通过port
对象。 - 适用场景:多标签页共享数据(如在线文档协作的实时同步)。
2. Service Worker
- 特点:运行在后台,独立于页面,支持离线缓存、拦截网络请求、推送通知等。
- 适用场景:PWA(渐进式网页应用)的离线功能、请求代理(如广告拦截)。
6)性能与注意事项
- 内存管理:每个 Worker 占用独立内存,避免创建过多 Worker(如超过 CPU 核心数)。
- 通信开销:频繁传递大对象(如 10MB+ 的数据)会增加序列化 / 反序列化时间,建议使用
Transferable
转移二进制数据。 - 错误处理:通过
onerror
监听 Worker 内的错误(如脚本加载失败、语法错误)。
总结
Web Worker 的核心是通过多线程隔离解决 JavaScript 单线程的性能瓶颈,其原理是在浏览器中创建独立工作线程,通过消息传递与主线程通信。适用场景包括大量计算、复杂算法等,需注意通信开销和资源限制。掌握 Web Worker 可显著提升长耗时任务的用户体验,是前端性能优化的重要工具。
4.工程优化
排查并移除冗余依赖、静态资源
- 移除项目模板冗余依赖
- 将public的静态资源移入assets。静态资源应该放在assets下,public只会单纯的复制到dist,应该放置不经webpack处理的文件,比如不兼容webpack的库,需要指定文件名的文件等等
Webpack优化
- 代码切割,使用
code splitting
将代码进行分割,避免将所有代码打包到一个文件,减少响应体积。 - 按需加载代码,在使用使用的时候加载代码。
- 压缩代码体积,可以减小代码体积
- 优化静态资源,使用字体图标、雪碧图、webp格式的图片、svg图标等
- 使用
Tree Shaking
删除未被引用的代码 - 开启
gzip
压缩 - 静态资源使用
CDN
加载,减少服务器压力
1. 如何提⾼webpack的打包速度?
(1)优化 Loader
对于 Loader 来说,影响打包效率首当其冲必属 Babel 了。因为 Babel 会将代码转为字符串生成 AST,然后对 AST 继续进行转变最后再生成新的代码,项目越大,转换代码越多,效率就越低。当然了,这是可以优化的。
首先我们优化 Loader 的文件搜索范围
javascript
体验AI代码助手
代码解读
复制代码
module.exports = {
module: {
rules: [
{
// js 文件才使用 babel
test: /.js$/,
loader: 'babel-loader',
// 只在 src 文件夹下查找
include: [resolve('src')],
// 不会去查找的路径
exclude: /node_modules/
}
]
}
}
对于 Babel 来说,希望只作用在 JS 代码上的,然后 node_modules
中使用的代码都是编译过的,所以完全没有必要再去处理一遍。
当然这样做还不够,还可以将 Babel 编译过的文件缓存起来,下次只需要编译更改过的代码文件即可,这样可以大幅度加快打包时间
javascript
体验AI代码助手
代码解读
复制代码
loader: 'babel-loader?cacheDirectory=true'
(2)HappyPack
受限于 Node 是单线程运行的,所以 Webpack 在打包的过程中也是单线程的,特别是在执行 Loader 的时候,长时间编译的任务很多,这样就会导致等待的情况。
HappyPack 可以将 Loader 的同步执行转换为并行的,这样就能充分利用系统资源来加快打包效率了
javascript
体验AI代码助手
代码解读
复制代码
module: {
loaders: [
{
test: /.js$/,
include: [resolve('src')],
exclude: /node_modules/,
// id 后面的内容对应下面
loader: 'happypack/loader?id=happybabel'
}
]
},
plugins: [
new HappyPack({
id: 'happybabel',
loaders: ['babel-loader?cacheDirectory'],
// 开启 4 个线程
threads: 4
})
]
(3)DllPlugin
DllPlugin 可以将特定的类库提前打包然后引入。这种方式可以极大的减少打包类库的次数,只有当类库更新版本才有需要重新打包,并且也实现了将公共代码抽离成单独文件的优化方案。DllPlugin的使用方法如下:
javascript
体验AI代码助手
代码解读
复制代码
// 单独配置在一个文件中
// webpack.dll.conf.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: {
// 想统一打包的类库
vendor: ['react']
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].dll.js',
library: '[name]-[hash]'
},
plugins: [
new webpack.DllPlugin({
// name 必须和 output.library 一致
name: '[name]-[hash]',
// 该属性需要与 DllReferencePlugin 中一致
context: __dirname,
path: path.join(__dirname, 'dist', '[name]-manifest.json')
})
]
}
然后需要执行这个配置文件生成依赖文件,接下来需要使用 DllReferencePlugin
将依赖文件引入项目中
javascript
体验AI代码助手
代码解读
复制代码
// webpack.conf.js
module.exports = {
// ...省略其他配置
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
// manifest 就是之前打包出来的 json 文件
manifest: require('./dist/vendor-manifest.json'),
})
]
}
(4)代码压缩
在 Webpack3 中,一般使用 UglifyJS
来压缩代码,但是这个是单线程运行的,为了加快效率,可以使用 webpack-parallel-uglify-plugin
来并行运行 UglifyJS
,从而提高效率。
在 Webpack4 中,不需要以上这些操作了,只需要将 mode
设置为 production
就可以默认开启以上功能。代码压缩也是我们必做的性能优化方案,当然我们不止可以压缩 JS 代码,还可以压缩 HTML、CSS 代码,并且在压缩 JS 代码的过程中,我们还可以通过配置实现比如删除 console.log
这类代码的功能。
(5)其他
可以通过一些小的优化点来加快打包速度
resolve.extensions
:用来表明文件后缀列表,默认查找顺序是['.js', '.json']
,如果你的导入文件没有添加后缀就会按照这个顺序查找文件。我们应该尽可能减少后缀列表长度,然后将出现频率高的后缀排在前面resolve.alias
:可以通过别名的方式来映射一个路径,能让 Webpack 更快找到路径module.noParse
:如果你确定一个文件下没有其他依赖,就可以使用该属性让 Webpack 不扫描该文件,这种方式对于大型的类库很有帮助
2. 如何减少 Webpack 打包体积
(1)按需加载
在开发 SPA 项目的时候,项目中都会存在很多路由页面。如果将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,希望首页能加载的文件体积越小越好,这时候就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash
这种大型类库同样可以使用这个功能。
按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。当然了,虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise
,当 Promise
成功以后去执行回调。
(2)Scope Hoisting
Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。
比如希望打包两个文件:
javascript
体验AI代码助手
代码解读
复制代码
// test.js
export const a = 1
// index.js
import { a } from './test.js'
对于这种情况,打包出来的代码会类似这样:
javascript
体验AI代码助手
代码解读
复制代码
[
/* 0 */
function (module, exports, require) {
//...
},
/* 1 */
function (module, exports, require) {
//...
}
]
但是如果使用 Scope Hoisting ,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码:
javascript
体验AI代码助手
代码解读
复制代码
[
/* 0 */
function (module, exports, require) {
//...
}
]
这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中你希望开启这个功能,只需要启用 optimization.concatenateModules
就可以了:
javascript
体验AI代码助手
代码解读
复制代码
module.exports = {
optimization: {
concatenateModules: true
}
}
(3)Tree Shaking
Tree Shaking 可以实现删除项目中未被引用的代码,比如:
javascript
体验AI代码助手
代码解读
复制代码
// test.js
export const a = 1
export const b = 2
// index.js
import { a } from './test.js'
对于以上情况,test
文件中的变量 b
如果没有在项目中使用到的话,就不会被打包到文件中。
如果使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。
3. 如何⽤webpack来优化前端性能?
⽤webpack优化前端性能是指优化webpack的输出结果,让打包的最终结果在浏览器运⾏快速⾼效。
- 压缩代码:删除多余的代码、注释、简化代码的写法等等⽅式。可以利⽤webpack的 UglifyJsPlugin 和 ParallelUglifyPlugin 来压缩JS⽂件, 利⽤ cssnano (css-loader?minimize)来压缩css
- 利⽤CDN加速: 在构建过程中,将引⽤的静态资源路径修改为CDN上对应的路径。可以利⽤webpack对于 output 参数和各loader的 publicPath 参数来修改资源路径
- Tree Shaking: 将代码中永远不会⾛到的⽚段删除掉。可以通过在启动webpack时追加参数 --optimize-minimize 来实现
- Code Splitting: 将代码按路由维度或者组件分块(chunk),这样做到按需加载,同时可以充分利⽤浏览器缓存
- 提取公共第三⽅库: SplitChunksPlugin插件来进⾏公共模块抽取,利⽤浏览器缓存可以⻓期缓存这些⽆需频繁变动的公共代码
4. 如何提⾼webpack的构建速度?
-
多⼊⼝情况下,使⽤ CommonsChunkPlugin 来提取公共代码
-
通过 externals 配置来提取常⽤库
-
利⽤ DllPlugin 和 DllReferencePlugin 预编译资源模块 通过 DllPlugin 来对那些我们引⽤但是绝对不会修改的npm包来进⾏预编译,再通过 DllReferencePlugin 将预编译的模块加载进来。
-
使⽤ Happypack 实现多线程加速编译
-
使⽤ webpack-uglify-parallel 来提升 uglifyPlugin 的压缩速度。 原理上 webpack-uglify-parallel 采⽤了多核并⾏压缩来提升压缩速度
-
使⽤ Tree-shaking 和 Scope Hoisting 来剔除多余代码
合理使用 Tree shaking
Tree shaking 的作用:消除无用的 JS 代码,减少代码体积
举个🌰:
javascript
代码解读
复制代码
// util.js
export function targetType(target) {
return Object.prototype.toString.call(target).slice(8, -1).toLowerCase();
}
export function deepClone(target) {
return JSON.parse(JSON.stringify(target));
}
项目中只使用了 targetType 方法,但未使用 deepClone 方法,项目打包后,deepClone 方法不会被打包到项目里
tree-shaking 原理:
依赖于ES6的模块特性,ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,这就是 tree-shaking 的基础
静态分析就是不需要执行代码,就可以从字面量上对代码进行分析。ES6之前的模块化,比如 CommonJS 是动态加载,只有执行后才知道引用的什么模块,就不能通过静态分析去做优化,正是基于这个基础上,才使得 tree-shaking 成为可能
Tree shaking 并不是万能的
并不是说所有无用的代码都可以被消除,还是上面的代码,换个写法 tree-shaking 就失效了
js
代码解读
复制代码
// util.js
export default {
targetType(target) {
return Object.prototype.toString.call(target).slice(8, -1).toLowerCase();
},
deepClone(target) {
return JSON.parse(JSON.stringify(target));
}
};
// 引入并使用
import util from '../util';
util.targetType(null)
同样的,项目中只使用了 targetType 方法,未使用 deepClone 方法,项目打包后,deepClone 方法还是被打包到项目里
在 dist 文件中搜索 deepClone 方法:
究其原因,export default 导出的是一个对象,无法通过静态分析判断出一个对象的哪些变量未被使用,所以 tree-shaking 只对使用 export 导出的变量生效
这也是函数式编程越来越火的原因,因为可以很好利用 tree-shaking 精简项目的体积,也是 vue3 全面拥抱了函数式编程的原因之一