• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

图片懒加载方案:剖析 react-lazyload 原理并改造

武飞扬头像
windyrain
帮助653

前言

懒加载(Lazy Load)是一种优化网站性能的技术,其作用是在网页加载时延迟加载图片、视频、音频等资源,仅在用户滚动到它们的位置时才加载。这种方式可以降低网站的初始加载时间,提高用户体验,减少带宽消耗和服务器负载。

1. 懒加载的概念和作用

懒加载的概念类似于一种"按需加载"的模式,即只有当需要使用资源时才会加载它们,而不是一次性加载所有资源。这种方式对于长网页和页面中包含大量图片和其他资源的情况尤为适用,可以显著降低页面的加载时间和带宽消耗。

懒加载技术在移动端的应用也越来越广泛,因为移动端网络条件相对较差,懒加载可以在一定程度上减少页面加载时间,提高用户体验。

2. 懒加载的使用条件

懒加载使用的前置条件是需要提前知道图片的宽高。原因如下:

  1. 使用 div 作为占位元素放置在懒加载区域,如果不知道图片的宽高,div 就无法知道自身应该如何布局和占位。

  2. 懒加载通常有个功能是移动到距离懒加载区域多远处就可以开始加载,比如 react-lazyload 中使用 offset 属性来标志,所以高度对于这个功能的计算是至关重要的。

通常我们会在上传图片的时候,将图片的宽高信息存储在链接上,如以下格式

https://cdn域名/图片名称_info_w=450_h=450_s=188145.jpg

下面提供一个拿到链接上宽高的方法,如果链接目前不是类似格式的小伙伴,可以让服务端处理,或者自己先手动拼接一下,测测效果。

/**
 * 获取图片期望高度
 * src 图片链接
 * length 每行需要展示图片个数
 */
const getExpectImageHeight = (src: string, length = 1): number => {
    const match = /w=(\d )_h=(\d )/.exec(src);
    if (!match) {
        return 0;
    }

    const [, w, h] = match;
    const parsedW = parseInt(w, 10);
    const parsedH = parseInt(h, 10);
    if (Number.isNaN(parsedW) || Number.isNaN(parsedH) || parsedW === 0) {
        return 0;
    }

    return parsedH / ((parsedW / window.innerWidth) * length);
};

还有一种情况是:图片不需要自适应高度,都是根据设计稿固定宽高的,这种情况下,可以使用宽高数据 * 对应屏幕缩放比来得到真实高度。

3. 懒加载的核心原理

下面通过对 react-lazyload 的源码进行分析,和大家聊聊懒加载的核心原理。

整体代码是非常简洁的,虽然不是 ts 的,阅读起来还是比较舒服,函数基本上是语义化的。我们先看看 index.jsx 。大体上分为这 3 个部分:

  • 找到滚动的容器,添加滚动事件监听

滚动的容器的默认值是 window 这可以适用于很多场景,但是还是有情况下,需要自己指定滚动容器,目前 react-lazyload 提供了两种模式。

  1. 根据 props 传入的 scrollContainer ,格式为 dom 的选择器即可。
  2. 根据 props 传入的 overflow ,使用 scrollParent 方法一级一级往父元素上找到 overflow 属性为 auto|scroll 的元素,认定为滚动容器。

找到滚动父容器后,为他增加 scroll 事件

  • 判断子元素是否可见

两种不同的滚动父容器模式,会影响判断子元素是否可视的判断,项目分别定义了 checkOverflowVisiblecheckNormalVisible 方法来检查元素是否可视。核心原理是利用当前元素的 getBoundingClientRect 方法,获取到他相对于视窗的 left top width height ,然后通过和与 offset 的加减,判断是否处于可视区域。

可以看到,checkNormalVisible 比较简单,只是对比了高度,这说明他并不能实现横向的懒加载。

