重溫 React router dom

感覺這篇文特別長。

簡述

雖然以前有學過 React router dom,也有實際用過的經驗,但感覺自己對有些東西還是不夠熟,所以今天就來重溫這個主題。

Router、Routes、Route,你們到底差在哪?

先讓我們看一段 code 再來解釋:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import Home from "./Home";
import About "./About";
import Profile from "./Profile";
import { HashRouter as Router, Routes, Route } from "react-router-dom"

export const App: React.FC = () => {
return (
<Router>
<a href='/home'>Show in every page</a>
<Routes>
<Route path="/" element={<Home />}></Route>
<Route path="/about" element={<About />}></Route>
<Route path="/profile" element={<Profile />}></Route>
</Routes>
</Router>
)
}

export default App;

首先 Router 其實是 BrowserRouterHashRouter 的別名,因為原名太長了才會改用這個名字來代表。

至於 BrowserRouterHashRouter 的最主要的差異在於網址會不會有 # ,知道這樣就夠了。

至於 RoutersRoute 的差別顯而易見,Routers 是用來把所有路由給包住的(注意字尾的 s),而 Route 則是用來顯示哪個網址要顯示哪個 Component。

另外這個例子裡還特意放了一個 <a>Routes 外面,其實是想順便說一件事情,就是像導覽列這種每一個頁面都會用到的 Component,我們就不會把它放在 Routes 裡。

基本結構與 404 頁面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from 'react'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import About from './pages/About'
import Home from './pages/Home'
import Profile from './pages/Profile'

export const App: React.FC = () => {
return (
<Router>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
<Route path='/profile' element={<Profile />} />
</Routes>
</Router>
)
}

export default App

就跟一開始的範例一樣,我們會在 Route 透過 pathelement 來指定「哪個路由顯示哪個 Component」。

但如果我們想要有個 404 頁面怎麼辦?可以這樣做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import About from './pages/About'
import ErrorPage from './pages/ErrorPage'
import Home from './pages/Home'
import Profile from './pages/Profile'

export const App: React.FC = () => {
return (
<Router>
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
<Route path='/profile' element={<Profile />} />
<Route path='*' element={<ErrorPage />} />
</Routes>
</Router>
)
}

export default App

這邊的技巧是把 path 指定為 * 並且放在最下面(一定要這樣子)

為什麼要這樣做?因為只有當上面都匹配不到時才會套用最後一個,所以我們只要把它對應到 404 的 Component 就好囉。

切換路由

切換路由主要有兩種方式,一種是透過 Link(react-router-dom 提供的元件),另一種是透過 useNavigate

這邊先來看 Link 的做法,首先要先建立一個 Nav 元件來當作導覽列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react'
import { Link } from 'react-router-dom'

const Nav: React.FC = () => {
return (
<ul>
<li>
<Link to='/'>Home</Link>
</li>
<li>
<Link to='/about'>About</Link>
</li>
<li>
<Link to='/profile'>Profile</Link>
</li>
</ul>
)
}

export default Nav

<Link> 的本體就是 <a> 而已,所以你也可以用 <a> 來取代。但通常會建議用 <Link>,因為如果用的是 HashRouter 就不能像上面一樣只寫 /about,而是得自己加上 #

接著再把 Nav 放到主頁面中就行囉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import About from './pages/About'
import ErrorPage from './pages/ErrorPage'
import Home from './pages/Home'
import Profile from './pages/Profile'
import Nav from './pages/Nav'

// 因為所有頁面都會出現,所以會放在 Routes 外面
export const App: React.FC = () => {
return (
<Router>
<Nav />
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
<Route path='/profile' element={<Profile />} />
<Route path='*' element={<ErrorPage />} />
</Routes>
</Router>
)
}

接著是第二種做法,用 useNavigate。這個可能會用在填完表單時要跳轉時的情境,換句話說就是透過 JS 來做切換:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react'
import { useNavigate } from 'react-router-dom'

const About: React.FC = () => {
// 記得是拿 call 完後的東西來用,不是直接 useNavigate("/")
const navigate = useNavigate()

return (
<div>
About page
<button onClick={() => navigate('/')}>Go back to home page</button>
</div>
)
}

export default About

動態路由

