likes
comments
collection
share

探索 ResizeObserver 的神奇力量

作者站长头像
站长
· 阅读数 11

观察者 API 家族,全部文章列表,欢迎点赞收藏

引言

  1. 是什么:介绍 ResizeObserver 技术基础知识兼容性以及原理

  2. 做什么:介绍 ResizeObserver 在多场景的应用实战代码,同时会介绍笔者总结的一些进阶技巧最佳实践,其中应用场景多达 10 个:

    • 响应式组件
    • 文本内容折叠并显示"...查看全部"
    • 虚拟列表支持动态高度
    • 拖拽和缩放
    • 在容器尺寸变化时做出反应
    • 感知交互行为的发生
    • 感知元素是否显示或隐藏
    • 响应式广告投放
    • 聊天窗口最新消息置底
    • 埋点真实性确认
  3. 哪些坑:介绍 ResizeObserver 怎么测试调试以及一些限制

文章约 10000 字,阅读时长 30 分钟

1. 介绍

从一道面试题开始

题目:如何让一个组件或者一个元素做到响应式?

每个人都有自己的答案,这里分析仅供参考

答: 思考这个问题,最先想到的可能是 CSS 媒体查询,根据不同设备的屏幕大小、方向和其他特征来创建响应式布局和样式,可以用于整体页面也可以用于某个组件。 JS 方法可以选用 window.matchMedia,效果类似 CSS 媒体查询。 更精细的控制,可以通过 window.resize 监听视口(viewport)变化,然后通过getBoundingClientRect 或者 getComputedStyle 来获取此时我们关心的元素大小,以此判断元素是否发生了变化,但这些 API 会导致 reflow,同时 resize 事件触发也有些频繁,有一定的性能问题。

ResizeObserver 出现的背景

仔细想一想上面的答案,相信很多小伙伴都会觉得有道理但没有抓住关键 性能问题先不考虑的情况下,上述三种方案依然存在一个致命问题:只能基于监测 viewport 的变化做出反应 试想下:有时候窗体的尺寸没有变化DOM 元素的尺寸有没有可能变化?(此时组件同样需要做响应式调整) 场景其实很多:

  • 最简单的,当动态添加或从 DOM 中删除元素时,会影响父元素的大小
  • 一个可拖拽调整大小的容器被改变尺寸时
  • 元素仅仅是显示隐藏切换时

反之 有的时候窗体的尺寸变化了,但是 DOM 元素的尺寸并没有变化,此时组件不需要做响应式调整 这些场景在现代 web 应用程序中非常频繁,通过以上分析,再加上性能考量,我们可以把问题精简为: 题目:如何高效的观察一个元素的尺寸变化? 为解决这个问题,一个全新的 API 就出来了,就是 ResizeObserver API,专门用来观察 DOM 元素的尺寸是否发生了变化,同时无需再手动调用 getBoundingClientRect 来获取元素的尺寸大小,它对任何元素大小变化做出反应,与引起变化的原因无关

ResizeObserver 基础使用

  1. 创建 ResizeObserver 实例:使用 new ResizeObserver() 构造函数创建一个 ResizeObserver 实例。可以将一个回调函数作为参数传递给构造函数,该回调函数将在尺寸变化时被调用。

    const observer = new ResizeObserver(callback);
    
  2. 观察目标元素:使用 ResizeObserver 实例的 observe() 方法来观察需要监听尺寸变化的目标元素。可以传入要观察的元素作为参数。

    const targetElement = document.getElementById('target');
    observer.observe(targetElement);
    
  3. 编写回调函数:定义一个回调函数,当目标元素的尺寸发生变化时将被触发。回调函数将接收一个参数,其中包含了尺寸变化的相关信息。

    function callback(entries, observer) {
      entries.forEach(entry => {
        console.log('目标元素的尺寸发生变化');
        console.log('新的宽度:', entry.contentRect.width);
        console.log('新的高度:', entry.contentRect.height);
      });
    }
    
  4. 停止观察尺寸变化(可选):如果不再需要监听目标元素的尺寸变化,可以使用 ResizeObserver 实例的 unobserve() 方法停止观察。

    observer.unobserve(targetElement);
    

