使用InterSection进行页面图片加载优化思路

讲讲业务场景

当页面上出现大量的图片/视频/音频 在页面的加载时进行同步请求 会造成页面卡顿

页面卡顿原因

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()的方法获取目标元素完整的边界矩形。
  • 遍历祖先元素应用裁剪 :接着,从目标元素的直接父级开始,向上遍历直到根元素。如果路径上的祖先元素设置了非 visibleoverflow属性或者是类似 <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的高性能秘诀并非在于为每个观察者"新开一个独立的异步线程",而是利用了浏览器现有的渲染引擎的工作机制

  1. 集成于渲染流水线 :交叉检测并非在独立的线程中循环不断计算,而是巧妙地"挂载"在浏览器的渲染过程中。核心计算发生在渲染帧(Frame)的某个特定阶段 ,通常是在样式计算(Style)和布局(Layout)之后。此时,浏览器已经为了绘制页面而计算出了每个元素的精确几何信息(位置、大小),IntersectionObserver可以直接利用这些现成的布局数据,避免了重复计算带来的性能损耗 。
  2. 批量异步处理 :正因为与渲染流程绑定,所有观察者的交叉状态计算是批量(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 通常需要:

  • 监听 scrollresize事件,并进行节流。
  • 使用 MutationObserver来监听 DOM 结构变化,因为这些变化可能影响元素位置。
  • 在事件处理程序中,循环遍历所有被观察的元素,使用 getBoundingClientRect()进行手动计算,这本身就会引发性能损耗。

这种模拟方式在性能和精度上都无法与原生实现相提并论,这也正是为什么应该尽可能使用原生 IntersectionObserver的原因

相关推荐
前端工作日常2 小时前
我学习到的AG-UI的功能:全面的交互支持
前端
LawrenceLan2 小时前
Flutter 零基础入门(十三):late 关键字与延迟初始化
开发语言·前端·flutter·dart
深耕AI2 小时前
【wordpress系列教程】03 网站页面的编辑
开发语言·前端
前端达人2 小时前
2026年React数据获取的第六层:从自己写缓存到用React Query——减少100行代码的秘诀
前端·javascript·react.js·缓存·前端框架
—Qeyser2 小时前
Flutter 生命周期完全指南:从出生到死亡的全过程
前端·javascript·flutter
YAY_tyy2 小时前
Turfjs 性能优化:大数据量地理要素处理技巧
前端·3d·arcgis·cesium·turfjs
hhcccchh2 小时前
学习vue第十二天 Vue开发工具链指南:从手工作坊到现代化工厂
前端·vue.js·学习
Yeats_Liao2 小时前
模型选型指南:7B、67B与MoE架构的业务适用性对比
前端·人工智能·神经网络·机器学习·架构·deep learning