Vite 約定式路由的最佳實踐

Next.js 想必大家不陌生吧,其中最爲熟知的就是約定式路由(基於文件系統)。現在我們來在 Vite 中巧妙地實現這一項省心的功能。

本文是以 React 結合 React-Router 實現,vue 的實現思路基本一致,只有後綴名和 vue-router 的差別,需要的可以照搬此方案。

路由形式

首先看看 Next.js 基於文件約定式路由長什麼樣。Next.js將文件添加到 pages 目錄時,它會自動生成對應的路由。在開發時省去了很多模板代碼,提升開發效率。

特性一:它將 index 文件名 js|jsx|ts|tsx 結尾的文件,映射成當前目錄的根路由:

特性二:支持嵌套目錄文件。如果創建嵌套文件夾結構,文件將自動以相同的方式生成路由:

特性三:使用括號語法。匹配動態命名參數:

這種路由方式看起來非常清晰,創建一個路由就如同寫組件一樣簡單。umijs 也支持約定式路由,形式基本一致,用過的想必也因此受益。然而 Vite 作爲一個腳手架提供更加通用的功能以支持 vuereact,自然不會耦合這種路由方案。

啓發

Vite 官方文檔中 https://cn.vitejs.dev/guide/features.html#glob-import Glob 導入是這樣介紹的:

Vite 支持使用特殊的 import.meta.glob 函數從文件系統導入多個模塊:

const modules = import.meta.glob('./dir/*.js');

以上將會被轉譯爲下面的樣子:

const modules = {
  './dir/foo.js': () =>import('./dir/foo.js'),
  './dir/bar.js': () =>import('./dir/bar.js'),
};

這個 API 就類似 Webpackrequire.context()。Nice. 可以來個大膽的想法,用 React.lazy 結合 React-Router v6 做個文件約定式路由。說做就做!我們需要做的事情只有一件,那就是將這個從文件讀取出來的 JSON 轉換爲 React-Router 配置。

先看一下 React-Router v6 的結構長這樣:

<Routes>
  <Route path="/" element={<App />}>
    <Route index element={<Home />} />
    <Route path="teams" element={<Teams />}>
      <Route index element={<LeagueStandings />} />
      <Route path=":teamId" element={<Team />} />
      <Route path="new" element={<NewTeamForm />} />
    </Route>
  </Route>
</Routes>

還有個 useRoutesJSON 的形式來配置路由:

const routes = [
  {
    element: <App />,
    path: '/',
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: 'teams',
        element: <Teams />,
        children: [
          {
            index: true,
            element: <LeagueStandings />,
          },
          {
            path: ':teamId',
            element: <Team />,
          },
          {
            path: 'new',
            element: <NewTeamForm />,
          },
        ],
      },
    ],
  },
];

// 導出路由組件
exportfunction PageRoutes() {
  return useRoutes(routes);
}

這樣只需要轉換成以上 JSON 結構就可以了。

路由規則

生成的方式,我們儘量與 next.js 保持一致, 並實現 umijs 形式的約定式 layout。但避免一個問題:避免將不需要的組件映射成路由。這點 next.js 必須將非路由相關的文件放到 pages 目錄之外。而 umijs 的排除規則是這樣的:

這點 umijs 確實做得有點複雜多餘了,一大堆規則很容易讓開發者暈頭轉向。在組件化的項目中,路由文件很多情況下會遠少於頁面組件。我們可以使用某種特殊標識,標明它是一個路由:

我們暫定 $ 開頭的文件作爲路由生成的規則

$.tsx 作爲 layout 而不是 umijs 中的 _layout.tsx

fast-glob https://github.com/mrmlnc/fast-glob#pattern-syntax 詳細文檔中支持更多用法,我們則需要讀取 pages 目錄下的所有 tstsx 文件,通配符可以這樣寫:

const modules = import.meta.glob('/src/pages/**/$*.{ts,tsx}');

我們有這樣一個目錄