// checkNormalVisible
return (
    // 元素距离页面顶部距离 - 预加载范围 <= 页面高度
    top - offsets[0] <= windowInnerHeight && 
    // 元素距离页面顶部距离   元素高度   预加载范围 >= 0
    top   elementHeight   offsets[1] >= 0
);

// checkOverflowVisible
  return (
    // 元素距离父容器顶部距离 - 预加载范围 <= 父容器最下方
    offsetTop - offsets[0] <= intersectionHeight &&
    // 元素距离父容器顶部距离   元素高度   预加载范围 >= 0
    offsetTop   height   offsets[1] >= 0 &&
    // 元素距离父容器左侧距离 - 预加载范围 <= 父容器最右方
    offsetLeft - offsets[0] <= intersectionWidth &&
    // 元素距离父容器左侧距离   元素宽度   预加载范围 >= 0
    offsetLeft   width   offsets[1] >= 0
  );
  • 更新视图

当检测到元素处于可视区域时,改变组件 visible 属性,并进行 forceUpdate. 这样被包裹的子元素就可以被正常展示了。很多懒加载文章里也提到了如果给 img 标签设置了 src 即使他是 display: none 也会发起网络请求,而标准的懒加载的属性LazyChrome77 , Safari16) 兼容性还不是很好,所以这个项目也是采用了不可视就不渲染的方式进行处理。

  render() {
    const {
      height,
      children,
      placeholder,
      className,
      classNamePrefix,
      style
    } = this.props;

    return (
      <div className={`${classNamePrefix}-wrapper ${className}`} ref={this.setRef} style={style}>
        {this.visible ? (
          children
        ) : placeholder ? (
          placeholder
        ) : (
          <div
            style={{ height: height }}
            className={`${classNamePrefix}-placeholder`}
          />
        )}
      </div>
    );
  }
  • 项目的其他细节点,整体来看还是蛮优秀的

    • 项目处理了滚动事件的防抖和截流模式

    • 项目处理了避免父滚动容器多次绑定 scroll 事件

    • 项目处理了 once 模式,这个模式的好处是减少了滚动时,对已经可视的元素进行不必要的 checkVisible

    • 项目封装了 event,因为需要对 dom 直接进行事件绑定而非使用 React 的事件绑定,所以要考虑兼容性。

    • passiveEvent 让滚动中执行的代码逻辑不影响滚动

4. 针对自身项目,对 react-lazyload 进行简化&改造

从 github 提交记录可以发现这个项目已经很久没有进行迭代了,所以是存在一些可以改进的空间的,针对项目自身的需求,可以对 react-lazyload 进行改造。

以下代码,对 react-lazyload 进行了 typescript 改造,并做了以下改动

  • perf: 优化合并了 checkNormalVisiblecheckOverflowVisible 方法,因为实现原理是一致的。checkOverflowVisible 是另一个方法的超集。另外原来 checkNormalVisible 中高度都取自 window 并没有来自 scrollContainer 的对应 dom ,其实是有 bug 的。

  • perf: 优化了 checkVisible 的实现,老代码中,无论是否启用 overflow 属性,都会去遍历寻找滚动的父元素的,这是消耗性能的。

  // 老代码
  const parent = scrollParent(node);
  const isOverflow =
    component.props.overflow &&
    parent !== node.ownerDocument &&
    parent !== document &&
    parent !== document.documentElement;
  const visible = isOverflow
    ? checkOverflowVisible(component, parent)
    : checkNormalVisible(component);
    
  // 新代码
 const visible = checkElementVisible(component, component.parent);
  • perf: 优化了 once 的实现。

    • 调整了 purgePending() 的执行顺序,因为 pending 中的元素是已经展示了,才被添加到 pending 中的,所以可以再 checkVisible 前执行。
    • 另外修改了 once 默认为 true, 因为我的项目只是单纯的想做懒加载,没有防爬虫和复杂列表性能问题,所以修改为 true,而且我认为更高性能的默认值可以降低使用者的使用成本。
