其实早在五月的时候就研究过这个,网上基本上是router-v5版本的demo,而v6版本废弃了不少v5版本的api,其中就包括demo里用到的。当时想模仿demo进行实现,可惜实现的效果并不尽人意,只做到了路由进入的动效。如果加上路由退出的效果,会出现闪烁的现象。然而由于忙着准备期末,就没有再深入。今天来完善一下之前的探究。
最开始为项目写的路由动效是仿照v5-demo的实现思路写的。参考的demo通过CSSTransition
的in
判定展示是否为当前路由,再用classNames
指定css动画名称。而v5的api里,<Route>
接收一个函数为children,无论是否匹配到当前路由,这个函数都会被调用,并且会传入match
参数表示当前路由是否被匹配,这就能很方便地指定CSSTransition
的in
参数。相关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;
}