戴兜

戴兜的小屋

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 雙重加持下,這個動畫已經沒有什麼意義了。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。