舉一個常見的例子,如果你想造訪某個人的 profile,那網址一定是長成這樣:

  • https://aaa.com/profile/peanu
  • https://aaa.com/profile/ppb
  • https://aaa.com/profile/huli

結構都是以 /profile/名稱 來組成,可是因為名稱不是固定的值,所以才會說這是「動態」的。

實現的方式也不難,直接來看 code。

首先先調整一下路由的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React from 'react'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import About from './pages/About'
import ErrorPage from './pages/ErrorPage'
import Home from './pages/Home'
import Profile from './pages/Profile'
import Nav from './pages/Nav'

export const App: React.FC = () => {
return (
<Router>
<Nav />
<Routes>
<Route path='/' element={<Home />} />
<Route path='/about' element={<About />} />
{/* 後面接 /:params */}
<Route path='/profile/:username' element={<Profile />} />
<Route path='*' element={<ErrorPage />} />
</Routes>
</Router>
)
}

export default App

只要加上 : 就代表這是動態的值,並且會用一個變數的方式來代表這個值。

接著到 <Profile /> 的部分做點調整,這邊會用到 useParams 這個 hook。

它的用途就是讓我們取得動態路由的內容,簡單來說,如果網址是 /profile/peanu,回傳值就會是 { username: "peanu" },以此類推。

1
2
3
4
5
6
7
8
9
10
import React from 'react'
import { useParams } from 'react-router-dom'

const Profile: React.FC = () => {
const { username } = useParams()

return <div>Hi, This is {username}'s Profile page</div>
}

export default Profile

調整完以後,現在到 /profile/paenu 時結果就會是 Hi, This is peanu's Profile page,其他也是以此類推。

加入動態路由的功能後,就可以根據路由的值來決定要呈現什麼內容,是非常實用的功能。

好,到這邊先中場休息。等一下會介紹我覺得比較複雜的東西:巢狀路由

另外有需要原始碼的話可以參考這裡

巢狀路由

這邊會用底下的範例來解說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import User from './pages/User'
import Nav from './pages/Nav'
import Home from './pages/Home'
import NoMatch from './pages/Nomatch'

export const App: React.FC = () => {
return (
<Router>
<Nav />
<Routes>
<Route index element={<Home />} />
<Route path='home' element={<Home />} />
<Route path='user' element={<User />} />
<Route path='*' element={<NoMatch />} />
</Routes>
</Router>
)
}

export default App

我們希望 /user 底下有 /user/profile/user/account 這兩個頁面,像這樣:

source: robinwieruch

首先先到 <User /> 中建立用來跳轉的連結:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react'
import { Link } from 'react-router-dom'

const User: React.FC = () => {
return (
<div className='container'>
<h2>User: PeaNu</h2>
<ul>
<li>
<Link to='profile'>Profile</Link>
</li>
<li>
<Link to='account'>Account</Link>
</li>
</ul>
</div>
)
}

export default User

路徑的部分可以寫成相對路徑或絕對路徑:

  • /user/profile(絕對)
  • profile(相對)

但請別寫成 /profile,因為用 / 來開頭就會被當成絕對路徑,所以會跳到 https://aaa.com/profile 這個位置。

做到這邊以後,點了連接應該會跳到 404 頁面,這是因為我們還沒有去設定路由,讓 React 知道 /user/profile 要顯示哪個 Component,所以接著來對這部分做設定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import User from './pages/User'
import Nav from './pages/Nav'
import Home from './pages/Home'
import NoMatch from './pages/Nomatch'
import Profile from './pages/Profile'
import Account from './pages/Account'

export const App: React.FC = () => {
return (
<Router>
<Nav />
<Routes>
<Route index element={<Home />} />
<Route path='home' element={<Home />} />
<Route path='user' element={<User />}>
{/* 因為是巢狀,所以得包在 user 的 Route 裡面 */}
<Route path='profile' element={<Profile />}></Route>
<Route path='account' element={<Account />}></Route>
</Route>
<Route path='*' element={<NoMatch />} />
</Routes>
</Router>
)
}

export default App

附註:關於 <Profile /><Account /> 這兩個元件就麻煩你自己去建立一下,這邊就不貼出來佔版面了。

