react-router(v6) + react-transition-group 实现路由切换动效
其实早在五月的时候就研究过这个,网上基本上是 router-v5 版本的 demo,而 v6 版本废弃了不少 v5 版本的 api,其中就包括 demo 里用到的。当时想模仿 demo 进行实现,可惜实现的效果并不尽人意,只做到了路由进入的动效。如果加上路由退出的效果,会出现闪烁的现象。然而由于忙着准备期末,就没有再深入。今天来完善一下之前的探究。
最开始为项目写的路由动效是仿照 v5-demo 的实现思路写的。参考的 demo 通过CSSTransition的in判定展示是否为当前路由,再用classNames指定 css 动画名称。而 v5 的 api 里,<Route>接收一个函数为 children,无论是否匹配到当前路由,这个函数都会被调用,并且会传入match参数表示当前路由是否被匹配,这就能很方便地指定CSSTransition的in参数。相关 demo 如下:
tsx{ 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判断匹配到的是否为当前路由。如下:
tsxconst 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:
tsxconst 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 如下:
tsximport { 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 稍做了修改,最终实现的可运行代码如下:
tsx// @/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;
css/* @/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: 0.3; transition: all 500ms; transform: translateX(-100%); }
项目线上地址:https://pinnacle.mjclouds.com/ ,欢迎内测~
2022/12/1 补:
经测试,原版编写的路由登录鉴权存在问题,<RequireAuth>修改为如下代码后行为恢复预期
tsxexport 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; }