讲讲业务场景
当页面上出现大量的图片/视频/音频 在页面的加载时进行同步请求 会造成页面卡顿
页面卡顿原因
1.网络请求的拥堵与等待
浏览器从服务器获取资源的第一步,就容易出现"堵车"。
- 资源过多,带宽竞争 :浏览器对同一域名的并发请求数有上限(通常6-8个)。当页面上有几十甚至上百张图片时,它们需要排队等待加载。这会直接拖慢后续关键资源(如CSS、JavaScript文件)的下载,从而阻塞整个页面的渲染。使用HTTP/1.1时,队头阻塞(Head-of-Line blocking)问题会更明显,即一个响应慢的资源会阻塞后续所有资源。
- 单个文件体积过大:一张未经压缩的高清图片或一段视频可能达到几MB甚至几十MB。在带宽有限的情况下,加载一个10MB的文件可能需要8秒以上,这会显著增加用户看到完整页面的时间。
2.浏览器解析、渲染与解码的压力
资源下载后,浏览器需要进行一系列处理才能将其呈现出来,这个阶段同样压力重重。
- 渲染阻塞:虽然图片、视频、音频本身不阻塞DOM树的构建,但它们依赖的CSS和JavaScript可能会。如果页面脚本需要等待这些多媒体资源的尺寸信息,或者CSS文件过大,浏览器就可能延迟渲染,导致白屏时间变长。
- 主线程占用与回流重绘 :浏览器的渲染、布局、绘制以及JavaScript运行主要都在主线程 上完成,这相当于浏览器的"大脑"。大量多媒体资源需要主线程进行解码和渲染,尤其是当图片或视频尺寸发生变化时,会触发昂贵的回流(重排)和重绘,进一步占用CPU资源,导致页面响应迟钝。
- 昂贵的解码成本:图片和视频文件需要被解码成浏览器能够直接处理的位图格式,这个过程非常消耗CPU资源。同时渲染大量图片或播放高清视频时,解码压力会陡增,尤其在处理GIF或自动播放的视频时更为明显。
3.内存与存储的持续占用
资源被加载和解码后,并不会马上消失,而是会持续占用系统资源。
- 内存占用激增:每张图片、每个视频帧解码后都会占用一定的内存。当用户快速滑动页面,大量图片涌入又来不及被垃圾回收机制回收时,内存占用会急剧上升。在内存有限的移动设备上,这可能导致浏览器崩溃或页面被强行重新加载。
- 存储I/O压力:大体积的多媒体文件会增加服务器磁盘的I/O压力。服务器需要花更多时间从磁盘读取数据再返回给浏览器,这可能间接影响服务器响应其他请求的速度
代码实现(React+TS):
前情提要:SongList组件中封装了SongMenu组件,并传参给子组件item(包含imgUrl)每个SongMenu中展示自己的imageUrl
NO.1 在每个Song-Menu实现一个观察者 与SongMenu的组件生命周期相关联
typescript
import React, { memo, FC, useRef, useEffect, useState } from 'react';
import { SongsMenuWrapper } from './style';
import { formatCount,getImageSize } from '../../utils/formar';
import { useIntersectionObserver } from '../../hooks/useInterSectionObserver';
import { isVisible } from '@testing-library/user-event/dist/utils';
interface IProps{
children?:React.ReactNode
itemData?:any
isVisible?:boolean
}
const SongsMenu: FC<IProps>= (props)=> {
const { itemData, isVisible = false } = props;
const [isLoaded, setIsLoaded] = useState(false);
const [imgRef, isIntersecting] = useIntersectionObserver<HTMLImageElement>({
once: true,
rootMargin: '0px 0px 200px 0px',
threshold: 0.1,
});
useEffect(() => {
if(isIntersecting && itemData?.picUrl && imgRef?.current && !isLoaded){
setIsLoaded(true);
imgRef.current!.src = getImageSize(itemData.picUrl, 100, 100);
}
}, [itemData, isIntersecting])
return (
<SongsMenuWrapper>
<div className="cover_top">
{/* 初始时src为空,避免提前加载 */}
<img
ref={imgRef}
alt={itemData?.name}
/>
<div className="cover sprite_cover">
<div className="info sprite_cover">
<span>
<i className='headset sprite_icon'>
{ formatCount(itemData?.playCount || 0) }
</i>
</span>
<i className='play sprite_icon'></i>
</div>
</div>
</div>
<div className="cover_bottom">
{itemData?.name}
</div>
</SongsMenuWrapper>
)
}
export default memo(SongsMenu);
IntserSectionObserver的实现原理
- 异步检测与更新队列 浏览器渲染引擎有一个专门的"更新相交观察步骤 "(Update Intersection Observations Steps)。这个步骤被集成在渲染帧的"更新渲染"(Update the rendering)阶段,通常在执行完
requestAnimationFrame回调之后进行。这意味着,浏览器会利用自身的布局信息来高效计算相交状态,而不是响应高频率的滚动事件。检测到的变化会被放入一个队列,异步地通知给观察者。 - 相交区域的计算算法当计算一个目标元素与根元素的相交情况时,浏览器会遵循一个特定的算法:
- 获取目标元素矩形 :首先,通过类似
getBoundingClientRect()的方法获取目标元素完整的边界矩形。 - 遍历祖先元素应用裁剪 :接着,从目标元素的直接父级开始,向上遍历直到根元素。如果路径上的祖先元素设置了非
visible的overflow属性或者是类似<iframe>的浏览上下文,则会根据这些元素的裁剪区域(clipping area)对第一步得到的矩形进行逐级裁剪。 - 映射与求交 :最终,将裁剪后得到的矩形映射到根元素的坐标空间,并与根元素的边界(可被
rootMargin扩展或收缩)求交,得到最终的intersectionRect(交叉区域)。相交比例则由intersectionRect面积与目标元素完整矩形面积的比值得出。 - 阈值(Threshold)的触发机制 你设置的
threshold数组(如[0, 0.25, 0.5, 1])决定了回调函数在哪些关键点触发。浏览器会跟踪当前的intersectionRatio和之前的状态。只有当相交比例穿过 你设置的阈值点时,才会将相应的条目加入回调的entries数组。例如,从 0.2 滚动到 0.4,如果设置了 0.25 和 0.5 两个阈值,则只会在穿过 0.25 时触发一次回调
InterSectionObserver的底层原理
1.观察者实例与目标管理
在底层,每个 IntersectionObserver实例内部确实维护着关键数据:
- 观察目标列表 :每个观察者实例都持有一个它正在观察的DOM元素列表。当你调用
observe(element)时,这个元素就会被添加到内部列表中进行追踪 。 - 配置信息 :实例化时传入的
root,rootMargin,threshold等选项也被存储在实例内部,用于后续的交叉计算 。
浏览器内核(如Blink)会维护一个全局的注册表(Registry) ,所有活跃的 IntersectionObserver实例都会在此注册,这确保了只要观察者还在工作,它和其观察的目标就不会被垃圾回收机制错误回收 。
2.异步检测与线程协作
IntersectionObserver的高性能秘诀并非在于为每个观察者"新开一个独立的异步线程",而是利用了浏览器现有的渲染引擎的工作机制。
- 集成于渲染流水线 :交叉检测并非在独立的线程中循环不断计算,而是巧妙地"挂载"在浏览器的渲染过程中。核心计算发生在渲染帧(Frame)的某个特定阶段 ,通常是在样式计算(Style)和布局(Layout)之后。此时,浏览器已经为了绘制页面而计算出了每个元素的精确几何信息(位置、大小),
IntersectionObserver可以直接利用这些现成的布局数据,避免了重复计算带来的性能损耗 。 - 批量异步处理 :正因为与渲染流程绑定,所有观察者的交叉状态计算是批量(Batched) 进行的。浏览器会在一个渲染帧内,集中处理所有需要检查的观察目标,计算它们的交叉状态。这个过程对JavaScript主线程是异步 的。计算完成后,如果发现某个目标的交叉状态发生了变化(例如,穿过了你设置的阈值),才会将对应的回调函数放入JavaScript的消息队列,等待主线程空闲时执行 。这种机制避免了在密集滚动等场景下,频繁的同步计算阻塞主线程,从而解决了传统
scroll事件监听带来的性能问题 。规范中提到,其实现优先级较低,甚至会采用类似requestIdleCallback的机制,在浏览器空闲时才执行回调,进一步减少对用户交互的影响
NO.2 封装通用的hooks,便于组件间通用
ini
import { useEffect, useRef, useState, RefObject } from 'react';
interface UseIntersectionObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
once?: boolean;
defaultVisible?: boolean;
}
export function useIntersectionObserver<T extends Element>(
options: UseIntersectionObserverOptions = {}
): [RefObject<T> | null, boolean] {
const {
root = null,
rootMargin = '0px',
threshold = 0,
once = false,
defaultVisible = false
} = options;
const targetRef = useRef<T>(null) as RefObject<T>;
const [isIntersecting, setIsIntersecting] = useState(defaultVisible);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
// 如果没有目标元素,不执行观察
if (!targetRef.current) return;
// 清除之前的观察器
if (observerRef.current) {
observerRef.current.disconnect();
}
// 创建新的观察器
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsIntersecting(entry.isIntersecting);
// 如果设置了once且元素可见,则停止观察
if (once && entry.isIntersecting) {
observerRef.current?.disconnect();
}
});
},
{ root, rootMargin, threshold }
);
// 开始观察目标元素
observerRef.current.observe(targetRef.current);
// 清理函数
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [root, rootMargin, threshold, once]);
return [targetRef, isIntersecting];
}
NO.3参考事件委托思想 在Song-list 实现一个观察者 观察n个SongMenu子组件(节省性能)
ini
import { useEffect, useRef, useState, RefObject } from 'react';
interface UseIntersectionObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
once?: boolean;
defaultVisible?: boolean;
}
interface BatchIntersectionResult<T extends Element> {
ref: (el: T | null, index: number) => void;
isIntersecting: (index: number) => boolean;
visibleIndices: number[];
}
// 单个元素版本
export function useIntersectionObserver<T extends Element>(
options: UseIntersectionObserverOptions = {}
): [RefObject<T>, boolean] {
const {
root = null,
rootMargin = '0px',
threshold = 0,
once = false,
defaultVisible = false
} = options;
// 使用类型断言解决初始值为null的类型问题
const targetRef = useRef<T>(null) as RefObject<T>;
const [isIntersecting, setIsIntersecting] = useState(defaultVisible);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
// 如果没有目标元素,不执行观察
if (!targetRef.current) return;
// 清除之前的观察器
if (observerRef.current) {
observerRef.current.disconnect();
}
// 创建新的观察器
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsIntersecting(entry.isIntersecting);
// 如果设置了once且元素可见,则停止观察
if (once && entry.isIntersecting) {
observerRef.current?.disconnect();
}
});
},
{ root, rootMargin, threshold }
);
// 开始观察目标元素
observerRef.current.observe(targetRef.current);
// 清理函数
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [root, rootMargin, threshold, once]);
return [targetRef, isIntersecting];
}
// 批量元素版本
export function useBatchIntersectionObserver<T extends Element>(
count: number,
options: UseIntersectionObserverOptions = {}
): BatchIntersectionResult<T> {
const {
root = null,
rootMargin = '0px',
threshold = 0,
once = false,
defaultVisible = false
} = options;
const targetsRef = useRef<(T | null)[]>(new Array(count).fill(null));
const [isIntersectingMap, setIsIntersectingMap] = useState<Map<number, boolean>>(
new Map(Array.from({ length: count }, (_, i) => [i, defaultVisible]))
);
const observerRef = useRef<IntersectionObserver | null>(null);
// 获取可见的索引
const visibleIndices = Array.from(isIntersectingMap.entries())
.filter(([_, visible]) => visible)
.map(([index]) => index);
// 设置ref的回调函数
const ref = (el: T | null, index: number) => {
if (index >= 0 && index < count) {
targetsRef.current[index] = el;
}
};
// 检查指定索引的元素是否可见
const isIntersecting = (index: number): boolean => {
return isIntersectingMap.get(index) || false;
};
useEffect(() => {
// 清除之前的观察器
if (observerRef.current) {
observerRef.current.disconnect();
}
// 创建新的观察器
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 找到当前元素的索引
const index = targetsRef.current.findIndex(el => el === entry.target);
if (index !== -1) {
setIsIntersectingMap(prev => {
const newMap = new Map(prev);
newMap.set(index, entry.isIntersecting);
return newMap;
});
// 如果设置了once且元素可见,则停止观察
if (once && entry.isIntersecting) {
observerRef.current?.unobserve(entry.target);
}
}
});
},
{ root, rootMargin, threshold }
);
// 开始观察所有目标元素
targetsRef.current.forEach((el) => {
if (el) {
observerRef.current?.observe(el);
}
});
// 清理函数
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [root, rootMargin, threshold, once, count]);
return { ref, isIntersecting, visibleIndices };
}
Polyfill 的实现思路
在不支持该 API 的旧版浏览器中,Polyfill 会模拟这一功能,其实现方式恰恰反衬了原生 API 的优雅。Polyfill 通常需要:
- 监听
scroll和resize事件,并进行节流。 - 使用
MutationObserver来监听 DOM 结构变化,因为这些变化可能影响元素位置。 - 在事件处理程序中,循环遍历所有被观察的元素,使用
getBoundingClientRect()进行手动计算,这本身就会引发性能损耗。
这种模拟方式在性能和精度上都无法与原生实现相提并论,这也正是为什么应该尽可能使用原生 IntersectionObserver的原因