2018年11月17日 星期六

[React] 搭配 React Router 打造一個動態麵包屑(dynamic breadcrumb)

這篇文章主要說明如何整合 react-router 來製作一個具有導覽功能的麵包屑(breadcrumb),也就是這個麵包屑可以根據當前使用者瀏覽的路由動態顯示出對應的名稱。由於會說明之所以這麼做的思路,因此篇幅較長;如果想要直接看如何使用這段程式碼,可以到 Github 上檢視 ReadMe,當中的說明較為精簡。。
img
這篇文章不會從頭開始說明 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                # 啟動專案
img
在這篇文章中不會說明 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 了:
img
如果到這一步有問題的話,可以對照看看這個 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 中,先載入 BrowserRouterSwitch
// /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';

ReactDOM.render(
  <BrowserRouter>
    <Switch>
      <App />
    </Switch>
  </BrowserRouter>,
  document.getElementById('root')
);
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,應該就能順利看到那些頁面。
img
對於 <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 時,也會出現相對應的畫面:
img
  • 關於路由的配置可進一步參考 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;
到目前為止完成畫面差不多完成了:
img
如果撰寫過程中有問題,可以和此 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 為例,當透過 propsbreadcrumbName 傳到該組件中時,雖然到路由 /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 屬性後面接的這個函式中,可以接收一個參數,我們把這個參數取名為 routePropsrouteProps 會傳回原本在 <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 Router render={() => <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>
  );
};
// ...
一整個乾淨清爽的感覺,是不是覺得精簡非常多啊!你可能會想為什麼不早點把這好東西拿出來!?哎呀,了解一下背後的原理也是不錯的嘛。
如果畫面以及路由切換都和剛剛一樣正常運作的話,就表示程式碼應該沒什麼問題。

根據路由取得麵包屑名稱

為了要取得每一個路由所對應到的麵包屑名稱,我們把路由的寫法改成路由設定檔(route config)的方式,接下來就要在特定的路由下取得麵包屑的名稱。

取得當前瀏覽器網址列的路由:location

每個透過 <Route /> 組件產生的頁面,都會帶有透過 React Router 添加的屬性,可以透過該頁面的 props 屬性取得,其中包含 history, location, matchroute

嵌套式路由:以 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 屬性可以讓我們知道當前瀏覽器網址列所在的路徑為何:
img
route 屬性則是來自當初設定好的路由配置,因此在這裡可以看到添加進去的 breadcrumbName 屬性。也就是說從該頁面的 props 就可以知道它的 breadcrumbName 為何
img

取得當前路由的外層路由名稱: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 取得的資訊,就可以幫助我們組出麵包屑了:
img
簡單的把 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/ 路由內的頁面就都可以看到麵包屑了:
img
因為我們在 HomeBooks 頁面都還沒放麵包屑進去,所以在那兩個頁面自然還不會看到麵包屑,再把麵包屑放入 HomeBooks 頁面前,先再把這個麵包屑優化一下。

優化麵包屑

最後一個麵包屑不要是連結

在剛剛的畫面中,你會發現即使已經在 Mobile 這一頁,Mobile Phone 的麵包屑仍然是可以點擊的連結,但一般來說當使用者已經在這個頁面時,該麵包屑就不該還可以被點擊:
img
這裡我們可以簡單判斷,先定一個名為 isActive 的變數,如果當前網址列的 URL 和 matchedRoutesroutepath 一樣時(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>;
  // ...
};

// ...
這時候使用者當前所在頁面的麵包屑就不會亮起,也不可點擊:
img
如果到這一步有問題的話,可以比對看看這個 commit

客製化麵包屑內容

到目前為止麵包屑的功能已經差不多了,但還有一個可以優化的地方,像是這頁如果我們想在 Electronics 前面多一個 Home 的麵包屑,像是這樣 Home / Electronics / Mobile Phone 該怎麼辦呢?
方法不難,既然我們可以是透過 matchedRoutes 透過迴圈去跑出所有的麵包屑,只要我們改一下 matchedRoutes 這個陣列的內容,自然可以客製化出想要的麵包屑
// /src/pages.js

