[ReactRouter] Bread Crumb
這篇文章主要說明如何整合 react-router 來製作一個具有導覽功能的麵包屑(breadcrumb),也就是這個麵包屑可以根據當前使用者瀏覽的路由動態顯示出對應的名稱。由於會說明之所以這麼做的思路,因此篇幅較長;如果想要直接看如何使用這段程式碼,可以到 Github 上檢視 ReadMe,當中的說明較為精簡。
另外,不會從頭開始說明 React 和 React Router 的使用,因此建議閱讀前應該具備基 本的 React 和 React Router 知識,不然可能會看得相當吃力。
來看看怎麼做吧!
建立 React 專案
在這裡我們直接使用 create-react-app
來建立一個簡單的 React 專案,就稱作 react-router-breadcrumb
:
$ create-react-app react-router-breadcrumb # 透過 create-react-app 建立專案
$ cd react-router-breadcrumb # 進入建立好的專案資料夾
另外,需要使用到 react-router-dom
來幫我們建立路由:
$ npm install react-router-dom # 安裝 react-router-dom
接著就可以啟動專案,然後到 localhost:3000
即可看到預設的畫面:
$ npm run start # 啟動專案
在這篇文章中不會說明
create-react-app
的使用,若有需要可自參閱到create-react-app
的官方文件。
前置清理與載入樣式
為了讓我們的畫面比較乾淨一些,就先直接套 Bootstrap 4 進來用,如果不想套的話也是可以,就是畫面會比較呆板一些。
在 /public/index.html
中把 Bootstrap 4 的 CDN 連結套用進來:
<!-- /public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<!-- import bootstrap here -->
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
/>
<title>React App</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
<!-- libs below are for bootstrap -->
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
</body>
</html>
接著把所有在 App.js
中預設的畫面都清掉,寫個「Hello React」確認沒問題就好:
// /src/App.js
import React, { Component } from 'react';
class App extends Component {
render() {
return <h1 className="text-primary">Hello React</h1>;
}
}
export default App;
畫面長這樣空空的,而且因為套了 Bootstrap 中 text-primary
的樣式,文字有變色,就代表有成功載入 Bootstrap 了:
如果到這一步有問題的話,可以對照看看這個 commit。
建立需要的頁面 pages 和 components
清理完後就可以來建立所需要的頁面。
建立頁面(Pages)
先把在這個專案中會使用的頁面建立起來,可以想像一個商城的結構大概是這樣,我們有三個外層的路由,分別是「首頁」、「書籍館」和「3C 商品館」,而 「3C 商品館」中又會細分出「手機館」、「桌機館」和「筆電館」,從這樣的結構可以看出,將會使用到嵌套式路由(Nested Routing):
- Home # 首頁
- Books # 書籍館
- Electronics # 3C 商品館
--- Mobile # 手機館
--- Desktop # 桌機館
--- Laptop # 筆電館
因為頁面(Page)的內容不是我們的重點,所以在本文中把所有的頁面元件(Page component)都寫在一支
pages.js
的檔案中。
// /src/pages.js
import React from 'react';
/**
* These are root pages
*/
const Home = () => {
return <h1 className="py-3">Home</h1>;
};
const Books = () => {
return <h1 className="py-3">Books</h1>;
};
const Electronics = () => {
return <h1 className="py-3">Electronics</h1>;
};
/**
* These are pages nested in Electronics
*/
const Mobile = () => {
return <h3>Mobile Phone</h3>;
};
const Desktop = () => {
return <h3>Desktop PC</h3>;
};
const Laptop = () => {
return <h3>Laptop</h3>;
};
export { Home, Books, Electronics, Mobile, Desktop, Laptop };
對照目前的 commit。
建立基本的路由
再來我們先建立基本的路由,以便透過輸入網址連到這些頁面。
在 index.js
中,先載入 BrowserRouter
和 Switch
:
// /src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch } from 'react-router-dom';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<Switch>
<App />
</Switch>
</BrowserRouter>
);
在 App.js
中,把當初 create-react-app
建立但用不到的內容都砍掉,只需要指定不同的路由應該要對應到哪些頁面就好,對於 <Route />
這個元件的概念不用想得太複雜,簡單理解成就是當瀏覽器網址列的 URL 和這個 path
相匹配到時,就會在「這個位置」顯示該 component
:
// /src/App.js
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { Index, Books, Electronics } from './pages';
class App extends Component {
render() {
return (
<div className="container">
{/* The corresponding component will show here if the current URL matches the path */}
<Route path="/" exact component={Index} />
<Route path="/books" component={Books} />
<Route path="/electronics" component={Electronics} />
</div>
);
}
}
export default App;
這時候當你在網址列輸入 /
, /books
, /electronics
,應該就能順利看到那些頁面。
對於
<Route />
這個元件的概念不用想得太複雜,簡單來說就是當瀏覽器的 URL 和這個path
相匹配到時,就會在「這個位置」載入該component
。
這時候因為還沒配置嵌套式路由的緣故,因此輸入 /electronics/mobile
時 還不會找到相對應的頁面,依照同樣的概念,我們可以在 Electronics 這個 Page 中加入 <Route />
元件,一旦當前瀏覽器網址列上的 URL 和 <Route />
中的 path
相配對時,就會在「這個位置」顯示出所指定的頁面。
因此,在 pages.js
中的 Electronics
元件中,加上路由:
// /src/page.js
import { Switch, Route } from 'react-router-dom';
// ...
const Electronics = () => {
return (
<div>
<h1>Electronics</h1>
<Switch>
{/* The component will show here if the current URL matches the path */}
<Route path="/electronics/mobile" component={Mobile} />
<Route path="/electronics/desktop" component={Desktop} />
<Route path="/electronics/laptop" component={Laptop} />
</Switch>
</div>
);
};
// ...
這時候當我們在輸入網址列 /electronics/mobile
時,也會出現相對應的畫面:
- 關於路由的配置可進一步參考 React Router 官方文件。
- 如果撰寫過程中有問題,可以和此 commit 對照。
建立導覽列元件
每一次都要從網址列輸入網址實在有點麻煩,既然路由都配置好了,先來做個導覽列方便使用吧。
為了方便示範,而且這個專佔中不會有太多的 React 元件,我們把在頁面中會套用到的元件都放在一隻叫做 components.js
的檔案中。
Navbar
基本上就是直接套用 Bootstrap 4 Navbar 的結構和樣式,並且搭配 react-router-dom
的 <Link>
來建立連結:
// /src/components.js
import React from 'react';
import { Link } from 'react-router-dom';
import logo from './logo.svg';
const Navbar = () => {
return (
<nav className="navbar navbar-expand-sm navbar-light bg-light">
<Link className="navbar-brand" to="/">
<img src={logo} alt="react-router-breadcrumb" width="30" height="30" />
</Link>
<button
className="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarContent"
aria-controls="navbarContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<div className="collapse navbar-collapse" id="navbarContent">
<ul className="navbar-nav">
<li className="nav-item">
<Link className="nav-link" to="/">
Home
</Link>
</li>
<li className="nav-item">
<Link className="nav-link" to="/books">
Books
</Link>
</li>
<li className="nav-item dropdown">
<Link
className="nav-link dropdown-toggle"
to="/electronics"
id="navbarDropdownMenuLink"
role="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
Electronics
</Link>
<div className="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<Link className="dropdown-item" to="/electronics/mobile">
Mobile Phone
</Link>
<Link className="dropdown-item" to="/electronics/desktop">
Desktop PC
</Link>
<Link className="dropdown-item" to="/electronics/laptop">
Laptop
</Link>
</div>
</li>
</ul>
</div>
</nav>
);
};
export { Navbar };
接著在 <App />
元件中載入 <Navbar />
即可:
// /src/App.js
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import { Home, Books, Electronics } from './pages';
import { Navbar } from './components';
class App extends Component {
render() {
return (
<div className="container">
{/* Put Navbar Here */}
<Navbar />
<Route path="/" exact component={Home} />
<Route path="/books" component={Books} />
<Route path="/electronics" component={Electronics} />
</div>
);
}
}
export default App;
到目前為止完成畫面差不多完成了:
如果撰寫過程中有問題,可以和此 commit 對照。
把麵包屑名稱帶入路由當中
碰到的困難
到目前為止,已經可以根據 react-router 顯示出相對應的頁面。一般來說,這樣的路由配置是沒有問題的,但這樣做在製作麵包屑時會碰到一個問題,我們將無法知道每一個對應到的 path
它的麵包屑名稱是什麼,什麼意思呢?
例如,當 path 是 /electronics/desktop
時,希望麵包屑名稱會顯示「Desktop PC」;當 path 為 /electronics
時,麵包屑名稱則要顯示「Electronics」,這些麵包屑的名稱是無法直接從路由的 path
看出來的。
直覺上,透過 React Router 提供的 render
方法,我們可以把麵包屑的名稱當做 props 傳到對應的 Page 當中,像下面這樣:
/**
* Although we can pass breadcrumb name into Page component
* through `render` method provided by React Router.
*
* However, we can only get the current Page breadcrumb name
* but not the breadcrumb name of it's parent in nested routing.
**/
<Route path="/books" render={(props) => <Books breadcrumbName="books" {...props} />} />
但這樣做會有個問題,以 /electronics/desktop
為例,當透過 props
把 breadcrumbName
傳到該元件中時,雖然到路由 /electronics/desktop
時我們可以取得這個 Page 的麵包屑名稱為 "Desktop PC",但是我們沒辦法知道 /electronics
的麵包屑名稱是什麼,然而,麵包屑需要顯示的樣子應該要會像這樣:
Home > Electronics > Desktop PC
因此直接透過 props 把 breadcrumbName 傳入頁面中似乎不能達到想要的功能。
解決方法一:定義路由表(堪用)
第一種解決方式是定義一個路由表(在這裡不使用),在 Ant Design 麵包屑元件的 Other Router Integration 中,說明了一種解法,就是先定義好路由表,接著再去把網址列當前的 URL 去跟這個定義好的路由表匹配,就可以知道每一個路由應該要顯示的麵包屑名稱為何。
定義好的路由表會長像這樣:
const breadcrumbNameMap = new Map([
// [path, breadcrumbName]
['/', 'Home'],
['/books', 'Book'],
['/electronics', 'Electronics'],
['/electronics/mobile', 'Mobile'],
['/electronics/desktop', 'Desktop'],
['/electronics/laptop', 'Laptop'],
]);
接著就可以去把當前網址列的 URL 和這個路由表匹配,以產生麵包屑,詳細的做法可以參考 Ant Design 麵包屑元件 的 Other Router Integration。
但這麼做的麻煩之處在於,每當我們要添加路由時,除了先透過 <Route path="/" component={Home} />
撰寫好路由後,還需要把這個新的路由添加到路由表中,如果忘了加,麵包屑就出不來。
沒辦法寫一次就直接套用覺得有些麻煩,因此後來我們決定不這麼用。
解決方法二:集中式路由設定管理(建議)
為了不要額外建立一個路由表,勢必要把路由所對應到的麵包屑名稱,集中設定在一個地方,而這種集中式路由管理的方式可以方便我們在一個地方把路由和麵包屑名稱都寫好。
關於集中式路由設定的寫法可以參考 React Router 的官網範例 Route Config。
於是我們要來重新組織一下路由,把它變成集中式的路由設定,並且可以把每一路由對應到的麵包屑名稱直接填入。
先建立一個名為 routes.js
的檔案,統一將路由定義在這裡:
// /src/routes.js
import { Home, Books, Electronics, Mobile, Desktop, Laptop } from './pages';
const routes = [
{
path: '/',
component: Home,
exact: true,
breadcrumbName: 'Home',
},
{
path: '/books',
component: Books,
breadcrumbName: 'Book',
},
{
path: '/electronics',
component: Electronics,
breadcrumbName: 'Electronics',
routes: [
{
path: '/electronics/mobile',
component: Mobile,
breadcrumbName: 'Mobile Phone',
},
{
path: '/electronics/desktop',
component: Desktop,
breadcrumbName: 'Desktop PC',
},
{
path: '/electronics/laptop',
component: Laptop,
breadcrumbName: 'Laptop',
},
],
},
];
export default routes;
接著在有使用 <Route />
元件的地方,原本是寫死在裡面的,現在改成用這個路由設定來產生,例如原本的 App.js
中路由 <Route />
的部分是這樣寫:
// /src/App.js
// ...
class App extends Component {
render() {
return (
<div className="container">
<Navbar />
<Route path="/" exact component={Home} />
<Route path="/books" component={Books} />
<Route path="/electronics" component={Electronics} />
</div>
);
}
}
// ...
export default App;
可以改成:
// /src/App.js
import routes from './routes';
class App extends Component {
render() {
return (
<div className="container">
<Navbar />
{/* Refactor for using routes config */}
{routes.map((route, i) => {
const { path, exact, routes } = route;
return (
<Route
key={i}
path={path}
exact={exact}
render={(routeProps) => <route.component routes={routes} {...routeProps} />}
/>
);
})}
</div>
);
}
}
export default App;
- 我們先把寫好的路由設定(route config)透過
import
載入進來。 - 接著把在 routes 設定檔中寫好的
path
,exact
透過 props 傳進去<Route path={path} exact={exact} />
。 - 對於有使用到嵌套式路由的頁面,為了要把嵌套在內的
routes
傳到該頁面內,我們不能直接寫<Route component={PageComponent} />
,因為這種寫法無法把資料透過 props 傳到頁面內。因此需要使用 React-Router 中另外提供的render
屬性。 - 在
render
屬性中需要代入一個函式,並回傳要轉譯的頁面,例如,render={() => <PageComponent />}
,這種寫法可以把資料透過 props 傳到某一 Page 當中。 - 如果
routes
裡面還有routes
表示它是嵌套式路由(nesting routes),一層路由裡還有其他路由,這時候要把它當成該頁面的元件傳進去,所以會有render={() => <PageComponent routes={routes} />}
的寫法。 - 在
render
屬性後面接的這個函式中,可以接收一個參數,我們把這個參數取名為routeProps
,routeProps
會傳回原本在<Route />
中可以拿到的match
,location
,history
等屬性。接著透過{...routeProps}
可以在把這些屬性注回到 Page 當中。寫起來會是這樣,render={(routeProps) => <PageComponent routes={routes} {...routeProps}/>}
。 - 最後,
<route.component />
可以動態指定要轉譯的 Page 為何。
如果你覺得上面這樣的寫法太複雜了,你還無法理解,可以先跳過繼續往後閱讀沒關係。
同樣的,因為在 Electronics
頁面中也有使用到 <Route>
元件,因此也可以改成這樣的寫法:
// /src/pages.js
// ...
// Get routes props from Electronics Page
const Electronics = ({ routes }) => {
return (
<div>
<h1 className="py-3">Electronics</h1>
<Switch>
{/* Refactor for using routes config */}
{routes.map((route, i) => {
const { path, exact, routes } = route;
return (
<Route
key={i}
path={path}
exact={exact}
render={(routeProps) => <route.component routes={routes} {...routeProps} />}
/>
);
})}
</Switch>
</div>
);
};
// ...
- 首先把在
App.js
時透過 React Routerrender={() => <PageComponent routes={routes} />}
傳進來的routes
拿出來。 - 和上面使用一樣的方法,透過
{routes.map()}
去把所有相關的<Route />
元件組出來。
改成這樣之後,路由還是可以正常切換。
- 如果不能切換,可能是有哪裡的程式碼打錯了,可以對照參考一下這個 commit。
- 關於集中式路由設定的寫法可以參考 React Router 的官網範例 Route Config。
乾淨清爽:使用 react-router-config
或許你會覺得在每個頁面中,都要透過 {routes.map()}
這一大塊程式碼,才能轉譯原本的路由很麻煩,好在當我們定義集中式路由之後,React Router 提供了我們 react-router-config 這個套件,這裡面提供了 renderRoutes
這個方法可以幫我們省去寫一大段程式碼的麻煩,而它的原理和我們剛剛實作的方法是很類似的。
$ npm install react-router-config
安裝好之後就可以來整理一下上面的程式碼,首先是 App.js
:
// /src/App.js
import React, { Component } from 'react';
import { renderRoutes } from 'react-router-config';
import { Navbar } from './components';
import routes from './routes';
class App extends Component {
render() {
return (
<div className="container">
<Navbar />
{/* use renderRoutes method here*/}
{renderRoutes(routes)}
</div>
);
}
}
export default App;
- 記得先 import
renderRoutes
,再把routes
帶進去,{renderRoutes(routes)}
,它就會幫你產出相對應的<Router />
元件。
在 Electronics
頁面中的寫法和剛剛類似,只有些微不同,不是直接拿 routes
,因為它把 routes
又包在 route
內,因此是拿 route.routes
:
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Nav, ElectronicsNav } from './components';
// ...
const Electronics = ({ route }) => {
return (
<div>
<h1 className="py-3">Electronics</h1>
{renderRoutes(route.routes)}
</div>
);
};
// ...
一整個乾淨清爽的感覺,是不是覺得精簡非常多啊!你可能會想為什麼不早點把這好東西拿出來!?哎呀,了解一下背後的原理也是不錯的嘛。
如果畫面以及路由切換都和剛剛一樣正常運作的話,就表示程式碼應該沒什麼問題。
- 關於 react-router-config 的更多用法可以參考其官方文件。
- 若撰寫過程有問題可以對照這個 commit
根據路由取得麵包屑名稱
為了要取得每一個路由所對應到的麵包屑名稱,我們把路由的寫法改成路由設定檔(route config)的方式,接下來就要在特定的路由下取得麵包屑的名稱。
取得當前瀏覽器網址 列的路由:location
每個透過 <Route />
元件產生的頁面,都會帶有透過 React Router 添加的屬性,可以透過該頁面的 props
屬性取得,其中包含 history
, location
, match
和 route
。
嵌套式路由:以 Mobile Page 為例
舉例來說,在 Mobile
這個頁面中,可以把 props
透過 console.log
顯示出來看一下:
// /src/pages.js
// ...
const Mobile = (props) => {
console.log('props in Mobile', props);
return <h3>Mobile Phone</h3>;
};
// ...
當在瀏覽器的導覽列輸入 localhost:3000/electronics/mobile
時,可以在 console
中看到 React Router 添加的屬性,其中 location.pathname
屬性可以讓我們知道當前瀏覽器網址列所在的路徑為何:
而 route
屬性則是來自當初設定好的路由配置,因此在這裡可以看到添加進去的 breadcrumbName
屬性。也就是說從該頁面的 props
就可以知道它的 breadcrumbName
為何:
取得當前路由的外層路由名稱:matchRoutes()
從上面的例子可以看到,雖然直接根據頁面內的 route.breadcrumbName
屬性就知道該頁面的麵包屑名稱是什麼。但是「嵌套式路由」中除了需要知道當前路由的麵包屑名稱外,還需要知道它上一層的名稱。
例如,當網址當前的路由是 /electronics/mobile
時,雖然可以知道這個 Page 的名稱是 Mobile Phone,但同時還需要知道 /electronics
的麵包屑名稱是 Electronics,因為麵包屑組起來是這樣的:
Home > Electronics > Mobile Phone
這時候我們需要使用到 react-router-config
提供的另一個方法,稱作 matchRoutes
。
matchRoutes
基本的使用方式像這樣,前面放當初定義好的路由設定檔,後面則放當前網址列的路由:
matchedRoutes = matchRoutes(routes, pathname);
把它寫到最到 Electronics
頁面中 console.log()
出來看看:
// /src/pages.js
import { renderRoutes, matchRoutes } from 'react-router-config';
import routes from './routes';
// ...
const Electronics = ({ route, location }) => {
const matchedRoutes = matchRoutes(routes, location.pathname);
console.log('matchedRoutes in Electronics', matchedRoutes);
return (
<div>
<h1 className="py-3">Electronics</h1>
{renderRoutes(route.routes)}
</div>
);
};
// ...
當在瀏覽器導覽列輸入 localhost:3000/electronics/mobile
時,從 console
的結果可以看到,透過 matchRoutes
這個方法,除了可以拿到當前路由的 breadcrumbName
外,還可以把上一層路由的麵包屑名稱也拿到,也就是說,透過 matchRoutes
取得的資訊,就可以幫助我們組出麵包屑了:
簡單的把 Electronics
頁面改一下,就可以製作出我們想要的麵包屑了:
// /src/pages.js
import { renderRoutes, matchRoutes } from 'react-router-config';
import { Link } from 'react-router-dom';
import routes from './routes';
// ...
const Electronics = ({ route, location }) => {
const matchedRoutes = matchRoutes(routes, location.pathname);
return (
<div>
<h1 className="py-3">Electronics</h1>
{/* Breadcrumb */}
<nav>
<ol className="breadcrumb">
{matchedRoutes.map((matchRoute, i) => {
const { path, breadcrumbName } = matchRoute.route;
return (
<li key={i} className="breadcrumb-item">
<Link to={path}>{breadcrumbName} </Link>
</li>
);
})}
</ol>
</nav>
{renderRoutes(route.routes)}
</div>
);
};
// ...
- 透過
matchRoutes()
可以取得所有從該網址 URL 開始,向上層推算的所有路由的麵包屑名稱 - 將配對出的
matchedRoutes
透過map
來跑迴圈,疊代出每一個麵包名稱(breadcrumbName
)和路由的路徑(path
)。
現在,當你在 localhost:3000/electronics/
路由內的頁面就都可以看到麵包屑了:
因為我們在 Home
和 Books
頁面都還沒放麵包屑進去,所以在那兩個頁面自然還不會看到麵包屑,再把麵包屑放入 Home
和 Books
頁面前,先再把這個麵包屑優化一下。
優化麵包屑
最後一個麵包屑不要是連結
在剛剛的畫面中,你會發現即使已經在 Mobile
這一頁,Mobile Phone
的麵包屑仍然是可以點擊的連結,但一般來說當使用者已經在這個頁面時,該麵包屑就不該還可以被點擊:
這裡我們可以簡單判斷,先定一個名為 isActive
的變數,如果當前網址列的 URL 和 matchedRoutes
中 route
的 path
一樣時(location.pathname === route.path
),表示這個配對到的路由就是使用者目前所在的頁面,isActive
會是 true
,這時候就不要使用 <Link />
產生連結。大概是這樣:
// /src/pages.js
// ...
const Electronics = ({ route, location }) => {
//...
<nav>
<ol className="breadcrumb">
{matchedRoutes.map((matchRoute, i) => {
const { path, breadcrumbName } = matchRoute.route;
// check whether the the path is the Page path user currently at
const isActive = path === location.pathname;
// if the Page path is user currently at, then do not show <Link />
return isActive ? (
<li key={i} className="breadcrumb-item active">
{breadcrumbName}
</li>
) : (
<li key={i} className="breadcrumb-item">
<Link to={path}>{breadcrumbName} </Link>
</li>
);
})}
</ol>
</nav>;
// ...
};
// ...
這時候使用者當前所在頁面的麵包屑就不會亮起,也不可點擊:
如果到這一步有問題的話,可以比對看看這個 commit。