完整示例代码如下:

const observer = new ResizeObserver(callback);

const targetElement = document.getElementById('target');
observer.observe(targetElement);

function callback(entries, observer) {
  entries.forEach(entry => {
    console.log('目标元素的尺寸发生变化');
    console.log('新的宽度:', entry.contentRect.width);
    console.log('新的高度:', entry.contentRect.height);
  });
}

MDN 官方介绍和示例请移步

2 兼容性

2.1 原生支持度

浏览器支持程度超过 95%IE11+ 以及其他现代浏览器直接引入项目 JS 就可以了。 探索 ResizeObserver 的神奇力量

2.2 polyfill

如果需要兼容更低版本的浏览器环境,你可能需要安装一个 polyfill,以下是较为常用的ResizeObserver polyfill 浏览器版本支持情况如下: 探索 ResizeObserver 的神奇力量

另一个可选项是:resize-observer-polyfill

3 实战应用

3.1 响应式组件

第一个场景,是开篇面试题的解决方案 这里以 Chrome web.dev 的一个方案作为示例:

const ro = new ResizeObserver(entries => {
  for (let entry of entries) {
    entry.target.style.borderRadius = Math.max(0, 250 - entry.contentRect.width) + 'px';
  }
});

ro.observe(document.querySelector('.box'));

这里展示了一个 box 元素根据其宽度更改边框半径,实际项目中有更复杂的逻辑和实现,但原理一致。

vue-responsive-components 库有具体实现代码,还有组件形式的实现,感兴趣的可以去看看

3.2 文本内容折叠并显示"...查看全部"

文字超出部分显示"...",这个效果大部分人可能会想到 css 实现,但加上折叠效果和点击"查看全部"展开效果,纯 css 很难完美实现这个功能,往往需要配合 JS 实现,这其中最重要的一个环节,就是随着传入的文本变动或者容器尺寸的变动要进行重新计算,这也正是 ResizeObserver 可以发挥的地方:

const observer = new ResizeObserver(() => {
    refresh(); // 重新计算
});
observer.observe(document.getElementById('target'));

这个功能有一个实现的 vue 库,vue-overflow-ellipsis,全部实现代码可以参考

3.3 虚拟列表支持动态高度

虚拟列表的实现过程中,列表项的高度 是一个必备的数据,依赖于这些数据虚拟列表才可能正常的工作,通过计算机和我们算法的的算力以及即时渲染能力换取 DOM 数量的减少。

  1. 列表项定高的虚拟列表,往往在初始化的时候就需要把高度值传递给组件。但有时候我们需要的组件是一个自适应的组件,需要在不同设备用不同的高度展示,尤其是在 px-to-viewport 方案时,这时候预先传入的列表项高度就不准,需要根据屏幕比例计算后传给组件或者初始化完成之后再计算一次
  2. 列表项动态高度的虚拟列表
    • 比如列表项是图片等资源需要一定加载时间之后才能确定高度
    • 比如列表项是动态内容每一项高度不一致
    • 比如列表项是标题+内容,内容是可以展开收起的
    • 比如虚拟列表的变种,虚拟树形组件

如果考虑动态的高度需求,这时候对列表项高度的监控就是一项必须做的工作,这时候就是 ResizeObserver 的一个使用场景了,而且使用 ResizeObserver 以上两种场景可以一种方案同时解决。 以下是 vue 中列表项节点的组件实现示例:

<template>
  <div ref="node">
    <slot></slot>
  </div>
</template>

<script>
// 向上找组件
import { findComponentUpward } from '@/utils/findComponents';
// 基于 requestAnimationFrame 的截流函数
import { rafThrottle } from '@/utils';

export default {
  name: 'virtual-node',
  props: {
    index: {
      type: Number,
    },
  },
  mounted() {
    const dom = this.$refs.node;
    this.observer = new ResizeObserver(rafThrottle(this.insideViewportCb));
    this.observer.observe(dom);
    this.virtualList = findComponentUpward(this, 'virtual-list');
  },
  methods: {
    insideViewportCb([entry]) {
      const height = entry.contentRect.height;
      if (height === 0) return;
      const cacheHeight = this.virtualList.positions[this.index].height;
      if (cacheHeight === height) return;
      this.$emit('size-change', {
        index: this.index,
        height,
      });
      return;
    },
  },
};
</script>

