Vite 約定式路由的最佳實踐
Next.js
想必大家不陌生吧,其中最爲熟知的就是約定式路由(基於文件系統)。現在我們來在 Vite
中巧妙地實現這一項省心的功能。
本文是以 React
結合 React-Router
實現,vue
的實現思路基本一致,只有後綴名和 vue-router
的差別,需要的可以照搬此方案。
路由形式
首先看看 Next.js
基於文件約定式路由長什麼樣。Next.js
將文件添加到 pages
目錄時,它會自動生成對應的路由。在開發時省去了很多模板代碼,提升開發效率。
特性一:它將 index
文件名 js|jsx|ts|tsx
結尾的文件,映射成當前目錄的根路由:
-
pages/index.js
→/
-
pages/blog/index.js
→/blog
特性二:支持嵌套目錄文件。如果創建嵌套文件夾結構,文件將自動以相同的方式生成路由:
-
pages/about.js
→/about
-
pages/blog/first-post.js
→/blog/first-post
-
pages/dashboard/settings/username.js
→/dashboard/settings/username
特性三:使用括號語法。匹配動態命名參數:
-
pages/blog/[slug].js
→/blog/:slug( /blog/hello-world)
-
pages/[username]/settings.js
→/:username/settings( /foo/settings)
這種路由方式看起來非常清晰,創建一個路由就如同寫組件一樣簡單。umijs
也支持約定式路由,形式基本一致,用過的想必也因此受益。然而 Vite
作爲一個腳手架提供更加通用的功能以支持 vue
和 react
,自然不會耦合這種路由方案。
啓發
在 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 就類似 Webpack
的 require.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>
還有個 useRoutes
以 JSON
的形式來配置路由:
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
的排除規則是這樣的:
-
以 . 或 _ 開頭的文件或目錄
-
以 d.ts 結尾的類型定義文件
-
以 test.ts、spec.ts、e2e.ts 結尾的測試文件(適用於 .js、.jsx 和 .tsx 文件)
-
components 和 component 目錄
-
utils 和 util 目錄
-
不是 .js、.jsx、.ts 或 .tsx 文件
-
文件內容不包含 JSX 元素
這點 umijs
確實做得有點複雜多餘了,一大堆規則很容易讓開發者暈頭轉向。在組件化的項目中,路由文件很多情況下會遠少於頁面組件。我們可以使用某種特殊標識,標明它是一個路由:
我們暫定 $
開頭的文件作爲路由生成的規則
-
pages/$index.tsx
→/
-
pages/blog/$index.tsx
→/blog
-
pages/$about.tsx
→/about
-
pages/blog/$[foo].tsx
→/blog/:foo( /blog/hello-world)
用 $.tsx
作爲 layout
而不是 umijs
中的 _layout.tsx
。
在 fast-glob
https://github.com/mrmlnc/fast-glob#pattern-syntax 詳細文檔中支持更多用法,我們則需要讀取 pages
目錄下的所有 ts
、tsx
文件,通配符可以這樣寫:
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
需要 BrowserRouter
的 Context
,否則會報錯。
function App() {
return (
<BrowserRouter>
<PageRoutes />
</BrowserRouter>
);
}
大功告成!預覽一下:
結語
想起幾年前寫 React-Router
v2 配置 JSON
的痛苦經歷歷歷在目。現在有了基於文件式路由用法,在 Vite
上面也能愉快地早點下班了。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/iY8N0lLqlamW0Q9dNmYtgw