使用gsap动画插件时Transform属性导致的fixed失效问题解决

579 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天 微信截图_20221122161726.png

背景

在写新项目的时候,想给页面路由切换添加一个动画,查了查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;

效果如下:

transition.gif

是不是很完美 wow~ ⊙o⊙。

Transform带来的困扰

在完成整个页面布局的基建后,打算开始先做侧边栏快捷入口,侧边栏一般都是固定定位,fixed在右侧的,我也是这么做的,但是做完后发现fixed失效了,如下图所示:

image.png

可以看到我们已经给侧边栏设置了 position: fixed; top: 300px; 但是,滚动屏幕后可恶侧边栏并没有像我们预想的那样相对窗口固定top-300,而是随着页面滚动而同步滚动了。

问题原因分析:

一 寻找可能导致问题的原因:

在看到这个问题的第一时间,首先直觉定位到的就是css的问题,于是在一顿修改/尝试下,发现了罪魁祸首原来是你 —— transform

image.png

image.png

我们注释掉这条css规则后,一切都好起来了。

image.png

二 深层原因查询

知道这点后,就不难办了,上google搜索关键字 transform fixed,早已有许多前人为我们解释了一切,原来是transform属性会改变DOM的布局方式,从而导致内部子元素的部分属性出现预料之外的问题。

这里简单总结一下transform带来的问题(特性):

  1. transform的元素会影响溢出区域。即在父元素为 overflow: scroll | auto; 的情况下,transform如果到了父级盒子外边,则父级盒子会出现滚动条(溢出区域因为transform而增大)。

  2. transform会创建一个新的层叠上下文(stack context),内部元素的层叠顺序则独立于外部元素了(简单说就是内部元素的z-index层级都是基于我们的父级transform元素了,如果父级是最高层级的,不论内部设置了多地的zIndex,其层级都高于外部的元素)。

  3. 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不就完了。

image.png

由背景我们知道,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>
...
  1. 在hook代码中,我们增加一个fixTransformBugRef来关联挂载transform属性的元素。
  2. 定义一个方法用于清除 fixTransformBugRef 元素上的 transform 属性。
  3. ヽ(✿゚▽゚)ノ好耶,transition提供了钩子,我们可以直接在 onComplete 回调中调用我们的清除函数,来实现动画结束后清除属性。 --> 如果没有这个钩子怎么办?【暂且想到的是,用定时器不断获取transform元素上的transform值,当其为 0 时,表示动画结束,此时我们调用清除函数,并结束定时器】

清除掉无用的 transform 后,一切都好起来了!ヽ(✿゚▽゚)ノ

结语:其实css中 transform perspective filter 这三个属性的有效值(存在效果的,非none,inherit等值)都会造成这个问题,因为他们都会使得元素创建一个包含块,导致定位的父级改变到自身上,从而导致fixed不再以顶层为定位父级了。