3.4 拖拽和缩放

  • 拖拽和调整元素大小
  • 拖拽时多元素联动
  • 拖放和布局编辑器
  • 多栏现实时控制最小尺寸
  • 图片裁剪和缩放 以上场景大同小异,以图片裁剪和缩放为例,展示代码:
// 获取图片容器元素和图片元素
const imageContainer = document.querySelector('.image-container');
const image = imageContainer.querySelector('img');

// 使用 ResizeObserver 监听容器尺寸变化
const resizeObserver = new ResizeObserver(function(entries) {
  for (let entry of entries) {
    // 获取容器的宽度和高度
    const containerWidth = entry.contentRect.width;
    const containerHeight = entry.contentRect.height;

    // 计算图片的缩放比例
    const imageWidth = image.naturalWidth;
    const imageHeight = image.naturalHeight;
    const scaleX = containerWidth / imageWidth;
    const scaleY = containerHeight / imageHeight;
    const scale = Math.max(scaleX, scaleY);

    // 根据缩放比例设置图片的样式
    const newWidth = Math.ceil(imageWidth * scale);
    const newHeight = Math.ceil(imageHeight * scale);
    const newLeft = Math.ceil((containerWidth - newWidth) / 2);
    const newTop = Math.ceil((containerHeight - newHeight) / 2);
    image.style.width = newWidth + 'px';
    image.style.height = newHeight + 'px';
    image.style.left = newLeft + 'px';
    image.style.top = newTop + 'px';
  }
});

resizeObserver.observe(imageContainer); // 监听图片容器的尺寸变化

以上代码会根据图片容器的尺寸变化,计算图片的缩放比例,并根据缩放比例设置图片的样式,以实现图片的裁剪和缩放效果。ResizeObserver API 会监听容器尺寸的变化,以实时更新图片的样式,确保图片始终填充并居中于容器中。

注意,这只是一个基本的示例,你可以根据自己的需求进行进一步的样式和交互优化,例如添加动画效果或调整图片的对齐方式。

3.5 在容器尺寸变化时做出反应

  • 响应式地图和地图标注
  • 自由绘制的画板
  • 数据可视化随大小调整展示内容或间隔
  • 响应式导航菜单
  • 结合 CSS Animation API 实现动态布局调整
  • 自适应弹窗和对话框

以上场景都是需要在父容器尺寸改变时做响应动作的场景,和3.13.2有些类似,但又有所不同,并不需要特别高的灵敏度但触发时机又需要精确控制,因此往往需要配合截流防抖或者其他控制逻辑一起使用,代码类似

3.6 感知交互行为的发生

这是 张鑫旭 大神的一个示例:

当元素里面的内容变多或变少的时候,如果没有把高度和宽度定死,则我们可以通过观察元素的尺寸是否有变化,而知道是否有交互行为发生。 例如,我们在使用 Ajax 发表评论的时候,需要把评论写入到现有的评论列表,通过观察评论容器的尺寸变化,我们就可以认为这个评论交互已经完成,然后脱离具体的业务逻辑完成其他一些需求,例如数据埋点,我们只需要观察特定容器尺寸是否变化,然后发送埋点数据即可。优点是数据埋点无侵入,无需写入到业务逻辑中,非常灵活,也利于日后维护。

原文链接在此

3.7 感知元素是否显示或隐藏

另一个 张鑫旭 大神的一个示例:

当一个元素使用 display:none 进行隐藏的时候,也是会触发尺寸变化的,于是也能够被观测到。 基于尺寸的观测要比基于属性的观测要更精准。因为一个元素的隐藏可能是通过其他元素的属性变化触发的,例如其他元素添加了一个类名,这个类名正好可以影响当前元素的隐藏。

原文链接在此

const objResizeObserver = new ResizeObserver(function () {
    if (getComputedStyle(img).display == 'none') {
        console.log('图片隐藏了');
    } else {
        console.log('图片显示了');
    }
});
// 观察图片元素
objResizeObserver.observe(img);