// 老代码
const lazyLoadHandler = () => {
    for (let i = 0; i < listeners.length;   i) {
        const listener = listeners[i];
        checkVisible(listener);
    }
    purgePending();
}

// 新代码
const lazyLoadHandler = () => {
    purgePending();
    for (let i = 0; i < listeners.length;   i) {
        const listener = listeners[i];
        checkVisible(listener);
    }
}

// 老代码 props.once 默认值 false
// 新代码
  • perf: 优化了overflow的实现,对 parent 的赋值放到了 componentDidMount 中,而不用在 checkVisible 时,每次都去找父滚动容器。

  • perf&break-change: 简化了 scrollParent 的实现

    • 如果想用 overflow 属性来自动寻找父滚动容器的方法,需要保证子元素的第一个能找到的 overflow-x|-yautoscroll 的就是滚动容器,原代码中对 overflow 的校验是 x, y 都要是自动或滚动,不太符合我的场景。
    • 目前项目中想自动寻找父容器的原因是,公共组件在不同路由使用,可能多个路由的滚动容器类名不同,如果每个路由都要单独传 scrollContainer 改造成本太大,所以采用自动寻找模式
// 新代码
const findScrollParent = (node: HTMLElement | null): HTMLElement => {
    if (!node || !(node instanceof HTMLElement)) {
        return document.documentElement;
    }

    const overflowRegex = /(auto|scroll)/;

    let parent = node.parentElement;
    while (parent) {
        const { overflow, overflowX, overflowY } = window.getComputedStyle(parent);

        if (overflowRegex.test(overflow) || overflowRegex.test(overflowX) || overflowRegex.test(overflowY)) {
            return parent;
        }

        parent = parent.parentElement;
    }

    return node.ownerDocument?.documentElement || document.documentElement;
};
  • break-change:移除了 throttledebounce 实现,因为很多项目都有自己的防抖和截流,而且每个子元素都可以修改父容器公共的 scroll事件,其实不太科学,增加了绑定,解绑 scroll 事件的复杂度,如果想实现,可能会再增加全局方法,可以对 scroll 事件做统一的防抖,截流设置。

  • fix:修复了占位消失时,图片未出现,导致 getBoundingClientRect 取值错误的问题

完整代码

/**
 * 懒加载组件
 */
import React, { Component } from "react";

const LISTEN_FLAG = "data-lazyload-listened";
const listeners: LazyLoad[] = [];
let pending: LazyLoad[] = [];
let passiveEvent: { capture: false; passive: true } | false = false;

const testPassiveEventSupported = () => {
  // try to handle passive events
  let passiveEventSupported = false;
  try {
    const opts = Object.defineProperty({}, "passive", {
      // eslint-disable-next-line getter-return
      get() {
        passiveEventSupported = true;
      },
    });
    window.addEventListener("test", () => {}, opts);
  } catch (e) {
    console.log(e);
  }
  // if they are supported, setup the optional params
  // IMPORTANT: FALSE doubles as the default CAPTURE value!
  passiveEvent = passiveEventSupported
    ? { capture: false, passive: true }
    : false;
};

testPassiveEventSupported();

const findScrollParent = (node: HTMLElement | null): HTMLElement => {
  if (!node || !(node instanceof HTMLElement)) {
    return document.documentElement;
  }

  const overflowRegex = /(auto|scroll)/;

  let parent = node.parentElement;
  while (parent) {
    const { overflow, overflowX, overflowY } = window.getComputedStyle(parent);

    if (
      overflowRegex.test(overflow) ||
      overflowRegex.test(overflowX) ||
      overflowRegex.test(overflowY)
    ) {
      return parent;
    }

    parent = parent.parentElement;
  }

  return node.ownerDocument?.documentElement || document.documentElement;
};

/**
 * Check if `component` is visible in overflow container `parent`
 * @param  {node} component React component
 * @param  {node} parent    component's scroll parent
 * @return {bool}
 */
