{
"title": "写一个炫酷的个人名片页✨✨",
"tags": [
"post",
"技术"
],
"sources": [
"xlog"
],
"external_urls": [
"https://daidr.xlog.app/card-page"
],
"date_published": "2023-04-10T15:40:00.000Z",
"content": "> 这篇文章主要介绍名片页的路由过渡是如何去做的\n\n## 介绍\n\n在19年,我就写了一个[**较为炫酷**的个人名片页](https://im-old.daidr.me)。当时的我热衷于使用各种过渡效果,当然,也尝试了很多新鲜的 css 特性,例如为了实现多种主题色使用了 css 变量(好像还是我首次使用flex布局呢)\n\n![image](ipfs://bafkreifvq3qlnyl7z7na7egixcj6654xgelnyfxb4mggritnvvlltjjvjq)\n\n但当时的我显然还尚未深谙前端布局之道🤯,许多页面元素在当时的浏览器渲染是正常的,现在却有些崩坏了,很多细节处理不完善,遂准备将这个名片页进行重制\n\n![image](ipfs://bafybeifdarho2oouoi4t56fzjtzuuvakg5sqnhor5ajknvc3pordipfkim)\n\n不过还是有挺多小巧思在里面的。比如为了防止滚动导致卡片被切开,给容器加了一个伪元素实现的阴影。实现很简单,效果却非常不错。\n\n![image](ipfs://bafkreiakeefue5t2l4epjkrgafp6slunz73h3stgrwxgtzbzvl3vgfofsi)\n\n现在重制也基本上完成了(还剩几个页面没写完,不过无伤大雅),可以先看看效果 [im.daidr.me](https://im.daidr.me)\n\n不难看出整体的页面风格和以前非常相似,不过确实很符合我对「炫酷」的想象\n\n## 技术栈\n\nVue3 + WindiCSS + SCSS + Nuxt\n\n这个名片页其实在去年12月就开始写了,刚开始没做SSR,最近尝试迁移到了Nuxt,路由动效之类兼容也很折磨,不过这不是这篇文章的重点,就不多说啦~\n\n文章页是前几个星期才刚加上的,目前是把旧WordPress博客当CMS用,但是有些太重了——给文章页加上了Redis swr缓存,才能勉强保证流畅访问。最近在食用基于 Crossbell 链的 [xLog](https://xlog.app/),感觉非常不错,希望研究明白之后能把文章页接入 xLog😗。\n\n## 分析\n\n### 路由结构\n\n首先是路由结构(关系到之后页面如何进行变换)。\n\n目前,这个名片页有5套页面 `/me`(别名 `/`),`/friends`,`/projects`,`/blog/:slug`,`/404`。他们的结构是这样的:\n\n```go\n├── `/`\n│ ├── `/me`\n│ └── `/friends`\n├── `/projects`\n├── `/blog/:slug`\n└── `/404`\n```\n\n因为 /me 和 /friends 页面的容器大小一致,翻牌的效果不太合适,所以互相切换时使用另外一套过渡效果。\n\n(现在看来这种实现并不合适,维护和自定义都会比较困难。Nuxt有自带的路由过渡配置选项,不需要依赖子路由来实现。)\n\n### 容器定位\n\n能够将一个容器固定在页面正中心的方法其实蛮少的,我使用的是 fixed 绝对定位。先给元素设置 `top: 50%; left: 50%;`,但这时候的元素并不在页面正中心(而是其左上角在页面中心)\n\n![image](ipfs://bafkreib7p2t4s6sefxr6cf43xozmaunk6iopydr6duhk2a7bktfjmiomku)\n\n所以需要接着设置 `transform: translate(-50%, -50%);`,将元素向左/向上偏移。\n\n![image](ipfs://bafkreid7cttizdcufnkzsohuypusg2irnbq4yvv52bfekqs3dlwx73june)\n\n### 翻牌过渡\n\n来讲讲图片中的翻牌过渡是如何实现的。(仅考虑翻牌元素本身)\n\n![GIF 2023-4-11 0-04-35 (2).gif](ipfs://bafybeihlpskssga7ebqqq4c4ozodjltpmfc66rh4ojucxjoufyybc4fvou)\n\n下面的代码各位 Vuer 一定不陌生,这能让 vue-router 在切换页面时应用过渡效果\n\n```markup\n<router-view v-slot=\"{ Component }\">\n <transition name=\"fade\">\n <component :is=\"Component\" />\n </transition>\n</router-view>\n```\n\n而实际上,Transition 组件可不仅仅能传递 name,还能够通过 JavaScript 具体控制过渡的每个环节。将过渡动效的相关逻辑封装到 `RouterTransition` 中:\n\n```markup\n<!-- App.vue -->\n<RouterView v-slot=\"{ Component }\">\n <RouterTransition>\n <component :is=\"Component\" />\n </RouterTransition>\n</RouterView>\n\n<!-- RouterView.vue -->\n<Transition \n ref=\"SlotRef\" :css=\"false\"\n @before-enter=\"onBeforeEnter\" @enter=\"onEnter\" @after-enter=\"onAfterEnter\"\n @before-leave=\"onBeforeLeave\" @leave=\"onLeave\"\n>\n <slot />\n</Transition>\n```\n\n#### 分析\n\n使用 JavaScript 去控制过渡,我们就需要知道过渡前后元素的尺寸以及位置,拿到元素倒是好办,但是这里有一个问题:需要应用过渡的元素并**不一定是页面根元素**。\n\n比方说 `/projects` 页面,只有顶部的菜单栏应用了过渡。所以需要有一个手段去标识这些元素。我使用的方法是为需要过渡的元素加上类名 `transition-page-wrapper`\n\n写一个工具函数,传入页面根元素,返回需要过渡的元素\n\n```javascript\nconst getTransitionContainer = (el) => {\n const containerClass = 'transition-page-wrapper';\n // 如果el不是元素,直接返回\n if (!el || !el.classList) {\n return el;\n }\n // 如果el是目标元素,直接返回\n if (el.classList.contains(containerClass)) {\n return el;\n }\n // 否则,遍历el的所有层级的子元素,找到目标元素\n for (let i = 0; i < el.children.length; i++) {\n const child = el.children[i];\n if (child.classList && child.classList.contains(containerClass)) {\n return child;\n } else {\n const _child = getTransitionContainer(child);\n if (_child != child) {\n return _child;\n }\n }\n }\n return el;\n}\n```\n\n#### 过渡开始前\n\n> 之后,路由切换前的页面元素会被称为 `fromEl`,路由切换后的页面元素会被称为 `toEl`\n\n首先,我们来搞定 `before-leave` 事件。在这个函数中,我们需要将 `fromEl` 的位置、尺寸信息记录下来,为了保证过渡顺滑,我还准备额外记录 `border-radius` 属性。\n\n```javascript\nconst fromWrapperStyle = {\n x: 0,\n y: 0,\n w: 0, // 宽度\n h: 0, // 高度\n br: 0, // border-radius 属性\n t: \"\", // transform 属性\n};\n```\n\n其中,`xywh` 的值可以使用 `getBoundingClientRect` 方法取到。而 `b`(border-radius) `t`(transform) 是 css 样式属性,元素的 style 属性只能拿到其内联样式,为了拿到浏览器计算之后元素的所有准确 css 样式,需要使用 `getComputedStyle` 方法。封装成 `writeCfgObj` 工具函数方便之后使用:\n\n```javascript\nconst writeCfgObj = (el, cfgObj) => {\n const elRect = el.getBoundingClientRect();\n cfgObj.x = elRect.x;\n cfgObj.y = elRect.y;\n cfgObj.w = elRect.width;\n cfgObj.h = elRect.height;\n const _style = getComputedStyle(el);\n cfgObj.br = parseFloat(_style.borderRadius.replace(\"px\", \"\"));\n cfgObj.t = _style.transform;\n}\n```\n\ntransition 组件的 `before-leave` 事件有一个参数,该参数会传递将在过渡中消失的元素(即 `fromEl`)\n\n```javascript\nconst onBeforeLeave = (fromEl) => {\n // 根据根元素,获取实际需要过渡的元素\n let _fromWrapper = getTransitionContainer(fromEl);\n \n // 之前写的工具方法,用于存入元素位置/尺寸/部分样式\n writeCfgObj(_fromWrapper, fromWrapperStyle);\n}\n```\n\n有了 `fromEl` 的位置/尺寸,接下来就是 `toEl` 的位置尺寸了,可以通过 `before-enter` 事件拿到\n\n需要注意和 `before-leave` 不同的是:此时的 `toEl` 实际上还没有被插入到 dom 树中 (都插入进去了还过渡什么),此时元素的位置和尺寸都没法直接获取,我们需要一些额外的步骤。\n\n```javascript\nconst onBeforeEnter = (toEl) => {\n // 复制一份 toEl\n let toWrapper = toEl.cloneNode(true);\n // 禁用过渡,防止元素自带 transition 的情况下,之后设置 opacity 出现不必要的穿帮\n toWrapper.style.transitionDuration = '0s'\n // 设置 opacity 为 0\n toWrapper.style.opacity = 0;\n // 插入到 body\n document.body.appendChild(toWrapper);\n\n // 取得克隆后容器内的过渡元素\n let _toWrapper = getTransitionContainer(toWrapper);\n writeCfgObj(_toWrapper, toWrapperStyle);\n \n // 移除\n toWrapper.remove();\n}\n```\n\n> 实际上就是将 `toEl` 克隆一份插入到 dom 中,获取完位置立刻删掉。因为 opacity 被我们设置成了 0,此时元素不可见,用户其实不太会感知到。\n> \n> `TransitionGroup` 的实现其实差不多\n\n#### 过渡进行中!\n\n拿到了 `toEl` 和 `fromEl` 的这些属性,过渡就可以开始啦!过渡主要会使用到 `tranform` 元素\n\n不过先别急😜,在开始过渡之前,我们需要算出 `toEl` 和 `fromEl` 的位置和尺寸差值,这样我们才方便使用 `translate` 和 `scale` 对元素应用变换。\n\n> 这里需要注意的是:我们对元素应用变换使用了 transform 属性,而元素本身可能就有位移。过渡的过程中,我们会对其进行覆盖,所以计算时千万别忘了把元素本身的位移考虑进去。\n\n```javascript\nconst calcDelta = (prevCfg, nextCfg, nextMatrix3dStr) => {\n const matrix3d = nextMatrix3dStr.replace(/matrix3d\\(|\\)/g, \"\").split(\",\").map((v) => parseFloat(v));\n // 转换为 translate\n const nextTranslateX = matrix3d[12];\n const nextTranslateY = matrix3d[13];\n\n // 计算 scale\n const scaleX = prevCfg.w / nextCfg.w;\n const scaleY = prevCfg.h / nextCfg.h;\n\n // 计算 delta\n let deltaX = prevCfg.x - prevCfg.x + nextTranslateX;\n let deltaY = prevCfg.y - prevCfg.y + nextTranslateY;\n\n // 因为进行了 scale,所以需要根据 scale 修正 delta\n deltaX -= (1 - scaleX) * nextCfg.w / 2;\n deltaY -= (1 - scaleY) * nextCfg.h / 2;\n\n return {\n deltaX,\n deltaY,\n scaleX,\n scaleY,\n };\n}\n```\n\n看到上面的代码可能会有些懵,`matrix3d` 是什么?什么时候冒出来的?\n\n还记得之前取元素 `transform` 属性时使用的 `getComputedStyle` 么?浏览器会返回**计算后**的样式。我们拿到的,并不是形似 `translate(-50%, -50%)` 的字符串,而是一个 `matrix3d` 函数所代表的变换矩阵。为了拿到元素的位移,我们只需要第13个参数 `a4` 和第14个参数 `b4` 就够了\n\n`scaleX/Y` – 通过 `toEl` 和 `fromEl` 的尺寸算出应该缩放的比例\n\n`deltaX/Y` – 通过 `toEl` 和 `fromEl` 的位置和位移算出应该移动的距离,由于需要进行缩放,还需要使用缩放比例对这个差值进行修正\n\n接下来,就可以正式来处理 `toEl` 的离开了,需要使用到 transition 组件的 `leave` 事件\n\n```javascript\nconst onLeave = (el, done) => {\n // 获取应该过渡的元素\n el = getTransitionContainer(el);\n\n // 强制赋予一个过渡效果\n el.style.transitionProperty = 'all';\n el.style.transitionDuration = '1300ms';\n \n // 让浏览器缓一缓 ε=ε=ε=┏(゜ロ゜;)┛\n requestAnimationFrame(() => {\n const d = calcDelta(toWrapperStyle, fromWrapperStyle, fromWrapperStyle.t);\n \n // 因为使用了 windicss,所以这里采用了覆盖 css 变量的方式\n // 也可直接使用 transform\n \n // 翻转\n // 可以看到这里进行了一个x轴和z轴的反转,这样正好把容器反过来\n el.style.setProperty(\"--tw-rotate-x\", \"180deg\");\n el.style.setProperty(\"--tw-rotate-z\", \"-180deg\");\n \n // 将容器(fromEl)移到新位置(toEl的位置)\n el.style.setProperty(\"--tw-translate-x\", `${d.deltaX}px`);\n el.style.setProperty(\"--tw-translate-y\", `${d.deltaY}px`);\n el.style.setProperty(\"--tw-scale-x\", `${d.scaleX}`);\n el.style.setProperty(\"--tw-scale-y\", `${d.scaleY}`);\n \n // 改变容器的圆角\n const scale = (d.scaleX + d.scaleY) / 2;\n el.style.borderRadius = toWrapperStyle.br / scale + \"px\";\n \n // 渐隐\n el.style.opacity = \"0\";\n })\n \n // 监听过渡结束事件,在 tranform 过渡完成之后,告知 transition 组件过渡已结束\n let _event = null;\n el.addEventListener('transitionend', _event = (ev) => {\n if (ev.target === el && ev.propertyName === 'transform') {\n el.removeEventListener('transitionend', _event);\n done();\n }\n })\n}\n```\n\n> 调用 leave 事件传递进来的 `done()` 回调函数之后, `fromEl` 就会被 transition 组件删除,不需要我们自己删除。\n\n现在,`fromEl` 已经完成过渡并且被清除了,最后一件事,就是要将 `toEl` 显示出来,正好和 `fromEl` 相反。`fromEl` 旋转 180°,`toEl` 就旋转 -180°。\n\n```javascript\nconst onEnter = (el, done) => {\n el.style.transitionDuration = '0s'\n const d = calcDelta(fromWrapperStyle, toWrapperStyle, toWrapperStyle.t);\n el.style.setProperty(\"--tw-rotate-x\", \"-180deg\");\n el.style.setProperty(\"--tw-rotate-z\", \"-180deg\");\n el.style.setProperty(\"--tw-translate-x\", `${d.deltaX}px`);\n el.style.setProperty(\"--tw-translate-y\", `${d.deltaY}px`);\n el.style.setProperty(\"--tw-scale-x\", `${d.scaleX}`);\n el.style.setProperty(\"--tw-scale-y\", `${d.scaleY}`);\n el.style.opacity = \"0\";\n const scale = (d.scaleX + d.scaleY) / 2;\n el.style.borderRadius = fromWrapperStyle.br / scale + \"px\";\n\n document.body.offsetHeight;\n\n requestAnimationFrame(() => {\n el.style.transitionProperty = 'all';\n el.style.transitionDuration = '1300ms';\n\n // 重置全部属性\n el.style.borderRadius = \"\";\n el.style.opacity = \"\";\n el.style.setProperty(\"--tw-rotate-x\", \"\");\n el.style.setProperty(\"--tw-rotate-z\", \"\");\n el.style.setProperty(\"--tw-translate-x\", \"\");\n el.style.setProperty(\"--tw-translate-y\", \"\");\n el.style.setProperty(\"--tw-scale-x\", \"\");\n el.style.setProperty(\"--tw-scale-y\", \"\");\n })\n \n let _event = null;\n el.addEventListener('transitionend', _event = (ev) => {\n if (ev.target === el && ev.propertyName === 'transform') {\n el.removeEventListener('transitionend', _event);\n done();\n }\n })\n}\n```\n\n这个 `onEnter` 函数看起来和之前的 `onLeave` 差不多,但仔细一看又差很多🤯。这是因为两者的原理是不一样的。\n\n`onLeave` 事件用于处理 `fromEl`,`fromEl` 在过渡完成后就要被删掉的,谁管它会不会残留什么乱七八糟的内联样式呢。所以,我们选择先给 `fromEl` 一个 transition 属性,然后给他赋予位移,使其慢慢过渡到新元素的位置。\n\n`onEnter` 事件用于处理 `toEl`,这里的 `toEl` 在过渡完成后是要留在页面上的,我们不能因为过渡,就往上面写一堆内联样式,写了至少也要在过渡完成后删掉。\n\n所以,这里的逻辑是:先禁用 transition,然后通过内联的 `transform` 将 `toEl` 放置到 `fromEl` 的位置上。这时候,开启 transition,然后删除之前设置的 transform 属性,`toEl` 就会过渡回来啦!而且过渡完成后,`transform` 属性不会残留在元素上,棒!\n\n#### 过渡完成后\n\n我们给 `toEl` 设置了 `transition` 属性,所以需要 `after-enter` 事件来「擦擦屁股」\n\n```javascript\nconst onAfterEnter = (el) => {\n el = getTransitionContainer(el);\n el.style.transitionProperty = '';\n el.style.transitionDuration = '';\n}\n```\n\n现在,整个翻牌过渡就完成啦\n\n你可能会发现在某些情况下,会出现另外一种过渡(加载超过100ms时,先转变到loading)\n\n![GIF 2023-4-11 0-09-40 (1).gif](ipfs://bafybeifizwbobewti6ca7dx6yb3u43jjjqeqywc76ev2gsegrqhe2hy2pq)\n\n这个动画通过路由守卫实现,原理也差不多,只是将 toEl/fromEl 替换成 Loading 元素。不过在 Workbox 和 Nuxt Prefetch 双重加持下,这个动画已经没有什么意义了。",
"attributes": [
{
"value": "card-page",
"trait_type": "xlog_slug"
}
]
}