3.8 响应式广告投放

响应式广告投放是一种广告投放策略,旨在根据用户的设备、屏幕尺寸和浏览器等特征,提供适应性更强的广告体验。它的目标是确保广告在各种设备上都能正确呈现,并在不同屏幕尺寸上保持良好的可视性和用户体验。 响应式广告投放使广告主可以更好地达到跨设备的广告覆盖,提高广告的可见性和点击率,并为用户提供一致且良好的广告体验。同时,响应式广告投放还简化了广告创作和管理的流程,减少了针对不同设备和屏幕尺寸进行独立投放的工作量和复杂性。

// 获取广告容器元素和广告占位元素
const adContainer = document.querySelector('.ad-container');
const adPlaceholder = adContainer.querySelector('.ad-placeholder');

// 创建 ResizeObserver 实例
const resizeObserver = new ResizeObserver(function(entries) {
  for (let entry of entries) {
    // 获取容器的宽度和高度
    const containerWidth = entry.contentRect.width;
    const containerHeight = entry.contentRect.height;

    // 根据容器的尺寸调整广告的大小和展示方式
    if (containerWidth >= 768) {
      // 大屏幕尺寸,显示大尺寸广告
      adPlaceholder.style.backgroundImage = 'url(big-ad.jpg)';
      adPlaceholder.style.backgroundSize = 'cover';
    } else {
      // 小屏幕尺寸,显示小尺寸广告
      adPlaceholder.style.backgroundImage = 'url(small-ad.jpg)';
      adPlaceholder.style.backgroundSize = 'contain';
    }
  }
});

resizeObserver.observe(adContainer); // 监听广告容器的尺寸变化

注意:此示例仅展示了根据尺寸展示不同广告的场景,实际场景要复杂的多,往往设计数据、文案、样式以及接口等等复杂逻辑

3.9 聊天窗口最新消息置底

这是来自 Chrome web.dev 的一个示例

一个有趣的例子是聊天窗口。在典型的从上到下的对话布局中出现的问题是滚动定位。为避免让用户感到困惑,最好将窗口贴在对话的底部,即最新消息出现的位置。此外,任何类型的布局更改(设想手机从横向变为纵向,反之亦然)都应该实现相同的效果。 利用 ResizeObserver 只需写段代码,即可满足两种情况的需要。调整窗口大小是一个 ResizeObserver 可以根据定义捕获的事件,但调用 appendChild() 也会调整该元素的大小(除非设置了 overflow: hidden ),因为它需要为新元素腾出空间。考虑到这一点,只需很少的行就可以达到预期的效果:

const ro = new ResizeObserver(entries => {  
document.scrollingElement.scrollTop =  
document.scrollingElement.scrollHeight;  
});  
  
// 监听 scrollingElement 了解窗口大小何时改变  
ro.observe(document.scrollingElement);  
// 监听 timeline 以处理新消息  
ro.observe(timeline);

3.10 埋点真实性确认

有些时候,我们对一些埋点进行真实性确认,比如有一个 A B 实验埋点,用户命中 A 需要展示 boxA 元素,命中 B 需要展示 boxB 元素,但因为网络、缓存等原因,上报的命中结果和真实命中结果可能会有差别。其他场景类似,比如在 300px 展示 box300500px 展示 box500 等场景。 这时候 ResizeObserver 可以提供这种观察,并且提供和业务解耦的确认(真实上报)逻辑。

// 假设当前 AB 结果为 A
const objResizeObserver = new ResizeObserver(function () {
    if (getComputedStyle(img).display == 'none') {
        trackExposure('ABTest', false); // 记录埋点无效
    } else {
        trackExposure('ABTest', true); // 记录埋点有效
    }
});
// 观察元素
objResizeObserver.observe(document.getElementById('boxA'));

4 进阶技巧和最佳实战