const checkElementVisible = (
  component: LazyLoad,
  parent: HTMLElement | undefined
) => {
  const node = component.ref.current;

  if (!node) return false;
  if (!parent) return null;

  const {
    top: parentTop,
    left: parentLeft,
    height: parentHeight,
    width: parentWidth,
  } = parent.getBoundingClientRect();

  const windowInnerHeight =
    window.innerHeight || document.documentElement.clientHeight;
  const windowInnerWidth =
    window.innerWidth || document.documentElement.clientWidth;

  // calculate top and height of the intersection of the element's scrollParent and viewport
  const intersectionTop = Math.max(parentTop, 0); // intersection's top relative to viewport
  const intersectionLeft = Math.max(parentLeft, 0); // intersection's left relative to viewport
  const intersectionHeight =
    Math.min(windowInnerHeight, parentTop   parentHeight) - intersectionTop; // height
  const intersectionWidth =
    Math.min(windowInnerWidth, parentLeft   parentWidth) - intersectionLeft; // width

  // check whether the element is visible in the intersection
  const { top, left, height, width } = node.getBoundingClientRect();

  const offsetTop = top - intersectionTop; // element's top relative to intersection
  const offsetLeft = left - intersectionLeft; // element's left relative to intersection

  const offsets = Array.isArray(component.props.offset)
    ? component.props.offset
    : [component.props.offset, component.props.offset]; // Be compatible with previous API

  return (
    offsetTop - offsets[0] <= intersectionHeight &&
    offsetTop   height   offsets[1] >= 0 &&
    offsetLeft - offsets[0] <= intersectionWidth &&
    offsetLeft   width   offsets[1] >= 0
  );
};

/**
 * Detect if element is visible in viewport, if so, set `visible` state to true.
 * If `once` prop is provided true, remove component as listener after checkVisible
 *
 * @param  {React} component   React component that respond to scroll and resize
 */
const checkVisible = function checkVisible(component: LazyLoad) {
  const node = component.ref.current;
  if (!(node instanceof HTMLElement)) {
    return;
  }

  const visible = checkElementVisible(component, component.parent);
  if (visible) {
    // Avoid extra render if previously is visible
    if (!component.visible) {
      if (component.props.once) {
        pending.push(component);
      }

      // eslint-disable-next-line no-param-reassign
      component.visible = true;
      component.forceUpdate();
    }
  } else if (!(component.props.once && component.visible)) {
    // eslint-disable-next-line no-param-reassign
    component.visible = false;
    if (component.props.unmountIfInvisible) {
      component.forceUpdate();
    }
  }
};

const purgePending = function purgePending() {
  pending.forEach((component) => {
    const index = listeners.indexOf(component);
    if (index !== -1) {
      listeners.splice(index, 1);
    }
  });

  pending = [];
};

const lazyLoadHandler = () => {
  // Remove `once` component in listeners
  purgePending();
  for (let i = 0; i < listeners.length;   i) {
    const listener = listeners[i];
    checkVisible(listener);
  }
};

type LazyLoadProps = {
  /**
   * 类名
   */
  className?: string;
  /**
   * 前缀
   */
  classNamePrefix?: string;
  /**
   * 是否仅检测一次
   */
  once?: boolean;
  /**
   * 元素高度
   */
  height: number;
  /**
   * 预渲染范围
   */
  offset: number | number[];
  /**
   * 自动检查父元素
   */
  overflow?: boolean;
  /**
   * 窗口大小变化时是否检查
   */
  resize?: boolean;
  /**
   * 滚动容器滚动时检查
   */
  scroll?: boolean;
  /**
   * 子元素
   */
  children: React.ReactNode;
  /**
   * 占位元素
   */
  placeholder?: React.ReactNode;
  /**
   * 滚动容器选择器
   */
  scrollContainer?: string;
  /**
   * 不可见时更新视图
   */
  unmountIfInvisible?: boolean;
  /**
   * 样式
   */
  style?: React.CSSProperties;
};