做到這以後看起來好像就差不多了?但其實還沒有,目前點連結後會發現網址確實會變成 /user/profile,可是並不會顯示 <Profile /> 的內容。

這是因為我們漏掉了 Outlet 這個東西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
import { Link, Outlet } from 'react-router-dom'

const User: React.FC = () => {
return (
<div className='container'>
<h2>User: PeaNu</h2>
<ul>
<li>
<Link to='profile'>Profile</Link>
</li>
<li>
<Link to='account'>Account</Link>
</li>
</ul>
{/* 加上 Outlet */}
<Outlet />
</div>
)
}

export default User

Outlet 是負責把匹配的子路由給顯示出來的 Component,這邊因為 Outlet 是放在 User 中,所以會根據剛剛寫的路由來匹配 profileaccount 這兩個路徑。

順道一提,如果你想避免 /user/profile/xxxx 的時候跳轉到 404,你可以透過 * 來處理:

1
2
3
4
5
6
7
8
9
10
11
12
13
<router>
<nav />
<routes>
<route index element={<Home />} />
<route path='home' element={<Home />} />
<route path='user' element={<User />}>
{/* 加上 * 號 */}
<route path='profile/*' element={<Profile />}></route>
<route path='account/*' element={<Account />}></route>
</route>
<route path='*' element={<Nomatch />} />
</routes>
<router>

OK,以上就是巢狀路由的教學,如果想參考原始碼的話可以到這邊來看。

另一種巢狀結構的思維

剛剛介紹的巢狀結構,實際在使用時看起來是像這樣:

original

可是如果我希望點下 profile 時,是跳轉到一個全新的頁面,而不是像上面那樣保留導覽列的情境呢?像這樣:

nested-route

這時候就要用比較 tricky 的作法了(也有人說是寫 App 的思維)。

首先要先回憶一下 Outlet 這個 Component 的用途,它的用途是把「對應的子路由顯示出來」,而剛剛我們是把它寫在 <User /> 裡面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
import { Link, Outlet } from 'react-router-dom'

const User: React.FC = () => {
return (
<div className='container'>
<h2>User: PeaNu</h2>
<ul>
<li>
<Link to='profile'>Profile</Link>
</li>
<li>
<Link to='account'>Account</Link>
</li>
</ul>
{/* 寫在這裡 */}
<Outlet />
</div>
)
}

export default User

所以當我們在造訪 /user/profile 的時候就會把對應的 Component 顯示到 <Outlet /> 裡面。也就是說不管我們怎麼切換路由,導覽列的內容會永遠擺在 <Outlte /> 上面。

既然如此,我們就不能把 <Outlet /> 放在 <User /> 中,可是這樣的話要放在哪裡?

