ResizeObserver API。带实例的教程

1,720 阅读4分钟

最近,我在工作中遇到了一个具有挑战性的设计:一个在顶部有一排按钮的组件。问题是,只要组件的宽度不足以容纳所有的按钮,这些动作就需要移到一个下拉菜单中。

随着基于组件的框架(如React和Vue.js)以及本地Web组件的日益普及,构建一个能够适应不同屏幕宽度和不同容器宽度的UI是一项挑战,而且变得更加普遍。同一个组件可能需要在宽的主内容区和窄的边栏中工作--在所有设备上都是如此。

什么是ResizeObserver

[ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)API是创建能够适应用户屏幕和容器宽度的UI的一个伟大工具。使用ResizeObserver ,我们可以在元素调整大小时调用一个函数,就像监听一个window [resize](https://developer.mozilla.org/en-US/docs/Web/API/Window/resize_event)事件。

ResizeObserver 的使用情况可能不是很明显,所以让我们看一下几个实际的例子。

填充一个容器

对于我们的第一个例子,假设你想在你的页面的英雄部分下面显示一排随机的灵感照片。你只想加载填充该行所需的照片,而且你想在容器宽度发生变化时根据需要添加或删除照片。

我们可以利用resize 事件,但也许我们的组件的宽度也会在用户折叠一个侧板时发生变化。这就是ResizeObserver 的用武之地。

请看CodePen上Kevin Drum(@kevinleedrum)的Pen
ResizeObserver - Fill Container

看一下我们这个例子的JavaScript,前几行设置了我们的观察者。

const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(document.querySelector(".container"));

我们创建了一个新的ResizeObserver ,将一个回调函数传递给构造器。然后我们告诉我们的新观察者要观察哪个元素。

请记住,如果你遇到这种需要,可以用一个观察者来观察多个元素。

之后,我们就可以进入UI的核心逻辑了。

const IMAGE_MAX_WIDTH = 200;
const IMAGE_MIN_WIDTH = 100;

function onResize(entries) {
  const entry = entries[0];
  const container = entry.target;
  /* Calculate how many images can fit in the container. */
  const imagesNeeded = Math.ceil(entry.contentRect.width / IMAGE_MAX_WIDTH);
  let images = container.children;

  /* Remove images as needed. */
  while (images.length > imagesNeeded) {
    images[images.length - 1].remove();
  }
  /* Add images as needed. */
  while (images.length < imagesNeeded) {
    let seed = Math.random().toString().replace(".", "");
    const newImage = document.createElement("div");
    const imageUrl = `https://picsum.photos/seed/${seed}/${IMAGE_MAX_WIDTH}`;
    newImage.style.backgroundImage = `url(${imageUrl})`;
    container.append(newImage);
  }
}

在为我们的图片定义了最小和最大的宽度后(这样它们就能填满整个宽度),我们声明我们的onResize 回调。ResizeObserver 将一个数组的 [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)对象给我们的函数。

因为我们只观察一个元素,我们的数组只包含一个条目。该条目对象提供了调整后的元素的新尺寸(通过contentRect 属性),以及对元素本身的引用(target 属性)。

使用我们更新后的元素的新宽度,我们可以计算出应该显示多少张图片,并将其与已经显示的图片数量进行比较(容器元素的children )。之后,就像删除元素或添加新元素一样简单。

为了演示,我展示了来自Lorem Picsum的随机图片。

将一个灵活的行改为列

我们的第二个例子解决了一个相当普遍的问题:当这些元素无法容纳在一行中时,将一个灵活的元素行改为列(没有溢出或包裹)。

使用ResizeObserver API,这完全是可能的。

请参阅CodePen上的笔
ResizeObserver - Flex Direction by Kevin Drum(@kevinleedrum)

我们在这个例子中的onResize 函数看起来是这样的。

let rowWidth;

function onResize(entries) {
  const entry = entries[0];
  const container = entry.target;
  if (!rowWidth)
    rowWidth = Array.from(container.children).reduce(
      (acc, el) => getElWidth(el) + acc,
      0
    );
  const isOverflowing = rowWidth > entry.contentRect.width;
  if (isOverflowing && !container.classList.contains("container-vertical")) {
    requestAnimationFrame(() => {
      container.classList.add("container-vertical");
    });
  } else if (
    !isOverflowing &&
    container.classList.contains("container-vertical")
  ) {
    requestAnimationFrame(() => {
      container.classList.remove("container-vertical");
    });
  }
}

该函数将所有按钮的宽度加起来,包括边距,以计算出容器需要多宽才能显示一排的所有按钮。我们把这个计算出来的宽度缓存在一个rowWidth 变量中,这个变量的作用域在我们的函数之外,这样我们就不会在每次元素大小调整时浪费计算的时间。

一旦我们知道了所有按钮所需的最小宽度,我们就可以将其与新的容器宽度进行比较,如果按钮不合适,就将该行转化为一列。为了达到这个目的,我们只需在容器上切换一个container-vertical 类。

容器查询呢?

ResizeObserver 可以解决的一些问题可以用CSS容器查询来更有效地解决,现在Chrome Canary支持这种查询。然而,容器查询的一个缺点是,它们需要已知的min-widthaspect-ratio 等的值。

另一方面,ResizeObserver ,给了我们无限的权力来检查整个DOM,并编写我们想要的复杂逻辑。另外,它已经被所有的主要浏览器所支持。

响应式工具条组件

还记得我提到的那个工作问题吗,我需要响应性地将按钮移到一个下拉菜单中?我们的最后一个例子也非常类似。

从概念上讲,这个例子是建立在前一个例子的基础上的,因为我们又一次检查了我们是否溢出了一个容器。在这种情况下,我们需要在每次删除一个按钮时重复这个检查,看看我们是否需要再删除一个按钮。

为了减少模板的数量,我在这个例子中使用了Vue.js,尽管这个想法应该适用于任何框架。我还使用了Popper来定位下拉菜单。

参见CodePen上的Pen
ResizeObserver - Responsive Toolbar,作者Kevin Drum(@kevinleedrum)

这个例子有相当多的代码,但我们会把它分解。我们所有的逻辑都在一个Vue实例(或组件)中。

new Vue({
  el: "#app",
  data() {
    return {
      actions: ["Edit", "Save", "Copy", "Rename", "Share", "Delete"],
      isMenuOpen: false,
      menuActions: [] // Actions that should be shown in the menu
    };
  },

我们有三个重要的数据属性,构成了我们组件的 "状态"。

  • actions 数组列出了我们需要在用户界面中显示的所有动作
  • isMenuOpen 布尔值是一个标志,我们可以通过切换来显示或隐藏动作菜单。
  • menuActions 数组将保存一个应该在菜单中显示的动作列表(当没有足够的空间将它们显示为按钮时)。

我们将根据需要在我们的onResize 回调中更新这个数组,然后我们的HTML就会自动更新。

  computed: {
    actionButtons() {
      // Actions that should be shown as buttons outside the menu
      return this.actions.filter(
        (action) => !this.menuActions.includes(action)
      );
    }
  },

我们正在使用一个名为actionButtonsVue计算属性来生成一个应该作为按钮显示的动作数组。它是menuActions 的倒数。

有了这两个数组,我们的HTML模板可以简单地迭代它们,分别创建按钮和菜单项。

  <div ref="container" class="container">
    <!-- Action buttons -->
    <button v-for="action in actionButtons" :key="action" @click="doAction(action)">
      {{ action }}
    </button>
    <!-- Menu button -->
    <button ref="menuButton" v-show="menuActions.length" @click.stop="toggleMenu">
      &hellip;
    </button>
    <!-- Action menu items -->
    <div ref="menu" v-show="isMenuOpen" class="menu">
      <button v-for="action in menuActions" :key="action" @click="doAction(action)">
        {{ action }}
      </button>
    </div>
  </div>

如果你不熟悉Vue模板的语法,不要太在意。只需要知道我们正在用click 事件处理程序从这两个数组中动态地创建按钮和菜单项,并且我们正在根据isMenuOpen 布尔值显示或隐藏一个下拉菜单。

ref 属性还允许我们从我们的脚本中访问这些元素,而不需要使用querySelector

Vue提供了一些生命周期方法,使我们能够在组件首次加载时设置我们的观察器,并在组件被销毁时将其清理。

  mounted() {
    // Attach ResizeObserver to the container
    resizeObserver = new ResizeObserver(this.onResize);
    resizeObserver.observe(this.$refs.container);
    // Close the menu on any click
    document.addEventListener("click", this.closeMenu);
  },
  beforeDestroy() {
    // Clean up the observer and event listener
    resizeObserver.disconnect();
    document.removeEventListener("click", this.closeMenu);
  },

现在是有趣的部分,也就是我们的onResize 方法。

  methods: {
    onResize() {
      requestAnimationFrame(async () => {
        // Place all buttons outside the menu
        if (this.menuActions.length) {
          this.menuActions = [];
          await this.$nextTick();
        }

        const isOverflowing = () =>
          this.$refs.container.scrollWidth > this.$refs.container.offsetWidth;

        // Move buttons into the menu until the container is no longer overflowing
        while (isOverflowing() && this.actionButtons.length) {
          const lastActionButton = this.actionButtons[
            this.actionButtons.length - 1
          ];
          this.menuActions.unshift(lastActionButton);
          await this.$nextTick();
        }
      });
    },

你可能注意到的第一件事是,我们把所有的东西都包在一个调用 [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).这只是简单地节制了我们的代码的运行频率(通常是每秒60次)。这有助于避免ResizeObserver loop limit exceeded 控制台警告,只要你的观察者回调试图在一个动画帧中运行多次,就会发生这种情况。

解决了这个问题后,我们的onResize 方法就开始在必要时重置为默认状态。默认状态是所有的动作都由按钮表示,而不是菜单项。

作为重置的一部分,它等待着对 [this.$nextTick](https://vuejs.org/v2/api/#Vue-nextTick)的调用,它告诉Vue继续更新它的虚拟DOM,所以我们的容器元素将回到它的最大宽度,并显示所有的按钮。

        // Place all buttons outside the menu
        if (this.menuActions.length) {
          this.menuActions = [];
          await this.$nextTick();
        }

现在我们有一整行的按钮,我们需要检查这行是否溢出,这样我们就知道我们是否需要把任何按钮移到我们的动作菜单中。

识别一个元素是否溢出的简单方法是比较它的scrollWidth 和它的offsetWidth 。如果scrollWidth 更大,那么该元素就会溢出。

        const isOverflowing = () =>
          this.$refs.container.scrollWidth > this.$refs.container.offsetWidth;

我们的onResize 方法的其余部分是一个while 循环。在每个迭代过程中,我们检查容器是否溢出,如果是,我们就再向menuActions 数组中移动一个动作。循环只有在容器不再溢出,或者我们将所有的动作移入菜单后才会中断。

请注意,我们在每个循环之后都在等待this.$nextTick() ,这样容器的宽度就可以在改变到this.menuActions 之后得到更新。

        // Move buttons into the menu until the container is no longer overflowing
        while (isOverflowing() && this.actionButtons.length) {
          const lastActionButton = this.actionButtons[
            this.actionButtons.length - 1
          ];
          this.menuActions.unshift(lastActionButton);
          await this.$nextTick();
        }

这包含了我们征服这一挑战所需的所有魔法。我们的Vue组件中的大部分其余代码都与下拉菜单的行为有关,这不在本文的范围之内。

总结

希望这些例子能够强调ResizeObserver API的实用性,特别是在基于组件的前端开发中。与CSS媒体查询、即将推出的容器查询resize 事件一起,它有助于建立现代网络上的响应式界面的基础。

The post TheResizeObserver API:有例子的教程首次出现在LogRocket博客上。