この記事では、名刺ページのルーティング遷移がどのように行われるかを主に紹介します。
イントロダクション#
19 年に、私はかなりクールな個人名刺ページを作成しました。当時の私はさまざまな遷移効果を使用することに熱中しており、もちろん、CSS 変数を使用して複数のテーマカラーを実現するなど、多くの新しい CSS 機能にも挑戦しました(フレックスレイアウトを使用したのはこれが初めてのようです)。
しかし、当時の私は明らかにフロントエンドのレイアウトの道を深く理解していませんでした🤯。多くのページ要素が当時のブラウザで正常にレンダリングされていましたが、今では少し崩れてしまい、多くの細部の処理が不完全だったため、この名刺ページを再制作することにしました。
とはいえ、内部にはかなりの工夫が施されています。たとえば、スクロールによってカードが切り取られないように、コンテナに擬似要素を追加して影を実現しました。実装は非常に簡単ですが、効果は非常に良いです。
現在、再制作はほぼ完了しました(まだいくつかのページが未完成ですが、大したことではありません)。まずは効果を見てみてください 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%;
を設定しますが、この時点では要素はページの真ん中にありません(左上隅がページの中心にあります)。
したがって、次に transform: translate(-50%, -50%);
を設定して、要素を左 / 上にオフセットする必要があります。
カードめくり遷移#
画像のカードめくり遷移がどのように実現されているかを説明します。(カードめくり要素自体のみを考慮します)
以下のコードは、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);
// 遷移を無効にして、要素が自動的に遷移するのを防ぎます
toWrapper.style.transitionDuration = '0s'
// 不透明度を0に設定
toWrapper.style.opacity = 0;
// bodyに挿入
document.body.appendChild(toWrapper);
// コピーした後のコンテナ内の遷移要素を取得
let _toWrapper = getTransitionContainer(toWrapper);
writeCfgObj(_toWrapper, toWrapperStyle);
// 削除
toWrapper.remove();
}
実際には、
toEl
をクローンして DOM に挿入し、位置を取得したらすぐに削除します。opacity を 0 に設定したため、この時点で要素は見えず、ユーザーはほとんど気づかないでしょう。
TransitionGroup
の実装もほぼ同様です。
遷移中!#
toEl
と fromEl
のこれらの属性を取得したら、遷移を開始できます!遷移では主に transform
要素を使用します。
しかし、急がないでください😜、遷移を開始する前に、toEl
と fromEl
の位置とサイズの差を計算する必要があります。そうすれば、translate
と scale
を使用して要素に変換を適用しやすくなります。
ここで注意が必要なのは、要素に変換を適用するために
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];
// スケールを計算
const scaleX = prevCfg.w / nextCfg.w;
const scaleY = prevCfg.h / nextCfg.h;
// 差分を計算
let deltaX = prevCfg.x - prevCfg.x + nextTranslateX;
let deltaY = prevCfg.y - prevCfg.y + nextTranslateY;
// スケールを行ったため、スケールに基づいて差分を修正する必要があります
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
– toEl
と fromEl
のサイズから、スケールすべき比率を算出します。
deltaX/Y
– toEl
と fromEl
の位置と移動から、移動すべき距離を算出します。スケールを行う必要があるため、この差分を修正する必要があります。
次に、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";
})
// 遷移終了イベントをリッスンし、transform遷移が完了した後に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
イベントは fromEl
を処理するために使用され、fromEl
は遷移が完了した後に削除されるため、残留するような乱雑なインラインスタイルを気にする必要はありません。したがって、最初に fromEl
に遷移属性を付与し、その後位置を与えて新しい要素の位置にゆっくりと遷移させます。
onEnter
イベントは toEl
を処理するために使用されます。この toEl
は遷移が完了した後にページに残るため、遷移のためにインラインスタイルをたくさん書くことはできません。書いた場合、少なくとも遷移が完了した後に削除する必要があります。
したがって、ここでのロジックは、最初に遷移を無効にし、インラインの transform
を使用して toEl
を fromEl
の位置に配置します。この時点で遷移を開始し、以前に設定した transform 属性を削除すると、toEl
は元の位置に戻ります!遷移が完了した後、transform
属性は要素に残らないので、素晴らしいです!
遷移完了後#
toEl
に transition
属性を設定したため、after-enter
イベントを使用して「後処理」を行います。
const onAfterEnter = (el) => {
el = getTransitionContainer(el);
el.style.transitionProperty = '';
el.style.transitionDuration = '';
}
これで、カードめくり遷移が完了しました。
特定の状況では、別の遷移(100ms を超える読み込み時に、最初にローディングに変わる)が発生することに気づくかもしれません。
このアニメーションはルーティングガードを使用して実現されており、原理はほぼ同じですが、toEl/fromEl をローディング要素に置き換えています。しかし、Workbox と Nuxt Prefetch の二重のサポートにより、このアニメーションはすでにあまり意味がなくなっています。