class LazyLoad extends Component<LazyLoadProps> {
  visible = false;

  ref = React.createRef<HTMLDivElement>();

  parent: HTMLElement | undefined;

  componentDidMount() {
    // It's unlikely to change delay type on the fly, this is mainly
    // designed for tests
    let scrollport: HTMLElement | null = document.documentElement;
    const { scrollContainer } = this.props;
    if (scrollContainer) {
      scrollport = document.querySelector<HTMLElement>(scrollContainer);
    }

    if (!scrollport) return;

    this.parent = scrollport;

    if (this.props.overflow) {
      const parent = findScrollParent(this.ref.current);
      if (parent && typeof parent.getAttribute === "function") {
        const listenerCount = 1   Number(parent.getAttribute(LISTEN_FLAG) || 0);
        if (listenerCount === 1) {
          parent.addEventListener("scroll", lazyLoadHandler, passiveEvent);
        }
        parent.setAttribute(LISTEN_FLAG, `${listenerCount}`);
      }

      this.parent = parent;
    } else if (listeners.length === 0) {
      const { scroll, resize } = this.props;

      if (scroll) {
        scrollport.addEventListener("scroll", lazyLoadHandler);
      }

      if (resize) {
        scrollport.addEventListener("resize", lazyLoadHandler);
      }
    }

    listeners.push(this);
    checkVisible(this);
  }

  shouldComponentUpdate() {
    return this.visible;
  }

  componentWillUnmount() {
    const { parent } = this;

    if (this.props.overflow) {
      if (parent && typeof parent.getAttribute === "function") {
        const listenerCount = Number(parent.getAttribute(LISTEN_FLAG) || 0) - 1;
        if (listenerCount === 0) {
          parent.removeEventListener("scroll", lazyLoadHandler, passiveEvent);
          parent.removeAttribute(LISTEN_FLAG);
        } else {
          parent.setAttribute(LISTEN_FLAG, `${listenerCount}`);
        }
      }
    }

    const index = listeners.indexOf(this);
    if (index !== -1) {
      listeners.splice(index, 1);
    }

    if (listeners.length === 0 && parent) {
      parent.removeEventListener("resize", lazyLoadHandler, passiveEvent);
      parent.removeEventListener("scroll", lazyLoadHandler, passiveEvent);
    }
  }

  render() {
    const { height, children, placeholder, className, classNamePrefix, style } =
      this.props;

    return (
      <div
        className={`${classNamePrefix}-wrapper ${className}`}
        ref={this.ref}
        style={{ height, ...style }}
      >
        {this.visible
          ? children
          : placeholder || (
              <div
                style={{ height }}
                className={`${classNamePrefix}-placeholder`}
              />
            )}
      </div>
    );
  }
}

(LazyLoad as any).defaultProps = {
  className: "",
  classNamePrefix: "lazyload",
  once: true,
  offset: 0,
  overflow: false,
  resize: false,
  scroll: true,
  unmountIfInvisible: false,
};

export default LazyLoad;

我也将这个项目发布到了 npm ,想尝试的同学可以试试。

yarn add react-lazyload-typescript

5. 结语

监听滚动事件,使用 getBoundingClientRect 进行元素可视判断是懒加载主要实现形式之一,随着浏览器日渐升级,很多懒加载的库开始转用 intersection-observer 来实现懒加载,之后会出专门的文章介绍,感兴趣的朋友可以点点关注和小心心,谢谢大家。

这篇文章转载于:学新通

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通
  • 本文地址: https://www.swvq.com/boutique/detail/tanfbghi
  • 联系方式: luke.wu●vfv.cc
系列文章
更多 icon
同类精品
更多 icon
我要评论
我的头像
精彩评论
继续加载