开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天
背景
在写新项目的时候,想给页面路由切换添加一个动画,查了查Vue官网的transition发现有个gsap的例子,效果针不戳,直接走起。
布局主页面
// layout.vue
<template>
<!-- 固定头部 -->
<top-nav />
<!-- routerView区域 -->
<div class="pt-[114px] flex-grow flex-shrink-0">
<router-view v-slot="{ Component, route }">
<transition
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<suspense>
<component :is="Component" :key="route.name" />
<template #fallback>
<div v-loading="true" class="w-full h-full"></div>
</template>
</suspense>
</transition>
</router-view>
</div>
<!-- 通用底部 -->
<common-footer />
</template>
<script setup lang="ts">
import { useRouterTransition } from './use-router-transition';
...
const { onBeforeEnter, onEnter, onLeave } = useRouterTransition();
...
</script>
路由动画hook
// use-router-transition.ts
/**
* 路由切换动画
*/
import gsap from 'gsap';
import { ref } from 'vue';
type DoneFn = (...args: SafeAny[]) => void | null;
const useRouterTransition = () => {
const onBeforeEnter = (el: HTMLElement) => {
gsap.set(el, {
x: -100,
opacity: 0
});
};
const onEnter = (el: HTMLElement, done: DoneFn) => {
gsap.to(el, {
x: 0,
duration: 1,
opacity: 1,
ease: 'elastic.inOut(2.5, 1)',
onComplete: done
});
};
const onLeave = (el: HTMLElement, done: DoneFn) => {
gsap.to(el, {
x: 300,
delay: 0,
opacity: 0,
duration: 0.2,
onComplete: done
});
};
return {
onBeforeEnter,
onEnter,
onLeave
};
};
export { useRouterTransition };
export default useRouterTransition;
效果如下:
是不是很完美 wow~ ⊙o⊙。
Transform带来的困扰
在完成整个页面布局的基建后,打算开始先做侧边栏快捷入口,侧边栏一般都是固定定位,fixed在右侧的,我也是这么做的,但是做完后发现fixed失效了,如下图所示:
可以看到我们已经给侧边栏设置了 position: fixed; top: 300px;
但是,滚动屏幕后可恶侧边栏并没有像我们预想的那样相对窗口固定top-300
,而是随着页面滚动而同步滚动了。
问题原因分析:
一 寻找可能导致问题的原因:
在看到这个问题的第一时间,首先直觉定位到的就是css的问题,于是在一顿修改/尝试下,发现了罪魁祸首原来是你 —— transform
。
我们注释掉这条css规则后,一切都好起来了。
二 深层原因查询
知道这点后,就不难办了,上google搜索关键字 transform fixed
,早已有许多前人为我们解释了一切,原来是transform属性会改变DOM的布局方式,从而导致内部子元素的部分属性出现预料之外的问题。
这里简单总结一下transform带来的问题(特性):
-
transform的元素会影响溢出区域。即在父元素为
overflow: scroll | auto;
的情况下,transform如果到了父级盒子外边,则父级盒子会出现滚动条(溢出区域因为transform而增大)。 -
transform会创建一个新的层叠上下文(stack context),内部元素的层叠顺序则独立于外部元素了(简单说就是内部元素的z-index层级都是基于我们的父级transform元素了,如果父级是最高层级的,不论内部设置了多地的zIndex,其层级都高于外部的元素)。
-
transform 的元素将会创建一个 containing block (包含块),所有的position为absolute和fixed的子元素、以及设置了background-attachment的背景将会相对于该元素的 padding box 布局(一般来说,固定定位元素,其包含块是由视口生成的,但在这种情况下,其包含块会进一步变成transform所在的元素上)。
这里导致我们问题的就是第三点,从描述可得,我们的内部 fixed 子元素其定位父级不再是我们心心念念的 window 了,它变成了 transform 所在的父级(containing block)。所以导致了我们滚动后fixed元素不动的问题。
解决思路分析:
1. 修改滚动区域
通过上面的描述我们可以得知,可爱的fixed已经变心了,它将以transform元素为基准进行定位,而我们页面的滚动区域目前是挂在根节点的,在滚动时transform节点会跟着滚动,所以内部的fixed会同步(滚动)。
知道了这点,很容易就能推导出一个解决方案:修改滚动区域
随后,我们将滚动区域放到 transform节点上,使得滚动时transform节点不动,内部区域进行滚动:
// layout.vue
...
<div class="pt-[114px] flex-grow flex-shrink-0">
<router-view v-slot="{ Component, route }">
<transition
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<suspense>
<!-- 设置transform所在元素高度,并设置超出滚动(topHeight/footerHeight是顶部与底部元素固定高度,这里写的伪代码不做赘述) -->
<div class="overflow-auto h-[100vh - topHeight - footerHeight]" :key="route.name + 'transitionDom'">
<component :is="Component" :key="route.name" />
</div>
<template #fallback>
<div v-loading="true" class="w-full h-full"></div>
</template>
</suspense>
</transition>
</router-view>
</div>
这样设置后,就只有transform节点内部区域为滚动区域了,我们的滚动事件也是在其内部发生,fixed元素能够正常按照我们的“要求”办事了。
2. 清除transform副作用
不过上面这种方法不完全满足我的项目(也能满足就是改动挺大很麻烦),因为我的footer组件是在transform之外的,且领导希望它要滚动到底部才显示,而不是一直固定到我们视窗的底部,这样我们修改滚动区域的方案就无法满足了。
方案1破产后,很快哈,想到了另一个适合的方案 —— 删掉无用的transform
不就完了。
由背景我们知道,transform属性是由gsap
动画插件动态添加的,而我们仅需要一个页面跳转过渡动画,而非需要这个效果常驻,所以在动画结束后删掉它,并没有什么副作用。
在确定基调后,我开始修改代码,想办法在动画结束后删掉多余的transform:
/**
* 路由切换动画
*/
import gsap from 'gsap';
import { ref } from 'vue';
type DoneFn = (...args: SafeAny[]) => void | null;
const useRouterTransition = () => {
// BUG:解决transform会创建一个containing block,导致fixed/absolute等定位失效的问题。
const fixTransformBugRef = ref<HTMLElement>();
const fixTransformBug = () => {
if (fixTransformBugRef.value) {
fixTransformBugRef.value.style.transform = ''; // 在动画完成后移除transform属性
}
};
const onBeforeEnter = (el: HTMLElement) => {
gsap.set(el, {
x: -100,
opacity: 0
});
};
const onEnter = (el: HTMLElement, done: DoneFn) => {
gsap.to(el, {
x: 0,
duration: 1,
opacity: 1,
ease: 'elastic.inOut(2.5, 1)',
onComplete: () => {
done();
fixTransformBug();
}
});
};
const onLeave = (el: HTMLElement, done: DoneFn) => {
gsap.to(el, {
x: 300,
delay: 0,
opacity: 0,
duration: 0.2,
onComplete: () => {
done();
fixTransformBug();
}
});
};
return {
onBeforeEnter,
onEnter,
onLeave,
fixTransformBugRef
};
};
export { useRouterTransition };
export default useRouterTransition;
// layout.vue
...
<div class="pt-[114px] flex-grow flex-shrink-0">
<router-view v-slot="{ Component, route }">
<transition
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<suspense>
<div ref="fixTransformBugRef" :key="route.name + 'transitionDom'">
<component :is="Component" :key="route.name" />
</div>
<template #fallback>
<div v-loading="true" class="w-full h-full"></div>
</template>
</suspense>
</transition>
</router-view>
</div>
...
- 在hook代码中,我们增加一个fixTransformBugRef来关联挂载transform属性的元素。
- 定义一个方法用于清除 fixTransformBugRef 元素上的 transform 属性。
- ヽ(✿゚▽゚)ノ好耶,transition提供了钩子,我们可以直接在 onComplete 回调中调用我们的清除函数,来实现动画结束后清除属性。 --> 如果没有这个钩子怎么办?【暂且想到的是,用定时器不断获取transform元素上的transform值,当其为 0 时,表示动画结束,此时我们调用清除函数,并结束定时器】
清除掉无用的 transform 后,一切都好起来了!ヽ(✿゚▽゚)ノ
结语:其实css中 transform
perspective
filter
这三个属性的有效值(存在效果的,非none,inherit等值)都会造成这个问题,因为他们都会使得元素创建一个包含块,导致定位的父级改变到自身上,从而导致fixed不再以顶层为定位父级了。