其實答案可能沒有你想像中的複雜,先來看 code 吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React from 'react'
import { HashRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
import User from './pages/User'
import Nav from './pages/Nav'
import Home from './pages/Home'
import NoMatch from './pages/Nomatch'
import Profile from './pages/Profile'
import Account from './pages/Account'

export const App: React.FC = () => {
return (
<Router>
<Nav />
<Routes>
<Route index element={<Home />} />
<Route path='home' element={<Home />} />
{/* 放在這裡 */}
<Route path='user' element={<Outlet />}>
{/* 這個的意思就是 /user 時顯示 <User /> */}
<Route path='' element={<User />} />
<Route path='profile/*' element={<Profile />}></Route>
<Route path='account/*' element={<Account />}></Route>
</Route>
<Route path='*' element={<NoMatch />} />
</Routes>
</Router>
)
}

export default App

我們把 <Outlet/> 直接放到 /user<Route> 上,意思就會是「請顯示 /user 的子路由」。接著我們又新增了一個 path="" 的子路由,這是什麼意思?

意思是當我在 /user 這個頁面時,會匹配到 /user"" 這個子路由,然後顯示 <User />。(有點饒口,你可能要多看幾次會比較好理解)

所以經過這樣設計後,就可以實現在 /user/profile 的時候不會出現導覽列,只會有 <Profile /> 的內容。

總之這邊只是想介紹一下這種做法,我對這種巢狀路由的操作還蠻陌生的,所以才特別記錄下來。

這邊也附上原始碼,有需要都可以去看看。

巢狀結構的優化

假設我有一個管理系統的網頁,路由結構如下:

  • /system
  • /system/groupA
  • /system/groupA
  • /ststem/groupA/add
  • /system/groupA/:groupCode

然後 group 可能有很多個,例如 A、B、C 等等,那我可能就會這樣設計:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<Router>
<Routes>
{/* /system */}
<Route path='system' element={<Outlet />}>
<Route path='' element={<SystemWelcom />} />
{/* /system/groupA */}
<Route path='groupA' element={<Outlet />}>
<Route path='' element={<GroupAList />} />
<Route path=':groupCode/*' element={<GroupALDetail />} />
<Route path='add/*' element={<GroupAAdd />} />
<Route path='*' element={<GroupAList />} />
</Route>
{/* /system/groupB */}
<Route path='groupB' element={<Outlet />}>
<Route path='' element={<GroupBList />} />
<Route path=':groupCode/*' element={<GroupBLDetail />} />
<Route path='add/*' element={<GroupBAdd />} />
<Route path='*' element={<GroupBList />} />
</Route>
{/* /system/groupC */}
<Route path='groupC' element={<Outlet />}>
<Route path='' element={<GroupCList />} />
<Route path=':groupCode/*' element={<GroupCLDetail />} />
<Route path='add/*' element={<GroupCAdd />} />
<Route path='*' element={<GroupCList />} />
</Route>
</Route>
</Routes>
</Router>

附註:如果這樣看有點抽象的話,我有寫一個簡單的範例,可以到這邊參考。

雖然這樣子寫沒什麼問題,但其實利用下面這種技巧可以寫更簡潔:

1
2
3
4
5
6
7
8
9
10
<Router>
<Routes>
<Route path='system' element={<Outlet />}>
<Route path='' element={<SystemWelcom />} />
<Route path='groupA/*' element={<GroupA />} />
<Route path='groupB/*' element={<GroupB />} />
<Route path='groupC/*' element={<GroupC />} />
</Route>
</Routes>
</Router>

這邊的技巧在於:

  • groupA/* 後面的 /*
  • groupA/* 後面的 /*
  • groupA/* 後面的 /*

因為我研究了蠻久才發現,所以多說幾次(X

你可能會想說用 /groupA 不行嗎?為什麼要 /groupA/*

絕對不行

這是因為 /groupA 的意思是「路徑必須是一字不差的 /groupA 才算匹配」,所以當網址 /groupA/add/groupA/list 時,都會被當作不匹配,也就不會顯示 <GroupA /> 這個元件。

/groupA/* 就不一樣了,/* 的意思是後面接什麼都可以,所以你要 /groupA/123 還是 /groupA/321 都無所謂,全部都會匹配成功,這個就是我們要的效果。

最後,你可能會好奇 <groupA /> 是什麼東西?這邊就順便說一下。

這邊是利用這樣的資料夾結構:

  • GroupA
    • List.tsx
    • Detail.tsx
    • Add.tsx
    • index.tsx

看到 index 的第一眼應該就能猜到一件事,就是只要看這支檔案就能知道這在幹嘛了,所以我們來看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import List from './List'
import Detail from './Detail'
import Add from './Add'

const GroupA: React.FC = () => {
return (
<Routes>
<Route path='' element={<List />} />
<Route path='add' element={<Add />} />
<Route path=':groupCode' element={<Detail />} />
</Routes>
)
}

export default GroupA

就是把對應的子路由和元件引入後在輸出而已,沒有你想的那麼複雜。

好啦,其實是我覺得要解釋很詳細的話會花很多時間,所以如果真的想研究的話,建議到這邊看原始碼比較快。

一個當初踩到的雷

如果一個 Component 有用到 <Link /> 或是 useNavigate 之類的東西的話,那它一定要放在 <Router /> 裡面,不然就會噴這個錯誤:

route-error

當初被這個雷卡很久,想說我根本沒用到 useHref 這個 hook 阿,真是的 QQ。

詳細可以參考這裡:https://stackoverflow.com/questions/70220413/error-usehref-may-be-used-only-in-the-context-of-a-router-component-it-wor

參考資料

React router dom 相關的 hook 來點不一樣的狀態管理 mobx
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×