其实早在五月的时候就研究过这个,网上基本上是router-v5版本的demo,而v6版本废弃了不少v5版本的api,其中就包括demo里用到的。当时想模仿demo进行实现,可惜实现的效果并不尽人意,只做到了路由进入的动效。如果加上路由退出的效果,会出现闪烁的现象。然而由于忙着准备期末,就没有再深入。今天来完善一下之前的探究。

最开始为项目写的路由动效是仿照v5-demo的实现思路写的。参考的demo通过CSSTransitionin判定展示是否为当前路由,再用classNames指定css动画名称。而v5的api里,<Route>接收一个函数为children,无论是否匹配到当前路由,这个函数都会被调用,并且会传入match参数表示当前路由是否被匹配,这就能很方便地指定CSSTransitionin参数。相关demo如下:

{routes.map(({ path, Component }) => (
  <Route key={path} exact path={path}>
    {({ match }) => (
      <CSSTransition
        in={match != null}
        timeout={300}
        classNames="page"
        unmountOnExit
      >
        <div className="page">
          <Component />
        </div>
      </CSSTransition>
    )}
  </Route>
))}

起初我的想法是仿照v5的实现方式,利用useLocation判断匹配到的是否为当前路由。如下:

const routes: RouteType[] = [
  {
    path: '/',
    auth: true,
    element: App,
    children: [
      { index: true, element: Home },
      {
        path: '/login', element: Login,
        children: [
          { index: true, element: PwdLogin },
          { path: '/login/register', element: PwdRegister },
          { path: '/login/forget', element: PwdForget },
        ]
      },
    ]
  },
  { path: '*', element: NotFound }
]

const Router: FC = (): ReactElement => {
  const location = useLocation()

  const DisplayRoute = ({ index = false, path = '/', element: Component, children }: RouteType): ReactElement => {
    return (
      <Route index={index} path={path} key={path}
        element={
          <CSSTransition
            in={path === location.pathname}
            key={path}
            timeout={300}
            classNames="page"
          >
            <Component />
          </CSSTransition>
        } >
        {
          children ?
            children.map((item: RouteType) =>
              <DisplayRoute
                {...item}
                path={item.index ? path : item.path}
              />) :
            null
        }
      </Route>
    )
  }
  return (
    <TransitionGroup className='router-wrapper h-full'>
      <Routes>
        {
          routes.map((props) => (
            <Route {...props} />
          ))
        }
      </Routes>
    </TransitionGroup>
  )
}
export default Router;

但实践中发现,旧路由退出时是直接被卸载的,而动画效果直接转接到了新路由上,如此就出现了闪动的问题。且每次切换路由时,都会重新渲染一遍所有的router

针对第二个问题,猜测可能是因为调用了useLocation方法,导致<Router>组件重新渲染,于是将location.pathname替换为window.location.pathname,并移除useLocation。替换后虽然切换路由时不再重新渲染,但是也无法触发路由动画了。

此时,我想到或许可以用React.memo进行缓存。React.memo默认浅比较,第二个参数可让用户自定义更新时机。所以可以尝试判断location.pathname是否等于变更前后的两个地址,并传入第二个参数。于是为<DisplayRoute>包裹了memo

const DisplayRoute = memo(({ index = false, path = '/', element: Component, children }: RouteType): ReactElement => {
  return (
    <Route index={index} path={path} key={path}
      element={
        <CSSTransition
          in={path === location.pathname}
          key={path}
          timeout={300}
          classNames="page"
        >
          <Component />
        </CSSTransition>
      } >
      {
        children ?
          children.map((item: RouteType) =>
            <DisplayRoute
              {...item}
              path={item.index ? path : item.path}
            />) :
          null
      }
    </Route>
  )
})

但是浏览器报错error: [undefined] is not a <Route> component. All component children of <Routes> must be a <Route>……,查了一下发现是因为<Routes>里不支持嵌套非路由组件。