ResizeObserver 是一个强大的 API,除了基本的用法,还有一些进阶技巧和最佳实战可以帮助你更好地应用 ResizeObserver。下面是一些值得注意的技巧和实践:

  1. 避免频繁触发回调 当元素的尺寸发生变化时,ResizeObserver 会立即触发回调函数。为了避免频繁触发回调,可以使用防抖或节流技术来控制回调的触发频率,以提高性能和优化用户体验。

  2. 监听根元素尺寸变化 除了监听单个元素的尺寸变化,ResizeObserver 还可以监听根元素(如 <body><html>)的尺寸变化。这对于需要响应整个页面尺寸变化的场景非常有用,例如实现响应式布局或全局样式的调整。

  3. 处理动态添加的元素 如果页面中存在动态添加的元素,需要在元素被添加到 DOM 后手动调用 observe 方法来开始监听其尺寸变化。同样,在元素被移除时,记得调用 unobserve 方法停止监听。

  4. 结合其他 API 使用 ResizeObserver 可以与其他 Web API 结合使用,增强其功能。例如,结合 Intersection Observer API 可以实现元素的可见性检测,结合 Mutation Observer API 可以监测 DOM 结构的变化。

  5. 性能优化 如果页面中存在大量需要监听尺寸变化的元素,为了避免性能问题,可以限制 ResizeObserver 的实例数量,或者只在需要的时候才创建实例。同时,可以选择性地监听某些关键元素的尺寸变化,而忽略其他不重要的元素。

  6. 兼容性处理 尽管 ResizeObserver API 是现代浏览器的标准 API,但为了确保在旧版本的浏览器中的兼容性,可以使用 polyfill 或库来提供类似 ResizeObserver 的功能。

  7. 销毁 ResizeObserver 实例 当不再需要监听尺寸变化时,记得手动调用 disconnect 方法来停止 ResizeObserver 的监听,以释放资源和避免潜在的内存泄漏。

  8. 循环调用 在回调函数中对元素的尺寸进行修改可能会导致循环调用的问题。因为每次尺寸变化都会触发 ResizeObserver 的回调函数,如果在回调函数中又修改了元素的尺寸,会再次触发回调,造成无限循环。为了避免这个问题,需要谨慎处理回调函数中对元素尺寸的修改操作。

5 总结

ResizeObserver API 的目标是提供一种高效且准确地监听 DOM 元素尺寸变化的方法,以满足开发者在动态布局和自适应界面方面的需求。 ResizeObserver 具有以下几个特性:

  1. 监听元素尺寸变化 ResizeObserver 主要用于监听指定元素的尺寸变化。它可以观察目标元素的宽度、高度、边界框等尺寸属性的变化,并在发生变化时触发回调函数。
  2. 高性能和效率 ResizeObserver 设计用于高性能和效率的尺寸变化检测。相对于传统的 resize 事件或 MutationObserverResizeObserver 只关注被观察元素的尺寸变化,避免了不必要的计算和触发,提供了更优化的性能。
  3. 同时观察多个元素 ResizeObserver 允许一次性观察多个元素的尺寸变化。通过将多个目标元素传递给 observe 方法,可以同时监听它们的尺寸变化,而不需要为每个元素单独创建监听器。
  4. 提供准确的尺寸信息 在回调函数中,ResizeObserver 提供了准确的尺寸信息。这包括目标元素的宽度、高度、上下左右边界的偏移量等,使开发者可以根据需要进行动态布局或样式调整。
  5. 触发方式灵活可控 ResizeObserver 的触发方式灵活可控。它可以通过设置不同的触发选项来决定在什么情况下触发回调函数,例如只在尺寸变化结束后触发、只在尺寸增加或减少时触发等。
  6. 兼容性和浏览器支持 ResizeObserverW3C 规范定义的标准 API,得到了现代浏览器的支持,包括 ChromeFirefoxSafariEdge 等。尽管在一些旧版本的浏览器中不被支持,但可以使用 polyfill 或第三方库来提供兼容性支持。
  7. 动态响应式布局 由于 ResizeObserver 可以精确监听元素的尺寸变化,它在实现动态响应式布局时非常有用。开发者可以根据元素尺寸的变化,实时调整布局或重新计算样式,从而实现更好的用户体验。 ResizeObserver 的出现填补了在原生 JavaScript 中监听元素尺寸变化的空白,为开发者提供了更好的解决方案,使得元素尺寸变化的检测更加准确、高效,从而改善了响应式布局的开发体验

最后