Resize Observer API

深入理解 Resize Observer API 如何观察元素尺寸变化、与传统 window.resize 事件对比、以及在响应式组件和容器查询中的应用。

Resize Observer API(Resize Observer API)

一、为什么需要 Resize Observer

1.1 传统方法的局限性

在 Resize Observer 出现之前,监听元素尺寸变化是一项复杂的任务:

window.resize 事件的问题

  • 只能监听窗口大小变化,无法监听特定元素
  • 在滚动时频繁触发
  • 无法获取具体哪个元素发生了变化
// 传统方法:定时轮询
function checkElementSize(element, callback) {
  let lastWidth = element.offsetWidth;
  let lastHeight = element.offsetHeight;

  setInterval(() => {
    const newWidth = element.offsetWidth;
    const newHeight = element.offsetHeight;

    if (newWidth !== lastWidth || newHeight !== lastHeight) {
      callback({ width: newWidth, height: newHeight });
      lastWidth = newWidth;
      lastHeight = newHeight;
    }
  }, 100);
}

getBoundingClientRect 的问题

  • 触发强制同步布局
  • 性能开销大
  • 需要手动管理轮询

1.2 Resize Observer 的优势

Resize Observer 是专门为观察元素尺寸变化设计的 API:

  1. 专门针对元素:观察特定元素的尺寸变化
  2. 异步执行:不在滚动时阻塞主线程
  3. 浏览器优化:内部使用 CSS containments 优化
  4. 与 CSS 布局协同:与 CSS 变化(如 flex、grid)无缝配合
const observer = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    console.log('Element:', entry.target);
    console.log('Content rect:', entry.contentRect);
    console.log('New size:', entry.contentRect.width, entry.contentRect.height);
  });
});

observer.observe(document.querySelector('.resizable'));

二、API 详解

2.1 基本用法

const observer = new ResizeObserver(callback);

function callback(entries, observer) {
  entries.forEach(entry => {
    // entry 类型是 ResizeObserverEntry
  });
}

observer.observe(element);
observer.unobserve(element);
observer.disconnect();

2.2 ResizeObserverEntry

// ResizeObserverEntry 的属性
entries.forEach(entry => {
  // 目标元素
  const target = entry.target;

  // 内容矩形(border-box 内的区域)
  const contentRect = entry.contentRect;
  console.log('Width:', contentRect.width);
  console.log('Height:', contentRect.height);
  console.log('X:', contentRect.x);
  console.log('Y:', contentRect.y);
  console.log('Top:', contentRect.top);
  console.log('Right:', contentRect.right);
  console.log('Bottom:', contentRect.bottom);
  console.log('Left:', contentRect.left);
});

2.3 边框盒 vs 内容盒

ResizeObserver 默认报告内容盒(content box)的尺寸变化:

// 观察内容盒(默认)
observer.observe(element);

// 观察边框盒(通过 ResizeObserverSize)
observer.observe(element, { box: 'border-box' });

// 观察设备像素比调整后的内容盒
observer.observe(element, { box: 'device-pixel-content-box' });

三、实际应用场景

3.1 响应式组件

// 根据容器宽度自动调整布局
class ResponsiveGrid {
  constructor(container, options = {}) {
    this.container = container;
    this.options = {
      minColumnWidth: 200,
      gap: 16,
      ...options
    };

    this.observer = new ResizeObserver(() => {
      this.updateLayout();
    });

    this.observer.observe(container);
  }

  updateLayout() {
    const containerWidth = this.container.clientWidth;
    const gap = this.options.gap;
    const minWidth = this.options.minColumnWidth;

    // 计算列数
    const columns = Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)));

    // 计算实际列宽
    const totalGap = gap * (columns - 1);
    const columnWidth = (containerWidth - totalGap) / columns;

    this.container.style.gridTemplateColumns = `repeat(${columns}, ${columnWidth}px)`;
    this.container.style.gap = `${gap}px`;
  }

  destroy() {
    this.observer.disconnect();
  }
}

3.2 文本省略与容器查询

// 根据容器宽度决定文本显示方式
class TextTruncator {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      lines: 1,
      ellipsis: '...',
      ...options
    };

    this.observer = new ResizeObserver(() => {
      this.checkTruncation();
    });

    this.observer.observe(element);
  }

  checkTruncation() {
    const width = this.element.clientWidth;
    const fontSize = parseFloat(getComputedStyle(this.element).fontSize);

    // 估算每行字符数
    const charWidth = fontSize * 0.5;  // 粗略估计
    const charsPerLine = Math.floor(width / charWidth);

    // 根据容器宽度决定是否截断
    if (charsPerLine < 20) {
      this.element.style.whiteSpace = 'nowrap';
      this.element.style.overflow = 'hidden';
      this.element.style.textOverflow = 'ellipsis';
    } else {
      this.element.style.whiteSpace = 'normal';
      this.element.style.overflow = 'visible';
      this.element.style.textOverflow = 'clip';
    }
  }

  destroy() {
    this.observer.disconnect();
  }
}

3.3 图表自适应

// 图表容器大小变化时重绘
class ResponsiveChart {
  constructor(container, chart) {
    this.container = container;
    this.chart = chart;
    this.observer = null;

    this.init();
  }

  init() {
    this.observer = new ResizeObserver(
      this.debounce((entries) => {
        entries.forEach(entry => {
          const { width, height } = entry.contentRect;
          if (width > 0 && height > 0) {
            this.chart.resize(width, height);
          }
        });
      }, 100)
    );

    this.observer.observe(this.container);
  }

