首页 / 文章 / 解决异步渲染的Web组件中的Vue高度过渡问题

解决异步渲染的Web组件中的Vue高度过渡问题

了解为何 Vue 的展开过渡动画在懒加载升级的 Web Components 周围会从 0px 动画到 0px,以及 ResizeObserver 是如何等到实际高度确定后才触发动画的。

1144 词

基于 Vue 的 <Transition> 构建的可重复使用的展开/折叠组件,通常会在 enter 钩子中测量元素的高度,并从 0 动画过渡到该数值。这种方法对原生元素和普通 Vue 组件很有效,但当子组件是动态加载的 Web Component 时却可能悄无声息地出问题:面板出现时完全没有动画效果。本指南将解释导致该问题的时间控制问题,说明为何轮询只能起到部分修复作用,以及如何使用 ResizeObserver 使得过渡在内容真正有尺寸时才开始。

为何动画会从 0px 动画到 0px

假设错误信息是由作为自定义元素实现的通知组件渲染的,并被包裹在展开过渡效果中:

<transition-expand>
  <notification-wrapper v-if="showError" />
</transition-expand>

当 showError 设为 true 时,Vue 会向 DOM 中插入 <notification-wrapper>,并立即调用过渡效果的 enter 钩子。对于 Vue 组件来说这没有问题,因为此时其标记内容已经存在。然而许多 Web Component 是异步升级的:该标签最初以一个空且未知的元素形式出现在 DOM 中,只有在其定义加载完毕且阴影 DOM 渲染完成后,它才会获得内容与高度。

实际的事件顺序如下:

v-if becomes true
        ↓
Vue inserts the element
        ↓
Transition enter() runs
        ↓
Height is still 0px
        ↓
Web Component finishes rendering
        ↓
Actual height becomes 160px

在第三步进行尺寸测量时,该元素仍只是一个空壳。它会按部就班地从 0px 动画过渡到测量得到的 0px,片刻之后组件就会在没有任何过渡效果的情况下以完整的 160px 高度显示出来。对用户而言,看起来就好像动画从未执行过一样。

轮询解决方案及其代价

显而易见的解决办法是等待元素报告出高度后再进行测量。在计算高度之前加入循环,即可通过每帧检查一次来实现这一目标:

while (element.scrollHeight === 0) {
  await new Promise(requestAnimationFrame)
}

这种方法确实有效。每次迭代都会等待下一帧动画,并再次判断scrollHeight是否仍为零。但其缺点在于会不断在每帧查询布局,直到有变化发生;如果内容始终没有增加高度,比如因为组件加载失败,那么只要该元素存在,循环就会持续运行。此外,它还会将enter函数变为异步函数,使得该钩子更难理解。浏览器本身已有针对这种情况的通知机制,因此无需每秒重复询问相同的问题六十次。

将测量与动画分离

在切换为观察者模式之前,稍微重构一下过渡代码会很有帮助。在典型的实现中,enter()函数既负责测量元素又负责执行动画。由于测量操作可能需要等待,因此最好将这两项任务分开处理。

动画代码本身保持不变,只是被移至一个名为animateEnter()的辅助函数中。新的enter()函数则只承担一个职责:决定何时开始动画:

  • 当元素已经可以测量时,立即调用animateEnter()。
  • 否则,等待元素可测量后再调用animateEnter()。

这种重构不会改变常见情况下的行为,同时让等待逻辑有一个清晰且独立的处理位置。

使用ResizeObserver等待实际高度

ResizeObserver 可以在元素尺寸发生变化时发出通知,这正是此处所需要的信号。提取出动画逻辑后,新的 enter() 函数变得很简短:

function enter(element: HTMLElement) {
  if (element.scrollHeight > 0) {
    animateEnter(element)
    return
  }
  const observer = new ResizeObserver(() => {
    if (element.scrollHeight === 0)
      return
    observer.disconnect()
    requestAnimationFrame(() => {
      animateEnter(element)
    })
  })
  observer.observe(element)
}

按顺序逐步分析。快速路径用于处理原生元素及已渲染的组件:如果 scrollHeight 的值为正数,动画会像之前一样立即开始。否则,会在该元素上附加一个观察者。其回调函数会再次检查高度,若高度仍为零则立即返回;这一保护措施很重要,因为观察者在开始监视时也会触发一次,此时元素可能仍为空。一旦出现实际高度,观察者就会断开连接,从而不会在后续尺寸变化时继续触发,动画则会在下一个动画帧开始,让浏览器有时间在过渡开始前完成布局调整。

只有动画开始的时间发生了变化,动画本身并未被修改。

生产环境中需要处理的边缘情况

根据组件的使用方式,有几种情况值得注意:

  • 如果元素在获得高度之前就被移除或隐藏,观察者仍然会保持关联。在过渡过程的离开或取消处理中断开该观察者关系,可以避免残留的观察者。
  • 观察者会对其监视的元素大小做出反应。如果你的beforeEnter步骤将元素的高度固定为0,请确认该状态下的元素确实能够改变大小,或者改为监视其内部内容。
  • 当过渡完全由 JavaScript hook 驱动时,Vue 要求你通过传递给 enter 的 done 回调来指示操作已完成。务必确保在延迟启动后该信号仍能被触发。
  • 为何观察者方式更为稳健

    与轮询方式相比,ResizeObserver 方式具有多项优势:

    • 无需为每一帧都循环检查布局。
    • 能够处理异步升级的 Web Components。
    • 也能处理延迟加载的 Vue 组件。
    • 可应对在首次渲染后尺寸发生变化的图片及其他内容。
    • 过渡过程保持通用性,无需了解其包裹的内容是什么。

    最后一点最为重要。expand组件无需知道其子元素是原生div、Vue组件还是自定义元素;它会等待被包裹的内容报告出非零尺寸后,再执行动画。

    总结

    对于大多数Vue应用而言,先测量再动画的处理方式依然完全适用,因为在这些应用中,内容在enter钩子执行时就已经完成布局。只有当渲染是异步的,且Vue插入元素时布局尚未准备好时,这种调整才显得重要。对于可复用的UI组件而言,更重要的教训是不要假设布局何时会准备好,而应响应浏览器自身的信号。这只是一个小的代码改动,却能显著提升可靠性;而在实际集成中发现的每一类类似边缘情况,都会让该组件在后续的每个项目中更加稳健。