react-router@v6中的踩坑记录

05-16, 2022

2022-07-31 更新了 相同 pathname 相互跳转的问题

最近的项目是基于 create-react-app 官方模板进行定制,以便统一公司内部的 react 相关技术栈,避免“百花齐放”。由于是新项目模板,所以在项目中直接内置了 react-router-dom@6.x,本以为这个项目已经那么成熟应该不会有坑了,结果却是一坑接一坑。下面就是一些踩坑实录。

放弃类组件支持

在函数组件的道路上,reactr-router-dom 属实激进,在 6.x 的版本里直接只提供 hook 形式的 api。这样的做法完全没有考虑旧代码的兼容和我们这种没有推广开 hooks 的开发团队。不过问题不大,官方不提供 withRouter,那我们自己实现一个好了。不过要注意我们实现的 withRouter 和旧的 api 还是有差异的。

import { useLocation, useNavigate, useParams } from "react-router-dom"

function withRouter(Component) {
  function ComponentWithRouterProp(props) {
    let location = useLocation()
    let navigate = useNavigate()
    let params = useParams()
    return <Component {...props} router={{ location, navigate, params }} />
  }

  return ComponentWithRouterProp
}

路由声明的方式

在旧版中,Route 组件的声明位置并没有强制约束,可以和我们其它的元素混合搭配。但是在新版中对相关组件的声明做了严格的约束,所有的路由声明都在 Routes 组件中,并且只能包含 Route 元素。这样的话原先的一些布局方案就行不通了,如原来的 navbar 是 navbar 代码与 Route 混搭的。在新版中我们可以使用嵌套路由来实现类似功能。

import { Outlet } from 'react-router-dom';

function Layout() {
  return (
    <>
      <Navbar />
      <Ooutlet />
    </>
  );
}


<Routes>
  <Route path="/" element={<Layout />}>
    <Route path="/" element={<Home />}>
  </Route>
</Routes>

上例中的 Layout 组件就是新版中用于渲染公共布局,其中的 Outlet 就是 react-router-dom 提供的用于渲染子路由的占位组件,其它的则为布局的元素。当然,该变更还会引发其它一系列的问题,可以继续往下看。

转场动画组件

在旧版中,官方是推荐使用 react-transition-group 来做转场动画,但是由于新版路由的声明方式发生变化,现在支持严格的 Routes 嵌套 Route 的方式,导致官方的 demo 现在执行不了。进过一番搜索后找到了适用于 v6.x 的写法:

import { useMatch, useLocation, Routes, Route } from 'react-router-dom';
import { TransitionGroup, CSSTransition } from 'react-transitoin-group';

function Routes() {
  const location = useLocation();

  return (
    <TransitionGroup>
      <CSSTranstion key={location.pathname} timeout={150} classNames="fade">
        <Routes location={location}>
          {routes.map(({path, index, element}) => (
            <Route
              index={index}
              key={path}
              path={path}
              element={element}
            />
          ))}
        </Routes>
      </CSSTransition>
    </TransitionGroup>
  )
}

但是,transition 是配套有 enterexit 阶段。这就意味着必然存在多页共存的场景,同时还意味着我们每个页面的布局必须为绝对定位,否则就会出现两个页面上下排列的情况,不过多页共存也是最初计划的 slide 动画必须会存在的。但是改变页面布局又会引起一系列其它的变更,导致和滚动的相关的逻辑都会发生变更,评估影响范围还是比较大。

经过一番折腾后,发现可以抛弃 react-transition-group 而使用 animate.css,将动画降级到 fade 样式即可。这样的好处是 animate.css 是纯 CSS 的库,这样可以解耦与 react-router-dom 的关联,并且 fade 动画可以规避多页共存的情况,并且转场效果在产品那里也是能接受的。最终代码效果如下:

import 'animate.css';

function animatify(route) {
  route.element = (
    <div
      key={route.path}
      className="animate__animated animate__fast animate__fadeIn"
    >
      <route.element />
    </div>
  )
  return route;
}

const HomePage = React.lazy(() => import('@/pages/home'))

const routes = [
  path: '/', index: true, element: HomePage
].map(animatify);


