其实早在五月的时候就研究过这个,网上基本上是 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: 0.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;
}