本篇為 [FE302] React 基礎 - hooks 版本 這門課程的學習筆記。如有錯誤歡迎指正!
參考文章:淺談新手在學習 SPA 時的常見問題:以 Router 為例
之前實作的留言板只有單一頁面,但隨著專案規模越大,需要藉由路由來渲染不同頁面時,就需要路由進行配置與管理,而 react-router-dom 套件就有提供這個功能。
接下來我們使用 React 搭配 Router,實作一個 SPA(Single Page Application)架構的部落格。
React Router:管理專案路由
可參考官方文件,我們可透過 React Router 套件來管理 URL 路由。
安裝 react-router-dom
1
| $ npm install react-router-dom
|
用 Component 的概念設計 Router
React Router 同樣是要用寫 React 方式去理解,也就是以 Component 的概念去設計一個 Router。
這其實和我們之前寫 Back-End 時很不一樣,例如 app.get('/comment') ,代表讀取留言的路由。
可參考官方提供的範例:
以下是上方範例的程式碼,透過連結改變的網址,由 Router 決定要 render 的畫面:
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 31 32
| export default function BasicExample() { return ( <Router> <div>
<ul> <li> <Link to="/">Home</Link> </li> <li> <Link to="/about">About</Link> </li> <li> <Link to="/dashboard">Dashboard</Link> </li> </ul>
<hr />
<Switch> <Route exact path="/"> <Home /> </Route> <Route path="/about"> <About /> </Route> <Route path="/dashboard"> <Dashboard /> </Route> </Switch> </div> </Router>
|
BrowserRouter vs HashRouter
而在引入 Router 時,其實有兩種方式:
- BrowserRouter:直接在網址帶入路徑,但這種方式在 GitHub Pages 上其實會出現問題
1 2 3 4 5 6
| import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";
|
如果是從首頁點選 dashboard,前端就會透過 JavaScript 提供的 API 把網址改成 /dashboard,能夠正常 render 畫面。
但如果是直接在網址後帶上 /dashboard,GitHub Pages 會去找 dashboard 資料夾底下的 index.html,此時瀏覽器會直接發 request 到該頁面,發生不如預期的錯誤。
- HashRouter:會在網址加上
/#/,瀏覽器就會去載入 # 符號之前的網址,即可改善上述問題
1 2
| Home https://bnpsd.csb.app/#/ dashboard https://bnpsd.csb.app/#/dashboard
|
有了以上關於 Router 的基本概念後,就來繼續實作專案吧!
實作:管理專案 Router
1. App.js:根據不同 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 29 30 31 32
| import React, { useState, useEffect } from "react"; import styled from "styled-components"; import PropTypes from "prop-types"; import LoginPage from "../../pages/LoginPage"; import HomePage from "../../pages/HomePage"; import Header from "../Header";
import { HashRouter as Router, Switch, Route } from "react-router-dom";
const Root = styled.div``;
export default function App() { return ( <Root> {/* Router: 包在最外層 */} <Router> {/* 導覽列: 共同區塊 */} <Header /> {/* Switch: 確保只會匹配第一個符合網址列的路由 */} <Switch> {/* exact path: 代表完整匹配;若只有 path 是部分匹配 */} <Route exact path="/"> <HomePage /> </Route> <Route exact path="/login"> <LoginPage /> </Route> </Switch> </Router> </Root> ); }
|
- 整理專案結構
- src
- components
- pages
- HomePage 首頁
- LoginPage 登入頁面
以建立 HomePage Component 為例,Header 和 LoginPage 也是用這個模式:
1 2 3 4 5 6
| import React, { useState, useEffect } from "react";
export default function HomePage() { return <div>Home Page</div>; }
|
而為了調整專案結構,需在 index.js 引入並引出 HomePage.js,可參考上篇筆記:
1 2
| export { default } from "./HomePage";
|
執行結果如下,可透過不同路由 render 相對應的頁面,其中 Header 是共同區塊不會變動:

參考資料:
- 淺談新手在學習 SPA 時的常見問題:以 Router 為例
- [React] 搭配 React Router 打造一個動態麵包屑(dynamic breadcrumb)
實作:從切板開始!
1. 切板與整合 react router
瞭解到如何管理路由之後,再來就是透過 component 切出想要的畫面。
首先進行 Header component 導覽列連結的部分。
方法一:透過 Link、useLoction
- 使用 useLocation 讀取當前位置,再透過 $active 這個屬性判斷符合哪個路徑,render 出相對應畫面:
1 2 3 4 5 6
| import { Link, useLocation } from "react-router-dom";
const location = useLocation();
<Nav to="/" $active={location.pathname === "/"}>
|
程式碼如下:
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| import React, { useState, useEffect } from "react"; import styled from "styled-components";
import { Link, useLocation } from "react-router-dom";
const HeaderContainer = styled.div` height: 58px; display: flex; justify-content: space-between; align-items: center; position: fixed; top: 0; left: 0; right: 0; border-bottom: 1px solid rgba(0, 0, 0, 0.2); box-shadow: 0px 0px 6px rgb(199, 197, 197); padding: 0px 32px; `;
const Brand = styled.h1` margin: 0; `;
const NavbarList = styled.ul` display: flex; align-items: center; list-style-type: none; text-decoration: none; margin: 0; padding: 0; `;
const Nav = styled(Link)` height: 58px; width: 100px; display: flex; justify-content: center; align-items: center; text-decoration: none; color: #666;
${(props) => props.$active && ` background: #eee; color: #222; `} `;
const LeftNavbar = styled.div` display: flex; align-items: center;
/* 代表在 LeftNavbar 底下的 NavbarList */ ${NavbarList} { margin-left: 32px; } `;
export default function Header() { const location = useLocation();
return ( <HeaderContainer> <LeftNavbar> <Brand> <Link exact to="/"> React 部落格 </Link> </Brand> <NavbarList> <Nav to="/" $active={location.pathname === "/"}> 首頁 </Nav> <Nav to="/new-post" $active={location.pathname === "/new-post"}> 發布文章 </Nav> </NavbarList> </LeftNavbar> <NavbarList> <Nav to="/login" $active={location.pathname === "/login"}> 登入 </Nav> </NavbarList> </HeaderContainer> ); }
|
方法二:NavLink
除了使用 useLocation 來判斷當前路徑,React Router 還有提供 NavLink 這個特殊的 Component,具有以下屬性:
- activeClassName(string):設置選中樣式,預設為 active
- activeStyle(object):當元素被選中時,為此元素添加樣式
- exact(bool):為 true 時,只有當完全符合時才會應用
- isActive(func):判斷連結是否執行額外功能
參考資料:
- 的介紹與使用
- React手册之Link和NaviLink区别
可透過 activeClassName 屬性或 inline style 行內樣式,來表示 NavLink 有無被選取,官方提供的範例如下:
引入使用 NavLink
1
| import { NavLink } from 'react-router-dom'
|
1. 一般寫法
1
| <NavLink to="/about">About</NavLink>
|
2. activeClassName: string
1 2 3
| <NavLink to="/faq" activeClassName="selected"> FAQs </NavLink>
|
3. activeStyle: object
1 2 3 4 5 6 7 8 9
| <NavLink to="/faq" activeStyle={{ fontWeight: "bold", color: "red" }} > FAQs </NavLink>
|
但在實際應用的時候,有遇到個問題,就是如果想搭配 style-component 使用,會不知該如何傳入 activeClassName 這個 props!
- How do I add an active class to a Link from React Router?
針對如何在 NavLink 組件中使用 activeClassName 屬性,參考一些網路上的範例進行改寫,以下示範兩種作法:
1. 透過 styled component 在 NavLink component,使用 .attr() 自訂屬性 activeClassName:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const activeClassName = 'nav-item-active'
const StyledLink = styled(NavLink).attrs({ activeClassName })`
&.${activeClassName} { background: #eee; } `;
<NavbarList> <StyledLink exact to="/">首頁</StyledLink> <StyledLink to="/new-post">發布文章</StyledLink> </NavbarList> </LeftNavbar> <NavbarList> <StyledLink to="/login">登入</StyledLink> </NavbarList>
|
可參考下方範例:
2. 將 activeClassName 視為一個 props,當 NavLink 被選中時,才會加上 activeClassName 屬性:
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 31 32 33
| const StyledLink = styled(NavLink)`
&.${(props) => props.activeClassName} { background: #eee; } `;
export default function Header() { return ( <HeaderContainer> <LeftNavbar> <Brand> <Link exact to="/"> React 部落格 </Link> </Brand> <NavbarList> <StyledLink exact to="/" activeClassName="active"> 首頁 </StyledLink> <StyledLink to="/new-post" activeClassName="active"> 發布文章 </StyledLink> </NavbarList> </LeftNavbar> <NavbarList> <StyledLink to="/login" activeClassName="active"> 登入 </StyledLink> </NavbarList> </HeaderContainer> ); }
|
其他參考資料:
- React Router - Basic:使用 isActive 屬性來判斷要 render 哪個 component
- React 中 Link 和 NavLink 组件 activeClassName、activeStyle 属性不生效的问题
實作:文章列表頁面
測試用的 API 同樣參考:Lidemy 學生專用 API Server,部落格要串接的是 Posts API,資料結構如下:
URL:https://student-json-api.lidemy.me/posts?userId=1

1. 串連 API:拿取所有 posts
通常會在 src 路徑底下,新增一個 WebAPI.js 專門用來管理串連 API 相關程式碼。
如下方程式碼,使用 fetch 串接 API 再進行資料處理:
1 2 3 4 5 6 7
| const BASE_URL = "https://student-json-api.lidemy.me";
export const getPosts = () => { return fetch(`${BASE_URL}/posts?_sort=createdAt&_order=desc`).then((res) => res.json() ); };
|
2. 顯示文章標題 & 時間
在文章列表頁面,我們希望能夠顯示文章標題(title)和時間(createdAt)這兩個資訊:
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
|
import React, { useState, useEffect } from "react"; import styled from "styled-components"; import PropTypes from "prop-types";
import { Link } from "react-router-dom"; import { getPosts } from "../../WebAPI";
const Root = styled.div` max-width: 80%; margin: 0 auto; `;
const PostContainer = styled.div` border-bottom: 1px solid rgba(0, 0, 0, 0.3); padding: 16px; display: flex; justify-content: space-between; align-items: center; `;
const PostTitle = styled(Link)` font-size: 24px; color: #333; text-decoration: none; `;
const PostDate = styled.div` color: rgba(0, 0, 0, 0.8); `;
function PostList({ post }) { return ( <PostContainer> <PostTitle to={`/posts/${post.id}`}>{post.title}</PostTitle> <PostDate>{new Date(post.createdAt).toLocaleDateString()}</PostDate> </PostContainer> ); }
PostList.propTypes = { post: PropTypes.object, };
export default function HomePage() { const [posts, setPosts] = useState([]);
useEffect(() => { getPosts().then((posts) => setPosts(posts)); }, []);
return ( <Root> {posts.map((post) => ( <PostList post={post} /> ))} </Root> ); }
|
1
| new Date(post.createdAt).toLocaleDateString()
|
1 2 3
| {posts.map((post) => ( <Post post={post} /> ))}
|
- title 改用連結,引入 Link component 使用
1 2 3 4 5 6 7 8 9 10 11
| import { Link } from "react-router-dom";
const PostTitle = styled(Link)` font-size: 24px; color: #333; text-decoration: none; `;
<PostTitle to={`/posts/${post.id}`}>{post.title}</PostTitle>
|
結果如下:

實作:單一文章頁面
1. 串聯 API:根據不同 id 拿取 post
接著是單一文章頁面,當我們在 Router 使用動態參數來讀取個別資料時,會需要取得 URL 上的 id 值。
在 WebAPI.js 中,根據路由上不同 id 來拿取相對應的 post:
1 2 3
| export const getPost = (id) => { return fetch(`${BASE_URL}/posts?id=${id}`).then((res) => res.json()); };
|
2. useParams:抓取 URL 上的指定值
透過 react-router 提供的 Hooks:useParams 就能更方便取得 id 值,而不需再透過 props.match.params 抓取 URL 路由的參數值。
以下是官方文件提供的範例,這裡指定的值就是 {slug}:
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
| import React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter as Router, Switch, Route, useParams } from "react-router-dom";
function BlogPost() { let { slug } = useParams(); return <div>Now showing post {slug}</div>; }
ReactDOM.render( <Router> <Switch> <Route exact path="/"> <HomePage /> </Route> // 抓取 URL 上的指定參數值 <Route path="/blog/:slug"> <BlogPost /> </Route> </Switch> </Router>, node );
|
3. App.js 設定路由
新增 PostPage 和 NewPostPage 的路由,並在 src\pages 資料夾建立 pages 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
| import { HomePage, NewPostPage, PostPage, LoginPage, } from "../../pages";
<Switch> {/* exact path: 完整匹配 */} <Route exact path="/"> <HomePage /> </Route> <Route exact path="/posts/:id"> <PostPage /> </Route> <Route exact path="/new-post"> <NewPostPage /> </Route> <Route exact path="/login"> <LoginPage /> </Route> </Switch>
|
稍微整理專案結構,在 src\pages 建立 index.js 來統一處理 pages 的引入引出動作:
1 2 3 4 5 6
| import HomePage from "./HomePage"; import LoginPage from "./LoginPage"; import PostPage from "./PostPage"; import NewPostPage from "./NewPostPage";
export { HomePage, LoginPage, PostPage, NewPostPage };
|
4. 實作 PostPage.js
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| import React, { useState, useEffect } from "react"; import styled from "styled-components";
import { useParams } from "react-router-dom"; import { getPost } from "../../WebAPI";
const PostContainer = styled.div` padding: 0 30px; max-width: 960px; margin: 8px auto; `;
const PostHeader = styled.div` margin-bottom: 16px; padding: 16px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.1); `;
const PostTitle = styled.div` font-size: 36px; font-weight: 700; `;
const PostDate = styled.div` font-size: 16px; color: rgba(0, 0, 0, 0.4); margin-top: 16px; `;
const PostBody = styled.div` font-size: 20px; letter-spacing: 3px; line-height: 1.5; `;
export default function PostPage() { const [post, setPost] = useState(null); const { id } = useParams();
useEffect(() => { getPost(id).then((post) => setPost(post[0])); }, [id]);
return ( <PostContainer> <PostHeader> {/* post &&: 確認陣列裡面有東西才會執行 */} <PostTitle>{post && post.title}</PostTitle> <PostDate> {post && new Date(post.createdAt).toLocaleString()} </PostDate> </PostHeader> <PostBody>{post && post.body}</PostBody> </PostContainer> ); }
|
- 用 useParams() 讀取網址列上的 id 值,並透過 useEffect 在 render 之後拿取資料,再以 setPost 來改變狀態:
1 2 3 4 5 6
| const [post, setPost] = useState(null); const { id } = useParams();
useEffect(() => { getPost(id).then((post) => setPost(post[0])); }, [id]);
|
- 需以
post && 確認陣列裡面有東西才會執行,以 post.title 為例:
1
| <PostTitle>{post && post.title}</PostTitle>
|
1 2 3 4 5
| new Date(post.createdAt).toLocaleString()
new Date(post.createdAt).toLocaleDateString()
|
結果如下:

結語
到這邊我們已經完成專案基本架構,設定路由,以及顯示全部文章、顯示單篇文章的功能,下一篇要繼續學習如何在 React 實作登入機制。
- 傳送門:[week 21] React 實戰篇:用 SPA 架構實作一個部落格(二)