export default function AppRoutes() {
  return (
    <Router>
      <Routes>
        <Route path="/" elemet={<Layout />}>
        {
          routes.map(({ index, path, element }) => (
            <Route
              key={path}
              index={index}
              path={path}
              element={element}
            />
          ))
        }
      </Routes>
    </Router>
  )
}

在我们的实际业务中,我们可能会需要根据页面的前进/后退来处理一些逻辑。但是在 6.x 版本开始,react-router-dom 不再向外暴露 history 对象,这样我们就没法根据堆栈信息来判断页面进退情况。查看新版 api 后发现有一个 useNavigationType,文档中描述的 navigationType 值类型有 POPPUSHREPLACE,单纯的从字面含义上来看,应该分别是对应 回退前进替换。但实际情况却是首次进入页面的 navigationType 竟然是 POP,不过好像接下来的跳转和回退的 navigationType 的值都是正确的。不过还有一种情况也需要注意,即我们通过 <a href="/#/next-page">next page</a> 这样的方式跳转时,也会被当做首次进入页面,但实际情况我们并没有重载 doucment(这个坑困扰了我好几天…)。

keep-alive 实现

keep-alive 的应用场景是单页路由回退的缓存逻辑,在未使用 keep-alive 的场景下,单页回退到的页面会重新执行完整的生命周期(即重新请求接口和渲染)。在我们大部分的场景中,回退时并不需要刷新页面,特别是一些长列表的情况,我们还需要保留跳出的滚动位置等等,keep-alive 就是用来做这个的。

在 react 生态中有 react-keep-alivereact-reaction 两个库,但是二者目前都没有适配 react-router-dom@6.x,直接使用的会问题。经过一番搜索之后,我找到一个通过自定义一个 outlet 来实现的 keep-alive 的方案,原方案 链接在此

import { ReactElement, useContext, useRef } from "react"
import { Freeze } from "react-freeze"
import { UNSAFE_RouteContext as RouteContext } from "react-router-dom"

function KeepAliveOutlet() {
  const caches = useRef({})

  const matchedElement = useContext(RouteContext).outlet // v6.3之后useOutlet会多了一层嵌套
  const matchedPath = matchedElement?.props?.value?.matches?.at(-1)?.pathname

  if (matchedElement && matchedPath) {
    caches.current[matchedPath] = matchedElement
  }

  return (
    <>
      {Object.entries(caches.current).map(([path, element]) => (
        <Freeze key={path} freeze={element !== matchedElement}>
          {element}
        </Freeze>
      ))}
    </>
  )
}

使用

// Layout.js

export default function Layout() {
  return (
    <>
      {/* 将 react-router-dom 官方的 outlet 替换成我们自己实现的 KeepAliveOutlet 即可 */}
      {/* <Outlet /> */}
      <KeepAliveOutlet />
    </>
  )
}

当然,上面实现的 KeepAliveOutlet 只是实现 keep-alive 的核心功能,要在实际项目中使用我们还需要拓展一下,增加回退页面时清除上一页面的缓存,支持手动清除缓存等等。

相同 pathname 相互跳转的问题

由于我们使用了 nest route 的特性,我们的页面路由示例可能会被 Outlet 缓存住,从而导致跳转新页面未生成新的页面组件实例。我遇到该的问题场景是跳转到与当前页相同的路径时,即:从 /a 点击跳转到 /a?query=xxx。这种场景其实也比较常见,比如在商品详情页内有一个推荐商品,需要跳转到推荐商品的详情。如果是 /a/b/a 的情况是不会存在上述问题的。

解决的办法就是为我们的 Outlet 手动指定 key 属性,从外部干预 Outlet 的缓存机制。但是需要注意,这个 key 必须要符合你的场景,千万别乱设置。比如我一开始用的就是 location.key,它是每次跳转都会生成一个新的 key,结果直接把我的 keep-alive 的逻辑搞挂了。最后我结合我的使用场景,使用的 key 是 searchParams,即:pathname 相同而 searchParams 不同时需要刷新页面,而 pathname 和 searchParams 都相同时不刷新。

demo

上述这些实践的我整理发布到我的 github 项目中了:react-router-v6,有需要的可以参考参考。