阅读视图

发现新文章,点击刷新页面。

开源发布 🚀 | 解决 Vue Hero 动画的最后一块拼图:完美支持 v-show!

背景

在前段时间我实现了一个Vue指令,用于实现元素的跨页面动画效果: 【Hero动画】用一个指令实现Vue跨路由/组件动画

但有个遗憾一直没解决:不支持v-show指令。

最近终于有时间了,决定攻克这个技术难题,让 Hero 动画更加完整!

为什么v-show这么棘手🤔

v-if / 路由切换

v-if路由切换的情况下,使用指令的mountedbeforeUnmount钩子非常方便,只需要在挂载时注册Hero元素,在卸载前执行过渡动画即可。

// 这种很简单:挂载时注册,卸载时执行动画
const heroAnimationDirective: Directive<HTMLElement, HeroAnimationProps> = {
  mounted(el, { value }) {
    el.dataset.heroId = value.heroId;
  },
  beforeUnmount(el, { value }) { 
    heroAnimation(el, value);
  }
};

v-show 触发的变化

v-show通过display属性控制显示/隐藏,没有卸载过程,只能通过beforeUpdateupdated钩子来监听元素的变化。 核心难点:如何区分是v-show触发的显示变化,还是其他响应式数据的变化?

解决方案思路

所以我们只能手动判断是否是v-show触发的变化,只有在display属性变化时,才执行过渡动画。 大致实现步骤:

  1. mounted钩子中,将相同heroId的元素注册到一个集合中,标记为v-show组合。
  2. updated钩子中,判断display状态,从而判断是否是v-show触发的变化。

1-1.png

实现

注册Hero元素

我们先定义一个Map,用于存储heroId和对应的v-show元素集合。 并且实现注册和注销函数。

// 元素映射表 用于v-show 元素对的匹配
const heroMap = new Map<string, Set<HTMLElement>>();

/**
 * 注册Hero元素
 * @param el Hero元素
 * @param heroId Hero ID
 */
function registerHero(el: HTMLElement, heroId: string) {
  if (!heroMap.has(heroId)) {
    heroMap.set(heroId, new Set());
  }
  heroMap.get(heroId)?.add(el);
}

/**
 * 注销Hero元素
 * @param el Hero元素
 * @param heroId Hero ID
 */
function unregisterHero(el: HTMLElement, heroId: string) {
  const set = heroMap.get(heroId);
  if (set) {
    set.delete(el);
    if (set.size === 0) heroMap.delete(heroId);
  }
}

除此之外,我们还需要在元素都挂载好之后,来验证每个heroId是否有且只有2个v-show元素。

/**
 * 验证Hero元素对是否匹配
 * @param heroId Hero ID
 */
function validatePair(heroId: string) {
  const set = heroMap.get(heroId);
  if (set) {
    if (set.size === 2) {
      set.forEach(el => {
        const display = getComputedStyle(el).display;
        (el as any).__isVShowPair = true;
        (el as any).__wasHidden = display === 'none';
        // 记录原始display属性
        display !== 'none' && ((el as any).__originDisplay = display);
      });
    } else if (set?.size < 2) {
      set.forEach(el => (el as any).__isVIfPair = true);
      heroMap.delete(heroId);
    } else {
      console.error(`Hero ID "${heroId}" 有 ${set.size} 个元素,预期 2 个`);
    }
  }
}

再在指令处调用方法:

  1. mounted钩子中注册并验证元素对.
  2. updated钩子中判断是否是v-show触发的变化,从而执行过渡动画。
  3. beforeUnmount钩子中注销元素对。
const heroAnimationDirective: Directive<HTMLElement, HeroAnimationProps> = {
  mounted(el, { value }) {
    const heroId = value.heroId;
    el.dataset.heroId = heroId;
    registerHero(el, heroId);

    queueMicrotask(() => validatePair(heroId));
  },
  updated(el, { value }) {
    if (!(el as any).__isVShowPair) return
    const wasHidden = (el as any).__wasHidden;
    const display = getComputedStyle(el).display;
    // 初始display为隐藏的元素触发 避免触发两次
    if (!wasHidden) {
      heroAnimation(el, value);
    }
    // 重新记录隐藏状态
    (el as any).__wasHidden = display === 'none';
    (display !== 'none' && !(el as any).__originDisplay) && ((el as any).__originDisplay = display);
  },
  beforeUnmount(el, { value }) {
    // v-if/路由切换元素触发动画
    if ((el as any).__isVIfPair) {
      heroAnimation(el, value);
    }
    unregisterHero(el, value.heroId);
  }
};

改造动画

