戴兜

戴兜的小屋

Coding the world.
github
bilibili
twitter

写一个炫酷的个人名片页✨✨

这篇文章主要介绍名片页的路由过渡是如何去做的

介绍#

在 19 年,我就写了一个较为炫酷的个人名片页。当时的我热衷于使用各种过渡效果,当然,也尝试了很多新鲜的 css 特性,例如为了实现多种主题色使用了 css 变量(好像还是我首次使用 flex 布局呢)

image

但当时的我显然还尚未深谙前端布局之道🤯,许多页面元素在当时的浏览器渲染是正常的,现在却有些崩坏了,很多细节处理不完善,遂准备将这个名片页进行重制

image

不过还是有挺多小巧思在里面的。比如为了防止滚动导致卡片被切开,给容器加了一个伪元素实现的阴影。实现很简单,效果却非常不错。

image

现在重制也基本上完成了(还剩几个页面没写完,不过无伤大雅),可以先看看效果 im.daidr.me

不难看出整体的页面风格和以前非常相似,不过确实很符合我对「炫酷」的想象

技术栈#

Vue3 + WindiCSS + SCSS + Nuxt

这个名片页其实在去年 12 月就开始写了,刚开始没做 SSR,最近尝试迁移到了 Nuxt,路由动效之类兼容也很折磨,不过这不是这篇文章的重点,就不多说啦~

文章页是前几个星期才刚加上的,目前是把旧 WordPress 博客当 CMS 用,但是有些太重了 —— 给文章页加上了 Redis swr 缓存,才能勉强保证流畅访问。最近在食用基于 Crossbell 链的 xLog,感觉非常不错,希望研究明白之后能把文章页接入 xLog😗。

分析#

路由结构#

首先是路由结构(关系到之后页面如何进行变换)。

目前,这个名片页有 5 套页面 /me(别名 /),/friends/projects/blog/:slug/404。他们的结构是这样的:

├── `/`
│   ├── `/me`
│   └── `/friends`
├── `/projects`
├── `/blog/:slug`
└── `/404`

因为 /me 和 /friends 页面的容器大小一致,翻牌的效果不太合适,所以互相切换时使用另外一套过渡效果。

(现在看来这种实现并不合适,维护和自定义都会比较困难。Nuxt 有自带的路由过渡配置选项,不需要依赖子路由来实现。)

容器定位#

能够将一个容器固定在页面正中心的方法其实蛮少的,我使用的是 fixed 绝对定位。先给元素设置 top: 50%; left: 50%;,但这时候的元素并不在页面正中心(而是其左上角在页面中心)

image

所以需要接着设置 transform: translate(-50%, -50%);,将元素向左 / 向上偏移。

image

翻牌过渡#

来讲讲图片中的翻牌过渡是如何实现的。(仅考虑翻牌元素本身)

GIF 2023-4-11 0-04-35 (2).gif

下面的代码各位 Vuer 一定不陌生,这能让 vue-router 在切换页面时应用过渡效果

<router-view v-slot="{ Component }">
  <transition name="fade">
    <component :is="Component" />
  </transition>
</router-view>

而实际上,Transition 组件可不仅仅能传递 name,还能够通过 JavaScript 具体控制过渡的每个环节。将过渡动效的相关逻辑封装到 RouterTransition 中:

<!-- App.vue -->
<RouterView v-slot="{ Component }">
    <RouterTransition>
        <component :is="Component" />
    </RouterTransition>
</RouterView>

<!-- RouterView.vue -->
<Transition 
     ref="SlotRef" :css="false"
     @before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter"
     @before-leave="onBeforeLeave" @leave="onLeave"
>
    <slot />
</Transition>

分析#

使用 JavaScript 去控制过渡,我们就需要知道过渡前后元素的尺寸以及位置,拿到元素倒是好办,但是这里有一个问题:需要应用过渡的元素并不一定是页面根元素

比方说 /projects 页面,只有顶部的菜单栏应用了过渡。所以需要有一个手段去标识这些元素。我使用的方法是为需要过渡的元素加上类名 transition-page-wrapper

写一个工具函数,传入页面根元素,返回需要过渡的元素

const getTransitionContainer = (el) => {
    const containerClass = 'transition-page-wrapper';
    // 如果el不是元素,直接返回
    if (!el || !el.classList) {
        return el;
    }
    // 如果el是目标元素,直接返回
    if (el.classList.contains(containerClass)) {
        return el;
    }
    // 否则,遍历el的所有层级的子元素,找到目标元素
    for (let i = 0; i < el.children.length; i++) {
        const child = el.children[i];
        if (child.classList && child.classList.contains(containerClass)) {
            return child;
        } else {
            const _child = getTransitionContainer(child);
            if (_child != child) {
                return _child;
            }
        }
    }
    return el;
}

过渡开始前#

之后,路由切换前的页面元素会被称为 fromEl,路由切换后的页面元素会被称为 toEl

