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