开源发布 🚀 | 解决 Vue Hero 动画的最后一块拼图:完美支持 v-show!
背景
在前段时间我实现了一个Vue指令,用于实现元素的跨页面动画效果:
【Hero动画】用一个指令实现Vue跨路由/组件动画
但有个遗憾一直没解决:不支持v-show指令。
最近终于有时间了,决定攻克这个技术难题,让 Hero 动画更加完整!
为什么v-show这么棘手🤔
v-if / 路由切换
在v-if和路由切换的情况下,使用指令的mounted和beforeUnmount钩子非常方便,只需要在挂载时注册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属性控制显示/隐藏,没有卸载过程,只能通过beforeUpdate和updated钩子来监听元素的变化。
核心难点:如何区分是v-show触发的显示变化,还是其他响应式数据的变化?
解决方案思路
所以我们只能手动判断是否是v-show触发的变化,只有在display属性变化时,才执行过渡动画。
大致实现步骤:
- 在
mounted钩子中,将相同heroId的元素注册到一个集合中,标记为v-show组合。 - 在
updated钩子中,判断display状态,从而判断是否是v-show触发的变化。
实现
注册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 个`);
}
}
}
再在指令处调用方法:
-
mounted钩子中注册并验证元素对. -
updated钩子中判断是否是v-show触发的变化,从而执行过渡动画。 -
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>
看看效果:
完美触发过渡😀
细节优化
快速切换优化
想到一个场景,如果快速点击按钮/切换路由,会出现什么效果。
可以看到连点两下按钮后,虽然只有一个动画再执行,但是目标元素已经变化到了最初的蓝色BOX,但是动画的路径却没有变化,这明显是不符合预期的。 预期效果应该是如果目标元素已经变化了,那么动画的路径也应该变化到新的目标位置。 实现步骤:
- 当触发动画时,先判断是否存在正在进行的动画。
- 如果存在,需要先中断当前动画,然后创建一个新的动画元素。
- 新的动画元素需要复制当前动画元素的所有样式。
- 新元素的位置需要设置为当前动画元素的位置。
- 最后,新元素作为起始元素,开始新的动画。
我们先定义一个映射表,用于存储当前正在进行的动画元素。
// 正在进行的动画元素映射表
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 });
})
}
再看看现在的效果:
这下可以实现移动到新的目标位置了😀。
动画时间优化
但这也带来了一个问题,就是动画时间。
现在中断动画后,当前动画元素过渡到新的目标位置还是需要2秒,但这不符合预期。
我们预想一个场景:
假设一个A到B的动画,过渡动画时间是2000ms。
- 在前进的途中,动画播放了
750ms,用户再次点击了按钮,那当前动画元素应该回到A位置,而过渡时间就是已播放的750ms。 - 在折返的途中,动画播放了
500ms,用户再次点击了按钮,那当前动画元素应该回到B位置,而过渡时间就是总播放时长2000ms减去A到B已过渡的250ms得到的1750ms。
根据这个逻辑,我们需要多记录几个信息:
- 动画当前被重播的次数,以此来判断是前进还是折返。
- 已前进的时长,以此来计算继续前进和折返的过渡时间。
- 动画开始时间,用于计算已播放时长。
我们修改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}`;
...
});
}
这时我们再看看效果:
这下动画时间就符合预期了🎉。
源码 和 使用
GitHub仓库
该指令的源码已经上传到github,如果对你有帮助,请点点star⭐:
GitHub vue-hero-cross
npm包安装
同时,也发布到了npm,你可以通过npm install vue-hero-cross安装来直接使用:
npm vue-hero-cross
🤝 参与贡献
如果你对这个项目感兴趣,欢迎:
- 提交 Issue 报告问题或建议。
- 提交 PR 添加新功能或修复 Bug。
- 在项目中实际使用并反馈体验。
- 分享给更多开发者