因为我们是在updated钩子中执行的动画,这时起始元素display属性已经被改变为none,我们需要先恢复原始值然后再执行动画。

/**
 * 执行元素的动画过渡
 * @param source 起始元素
 * @param props 动画属性
 */
async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 容器
  const containerEl: HTMLElement = isRef(container)
    ? container.value ?? document.body
    : typeof container === 'string'
      ? document.querySelector(container) ?? document.body
      : container;
  const containerRect = getRect(containerEl);

  // v-show 标识
  const isVShowPair = (source as any).__isVShowPair;

  // v-show情况下,需要先显示元素,才能获取到正确的位置信息
  if (isVShowPair) {
    source.style.setProperty('display', (source as any).__originDisplay || 'block');
    await nextTick();
  }

  const rect = getRect(source);
  const clone = source.cloneNode(true) as HTMLElement;

  copyStyles(source, clone);
  // v-show 恢复隐藏
  isVShowPair && source.style.setProperty('display', 'none');
  await nextTick();

  let target: HTMLElement | null = null;

  if (isVShowPair) {
    // 从映射表中获取目标元素
    const set = heroMap.get(heroId);
    set && set.forEach(item => item !== source && (target = item));
  } else {
    target = document.querySelector(
      `[data-hero-id="${heroId}"]:not([data-clone]):not([style*="display: none"])`
    ) as HTMLElement;
  }

  if (!target) return;

  ...先前的动画逻辑
}

简单来个页面测试一下

<template>
  <button @click="flag = !flag">触发</button>
  <div class="container">
    <div
      v-show="flag"
      v-hero="animationProps"
      class="box1"
    />
    <div
      v-show="!flag"
      v-hero="animationProps" 
      class="box2"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue-demi';
import type { HeroAnimationProps } from 'vue-hero-cross';

const flag = ref(false)
const boxRef = ref<HTMLDivElement>()

const animationProps: HeroAnimationProps = {
  heroId: 'box',
  duration: '2s',
  position: 'absolute',
  container: '.container'
}
</script>

<style scoped>
.container {
  position: relative;
  width: 500px;
  height: 500px;
  border: 1px solid #000;
  border-radius: 12px;
  overflow: hidden;
}
.box1 {
  position: absolute;
  top: -50px;
  left: -50px;
  width: 200px;
  height: 200px;
  background-color: red;
  border-radius: 12px;
}

.box2 {
  position: absolute;
  bottom: -50px;
  right: -50px;
  width: 300px;
  height: 300px;
  background-color: blue;
  border-radius: 50%;
  transform: rotate(45deg);
}
</style>

看看效果:

1-2.gif

完美触发过渡😀

细节优化

快速切换优化

想到一个场景,如果快速点击按钮/切换路由,会出现什么效果。

1-3.gif

可以看到连点两下按钮后,虽然只有一个动画再执行,但是目标元素已经变化到了最初的蓝色BOX,但是动画的路径却没有变化,这明显是不符合预期的。 预期效果应该是如果目标元素已经变化了,那么动画的路径也应该变化到新的目标位置。 实现步骤:

  1. 当触发动画时,先判断是否存在正在进行的动画。
  2. 如果存在,需要先中断当前动画,然后创建一个新的动画元素。
  3. 新的动画元素需要复制当前动画元素的所有样式
  4. 新元素的位置需要设置为当前动画元素的位置
  5. 最后,新元素作为起始元素,开始新的动画。

我们先定义一个映射表,用于存储当前正在进行的动画元素。

// 正在进行的动画元素映射表
const animatingMap = new Map<string, HTMLElement>();

然后再实现中断当前动画的逻辑。

async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 中断动画标识
  let isInterruptedAnimation = false;
  // 容器
  const containerEl: HTMLElement = isRef(container)
    ? container.value ?? document.body
    : typeof container === 'string'
      ? document.querySelector(container) ?? document.body
      : container;
  const containerRect = getRect(containerEl);

  // 存在正在进行的动画,需要中断
  if (animatingMap.has(heroId)) {
    // 当前动画元素
    const animatingEl = animatingMap.get(heroId) as HTMLElement;
    const animatingElStyle = window.getComputedStyle(animatingEl);

    // 克隆当前动画元素,用于新的动画
    const newSource = animatingEl.cloneNode(true) as HTMLElement;
    copyStyles(animatingEl, newSource);
    // copyStyles 函数排除了 left、top 样式,手动计算并设置当前动画元素的位置
    newSource.style.left = animatingElStyle.left;
    newSource.style.top = animatingElStyle.top;
    containerEl.appendChild(newSource);

    // 移除旧的动画元素
    containerEl.removeChild(animatingEl);
    
    source = newSource;
    isInterruptedAnimation = true;
  }

  ...

  copyStyles(source, clone);
  // v-show 恢复隐藏
  isVShowPair && source.style.setProperty('display', 'none');
  // 这时候的source是我们手动添加的 现在需要手动移除
  isInterruptedAnimation && containerEl.removeChild(source);
  await nextTick();

  ...

  containerEl.appendChild(clone);
  // 添加动画元素到映射表
  animatingMap.set(heroId, clone);

  requestAnimationFrame(() => {
    ...

    clone.addEventListener('transitionend', () => {
      ...
      // 动画结束后删除
      animatingMap.delete(heroId);
    }, { once: true });
  })
}

