最近,我在工作中遇到了一个具有挑战性的设计:一个在顶部有一排按钮的组件。问题是,只要组件的宽度不足以容纳所有的按钮,这些动作就需要移到一个下拉菜单中。
随着基于组件的框架(如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-width 、aspect-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)
);
}
},
我们正在使用一个名为actionButtons 的Vue计算属性来生成一个应该作为按钮显示的动作数组。它是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">
…
</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博客上。