const Electronics = ({ route, location }) => {
  let matchedRoutes = matchRoutes(routes, location.pathname);

  // Customize breadcrumb through modifying matchRoutes array
  matchedRoutes = [
    {
      route: {
        path: '/',
        breadcrumbName: 'Home'
      }
    },
    ...matchedRoutes
  ];

  return (
    // ...
  );
};
在原本的 matchedRoues 陣列中,多添加了一個 route 物件,如此在稍後產生麵包屑的時候,自然就會多 Home 的麵包屑:
img
如果到這一步有問題的話,可以比對這個 commit

建立麵包屑組件

寫到這裡已經完成了麵包屑,最後因為在 Home, Books 頁面中也都會使用到麵包屑,因此可以把剛剛寫在 Electronics 頁面中的麵包屑拆成一個組件,直接套用到其他有需要使用的頁面即可:
// /src/pages.js

// ...
const Electronics = ({ route, location }) => {
  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      {/* move to component */}
      <Breadcrumb locationPath={location.pathname} />

      {renderRoutes(route.routes)}
    </div>
  );
};
//...
把原本的內容放到 components.js 中:
// /src/components.js
import { matchRoutes } from 'react-router-config';
import routes from './routes';
// ...

const Breadcrumb = ({ locationPath }) => {
  let matchedRoutes = matchRoutes(routes, locationPath);

  return (
    <nav>
      <ol className="breadcrumb">
        {matchedRoutes.map((matchRoute, i) => {
          const { path, breadcrumbName } = matchRoute.route;
          const isActive = path === locationPath;

          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>
  );
};

export { Navbar, Breadcrumb };
這時候因為已經把 breadcrumb 抽成一個 component,所以剛剛透過修改 matchedRoutes 來客制化麵包屑的方式不能寫死在 <Breadcrumb /> 中,而是應該要可以在不同的頁面客製化出不同的麵包屑內容。
因此,我們在 <Breadcrumb /> 組件中新增一個名為 onMatchedRoutes 的 callback function:
// /src/components.js

// ...
// User can use the onMatchedRoutes callback to modify breadcrumb
const Breadcrumb = ({ locationPath, onMatchedRoutes }) => {
  let matchedRoutes = matchRoutes(routes, locationPath);

  if (typeof onMatchedRoutes === 'function') {
    matchedRoutes = onMatchedRoutes(matchedRoutes);
  }

  return (
    // ...
  );
};

// ...
讓使用者在使用頁面中使用這個組件時,還有機會去修改要顯示的麵包屑為何:
// /src/pages.js

// ...
const Electronics = ({ route, location }) => {
  // Provide a function as props into <Breadcrumb /> to modify breadcrumb
  const onMatchedRoutes = (matchedRoutes) => {
    return [
      {
        route: {
          path: '/',
          breadcrumbName: 'Home'
        }
      },
      ...matchedRoutes
    ];
  };

  return (
    <div>
      <h1 className="py-3">Electronics</h1>

      {/* Pass onMatchedRoutes function as props here */}
      <Breadcrumb
        locationPath={location.pathname}
        onMatchedRoutes={onMatchedRoutes}
      />

      {renderRoutes(route.routes)}
    </div>
  );
};
如果到這一步有問題的話,可以對照這個 commit

套用麵包屑組件

寫到這裡就大功告成拉!最後我們把 <Breadcrumb /> 也放到 HomeBooks 中:
// /src/pages.js

// ...
const Home = ({ location }) => {
  return (
    <div>
      <h1 className="py-3">Home</h1>
      <Breadcrumb locationPath={location.pathname} />
    </div>
  );
};

const Books = ({ location }) => {
  const onMatchedRoutes = (matchedRoutes) => {
    return [
      {
        route: {
          path: '/',
          breadcrumbName: 'Home'
        }
      },
      ...matchedRoutes
    ];
  };

  return (
    <div>
      <h1 className="py-3">Books</h1>
      <Breadcrumb
        locationPath={location.pathname}
        onMatchedRoutes={onMatchedRoutes}
      />
    </div>
  );
};

// ...
完成後的畫面就像這樣子,麵包屑可以根據路由自動變換,如果有需要客製化添加麵包屑的地方,也可以透過 onMatchedRoutes 這個 callback function 來修改:
img
如果到這一步有問題的話,可以對照參考這個 commit

參考