PAYATHON 2026

如何使用 react-router 动态构建 Routes

支付阿杰

结论

不要让 API 返回前端组件的文件路径,也不要通过 require(PathToComponent) 加载任意路径。API 应返回稳定的 feature 标识,例如 paymentReconciliation。前端维护一份“feature 标识 → 动态 import”的注册表,并根据 API 配置生成 <Route>。

Webpack、Vite 等构建工具需要在构建阶段确定可能加载的模块,无法可靠分析 API 在运行时返回的任意字符串路径。

推荐实现:组件注册表配合 React.lazy

可以将 API 返回的数据调整为:

{
  "links": [
    {
      "label": "Payment Reconciliation",
      "feature": "paymentReconciliation",
      "url": "/paymentreconciliation"
    },
    {
      "label": "Device Integration",
      "feature": "deviceIntegration",
      "url": "/deviceintegration"
    }
  ]
}

然后在前端建立注册表:

import React, { lazy, Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';

const featureRegistry = {
  paymentReconciliation: lazy(() =>
    import('./components/_paymentReconciliation/paymentReconciliationPage')
  ),
  deviceIntegration: lazy(() =>
    import('./components/_deviceIntegration/deviceIntegrationPage')
  ),
};

再根据 API 返回的配置生成路由:

export default function AppRoutes({ links }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        {links.map((item) => {
          const FeatureComponent = featureRegistry[item.feature];

          if (!FeatureComponent) {
            console.warn(`Unknown feature: ${item.feature}`);
            return null;
          }

          return (
            <Route
              key={item.feature}
              path={item.url}
              element={<FeatureComponent />}
            />
          );
        })}

        <Route path="*" element={<div>Page not found</div>} />
      </Routes>
    </Suspense>
  );
}

对应的组件通常使用默认导出:

export default function PaymentReconciliationPage() {
  return <div>Payment Reconciliation</div>;
}

如果组件使用命名导出,可以在动态导入时进行转换:

const featureRegistry = {
  paymentReconciliation: lazy(() =>
    import('./components/_paymentReconciliation/paymentReconciliationPage')
      .then((module) => ({
        default: module.PaymentReconciliationPage,
      }))
  ),
};

在 React 启动前获取配置

如果必须在 bootstrapping React 之前取得配置,可以先请求 API,成功后再创建 React 根节点:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import AppRoutes from './AppRoutes';

async function bootstrap() {
  const response = await fetch('/api/features');

  if (!response.ok) {
    throw new Error(`Failed to load features: ${response.status}`);
  }

  const config = await response.json();

  ReactDOM.createRoot(document.getElementById('root')).render(
    <BrowserRouter>
      <AppRoutes links={config.links ?? []} />
    </BrowserRouter>
  );
}

bootstrap().catch((error) => {
  console.error(error);

  ReactDOM.createRoot(document.getElementById('root')).render(
    <div>Unable to load application configuration.</div>
  );
});

另一种做法是先渲染应用外壳,等请求完成后再更新路由配置。具体选择取决于加载期间是否需要显示登录页、骨架屏或错误页面。

为什么不能直接使用 API 返回的路径

下面这种写法通常不可取:

const Component = lazy(() => import(menuItem.component));

menuItem.component 是运行时变量,构建工具无法提前判断它可能指向哪些文件,因此目标模块可能不会被打包进产物。即使某些构建配置支持有限的动态路径,通常也只允许静态分析范围内的目录,例如:

import(`./pages/${pageName}.jsx`);

这种写法仍受构建工具的规则限制,还可能把目录中的大量模块都打进构建结果。它并不意味着 API 可以返回任意本地路径。

handler={require(PathToComponent)} 是旧式 React Router 或 CommonJS 场景中的写法。现代 React Router 不再使用 handler。React Router v6 的写法是:

<Route path="/example" element={<ExamplePage />} />

require() 同样受模块打包阶段的静态分析限制,不能安全地把服务器返回的任意字符串当作模块地址。

使用 React Router 数据路由

如果项目使用 React Router 的数据路由 API,也可以通过路由对象的 lazy 属性加载模块,但仍建议保留注册表:

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const routeLoaders = {
  paymentReconciliation: () =>
    import('./routes/paymentReconciliationRoute'),
  deviceIntegration: () =>
    import('./routes/deviceIntegrationRoute'),
};

function createFeatureRouter(links) {
  return createBrowserRouter([
    ...links.flatMap((item) => {
      const loader = routeLoaders[item.feature];

      if (!loader) {
        return [];
      }

      return [
        {
          path: item.url,
          lazy: loader,
        },
      ];
    }),
    {
      path: '*',
      element: <div>Page not found</div>,
    },
  ]);
}

export default function App({ links }) {
  const router = createFeatureRouter(links);

  return <RouterProvider router={router} />;
}

路由模块可以这样导出:

export function Component() {
  return <div>Payment Reconciliation</div>;
}

具体的导出约定取决于项目使用的 React Router 版本,应以当前版本的数据路由文档为准。

如果 feature 数量很多

Vite 项目可以使用 import.meta.glob 收集指定目录中的模块:

import React, { lazy } from 'react';

const pageModules = import.meta.glob('./components/**/*Page.jsx');

function getLazyComponent(modulePath) {
  const loader = pageModules[modulePath];
  return loader ? lazy(loader) : null;
}

不过,后端仍不应直接控制 modulePath。更稳妥的方式是由前端将 feature 标识转换为路径:

const featurePaths = {
  paymentReconciliation:
    './components/_paymentReconciliation/paymentReconciliationPage.jsx',
  deviceIntegration:
    './components/_deviceIntegration/deviceIntegrationPage.jsx',
};

function resolveFeature(feature) {
  const path = featurePaths[feature];
  const loader = path ? pageModules[path] : null;

  return loader ? lazy(loader) : null;
}

import.meta.glob 是 Vite 提供的构建能力,不是标准 JavaScript 或 React Router API。Webpack 项目需要使用对应的机制,或者继续采用显式注册表。

注意事项

  • API 最好只返回 feature、url、label 等业务配置,不要暴露前端源代码的目录结构。
  • 动态 import 只负责按需加载代码块,这些模块仍须在构建时包含在部署产物中。
  • 遇到无法识别的 feature 时,应跳过并记录错误,或者显示受控的错误页面,不能直接尝试执行服务器返回的路径。
  • subscription 配置只能控制界面和路由是否显示,不能替代后端权限校验。相关 API 仍需在服务端验证用户权限。
  • url 也需要校验,防止出现重复路由、无效格式或意外覆盖固定路由。
  • 如果希望在部署后不重新构建前端也能添加全新的功能模块,普通动态 import 无法满足要求,需要采用 Module Federation、远程模块或插件系统。这是另一套架构,还要处理模块签名、版本兼容和远程代码信任问题。

对于常规的订阅功能开关,组件注册表简单而可靠:后端决定启用哪些 feature,前端决定每个 feature 对应哪段可执行代码。

备注:内容仅供参考。