  debounce(fn, delay) {
    let timeoutId;
    return (...args) => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => fn(...args), delay);
    };
  }

  destroy() {
    this.observer.disconnect();
  }
}

3.4 视频播放器适配

// 根据容器大小选择视频源
class AdaptiveVideoPlayer {
  constructor(container, video) {
    this.container = container;
    this.video = video;
    this.observer = null;

    this.init();
  }

  init() {
    this.observer = new ResizeObserver(() => {
      this.selectVideoSource();
    });

    this.observer.observe(this.container);
  }

  selectVideoSource() {
    const width = this.container.clientWidth;

    // 根据宽度选择视频质量
    if (width < 480) {
      this.video.src = this.video.dataset.src480;
    } else if (width < 720) {
      this.video.src = this.video.dataset.src720;
    } else if (width < 1080) {
      this.video.src = this.video.dataset.src1080;
    } else {
      this.video.src = this.video.dataset.src4k;
    }
  }

  destroy() {
    this.observer.disconnect();
  }
}

四、与容器查询的对比

4.1 容器查询(CSS Container Queries)

容器查询是 CSS 的新特性,允许基于父容器尺寸应用样式:

/* 定义容器 */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 根据容器宽度应用样式 */
@container card (min-width: 300px) {
  .card {
    display: grid;
    grid-template-columns: 200px 1fr;
  }
}

4.2 Resize Observer vs 容器查询

特性 Resize Observer 容器查询
触发时机 JavaScript 回调 CSS 规则应用
用途 逻辑处理、DOM 操作 样式变化
灵活性 可以执行任意 JavaScript 仅限于 CSS 声明
性能 异步、浏览器优化 CSS 引擎优化
浏览器支持 良好 逐渐支持中

4.3 配合使用

// Resize Observer 处理逻辑
// 容器查询处理样式
class SmartComponent {
  constructor(element) {
    this.element = element;
    this.observer = null;

    // 添加 container 类
    this.element.classList.add('container');

    // Resize Observer 处理数据相关的逻辑
    this.observer = new ResizeObserver((entries) => {
      entries.forEach(entry => {
        const width = entry.contentRect.width;

        // 更新 data 属性,触发 CSS 容器查询
        this.element.dataset.width = Math.floor(width);

        // 同时执行 JavaScript 逻辑
        this.updateVirtualList(width);
      });
    });

    this.observer.observe(this.element);
  }

  updateVirtualList(width) {
    // 根据宽度调整虚拟列表
    if (width > 600) {
      this.virtualList.setColumns(4);
    } else if (width > 400) {
      this.virtualList.setColumns(2);
    } else {
      this.virtualList.setColumns(1);
    }
  }

  destroy() {
    this.observer.disconnect();
  }
}

五、性能最佳实践

5.1 共享观察者

// 多个元素使用同一个观察者
class SharedResizeObserver {
  constructor(callback) {
    this.callback = callback;
    this.observer = new ResizeObserver((entries) => {
      this.callback(entries);
    });
    this.targets = new WeakSet();
  }

  observe(element) {
    if (!this.targets.has(element)) {
      this.targets.add(element);
      this.observer.observe(element);
    }
  }

  unobserve(element) {
    this.targets.delete(element);
    this.observer.unobserve(element);
  }

  disconnect() {
    this.observer.disconnect();
    this.targets.clear();
  }
}

5.2 防抖处理

class DebouncedResizeObserver {
  constructor(callback, delay = 100) {
    this.callback = callback;
    this.delay = delay;
    this.observer = null;
    this.timeouts = new Map();

    this.init();
  }

  init() {
    this.observer = new ResizeObserver((entries) => {
      entries.forEach(entry => {
        // 清除之前的 timeout
        if (this.timeouts.has(entry.target)) {
          clearTimeout(this.timeouts.get(entry.target));
        }

        // 设置新的 timeout
        this.timeouts.set(entry.target, setTimeout(() => {
          this.callback(entry);
          this.timeouts.delete(entry.target);
        }, this.delay));
      });
    });
  }

  observe(element) {
    this.observer.observe(element);
  }

  disconnect() {
    this.observer.disconnect();
    this.timeouts.forEach(timeout => clearTimeout(timeout));
    this.timeouts.clear();
  }
}

5.3 只在尺寸实际变化时处理

const observer = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    const { width, height } = entry.contentRect;
    const prev = entry.target.dataset.prevSize;

    if (prev) {
      const [prevWidth, prevHeight] = prev.split(',').map(Number);

      // 只在尺寸实际变化时处理
      if (prevWidth !== Math.round(width) || prevHeight !== Math.round(height)) {
        entry.target.dataset.prevSize = `${width},${height}`;
        onResize(entry);
      }
    } else {
      entry.target.dataset.prevSize = `${width},${height}`;
      onResize(entry);
    }
  });
});

function onResize(entry) {
  console.log('Size changed:', entry.contentRect.width, entry.contentRect.height);
}

六、浏览器支持与降级

6.1 检测支持

function isResizeObserverSupported() {
  return 'ResizeObserver' in window;
}

if (!isResizeObserverSupported()) {
  // 使用轮询降级
  function pollResize(element, callback) {
    let lastWidth = element.offsetWidth;
    let lastHeight = element.offsetHeight;

    setInterval(() => {
      const newWidth = element.offsetWidth;
      const newHeight = element.offsetHeight;

      if (newWidth !== lastWidth || newHeight !== lastHeight) {
        callback({ width: newWidth, height: newHeight });
        lastWidth = newWidth;
        lastHeight = newHeight;
      }
    }, 100);
  }
}

参考资料

延展阅读