彻底搞定react路由跳转动画的优化方案

7,436 阅读12分钟

背景

前端项目开发中,为了提升用户体验,增加页面跳转动画可以增加友好度,不过你真的彻底搞定了这个功能点吗?

本文探究的方案是基于一次react-router + react-transition-group实现转场动画的探索这篇文章里的例子,补充了原文没有提到的点,结合实际开发中遇到的问题,优化解决了一些棘手的问题,可以说是当前最全最优方案。推荐先看完那篇文章后,再来探究本文讨论的技术问题。

遇到的问题

在这里,react项目的路由跳转动画是基于react-router + react-transition-group实现的,例子中对一些隐藏的问题没有拿出来说,所以对直接接入动画路由的用户来说会遇到一些问题,总结下来有三个方面:

  1. 动画过程中的问题
  2. 手机端自带滑动动画和前端动画冲突
  3. 因前进后退判断的问题,引发动画执行方案

接下来我一个个演示解决。

动画过程中的问题

页面定位问题

我根据例子中的动画demo接入后,得到类似这样的结果,先看图: --- 这个问题其实是原例子中被隐藏解决了的,作为一个接入方,我并不会意识到这一点。

通过了解react-transition-group的工作机制和查看dom可以看到,是因为两个页面同时存在的时候,两个div的定位位置的一上一下,导致动画过程中并不能同时展示两个页面,如下示意图 动画过程中

原例子中,作者给每个页面路由的容器添加了绝对定位,所以页面之间都会并排展示。 image 对于这种情况,我们可以在动画css里面给所有enterexit的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>

但是当我们在外部添加了路由动画后,初次加载时,会出现这样的现象。为了效果更加明显,我手动增加了动画时长和加载时长。 ---

可以看到,懒加载的页面,并没有走跳转动画,感觉就像前一个页面挡住了新的页面,然后退出一样。

我们可以通过观察节点的变化,可以发现端倪。 image

可以明显看到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节点。想来想去,这个问题解决办法只有一个:SuspenseCSSTransition之间加一个div作为代理动画的容器, 这样一来,执行动画的dom不再会被替换,就避免了这一问题。看看效果: ---

手指滑动影响动画的问题

动画过程中,我们一般认为只是页面切换状态的效果,用户不论进行什么操作不应该有太多影响,所以需要避免这样的情况。(放慢动画来看) --- 由于此时容器内有两个页面的dom,可以任一滚动,因此需要在动画期间控制一下用户手势。做法有两种:

  1. 通过js监听CSSTransition的事件,判断对动画期间的触摸事件进行阻止;
  2. 通过增加css属性。

显然后者简单很多,只要给所有enterexit的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来决定当前的动画,但是浏览器中这样的处理有时候并不是我们理解的前进和后退,看看现象,这个在移动端用拖拽行为也是一样。 image

原来,是因为history.action这个字段的值,在调用history.push时为PUSH,调用history.replace时为REPLACE,而当调用history.(go|goBack|goForward)时,都是POP。浏览器的前进后退按钮,只是调用了goBackgoForward,因此如果点击浏览器的前进返回按钮,并不知道此时的路由行为是前进还是后退

关于页面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;
};

细心会发现上面配置中,DetailPagepath配的是'/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>

只是这个方案有个小问题,在同级或者同一个路由的跳转,在上面代码中是没有给动画执行的,因为如果添加的话就会这样的状态。 image 由于此时页面间的跳转全靠层级来判断,因此前进和后退的页面层级一样的情况下,都是执行一样的动画。所以如果要解决这个问题,可以换下一个方案。

总结:

  • 优点:不依赖任何用户行为,能正常展示页面间的动画;状态稳定,可维持
  • 缺点:同层级页面跳转没有动画;模式较固定,在某些低层级页面跳转高层级页面的业务场景,并不都应该回退;需要配置层级,相对麻烦一点

记录页面跳转堆栈方案

另一个方案可以选择不用配置。

由于我们对页面间跳转的理解本质也是一个堆栈的结构,所以我们只要判断,要跳转的页面是本页的上一页就回退,不是就前进,这样问题就会直白很多,而且我们可以只记录location.pathname即可。

这一方案的思想在很多app里应用,比如你点击自己微信的头像进入自己朋友圈页面,然后继续点击头像,这样往复操作,会发现两个页面之间并没有明显的上下级关系,只是因为打开页面是上一页,就是回退,否则就前进。-->

实现起来也比较简单:每次路由变化,通过判断当前路由是不是堆栈中的倒数第二条(即上一页),来确定本次动画是不是后退。是,则删除堆栈最后一条数据,并回退;否,则添加当前路由进堆栈,并前进。

同时,还需要考虑的一个问题是:在页面刷新或者关闭后,堆栈记录就会清空,因此需要在sessionStorage缓存一下。

浏览器的historysessionStorage一样,都是仅在当前会话下有效,关闭页面或浏览器后被清除

那么判断动画的部分代码如下:

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…

  1. 解决了滑动动画冲突和懒加载等bug的初步方案。
  2. 根据页面层级配置动画的方案
  3. 记录路由跳转的方案

结尾

说了这么多,最后发现,问题最少的解决方案其实就是:

↓ ↓ ↓

↓ ↓ ↓

↓ ↓ ↓

不要加动画~ image

感谢观看~