首先,我们来搞定 before-leave 事件。在这个函数中,我们需要将 fromEl 的位置、尺寸信息记录下来,为了保证过渡顺滑,我还准备额外记录 border-radius 属性。

const fromWrapperStyle = {
    x: 0,
    y: 0,
    w: 0, // 宽度
    h: 0, // 高度
    br: 0, // border-radius 属性
    t: "", // transform 属性
};

其中,xywh 的值可以使用 getBoundingClientRect 方法取到。而 b(border-radius) t(transform) 是 css 样式属性,元素的 style 属性只能拿到其内联样式,为了拿到浏览器计算之后元素的所有准确 css 样式,需要使用 getComputedStyle 方法。封装成 writeCfgObj 工具函数方便之后使用:

const writeCfgObj = (el, cfgObj) => {
    const elRect = el.getBoundingClientRect();
    cfgObj.x = elRect.x;
    cfgObj.y = elRect.y;
    cfgObj.w = elRect.width;
    cfgObj.h = elRect.height;
    const _style = getComputedStyle(el);
    cfgObj.br = parseFloat(_style.borderRadius.replace("px", ""));
    cfgObj.t = _style.transform;
}

transition 组件的 before-leave 事件有一个参数,该参数会传递将在过渡中消失的元素(即 fromEl

const onBeforeLeave = (fromEl) => {
    // 根据根元素,获取实际需要过渡的元素
    let _fromWrapper = getTransitionContainer(fromEl);
    
    // 之前写的工具方法,用于存入元素位置/尺寸/部分样式
    writeCfgObj(_fromWrapper, fromWrapperStyle);
}

有了 fromEl 的位置 / 尺寸,接下来就是 toEl 的位置尺寸了,可以通过 before-enter 事件拿到

需要注意和 before-leave 不同的是:此时的 toEl 实际上还没有被插入到 dom 树中 (都插入进去了还过渡什么),此时元素的位置和尺寸都没法直接获取,我们需要一些额外的步骤。

const onBeforeEnter = (toEl) => {
    // 复制一份 toEl
    let toWrapper = toEl.cloneNode(true);
    // 禁用过渡,防止元素自带 transition 的情况下,之后设置 opacity 出现不必要的穿帮
    toWrapper.style.transitionDuration = '0s'
    // 设置 opacity 为 0
    toWrapper.style.opacity = 0;
    // 插入到 body
    document.body.appendChild(toWrapper);

    // 取得克隆后容器内的过渡元素
    let _toWrapper = getTransitionContainer(toWrapper);
    writeCfgObj(_toWrapper, toWrapperStyle);
    
    // 移除
    toWrapper.remove();
}

实际上就是将 toEl 克隆一份插入到 dom 中,获取完位置立刻删掉。因为 opacity 被我们设置成了 0,此时元素不可见,用户其实不太会感知到。

TransitionGroup 的实现其实差不多

过渡进行中!#

拿到了 toElfromEl 的这些属性,过渡就可以开始啦!过渡主要会使用到 tranform 元素

不过先别急😜,在开始过渡之前,我们需要算出 toElfromEl 的位置和尺寸差值,这样我们才方便使用 translatescale 对元素应用变换。

这里需要注意的是:我们对元素应用变换使用了 transform 属性,而元素本身可能就有位移。过渡的过程中,我们会对其进行覆盖,所以计算时千万别忘了把元素本身的位移考虑进去。

const calcDelta = (prevCfg, nextCfg, nextMatrix3dStr) => {
    const matrix3d = nextMatrix3dStr.replace(/matrix3d\(|\)/g, "").split(",").map((v) => parseFloat(v));
    // 转换为 translate
    const nextTranslateX = matrix3d[12];
    const nextTranslateY = matrix3d[13];

    // 计算 scale
    const scaleX = prevCfg.w / nextCfg.w;
    const scaleY = prevCfg.h / nextCfg.h;

    // 计算 delta
    let deltaX = prevCfg.x - prevCfg.x + nextTranslateX;
    let deltaY = prevCfg.y - prevCfg.y + nextTranslateY;

    // 因为进行了 scale,所以需要根据 scale 修正 delta
    deltaX -= (1 - scaleX) * nextCfg.w / 2;
    deltaY -= (1 - scaleY) * nextCfg.h / 2;

    return {
        deltaX,
        deltaY,
        scaleX,
        scaleY,
    };
}

看到上面的代码可能会有些懵,matrix3d 是什么?什么时候冒出来的?

还记得之前取元素 transform 属性时使用的 getComputedStyle 么?浏览器会返回计算后的样式。我们拿到的,并不是形似 translate(-50%, -50%) 的字符串,而是一个 matrix3d 函数所代表的变换矩阵。为了拿到元素的位移,我们只需要第 13 个参数 a4 和第 14 个参数 b4 就够了

scaleX/Y – 通过 toElfromEl 的尺寸算出应该缩放的比例

deltaX/Y – 通过 toElfromEl 的位置和位移算出应该移动的距离,由于需要进行缩放,还需要使用缩放比例对这个差值进行修正

接下来,就可以正式来处理 toEl 的离开了,需要使用到 transition 组件的 leave 事件

const onLeave = (el, done) => {
    // 获取应该过渡的元素
    el = getTransitionContainer(el);

    // 强制赋予一个过渡效果
    el.style.transitionProperty = 'all';
    el.style.transitionDuration = '1300ms';
    
    // 让浏览器缓一缓 ε=ε=ε=┏(゜ロ゜;)┛
    requestAnimationFrame(() => {
        const d = calcDelta(toWrapperStyle, fromWrapperStyle, fromWrapperStyle.t);
        
        // 因为使用了 windicss,所以这里采用了覆盖 css 变量的方式
        // 也可直接使用 transform
        
        // 翻转
        // 可以看到这里进行了一个x轴和z轴的反转,这样正好把容器反过来
        el.style.setProperty("--tw-rotate-x", "180deg");
        el.style.setProperty("--tw-rotate-z", "-180deg");
        
        // 将容器(fromEl)移到新位置(toEl的位置)
        el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
        el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
        el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
        el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
        
        // 改变容器的圆角
        const scale = (d.scaleX + d.scaleY) / 2;
        el.style.borderRadius = toWrapperStyle.br / scale + "px";
        
        // 渐隐
        el.style.opacity = "0";
    })
    
    // 监听过渡结束事件,在 tranform 过渡完成之后,告知 transition 组件过渡已结束
    let _event = null;
    el.addEventListener('transitionend', _event = (ev) => {
        if (ev.target === el && ev.propertyName === 'transform') {
            el.removeEventListener('transitionend', _event);
            done();
        }
    })
}

调用 leave 事件传递进来的 done() 回调函数之后, fromEl 就会被 transition 组件删除,不需要我们自己删除。

现在,fromEl 已经完成过渡并且被清除了,最后一件事,就是要将 toEl 显示出来,正好和 fromEl 相反。fromEl 旋转 180°,toEl 就旋转 -180°。

const onEnter = (el, done) => {
    el.style.transitionDuration = '0s'
    const d = calcDelta(fromWrapperStyle, toWrapperStyle, toWrapperStyle.t);
    el.style.setProperty("--tw-rotate-x", "-180deg");
    el.style.setProperty("--tw-rotate-z", "-180deg");
    el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
    el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
    el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
    el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
    el.style.opacity = "0";
    const scale = (d.scaleX + d.scaleY) / 2;
    el.style.borderRadius = fromWrapperStyle.br / scale + "px";

    document.body.offsetHeight;

    requestAnimationFrame(() => {
        el.style.transitionProperty = 'all';
        el.style.transitionDuration = '1300ms';

        // 重置全部属性
        el.style.borderRadius = "";
        el.style.opacity = "";
        el.style.setProperty("--tw-rotate-x", "");
        el.style.setProperty("--tw-rotate-z", "");
        el.style.setProperty("--tw-translate-x", "");
        el.style.setProperty("--tw-translate-y", "");
        el.style.setProperty("--tw-scale-x", "");
        el.style.setProperty("--tw-scale-y", "");
    })
    
    let _event = null;
    el.addEventListener('transitionend', _event = (ev) => {
        if (ev.target === el && ev.propertyName === 'transform') {
            el.removeEventListener('transitionend', _event);
            done();
        }
    })
}

这个 onEnter 函数看起来和之前的 onLeave 差不多,但仔细一看又差很多🤯。这是因为两者的原理是不一样的。

onLeave 事件用于处理 fromElfromEl 在过渡完成后就要被删掉的,谁管它会不会残留什么乱七八糟的内联样式呢。所以,我们选择先给 fromEl 一个 transition 属性,然后给他赋予位移,使其慢慢过渡到新元素的位置。

onEnter 事件用于处理 toEl,这里的 toEl 在过渡完成后是要留在页面上的,我们不能因为过渡,就往上面写一堆内联样式,写了至少也要在过渡完成后删掉。

所以,这里的逻辑是:先禁用 transition,然后通过内联的 transformtoEl 放置到 fromEl 的位置上。这时候,开启 transition,然后删除之前设置的 transform 属性,toEl 就会过渡回来啦!而且过渡完成后,transform 属性不会残留在元素上,棒!

过渡完成后#

我们给 toEl 设置了 transition 属性,所以需要 after-enter 事件来「擦擦屁股」

const onAfterEnter = (el) => {
    el = getTransitionContainer(el);
    el.style.transitionProperty = '';
    el.style.transitionDuration = '';
}

现在,整个翻牌过渡就完成啦

你可能会发现在某些情况下,会出现另外一种过渡(加载超过 100ms 时,先转变到 loading)

GIF 2023-4-11 0-09-40 (1).gif

这个动画通过路由守卫实现,原理也差不多,只是将 toEl/fromEl 替换成 Loading 元素。不过在 Workbox 和 Nuxt Prefetch 双重加持下,这个动画已经没有什么意义了。

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。