Vue 不定高展开动效及其原理

使用场景

在大多数 APP 中,都有问答模块,问答模块的静态页面开发并不复杂,也没有特殊的交互。唯一有一点难度应该是回答部分的展开特效。

  • 展开时,需要从上往下将回答部分的 div 慢慢撑开,上面的箭头也要有旋转的特效。
  • 收回时,需要从下往上将回答部分的 div 慢慢缩小,上面的箭头也要有旋转的特效。

对于一般的展开、隐藏特效,只需要在对应元素的 height 上面增加过渡效果即可。但问题是:不知道对应的 div 的高度,其高度是内部的元素自动撑开的,此时直接在 height 属性上面添加过渡效果会失效(后面会说明原因)。

对于箭头的旋转,则只需要在箭头元素的 transform 上面增加过渡效果,然后让其旋转 180 度(rotateZ(180deg))即可,这个比较好实现。

背景

今天做需求时,正好需要做这种特效。先介绍一下列表的数据结构和其 DOM 结构。

列表数据结构如下:

typescript 复制代码
// QaItem 表示问答的每一项
interface QaItem {
  Q: string;  // 问题
  A: string;  // 回答
  show: boolean;  // 是否展示
}

// QaList 表示问答列表
type QaList = QaItem[];

项目中并未使用 TypeScript,这里用 interface 是为了方便理解。

DOM 结构(Vue 版本)如下:

ini 复制代码
<div class="qa panel">
  <div class="qa__title">
    常见问题
  </div>
  <div class="list-qa">
    <div
      v-for="(item, ind) in qaList"
      :key="ind"
      class="list-qa__item"
    >
      <div class="list-qa__question">
        <span>{{ item.Q }}</span>
        <span class="list-qa__question__arrow" />
      </div>
      <span class="list-qa__answer">
        {{ item.A }}
      </span>
    </div>
  </div>
</div>

上面的结构简化了一些交互逻辑和展示逻辑,默认问答列表的每一项都会展示。最外层包裹了一层 div,上面是标题,下面是问答列表,问答列表的每一项包括问题、箭头 icon 和答案。

实现因为项目使用的框架是 Vue,所以以 Vue 为例,来分析一下如何实现它,以及其实现的原理。

回答是否展示,可以用一个变量控制,这里是 qaItem 的 show 属性。使用 v-show 实现,因为用户可能会多次点击箭头,导致回答频繁地展示或隐藏。

xml 复制代码
<div
  v-for="(item, ind) in qaList"
  :key="ind"
  class="list-qa__item"
>
  <!-- 。。。省略不相关元素。。。 -->
  <span
    v-show="item.show"
    class="list-qa__answer"
  >
    {{ item.A }}
  </span>
</div>

transition 组件

在 Vue 中,可以使用 transition 组件来为元素添加动态效果。transition 组件让我们可以为使用条件渲染(v-if、v-show)的元素添加进入、离开时的过渡效果。

ini 复制代码
<div id="demo">
  <button v-on:click="show = !show">
    Toggle
  </button>
  <transition name="fade">
    <p v-if="show">hello</p>
  </transition>
</div>
css 复制代码
.fade-enter-active, .fade-leave-active {
  transition: opacity .5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}

这样在 name 为 fade 的 transition 组件包裹的 p 标签展示和隐藏时,会有一个 0.5s 的淡入淡出效果。

过渡效果原理

在展示时,p 标签的 opacity(透明度)会从 0(.fade-enter 类选择器中设定的值)开始增加,经过 0.5s 之后,增加至 opacity: 1(元素默认的透明度 opacity 为 1)。

在隐藏时,p 标签的 opacity(透明度)会从 1 开始减少,经过 0.5s 之后,减少至 opacity: 0(.fade-leave-to 类选择器中设定的值)。

这样就实现了淡入淡出效果。

同样的,如果我们想让一个元素展示时高度从 0 开始增加,经过某一个时间,达到具体的值;隐藏时高度从该具体值开始减少,经过某一个时间,达到 0。这样就能实现我们前面需要的效果。

我们可以用 css transition 为某一个元素设置过渡效果,过渡效果作用在这个元素的某个属性上、过渡效果的时长等。

css 复制代码
.box {
  transition: height 1s;
}

上面代表为 class 为 box 的元素设置了过渡效果,作用在它的 height 属性上面,过渡效果的时长为 1s。当该元素的高度从某一个值变化到另一个值时,就会有一个长为 1s 的过渡效果。

过渡效果的本质是:当作用的属性的值变化时,并不会立即从一个值变为另一个值,而是在变化的过程中,将中间状态呈现出来。

例如:设置了过渡效果的元素的高度(height)从 0 变化到 100px 时,并不是直接从 0 变化到 100px 的,其变化过程是一个连续的状态,从 0 到 1px,从 1px 到 2px······直到 100px。把中间的高度展现出来,就可以让用户看到过渡效果。