再看看现在的效果:

1-4.gif

这下可以实现移动到新的目标位置了😀。

动画时间优化

但这也带来了一个问题,就是动画时间。 现在中断动画后,当前动画元素过渡到新的目标位置还是需要2秒,但这不符合预期。 我们预想一个场景: 假设一个AB的动画,过渡动画时间是2000ms

  1. 前进的途中,动画播放了750ms,用户再次点击了按钮,那当前动画元素应该回到A位置,而过渡时间就是已播放的750ms
  2. 折返的途中,动画播放了500ms,用户再次点击了按钮,那当前动画元素应该回到B位置,而过渡时间就是总播放时长2000ms减去AB已过渡的250ms得到的1750ms

1-6.png

根据这个逻辑,我们需要多记录几个信息:

  1. 动画当前被重播的次数,以此来判断是前进还是折返
  2. 前进的时长,以此来计算继续前进折返的过渡时间。
  3. 动画开始时间,用于计算已播放时长。

我们修改animatingMap的类型,添加这些属性。 再添加一个方法,用于转换duration为毫秒数。

// 正在进行的动画元素映射表
interface AnimatingInfo {
  el: HTMLElement;
  count: number;
  elapsed: number;
  startTime: number;
}
const animatingMap = new Map<string, AnimatingInfo>();

/**
 * 解析动画时长
 * @param d 时长字符串或数字
 * @returns 时长(毫秒)
 */
function parseDuration(d: string | number): number {
  if (typeof d === 'number') return d
  const match = String(d).match(/^([\d.]+)\s*(s|ms)?$/)
  if (!match) return 1000
  const [, n, unit] = match
  return unit === 's' ? parseFloat(n) * 1000 : parseInt(n, 10)
}

我们再改造heroAnimation函数,来实现动画时间优化。

async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 解析时长
  let durationMs = parseDuration(duration);
  ...
  const animatingInfo = animatingMap.get(heroId);
  // 存在正在进行的动画,需要中断
  if (animatingInfo) {
    const timeElapsed = performance.now() - animatingInfo.startTime;
    // 前进 还是 折返
    const isForward = animatingInfo.count % 2 === 0;

    animatingInfo.elapsed = isForward
      ? (animatingInfo.elapsed || 0) - timeElapsed
      : animatingInfo.elapsed + timeElapsed;
    
    durationMs = isForward
      ? durationMs - animatingInfo.elapsed
      : animatingInfo.elapsed;
    
    // 当前动画元素
    const animatingEl = animatingInfo.el;
    const animatingElStyle = window.getComputedStyle(animatingEl);
    ...
  }

  ...

  containerEl.appendChild(clone);
  // 更新动画元素
  const animationData = animatingInfo || {
    el: clone,
    count: 1,
    elapsed: 0,
    startTime: performance.now(),
  }
  if (animatingInfo) {
    animatingInfo.el = clone;
    animatingInfo.count++;
    animatingInfo.startTime = performance.now();
  }
  animatingMap.set(heroId, animationData);

  requestAnimationFrame(() => {
    // 改用转换后的时间
    clone.style.transition = `all ${durationMs}ms ${timingFunction} ${delay}`;
    ...
  });
}

这时我们再看看效果:

1-5.gif

这下动画时间就符合预期了🎉。

源码 和 使用

GitHub仓库

该指令的源码已经上传到github,如果对你有帮助,请点点star⭐: GitHub vue-hero-cross

npm包安装

同时,也发布到了npm,你可以通过npm install vue-hero-cross安装来直接使用: npm vue-hero-cross

🤝 参与贡献

如果你对这个项目感兴趣,欢迎:

  1. 提交 Issue 报告问题或建议。
  2. 提交 PR 添加新功能或修复 Bug。
  3. 在项目中实际使用并反馈体验。
  4. 分享给更多开发者
❌