“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情”
大佬指的是AnthonyFu,之前在逛b站的时候无意间看到了大佬发布的视频录播,视频的内容是用vue3实现的一个跨路由组件,当两个路由页面中有同样内容的时候,则可以为该元素添加跨路由的动画过渡效果,使其看起来像是跨路由一样,具体可以看gif图(注意页面路由)。
当看到这个效果的时候我觉得还挺有意思的,刚好自己又在学react,于是就想着看能不能自己用react实现一下,大致的思路并不难,但是真的实现起来坑还是挺多的。
首先先说一下自己的一个大概思路吧,先假设页面a有一张图片a,页面b也有一张图片b(图片为同一张图片但是大小以及位置不同),当a页面向b页面进行路由跳转的时候便可以让图片a过渡到图片b的样式,我的想法是将该图片写入到我定义的一个自定义组件Trans中,然后在Trans内使用hook来捕捉图片a的卸载以及图片b的添加。
export default function Trans(props) {
useLayoutEffect(() => {
let target = props.children.ref.current
let nodeStyle = props.children.props.style
Promise.resolve().then(() => transitMoveNode(target, props.children.key, nodeStyle))
return () => {
transitCloneNode(target, props.children.key)
}
},[])
return (
<>{props.children}</>
)
}
在这里使用的hook是useLayoutEffect,并不是useEffect,使用useEffect会出现无法正确获取target元素在页面中显示的位置,useLayoutEffect可以解决这个问题。
当a页面向b页面跳转时会先执行transitCloneNode方法,该方法的参数target此时就是图片a元素,执行该方法后会将a元素移动到html标签内部同时为该元素添加特定的css样式,使其在页面中显示的位置相对于原位置来说不变,稍后页面b进行渲染,此时便会执行transitMoveNode方法,Trans组件会发现元素a与元素b的key值相等,这时会把元素b的opacity的值设为0,便会使元素a过渡到元素b的样式,过渡完毕后会触发元素a身上的ontransitionend事件,触发的事件会将元素a从页面上移除,同时将元素b的opacity的值设为1,从而达到一个跨路由的过渡效果。
在这里有点坑的地方就是将要卸载的元素a只能将其移动的方式移动到html标签内,我所使用的方法是appendChild,这里不能对元素a先进行克隆后再插入,这么做会使得页面出现闪烁。
import React, { useLayoutEffect } from 'react'
export default function Trans(props) {
useLayoutEffect(() => {
let target = props.children.ref.current
let nodeStyle = props.children.props.style
//不放在微任务内会使得元素的某些属性会无法正确的获取到(元素的位置信息均为0)
Promise.resolve().then(() => transitMoveNode(target, props.children.key, nodeStyle))
return () => {
transitCloneNode(target, props.children.key)
}
},[])
return (
<>{props.children}</>
)
}
//用来存储被移动到html标签下方的元素
const startMap = new Map()
const root = document.querySelector('html')
//该函数的作用是获取到将要被移除的节点,并将该元素移动到html标签下方
//并添加对应的css样式使得该元素在页面中实际所显示的位置不变
//被移动了的节点会存储到startMap内,键值为给定的key值
function transitCloneNode(node, key) {
if(node==null){
return 0
}
let {top,left} = node.getBoundingClientRect()
//这里添加一个微任务是因为--如果要移动多个元素,那么移动一个后该元素后面的元素位置可能会受到影响,所以放到一个微任务里面去进行,并且在进行此步骤之前先获取该元素在页面中显示的位置
Promise.resolve().then(() => {
let start = node
start.classList.add('transit-0000')
start.style.cssText += `top:${top}px !important;left:${left}px !important;`
startMap.set(key, start)
root.appendChild(start)
clearMap()
})
}
//该函数的作用是获取到将要新增的节点,在startMap内查找有没有对应的被移动到html标签下方的节点
//如果没有则直接退出
//如果有,则将新增的节点的opacity属性设为0, 并获取新增节点在页面中显示的位置
//将与之对应的之前被移动到html标签下方的节点(旧节点)的显示位置过渡到新节点的显示位置
//然后移除新节点的opacity属性同时将旧节点从dom中移除
function transitMoveNode(node, key, style) {
if (startMap.get(key) == undefined) {
return 0
}
node.style.opacity = 0
let start = startMap.get(key)
startMap.delete(key)
let startPos = start.getBoundingClientRect()
let nodePos = node.getBoundingClientRect()
let moveLeft = nodePos.left - startPos.left
let moveTop = nodePos.top - startPos.top
//此处多加0.0001是避免新旧节点所显示的位置一样,位置一样可能会使得旧节点不会进行过渡动画
//也就不会触发ontransitionend事件,便会一直显示在页面上
start.style.cssText += `transform: translateX(${moveLeft + 0.0001}px) translateY(${moveTop + 0.0001}px) !important;`
start.classList = node.classList
start.classList.add('transit-0000')
for (let h in style) {
if (h != 'top' && h != 'left') {
start.style[h] = style[h]
}
}
start.ontransitionend = () => {
node.style.opacity = ''
start.ontransitionend = () => { }
root.removeChild(start)
}
}
let x = true
//该函数的作用是清除没有与之对应的元素,比如在a页面有个被Trans包裹的a元素
//但是b页面并没有与a元素对应key值的元素,此时就需要该函数来删除a元素
function clearMap() {
if (x) {
x = false
Promise.resolve().then(() => {
for (let [key, val] of startMap) {
root.removeChild(val)
startMap.delete(key)
}
x = true
})
}
}
//新增一个样式类
(function createStyle(){
let text=document.createElement('style')
text.setAttribute('type','text/css')
text.innerHTML=`
.transit-0000{
transition: all ease .4s !important;
position:absolute !important;
top:0px;
left:0px;
}
.transit-0000 *{
transition: all ease .4s !important;
}
`
document.querySelector('head').appendChild(text)
}())
github地址 最后贴一个实际效果图(注意路由):