###### tags: `React` `GitHub`
# [week 21] React 實戰篇:做出一個的留言版 - 新增、刪除功能 & 部署到 GitHub
> 本篇為 [[FE302] React 基礎 - hooks 版本](https://lidemy.com/p/fe302-react-hooks) 這門課程的學習筆記。如有錯誤歡迎指正!
## 前置作業
### 整理專案結構
在開始之前,可先整理 React 專案的資料結構, 例如將 src 路徑下的檔案分成 components 和 constants 兩類:
![](https://i.imgur.com/ueZO7k3.png)
Todo 是備份我們前面做的 TodoList 範例,MessageBoard 則是本篇要實作的內容。
### 簡化結構:透過 index.js 引入並引出 APP.js
這裡有個小技巧,之所以在 APP 資料夾底下還有個 index.js,是為了在 src 根目錄底下的 index.js 可以用 `./component/App` 直接引入,而不需寫成 `./component/App/App`,藉此簡化資料結構:
```javascript=
// scr 底下的 index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./component/App";
ReactDOM.render(
<App />,
document.getElementById("root")
);
```
但這樣其實有個壞處,只看檔名會不知道其功能,必須搭配所在資料夾。因此會改成由 App 資料夾底下的 index.js 引入 App.js 再匯出:
```javascript=
// scr\component\App 底下的 index.js
import App from "./App";
export default App;
===也可以簡寫成===
// re-export: 目的是簡化資料結構
export { default } from "./App";
```
因此實際上還是在撰寫 App.js 這個檔案,這是實際工作中常用的小技巧:
```javascript=
// scr\component\App 底下的 App.js
import React from "react";
function App() {
return <div>Hello!</div>;
}
export default App;
```
結果如下,能夠正常 render 出 App:
![](https://i.imgur.com/eqaieOh.png)
完成前置作業之後,接著就可以開始實作留言板了!
## 第一步:切版
同樣引入 styled-components 來寫 CSS:
```javascript=
import React from "react";
import styled from "styled-components";
```
### 思考 Component 架構
先思考頁面上需要哪些功能,切出相對應的 Component 架構:
```javascript=
// Message Component: 可從傳入的參數去思考架構
function Message({ author, time, children }) {
return (
<MessageContainer>
<MessageHead>
<MessageAuthor>{author}</MessageAuthor>
<MessageTime>{time}</MessageTime>
</MessageHead>
<MessageBody>{children}</MessageBody>
</MessageContainer>
);
}
function App() {
return (
<Page>
<Title>React 留言板</Title>
<MessageForm>
<MessageLable>留言內容</MessageLable>
<MessageTextArea rows={8} />
<SubmitButton>送出</SubmitButton>
</MessageForm>
<MessageList>
// 需要傳入參數的 Message 也是 Component
<Message author={"Heidi"} time="2020-12-05 12:12:12">
一則留言
</Message>
</MessageList>
</Page>
);
}
export default App;
```
再透過 styled-components 來修改樣式:
```javascript=
// contariner
const Page = styled.div`
max-width: 800px;
margin: 0 auto;
font-family: "monospace", "微軟正黑體";
box-shadow: 0px 0px 16px rgb(199, 197, 197);
border-radius: 8px;
padding: 12px 28px;
color: #6c6c6c;
box-sizing: border-box;
`;
const Title = styled.h1`
text-align: center;
`;
// 表單區塊 form
const MessageForm = styled.form`
margin-top: 16px;
font-size: 18px;
`;
const MessageLable = styled.div``;
const MessageTextArea = styled.textarea`
display: block;
margin-top: 8px;
width: 95%;
border-color: rgba(0, 0, 0, 0.125);
`;
const SubmitButton = styled.button`
margin-top: 8px;
color: #ddd;
background-color: #343a40;
border: 1px solid transparent;
border-radius: 4px;
font-size: 16px;
padding: 6px 12px;
`;
// 顯示留言區塊
const MessageList = styled.div`
margin-top: 16px;
`;
const MessageContainer = styled.div`
border: 1px solid rgba(0, 0, 0, 0.125);
padding: 16px;
border-radius: 4px;
`;
const MessageHead = styled.div`
display: flex;
`;
const MessageAuthor = styled.div`
margin-right: 12px;
color: #232323;
`;
const MessageTime = styled.div``;
const MessageBody = styled.div`
margin-top: 8px;
word-break: break-all;
white-space: pre-line;
`;
```
結果如下:
<iframe src="https://codesandbox.io/embed/focused-voice-nyjgq?fontsize=14&hidenavigation=1&theme=dark"
style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;"
title="React 留言板 - 切版"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
刻好介面以後,再來就可以串接 API 讀取資料啦!
## 第二步:串接 API
測試用的 API 可參考:[Lidemy 學生專用 API Server](https://github.com/Lidemy/lidemy-student-json-api-server),留言板要串接的是 Comments API,資料結構如下:
URL:https://student-json-api.lidemy.me/comments
![](https://i.imgur.com/2yjjMQL.png)
### useState 初始 component 狀態
引入 useState 使用,設定 component 初始狀態:
```javascript=
import React, {useState} from "react";
function APP() {
// 設定初始狀態
const [messages, setMessages] = useState([]);
// ...
}
```
### 根據處理選擇 eventHandler 或 useEffect
當我們要對 Component 做一些事情時,通常會有兩種方式:
- eventHandler 事件機制
- useEffect:在 render 之後要做的事情
而我們的目標是「在 render 之後拿取資料」,因此在這裡選用 useEffect:
```javascript=
import React, {useState, useEffect} from "react";
```
### useEffect:在 render 之後拿取資料
接著寫 useEffect 在 component mount 之後要做的事情,透過 fetch 來拿取 API 的資料,轉成 JSON 格式後,再以 setMessages 改變狀態,也可用 catch 進行錯誤處理:
```javascript=
// Comments API
const API_ENDPOINT =
"https://student-json-api.lidemy.me/comments?_sort=createdAt&_order=desc";
function App() {
const [messages, setMessages] = useState([]);
const [messageMessageApiError, setMessageApiError] = useState(null);
// 第二個參數傳入 [] 代表只在 componet mount 後執行
useEffect(() => {
fetch(API_ENDPOINT)
.then((res) => res.json())
.then((data) => {
setMessages(data);
})
.catch((err) => {
setMessageApiError(err.message);
});
}, []);
// ...
}
```
### 用 map() 方式拿取 List 結構的資料
並在 component 加上與傳入參數相對應的屬性,List 結構的資料會用 map() 方式拿取,用 id 當 key:
```javascript=
function APP() {
// ...
return (
<Page>
<Title>React 留言板</Title>
<MessageForm>
<MessageLable>留言內容</MessageLable>
<MessageTextArea rows={8} />
<SubmitButton>送出</SubmitButton>
</MessageForm>
<MessageList>
{messages.map((message) => (
<Message
key={message.id}
author={message.nickname}
time={message.createdAt}
>
{message.body}
</Message>
))}
</MessageList>
</Page>
);
}
```
接著微調 MessageContainer styled,每則留言會跟上一則有距離,有兩種寫法:
```javascript=
const MessageContainer = styled.div`
// ...
// 第一種
$:not(:first-child) {
margin-top: 8px;
}
// 第二種(較容易記)
& + & {
margin-top: 8px;
}
`;
```
串接 API 結果如下:
<iframe src="https://codesandbox.io/embed/react-liuyanban-chuanjie-api-kunz1?fontsize=14&hidenavigation=1&theme=dark"
style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;"
title="React 留言板 - 串接 API"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
### 錯誤處理:messageApiError
實作一個 ErrorMessage Component 來顯示錯誤訊息:
```javascript=
const ErrorMessage = styled.div`
margin-top: 16px;
color: #db4c3f;
`;
function App() {
const [messages, setMessages] = useState([]);
const [messageApiError, setMessageApiError] = useState(null);
// 第二個參數傳入 [] 代表只在 componet mount 後執行
useEffect(() => {
fetch(API_ENDPOINT)
.then((res) => res.json())
.then((data) => {
setMessages(data);
})
.catch((err) => {
setMessageApiError(err.message);
});
}, []);
return (
<Page>
<Title>React 留言板</Title>
<MessageForm>
<MessageLable>留言內容</MessageLable>
<MessageTextArea rows={8} />
<SubmitButton>送出</SubmitButton>
</MessageForm>
{messageMessageApiError && (
<ErrorMessage>
{/* 直接 render object 會出錯,因此需轉成 string */}
Something went wrong. {messageMessageApiError.toString()}
</ErrorMessage>
)}
// ...
```
例如修改 API_ENDPOINT 網址,多加其他參數:
```javascript=
const API_ENDPOINT =
"https://1student-json-api.lidemy.me/comments?_sort=createdAt&_order=desc";
```
畫面就會顯示錯誤訊息:
![](https://i.imgur.com/YTjRWbn.png)
### 錯誤處理:No Message
當沒有留言,也就是 `messages.length === 0` 時才會出現:
```javascript=
{messageMessageApiError && (
<ErrorMessage>
{/* 直接 render object 會出錯,因此需轉成 string */}
Something went wrong. {messageMessageApiError.toString()}
</ErrorMessage>
)}
// 當沒有留言時才會出現
{messages.length === 0 && <div>No Message</div>}
```
結果如下:
![](https://i.imgur.com/KNtiZ2T.png)
但這種寫法其實會有個問題,也就是重新 render 的一瞬間會先看到 No Message,然後才是讀取到的 API 資料。
可在判斷加上 `messages &&`,必須確認裡面有東西(是一個陣列)才會執行:
```javascript=
function App() {
const [messages, setMessages] = useState(null);
// ...
{messageMessageApiError && (
<ErrorMessage>
{/* 直接 render object 會出錯,因此需轉成 string */}
Something went wrong. {messageMessageApiError.toString()}
</ErrorMessage>
)}
{/* 確認裡面有東西才會執行這一行 */}
{messages && messages.length === 0 && <div>No Message</div>}
<MessageList>
{/* 確認裡面有東西才會執行這一行 */}
{messages &&
messages.map((message) => (
<Message
key={message.id}
author={message.nickname}
time={message.createdAt}
>
{message.body}
</Message>
))}
</MessageList>
```
### 加上 PropTypes 參數型別
> 可參考:[[week 21] 補充:在 React 使用 PropTypes 進行型別檢查](https://hackmd.io/@Heidi-Liu/note-be302-proptypes)
根據終端機顯示的警告訊息,幫 Component 加上 PropTypes:
![](https://i.imgur.com/D7b3jSJ.png)
引入 prop-types 套件:
```javascript=
import PropTypes from "prop-types"
```
Message Component:
```javascript=
Message.propTypes = {
author: PropTypes.string,
time: PropTypes.string,
// 可 render 的參數型別是 node
children: PropTypes.node,
};
```
會發現 time 格式有誤:
![](https://i.imgur.com/xcTS0fU.png)
可透過 JavaScript 的 new Date 轉成一個 Object,再用 toLocaleString() 這個方法,根據瀏覽器的語言轉換成我們看得懂的格式:
> 這樣寫的好處就是不需另外引入 Library!
![](https://i.imgur.com/u7GkPa8.png)
接著修改程式碼,轉換 `message.createdAt` 的格式:
```javascript=
messages.map((message) => (
<Message
key={message.id}
author={message.nickname}
time={new Date(message.createdAt).toLocaleString()}
>
```
這樣就成功轉換時間格式:
![](https://i.imgur.com/LE8sQdl.png)
## 第三步:實作新增留言功能
### 控制 Controled Component:設定 useState
在表單中,textarea 或 input 就屬於受控組件,而受不受控的組件比較如下:
- 受控組件(Controlled Component):具有 mutable state 可變屬性,能夠以 setState 的形式去控制資料
- 非受控組件(Uncontrolled Component):需透過 ref 取得 DOM 元素來更新資料
在表單中的 MessageTextArea 屬於 Controled Component,需透過 useState 來改變狀態,可設定 onChange 綁定事件機制,根據輸入的 value 來 setValue:
```javascript=
const [value, setValue] = useState();
const handleTextareaChange = (e) => {
setValue(e.target.value);
};
// ...
return (
<Page>
<Title>React 留言板</Title>
<MessageForm>
<MessageLable>留言內容</MessageLable>
<MessageTextArea
value={value}
onChange={handleTextareaChange}
rows={8}
/>
<SubmitButton>送出</SubmitButton>
```
### 阻止表單預設行為 e.preventDefault()
這時按下送出鍵時,頁面會重新整理,這是預設的送出表單行為。因此我們要在 Form 加上 onSubmit 事件機制,以 e.preventDefault() 阻止預設行為,再執行我們想要的事情:
```javascript=
const handleFormSubmit = (e) => {
// 阻止預設的表單發送行為
e.preventDefault();
};
// ...
return (
<Page>
<Title>React 留言板</Title>
<MessageForm onSubmit={handleFormSubmit}>
```
### 新增留言功能 POST
使用[API 文件](https://github.com/Lidemy/lidemy-student-json-api-server#comments) 提供的方法來新增資料:
```javascript=
const handleFormSubmit = (e) => {
// 阻止預設的表單發送行為
e.preventDefault();
fetch('https://student-json-api.lidemy.me/comments', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
nickname: 'hello',
body: 'comment content'
})
})
.then(res => res.json())
// 顯示留言
.then(data => fetchMessages())
};
// 和 useEffect 進行同樣處理,可把程式碼抽出來寫
const fetchMessages = () => {
return fetch(API_ENDPOINT)
.then((res) => res.json())
.then((data) => {
setMessages(data);
})
.catch((err) => {
setMessageApiError(err.message);
});
};
```
### 當表單為空時顯示錯誤訊息
但如果在 textarea 為空時送出 submit,會顯示下方錯誤訊息:
![](https://i.imgur.com/4bgQ9IA.png)
因此可在顯示訊息前可進行錯誤處理,根據是否 ok 顯示錯誤 `data.message`:
```javascript=
const [postMessageError, setPostMessageError] = useState();
// ...
const handleFormSubmit = (e) => {
// 阻止預設的表單發送行為
e.preventDefault();
fetch("https://student-json-api.lidemy.me/comments", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
nickname: "Heidi",
body: value,
}),
})
.then((res) => res.json())
.then((data) => {
// 在顯示訊息前可進行錯誤處理
if (!data.ok) {
setPostMessageError(data.message);
}
fetchMessages();
});
```
再根據有無 postMessageError 決定是否顯示:
```javascript=
return (
<Page>
<Title>React 留言板</Title>
<MessageForm onSubmit={handleFormSubmit}>
<MessageLable>留言內容</MessageLable>
<MessageTextArea
value={value}
onChange={handleTextareaChange}
rows={8}
/>
<SubmitButton>送出</SubmitButton>
{postMessageError && <ErrorMessage>{postMessageError}</ErrorMessage>}
```
![](https://i.imgur.com/ngJRkTg.png)
### onFocus 事件處理
但其實在送出新的留言之前,也就是當使用者 focus 在 textarea 區塊時,就可以取消錯誤訊息,可透過 onFocus 事件處理:
```javascript=
const handleTextareaFocus = () => {
setPostMessageError(null);
};
// ...
<MessageTextArea
value={value}
onChange={handleTextareaChange}
// onfocus 時就取消顯示錯誤
onFocus={handleTextareaFocus}
rows={8}
/>
<SubmitButton>送出</SubmitButton>
{postMessageError && <ErrorMessage>{postMessageError}</ErrorMessage>}
```
這樣在 focus textarea 時,錯誤訊息就會消失。
但是還有個問題,就是即使表單為空時還是可以不斷送出 request:
![](https://i.imgur.com/GaaUrO8.png)
###
可透過判斷「是否能送出留言的 state」解決這個問題:
```javascript=
// 預設為 false
const [isLoadingPostMessage, setIsLoadingPostMessage] = useState(false);
const handleFormSubmit = (e) => {
// 阻止預設的表單發送行為
e.preventDefault();
// 若為 true 就直接返回
if (isLoadingPostMessage) {
return;
}
// 要發送 API 之前設成 true
setIsLoadingPostMessage(true);
fetch("https://student-json-api.lidemy.me/comments", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
nickname: "Heidi",
body: value,
}),
})
.then((res) => res.json())
.then((data) => {
// 收到結果後設成 false
setIsLoadingPostMessage(false);
// 在顯示訊息前可進行錯誤處理
if (!data.ok) {
setPostMessageError(data.message);
return;
}
fetchMessages();
})
.catch((err) => {
setIsLoadingPostMessage(false);
setPostMessageError(err.message);
});
};
```
或是建立一個 Loading Component 並 render 在畫面上,這樣 button 就不會被點擊:
```javascript=
// 會遮住整個畫面
const Loading = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
color: white;
font-size: 30px;
// 垂直水平置中
display: flex;
align-items: center;
justify-content: center;
`;
return (
<Page>
{isLoadingPostMessage && <Loading>Loading...</Loading>}
<Title>React 留言板</Title>
```
結果如下:
![](https://i.imgur.com/dBlbzTo.png)
### 開發者工具:調整網速
可以透過開發者工具 -> 選擇 Slow 3G 降低網速,以觀察 Loading 的狀況:
![](https://i.imgur.com/k7iUd6V.png)
或是選擇 Add... 來自己新增一個 super slow 網速,把下載速度設為 1 kb/s:
![](https://i.imgur.com/1I9VSnY.png)
### 刪除功能:filter()
在之前[實戰 TodoList](https://hackmd.io/X1nVYagJTLagp7D3T8g5GA#Todo-List-%E7%B8%BD%E7%B5%90) 中有提到,刪除功能會用 filter() 處理:
```javascript=
const handleDeleteTodo = id => {
// 留下該 id 以外的 todo
setTodos(todos.filter(todo => todo.id !== id))
}
```
在留言板也是相同道理,只是再加上串接 API 的步驟!
若要在 component 加上事件機制,可將 function 作為參數從父層傳到子層,步驟如下:
#### 1. 把要做的 function 寫在 Parent
並傳入參數給 Children,這裡指的就是 handleDeleteMessage
```javascript=
// function APP () { ...
{messages &&
messages.map((message) => (
<Message
key={message.id}
author={message.nickname}
time={new Date(message.createdAt).toLocaleString()}
handleDeleteMessage={handleDeleteMessage}
message={message}
>
{message.body}
</Message>
```
#### 2. 再由 Children 根據 onClick 點擊事件去呼叫 function
這裡要傳入 `message.id` 表示選中的 id:
```javascript=
function Message({ author, time, children, handleDeleteMessage, message }) {
return (
<MessageContainer>
<MessageHead>
<MessageAuthor>{author}</MessageAuthor>
<MessageTime>{time}</MessageTime>
<MessageDeleteButton
onClick={() => {
handleDeleteMessage(message.id);
}}
>
刪除
</MessageDeleteButton>
</MessageHead>
<MessageBody>{children}</MessageBody>
</MessageContainer>
);
}
```
#### 3. 接著在 Parent 處理 function
DELETE method 不用傳入 request body,fetch 後直接用 .then 接續後面的動作,也就是以 setMessages 改變 component 狀態:
```javascript=
// function APP () { ...
const handleDeleteMessage = (id) => {
fetch("https://student-json-api.lidemy.me/comments/" + id, {
method: "DELETE",
})
.then((res) => res.json())
.then(() => {
setMessages(messages.filter((message) => message.id !== id));
})
.catch((err) => {
console.log(err);
});
};
```
結果如下:
<iframe src="https://codesandbox.io/embed/react-liuyanban-shanchuliuyangongneng-k495s?fontsize=14&hidenavigation=1&theme=dark"
style="width:100%; height:400px; border:0; border-radius: 4px; overflow:hidden;"
title="React 留言板 - 刪除留言功能"
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
></iframe>
參考資料:
- [Q: How would I make a DELETE request? #154](https://github.com/github/fetch/issues/154)
- [Javascript: Fetch DELETE and PUT requests](https://stackoverflow.com/questions/40284338/javascript-fetch-delete-and-put-requests)
---
## 部署到 Server
詳細步驟可參考官方文件:https://create-react-app.dev/docs/deployment/
### 優化專案:npm run build
在部署之前,可透過 npm run build 指令來優化專案程式碼,像是進行 bundle、去除空格等:
```
// 這三種寫法效果均同
$ npm run build
$ yarn build 或是 $ yarn run build
```
build 完成後如下方畫面,可跟著下方步驟在本地端架 server:
![](https://i.imgur.com/VFbtIHe.png)
### 部署前預覽:在本地端架 server
輸入下方指令,會在本地端建立一個 HTTP server,可在 `http://localhost:5000/` 預覽畫面:
```
$ npm install -g serve
$ serve -s build
```
![](https://i.imgur.com/hCutfMm.png)
並且會在 React 專案建立 build 資料夾,存放 HTML、JS 等檔案:
![](https://i.imgur.com/4PXaJep.png)
### 部署到 GitHub Pages
可參考官方文件:https://create-react-app.dev/docs/deployment/#github-pages
#### 1. 在部署之前,記得要先在自己的 [GitHub](https://github.com/) 頁面新增專案:
![](https://i.imgur.com/9wgrgLW.png)
#### 2. 初始化 git 專案並建立 remote 節點,就可以把專案 push 到 GitHub 頁面:
```
$ git init
$ git add .
$ git remote add origin https://github.com/heidiliu2020/react-board-test.git
$ git branch -M main
$ git push -u origin main
```
結果如下:
![](https://i.imgur.com/lbZa3tM.png)
#### 3. 接著在 package.json 檔案新增 homepage,value 是 GitHub 的專案網址:
把下方內容改成自己的專案網址:
```json=
"homepage": "https://myusername.github.io/my-app",
```
![](https://i.imgur.com/b4qKA6z.png)
#### 4. 安裝 gh-pages
```
$ npm install --save gh-pages
```
#### 5. 在 package.json 新增下方內容,用來發布到 GitHub 的指令:
```json=
"scripts": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build",
"start": "react-scripts start",
"build": "react-scripts build",
```
#### 6. 部署到 GitHub
輸入下方指令即可完成部署:
```
$ npm run deploy
```
點選專案的 Setting 頁面:
![](https://i.imgur.com/caHj49j.png)
往下拉可以查看 GitHub Page 的網址:
![](https://i.imgur.com/xa6TJwt.png)
## 結語
其實在實作留言板之後,會發現思考方式和 Todolist 很類似,只是多了串接 API 的步驟來拿取資料,此外像是資料結構、建立 Component、透過事件機制或改變狀態來 render 畫面等等。
或許是因為,之前是一邊實作一邊聽講解的關係,觀念會比較零散一點,但這次又再從頭實做一個小專案,瞭解到該如何整理專案結構,根據想要的頁面功能似去建立 Component,熟悉 styled-components 的寫法。
Huli 在這章節最後說可以挑戰看看分頁和刪除功能,自己就另外去找資料來挑戰看看,刪除功能的話,感覺之前在其他工具也有講到類似觀念,實作起來應該不會太難…?(馬上立 Flag),雖然花了一點時間去 debug,但也總算被自己試成功了!獲得小小的成就感,之後也想找時間來試試看分頁功能,總之先繼續前進部落格實作吧!