├─pages
│  │  $.tsx
│  │  $index.tsx
│  │
│  └─demo
│      │  $index.tsx
│      │
│      └─demo-child
│              $hello-world.tsx
│              $index.tsx
│              $[name].tsx

打印 modules 結果如下:

實現

我們可以先將 modules 變量轉換爲嵌套結構的 JSON 便於理解(先忽略 $.tsx):

import { set } from 'lodash-es';

/**
 * 根據 pages 目錄生成路徑配置
 */
function generatePathConfig(): Record<string, any> {
  // 掃描 src/pages 下的所有具有路由文件
  const modules = import.meta.glob('/src/pages/**/$*.{ts,tsx}');

  const pathConfig = {};
  Object.keys(modules).forEach((filePath) => {
    const routePath = filePath
      // 去除 src/pages 不相關的字符
      .replace('/src/pages/', '')
      // 去除文件名後綴
      .replace(/.tsx?/, '')
      // 轉換動態路由 $[foo].tsx => :foo
      .replace(/\$\[([\w-]+)]/, ':$1')
      // 轉換以 $ 開頭的文件
      .replace(/\$([\w-]+)/, '$1')
      // 以目錄分隔
      .split('/');
    // 使用 lodash.set 合併爲一個對象
    set(pathConfig, routePath, modules[filePath]);
  });
  return pathConfig;
}

打印的 generatePathConfig() 目錄結構結果如下:

現在已經很接近 React-Router 的配置了。

我們只需要將 import() 語法稍微封裝一下 () => import('./demo/index.tsx') 基礎上包一層 React.lazy 將其轉換爲組件:

/**
 * 爲動態 import 包裹 lazy 和 Suspense
 */
function wrapSuspense(importer: () => Promise<{ default: ComponentType }>) {
  if (!importer) {
    returnundefined;
  }
  // 使用 React.lazy 包裹 () => import() 語法
  const Component = lazy(importer);
  // 結合 Suspense,這裏可以自定義 loading 組件
  return (
    <Suspense fallback={null}>
      <Component />
    </Suspense>
  );
}

我們將 pathConfig 遞歸將其轉換爲 React-Router 的配置

/**
 * 將文件路徑配置映射爲 react-router 路由
 */
function mapPathConfigToRoute(cfg: Record<string, any>): RouteObject[] {
  // route 的子節點爲數組
  returnObject.entries(cfg).map(([routePath, child]) => {
    // () => import() 語法判斷
    if (typeof child === 'function') {
      // 等於 index 則映射爲當前根路由
      const isIndex = routePath === 'index';
      return {
        index: isIndex,
        path: isIndex ? undefined : routePath,
        // 轉換爲組件
        element: wrapSuspense(child),
      };
    }
    // 否則爲目錄,則查找下一層級
    const { $, ...rest } = child;
    return {
      path: routePath,
      // layout 處理
      element: wrapSuspense($),
      // 遞歸 children
      children: mapPathConfigToRoute(rest),
    };
  });
}

最後組裝這個配置:

function generateRouteConfig(): RouteObject[] {
  const { $, ...pathConfig } = generatePathConfig();
  // 提取跟路由的 layout
  return [
    {
      path: '/',
      element: wrapSuspense($),
      children: mapPathConfigToRoute(pathConfig),
    },
  ];
}

const routeConfig = generateRouteConfig();

打印這個 routeConfig 配置試試:

最後將封裝的組件插入到 App

export function PageRoutes() {
  return useRoutes(routeConfig);
}

至於爲什麼要將 PageRoutes 單獨做成個組件,因爲 useRoutes 需要 BrowserRouterContext,否則會報錯。

function App() {
  return (
    <BrowserRouter>
      <PageRoutes />
    </BrowserRouter>
  );
}

大功告成!預覽一下:

結語

想起幾年前寫 React-Router v2 配置 JSON 的痛苦經歷歷歷在目。現在有了基於文件式路由用法,在 Vite 上面也能愉快地早點下班了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/iY8N0lLqlamW0Q9dNmYtgw