调试半天无果,只能再去翻翻react-transition-group官网。由于五月份写路由动效的时候,官网还没有更新v6的写法,一开始就没先看官网。此时突然发现react-transition-group推了新版本,并且在官网上更新了如何为v6编写路由动效的演示(不过example上方的说明似乎还没改,谈到的实现思路还是v5路由实现动效的写法

官网提供的demo如下:

import { createRef } from 'react'
import { createRoot } from 'react-dom/client'
import {
  createBrowserRouter,
  RouterProvider,
  NavLink,
  useLocation,
  useOutlet,
} from 'react-router-dom'
import { CSSTransition, SwitchTransition } from 'react-transition-group'
import { Container, Navbar, Nav } from 'react-bootstrap'
import Home from './pages/home'
import About from './pages/about'
import Contact from './pages/contact'
import 'bootstrap/dist/css/bootstrap.min.css'
import './styles.css'

const routes = [
  { path: '/', name: 'Home', element: <Home />, nodeRef: createRef() },
  { path: '/about', name: 'About', element: <About />, nodeRef: createRef() },
  {
    path: '/contact',
    name: 'Contact',
    element: <Contact />,
    nodeRef: createRef(),
  },
]

const router = createBrowserRouter([
  {
    path: '/',
    element: <Example />,
    children: routes.map((route) => ({
      index: route.path === '/',
      path: route.path === '/' ? undefined : route.path,
      element: route.element,
    })),
  },
])

function Example() {
  const location = useLocation()
  const currentOutlet = useOutlet()
  const { nodeRef } =
    routes.find((route) => route.path === location.pathname) ?? {}
  return (
    <>
      <Navbar bg="light">
        <Nav className="mx-auto">
          {routes.map((route) => (
            <Nav.Link
              key={route.path}
              as={NavLink}
              to={route.path}
              className={({ isActive }) => (isActive ? 'active' : undefined)}
              end
            >
              {route.name}
            </Nav.Link>
          ))}
        </Nav>
      </Navbar>
      <Container className="container">
        <SwitchTransition>
          <CSSTransition
            key={location.pathname}
            nodeRef={nodeRef}
            timeout={300}
            classNames="page"
            unmountOnExit
          >
            {(state) => (
              <div ref={nodeRef} className="page">
                {currentOutlet}
              </div>
            )}
          </CSSTransition>
        </SwitchTransition>
      </Container>
    </>
  )
}

const container = document.getElementById('root')
const root = createRoot(container)
root.render(<RouterProvider router={router} />)

总结一下,实现思路是为所有路由添加nodeRef参数标识该组件(nodeRef的实际作用是添加动画时会在nodeRef所控制的组件上添加动画),之后在根页面组件中,用useLocation获取当前pathname,根据pathname在路由的config里找到匹配该路径的组件。由于动画会在nodeRef控制的组件上添加,且每个组件有独立的nodeRef,此时可以将展示组件的nodeRef用匹配到的组件的nodeRef替换,这样两个组件的进入与退出动画是独立的,不会像之前的实现一样,被卸载的组件由于直接被卸载而不存在退出动画。与此同时,展示组件内部用useOutlet展示匹配到的组件。这样就实现了路由改变时的动效切换。

我本人由于还需要在项目里添加路由登录鉴权控制,对demo稍做了修改,最终实现的可运行代码如下:

// @/utils/auth.ts
export function RequireAuth({ children }: { children: ReactElement }) {
  const authed = !!getToken();
  const navigator = useNavigate();
  const location = useLocation();

  if (!authed && !location.pathname.includes('login')) {
    navigator('/login')
  }

  return children;
}

// @/router/index.ts
import { RequireAuth } from "@/utils/auth";

export const routesConfig: RouteType[] = [
  {
    path: '/',
    element: <App />,
    children: [
      { index: true, element: <Home /> },
      {
        path: '/login', element: <Login />, public: true,
        children: [
          { index: true, element: <PwdLogin />, public: true, },
          { path: 'register', element: <PwdRegister />, public: true, },
          { path: 'forget', element: <PwdForget />, public: true, },
        ]
      },
    ]
  },
  { path: '*', element: <NotFound /> }
]

const routesWithRef = (node: RouteType) => {
  if (node.children) {
    node.children = node.children.map((child) => routesWithRef(child));
  }
  return {
    ...node,
    nodeRef: createRef()
  }
}
export const routes = routesConfig.map((config) => routesWithRef(config));

const paintRoute = (props: RouteType) => {
  return (
    <Route {...props} element={
      props.public ?
        props.element :
        <RequireAuth>
          {props.element}
        </RequireAuth>
    }>
      {
        !props.children ? null :
          props!.children!.map((child) => paintRoute({ ...child, index: (child.path && child.path === props.path) || child.index }))
      }
    </Route>
  )
}

export default () => {
  return (
    <Routes>
      {
        routesConfig.map((config) => paintRoute(config))
      }
    </Routes>
  )
}

// @/app.tsx
import { routes } from './router'
import './index.css'

function App() {
  const location = useLocation()
  const currentOutlet = useOutlet()
  const { nodeRef } =
    routes.find((route) => route.path === location.pathname) ?? {}

  return (
    <SwitchTransition>
      <CSSTransition
        key={location.pathname}
        nodeRef={nodeRef}
        timeout={400}
        classNames="forward-from-right"
        unmountOnExit
      >
        {(state) => (
          <div ref={nodeRef} className="h-full">
            {currentOutlet}
          </div>
        )}
      </CSSTransition>
    </SwitchTransition>
  )
}

export default App
/* @/index.css */
/* 此处的实现是从左淡入、从右淡出的路由切换效果 */
.forward-from-right-enter {
  z-index: 2;
  opacity: 0;
  transform: translateX(50%);
}

.forward-from-right-enter-active {
  z-index: 2;
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.forward-from-right-exit {
  z-index: 1;
  opacity: 1;
  transform: translateX(0%);
}

.forward-from-right-exit-active {
  z-index: 1;
  opacity: .3;
  transition: all 500ms;
  transform: translateX(-100%);
}

项目线上地址:https://pinnacle.mjclouds.com/ ,欢迎内测~


2022/12/1 补:

经测试,原版编写的路由登录鉴权存在问题,<RequireAuth>修改为如下代码后行为恢复预期

export function RequireAuth({ children }: { children: ReactElement }) {
  const authed = !!getToken();
  const navigator = useNavigate();
  const location = useLocation();

  useEffect(()=>{
    if ( !authed && !(location.pathname.includes('account'))) {
      navigator('/account')
    }
  },[isPublic,children,location])

  return children;
}