背景
前端项目开发中,为了提升用户体验,增加页面跳转动画可以增加友好度,不过你真的彻底搞定了这个功能点吗?
本文探究的方案是基于一次react-router + react-transition-group实现转场动画的探索这篇文章里的例子,补充了原文没有提到的点,结合实际开发中遇到的问题,优化解决了一些棘手的问题,可以说是当前最全最优方案。推荐先看完那篇文章后,再来探究本文讨论的技术问题。
遇到的问题
在这里,react
项目的路由跳转动画是基于react-router
+ react-transition-group
实现的,例子中对一些隐藏的问题没有拿出来说,所以对直接接入动画路由的用户来说会遇到一些问题,总结下来有三个方面:
- 动画过程中的问题
- 手机端自带滑动动画和前端动画冲突
- 因前进后退判断的问题,引发动画执行方案
接下来我一个个演示解决。
动画过程中的问题
页面定位问题
我根据例子中的动画demo接入后,得到类似这样的结果,先看图: 这个问题其实是原例子中被隐藏解决了的,作为一个接入方,我并不会意识到这一点。
通过了解react-transition-group
的工作机制和查看dom可以看到,是因为两个页面同时存在的时候,两个div的定位位置的一上一下,导致动画过程中并不能同时展示两个页面,如下示意图
原例子中,作者给每个页面路由的容器添加了绝对定位,所以页面之间都会并排展示。
对于这种情况,我们可以在动画css里面给所有enter
和exit
的class加上一段定位样式,这样就不用再业务中处理了。
.forward-enter, .forward-exit, .back-enter, .back-exit {
position: absolute !important;
top: 0;
width: 100%;
}
懒加载页面带来的动画问题
为了提升单页应用的加载性能,我们往往会通过lazy
和<Suspense/>
的来实现懒加载。
const DetailPage = lazy(() => import('../Pages/DetailPage'))
<Suspense fallback={<Spin/>}>
<Switch location={location}>
<Route exact path={'/detail'} component={DetailPage} />
...
</Switch>
</Suspense>
但是当我们在外部添加了路由动画后,初次加载时,会出现这样的现象。为了效果更加明显,我手动增加了动画时长和加载时长。
可以看到,懒加载的页面,并没有走跳转动画,感觉就像前一个页面挡住了新的页面,然后退出一样。
我们可以通过观察节点的变化,可以发现端倪。
可以明显看到dom变化了三次,而在react-transition-group
的变换过程中,dom的变化一般是这样。
<TransitionGroup>
<div>0</div>
</TransitionGroup>
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
<TransitionGroup>
<div>0<div/><!-- 出场动画 -->
<div>1</div><!-- 入场动画 -->
</TransitionGroup>
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
<TransitionGroup>
<div>1</div>
</TransitionGroup>
但懒加载的情况下,变成了这样。
<TransitionGroup>
<div>0</div>
</TransitionGroup>
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
<TransitionGroup>
<div>0</div><!-- 出场动画 -->
<Sniper/><!-- 入场动画 -->
</TransitionGroup>
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
<TransitionGroup>
<div>0</div><!-- 出场动画 -->
<div>1</div>
</TransitionGroup>
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
<TransitionGroup>
<div>1</div>
</TransitionGroup>
这是因为懒加载替换了正在执行入场动画的dom,导致enter
的class没有继续附加给替换后的dom节点。想来想去,这个问题解决办法只有一个:给Suspense
和CSSTransition
之间加一个div
作为代理动画的容器, 这样一来,执行动画的dom不再会被替换,就避免了这一问题。看看效果:
手指滑动影响动画的问题
动画过程中,我们一般认为只是页面切换状态的效果,用户不论进行什么操作不应该有太多影响,所以需要避免这样的情况。(放慢动画来看) 由于此时容器内有两个页面的dom,可以任一滚动,因此需要在动画期间控制一下用户手势。做法有两种:
- 通过js监听
CSSTransition
的事件,判断对动画期间的触摸事件进行阻止; - 通过增加css属性。
显然后者简单很多,只要给所有enter
和exit
的active中加上对触摸事件的处理即可:
.forward-enter-active,.forward-exit-active,
.back-enter-active, .back-exit-active{
touch-action: none;
}
touch-action
用于设置触摸屏用户如何操纵元素的区域,具体用法和兼容性可以参考touch-action - CSS(层叠样式表)
其他问题
中途也发现了一些其他的动画问题,比如过程中,点击跳转,导致的动画混乱的问题;可滚动页面之间跳转的问题;不过把react-transition-group
升级到最新后,就没发现这些问题了。
手机端自带滑动动画和前端动画冲突
在实际业务场景中可能还会发现这样一个问题:现在有的浏览器或者app,可以通过滑动web视图来操作前进和后退,比如:
这样会导致浏览器的效果和前端动画冲突,看上去像执行了两次动画。这一现象在iphone中更加普遍,app把前进后退的操作通过拖拽行为表现出来,对于前端而言,并不知道自己的webview以什么样的形式被拖动,也不能阻止这一行为,因此并不能通过阻止触摸事件等等方式处理。
在我做过的业务中,由于这样的行为一般出现在IOS
的app内,处理办法是通过判断系统是否是IOS
,来决定回退动画是否展示,这个办法比较粗糙,但一时间也没有更好办法。
不过,后来经过探索touchend
事件和路由跳转的时机发现,拖拽网页导致跳转的行为,跳转事件一般不会晚于touchend
事件16ms
(估计就是一帧的时长),而且安卓手机在某些浏览器都不会触发touchend
事件。因此我们可以:
通过监听滑动事件,如果跳转发生在滑动过程中,则不进行任何动画。且
touchend
事件触发的16ms
之内,也依旧算作滑动过程中。
更改后的代码如下:
// router.js
let needAnimation = true // 控制滑动自带动画冲突
const ANIMATION_MAP = { // 动画类名
PUSH: 'forward',
POP: 'back',
REPLACE: 'forward',
}
const delayReset = () => { // 延后重置控制参数
setTimeout(() => {
needAnimation = true
}, 16)
}
window.addEventListener('touchstart', e => {
needAnimation = true
})
window.addEventListener('touchmove', e => {
needAnimation = false
})
window.addEventListener('touchend', delayReset)
const render = ({location, history}) => {
delayReset() // 防止某些浏览器不触发touchend
return (
<TransitionGroup
className="router-wrapper"
childFactory={child => React.cloneElement(
child,
{ classNames: needAnimation ? ANIMATION_MAP[history.action] : '' }
)}
>
<CSSTransition
timeout={500}
key={location.pathname}
>
<div>
<Suspense fallback={<Spin/>}>
<Switch location={location}>
<Route exact path={'/'} component={HomePage} />
...
</Switch>
</Suspense>
</div>
</CSSTransition>
</TransitionGroup>
)}
export default () => <BrowserRouter>
<Route path='/' render={render}/>
</BrowserRouter>
这样一来,手动拽动页面就不会有动画,但是点击页面按钮并不受影响,安卓和IOS
通用。
因前进后退判断的问题,引发动画执行方案
如果对页面前进后退不需要考虑浏览器的行为,那么可以不采用接下来的探讨方案。
原例子中采用了根据history.action
来决定当前的动画,但是浏览器中这样的处理有时候并不是我们理解的前进和后退,看看现象,这个在移动端用拖拽行为也是一样。
原来,是因为history.action
这个字段的值,在调用history.push
时为PUSH
,调用history.replace
时为REPLACE
,而当调用history.(go|goBack|goForward)
时,都是POP
。浏览器的前进后退按钮,只是调用了goBack
和goForward
,因此如果点击浏览器的前进返回按钮,并不知道此时的路由行为是前进还是后退。
关于页面
history
的工作流程可以看看这篇文章,会对为什么有上述的现象有更深刻的了解。
基于这样的原因,我们因此不能太依赖location.action
,由此,我想到了两个比较可行的方案:
- 配置页面层级
- 记录页面跳转堆栈
配置页面层级方案
原例子中,最后优化也是希望用配置的形式,给每个页面配置进入退出的行为,只是觉得这样配置的方式有些复杂。
我觉得方案可以是通过配置,给每个路由配置层级,前端通过判断两个路由的层级,来决定执行什么样的动画。 简单来看看路由配置文件:
meta对象存放了每个路由配置的内置参数,index越小代表层级越高,跳到index更大的页面,都是执行前进动画,反之为后退动画。
// routerConfig.js
export const RouterConfig = [
{
path: '/',
render: props => <HomePage {...props} />,
meta: {
index: 0
}
},
{
path: '/about',
render: props => <AboutPage {...props} />,
meta: {
index: 2
}
},
{
path: '/list',
render: props => <ListPage {...props} />,
meta: {
index: 2
}
},
{
path: '/detail/:id(\\d+)',
render: props => <DetailPage {...props} />,
meta: {
index: 3
}
}
]
这里跟原例子中给每个路由配置一个前进后退动画差不多,但是有一个难点:我们没有办法在路由匹配之前得到当前路径对应的路由。
意思就是我们在判断当前使用前进还是后退动画的时候,并不知道此时的路径应该匹配哪个组件或者render。可能你会觉得,这不就是判断location.pathname === path
嘛,有什么复杂的?
如果你怎么想,就犯了跟原例子补充的那部分一样的错误,忽略了路由匹配的其他高级规则。
const getSceneConfig = location => {
// 直接判断location.pathname和config.path是不是相等,并不严谨
const matchedRoute = RouterConfig.find(config => new RegExp(`^${config.path}$`).test(location.pathname));
return (matchedRoute && matchedRoute.sceneConfig) || DEFAULT_SCENE_CONFIG;
};
细心会发现上面配置中,DetailPage
的path
配的是'/detail/:id(\\d+)'
,这样的配置并不能用判断相等来匹配,因此我们需要有一个跟据location.pathname找到config中对应数据的算法。
好在react-router
给我们提供了一个叫做matchPath的方法,用途就是可以使用<Route>
相同匹配的规则,判断一个路径和path配置是否匹配, 如果匹配,则返回匹配后的match
,否则返回null
。
因此我们可以给路由配置增加一个getMatchRouter
方法:
// routerConfig.js
import { matchPath } from "react-router-dom"
export const getMatchRouter = (pathname, configs) =>
configs.find(config => matchPath(pathname, {
exact: true,
strict: false,
...config
})
)
那么,这样路由动画的router配置就是:
// router.js
import React, { lazy, Suspense } from 'react';
import { RouterConfig, getMatchRouter }from './RouteConfig';
import { Route, Switch, withRouter, BrowserRouter} from 'react-router-dom';
import { Spin } from 'antd'
import './index.css'
import { CSSTransition, TransitionGroup } from 'react-transition-group';
let needAnimation = true // 控制滑动自带动画冲突
const delayReset = () => { // 延后重置控制参数
setTimeout(() => {
needAnimation = true
}, 16)
}
window.addEventListener('touchstart', e => {
needAnimation = true
})
window.addEventListener('touchmove', e => {
needAnimation = false
})
window.addEventListener('touchend', delayReset)
// 通过判断两个路由配置的index,来计算出使用前进还是后退的动画
const getClassName = (location, oldLocation) => {
// 根据前后两个页面的location.pathname,得到对应的配置自定义参数meta
const currentRoute = getMatchRouter(location.pathname, RouterConfig) || {};
const oldRoute = getMatchRouter(oldLocation.pathname, RouterConfig) || {};
const currentIndex = currentRoute.meta && currentRoute.meta.index
const oldIndex = oldRoute.meta && oldRoute.meta.index
if(!needAnimation || oldIndex === currentIndex) return '' // 同级跳转,或者滑动中,不执行动画
return oldIndex > currentIndex ? 'back' : 'forward'
}
let oldLocation = {}
const render = ({location, history, match}) => {
const classNames = getClassName(location, oldLocation);
delayReset() // 防止某些浏览器不触发touchend
// 更新旧location
oldLocation = location;
return <TransitionGroup
className="router-wrapper"
childFactory={child => React.cloneElement(
child,
{ classNames }
)}
>
<CSSTransition timeout={500} key={location.pathname} >
<div>
<Suspense fallback={<Spin/>}>
<Switch location={location}>
{
RouterConfig.map((config, index) => (
<Route exact key={index} {...config}/>
))
}
</Switch>
</Suspense>
</div>
</CSSTransition>
</TransitionGroup>
}
export default () => <BrowserRouter>
<Route path='/' render={render}/>
</BrowserRouter>
只是这个方案有个小问题,在同级或者同一个路由的跳转,在上面代码中是没有给动画执行的,因为如果添加的话就会这样的状态。 由于此时页面间的跳转全靠层级来判断,因此前进和后退的页面层级一样的情况下,都是执行一样的动画。所以如果要解决这个问题,可以换下一个方案。
总结:
- 优点:不依赖任何用户行为,能正常展示页面间的动画;状态稳定,可维持
- 缺点:同层级页面跳转没有动画;模式较固定,在某些低层级页面跳转高层级页面的业务场景,并不都应该回退;需要配置层级,相对麻烦一点
记录页面跳转堆栈方案
另一个方案可以选择不用配置。
由于我们对页面间跳转的理解本质也是一个堆栈的结构,所以我们只要判断,要跳转的页面是本页的上一页就回退,不是就前进,这样问题就会直白很多,而且我们可以只记录location.pathname
即可。
实现起来也比较简单:每次路由变化,通过判断当前路由是不是堆栈中的倒数第二条(即上一页),来确定本次动画是不是后退。是,则删除堆栈最后一条数据,并回退;否,则添加当前路由进堆栈,并前进。
同时,还需要考虑的一个问题是:在页面刷新或者关闭后,堆栈记录就会清空,因此需要在sessionStorage
缓存一下。
浏览器的
history
和sessionStorage
一样,都是仅在当前会话下有效,关闭页面或浏览器后被清除
那么判断动画的部分代码如下:
const routerStack = (sessionStorage.getItem('ROUTER_STACK') || '').split(',').filter(Boolean) // 路由堆栈记录
const getClassName = location => {
if(!needAnimation) return ''
const index = routerStack.lastIndexOf(location.pathname) // 这里要找出现的最后一条记录
if (index >= 0 && routerStack.length - 1 === index) return 'forward' // 重复打开同样的路由不增加记录
const isLastRoute = index >= 0 && index === routerStack.length - 2 // 存在且是上一页
const className = isLastRoute ? 'back' : 'forward'
if (isLastRoute) routerStack.pop()
else routerStack.push(location.pathname)
sessionStorage.setItem('ROUTER_STACK', routerStack.join()) // 更改后随时保存
return className
}
总结:
- 优点:不需要其他配置;动画完全符合用户操作;
- 缺点:对
history
的前进后退并不敏感,只关心要变化的路由,因此可能在某些从主页跳转详情等业务的情况下,不一定是前进
,因为那个详情页是跳到主页的上一页;history.go(-2)
无法做到回退动画。
本质难点就是没法完全模拟得到
history
,根据popState
事件也无法得知页面是前进还是后退
归纳
本文基于其他开发者总结的路由跳转动画的方案,做了更深层次的探讨。 最后归纳了三个较为合理的方案, 可以查看源码(github.com/yinran100/r…
- 解决了滑动动画冲突和懒加载等bug的初步方案。
- 根据页面层级配置动画的方案
- 记录路由跳转的方案
结尾
说了这么多,最后发现,问题最少的解决方案其实就是:
↓ ↓ ↓
↓ ↓ ↓
↓ ↓ ↓
不要加动画~
感谢观看~