再例如:设置了过渡效果的元素的透明度(opacity)从 0 变化到 1 时,并不是直接从 0 变化到 1 的,其变化过程也是一个连续的状态,从 0 到 0.1,从 0.1 到 0.2······直到 opacity 为1。这样用户就可以看到一个元素从透明状态逐渐变得清晰。当然,并不一定就是从 0 变化到 0.1,然后从 0.1 变化到 0.2,这个过程是一个连续的过程,它的值在慢慢增加,增量是多少并不重要。

需要实现过渡效果,就需要一个起始态和一个终止态,浏览器能够从起始态逐步过渡到终止态。也就是从起始态到终止态之间的部分是连续的,是可以计算的,这样浏览器才能把中间的状态给我们呈现出来。

再回到之前的问题:不知道 div 的高度,其高度是内部的元素自动撑开的,此时直接在 height 属性上面添加过渡效果会失效。

为什么会失效?

对于一个 div,如果它的高度是由子元素撑开的,那么它的 css 样式 height 属性的值为 auto。从 0 变到 auto,或者从 auto 变到 0,其中间状态都是不可计算的,浏览器没发给我们展示出中间状态,所以我们看不到过渡效果。

既然从 0 变到 auto,或者从 auto 变到 0,中间状态无法计算,那我们可以显式地告诉浏览器一个数值,应该从 0 变到多少,或者从多少变到 0,让浏览器可以计算出中间状态,这样不就能看到过渡效果了吗?

解决

当展开时,起始态为 0,我们通过 getComputedStyle(element).height 得到元素的具体高度 x(终止态)。给元素设置 transition 属性,然后将元素的高度从 0 变到 x,这样就能实现展开的动效了。

ini 复制代码
<transition
  name="slide"
  @before-enter="beforeEnter"
  @enter="enter"
  @after-enter="afterEnter"
  @before-leave="beforeLeave"
  @leave="leave"
  @after-leave="afterLeave"
 >
  <span
    v-show="item.show"
    class="list-qa__answer"
  >{{ item.A }}</span>
</transition>
ini 复制代码
beforeLeave(el) {
  // 给元素设置过渡效果
  el.style.transition = '0.3s height ease-in-out';
  // 高度变化时,让其内容隐藏
  el.style.overflow = 'hidden';
},
leave(el) {
  el.style.height = 'auto';
  // 设置高度为具体的值
  el.style.height = window.getComputedStyle(el).height;
  // 强制浏览器回流,否则浏览器会合并两次元素的高度更改(回流重绘的知识)
  el.offsetHeight;
  el.style.height = '0px';
},
afterLeave(el) {
  // el.style.height = null;
  // 收尾工作,展示完过渡效果之后,设为原来的值
  el.style.transition = '';
  el.style.overflow = 'visible';
},

这里需要给元素设置 overflow: hidden,在元素高度小于内部内容的高度时,才会隐藏内容。

同样地,隐藏时先通过 getComputedStyle(element).height 得到元素的具体高度 x(起始态),给元素设置 transition 属性,然后将元素的高度从 x 变到 0,这样就能实现隐藏的动效了。

ini 复制代码
beforeEnter(el) {
  // 给元素设置过渡效果
  el.style.transition = '0.3s height ease-in-out';
  // 高度变化时,让其内容隐藏
  el.style.overflow = 'hidden';
},
enter(el) {
  el.style.height = 'auto';
  // 保存元素原来的高度
  const endWidth = window.getComputedStyle(el).height;
  el.style.height = '0px';
  // 强制浏览器回流,否则浏览器会合并两次元素的高度更改(回流重绘的知识)
  el.offsetHeight;
  el.style.height = endWidth;
},
afterEnter(el) {
  // el.style.height = null;
  // 收尾工作,展示完过渡效果之后,设为原来的值
  el.style.transition = '';
  el.style.overflow = 'visible';
},

箭头的旋转动效就比较简单了。先设置过渡效果,然后只需要在点击箭头的时候,动态为这个元素添加一个类名,让其旋转属性生效(rotateZ(180deg));当再一次点击的时候,去掉这个类名就好了。

ini 复制代码
<span
  class="list-qa__question__arrow"
  :class="{'list-qa__question__rotate-arrow': !item.show}"
  @click="onClickPromblem(ind)"
/>
kotlin 复制代码
onClickPromblem(index) {
  const qaItem = this.qaList[index];
  this.$set(qaItem, 'show', !qaItem.show);
},
css 复制代码
list-qa__question__arrow {
  width: 12px;
  height: 12px;
  background: url(https://m.hellobike.com/resource/helloyun/21588/RIBiB_SketchPngf4c3c2445f4522fe182c1d02d45a6201fa03ecfb14550a3269204012abdcfa09) center no-repeat;
  transition: transform .4s;
}
list-qa__question__rotate-arrow {
  transform: rotateZ(180deg);
  transition: transform .4s;
}

(本文作者:旷卓)

关注公众号「哈啰技术」,第一时间收到最新技术推文。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax