Try   HackMD
tags: React GitHub

[week 21] React 實戰篇:做出一個的留言版 - 新增、刪除功能 & 部署到 GitHub

本篇為 [FE302] React 基礎 - hooks 版本 這門課程的學習筆記。如有錯誤歡迎指正!

前置作業

整理專案結構

在開始之前,可先整理 React 專案的資料結構, 例如將 src 路徑下的檔案分成 components 和 constants 兩類:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Todo 是備份我們前面做的 TodoList 範例,MessageBoard 則是本篇要實作的內容。

簡化結構:透過 index.js 引入並引出 APP.js

這裡有個小技巧,之所以在 APP 資料夾底下還有個 index.js,是為了在 src 根目錄底下的 index.js 可以用 ./component/App 直接引入,而不需寫成 ./component/App/App,藉此簡化資料結構:

// 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 再匯出:

// scr\component\App 底下的 index.js import App from "./App"; export default App; ===也可以簡寫成=== // re-export: 目的是簡化資料結構 export { default } from "./App";

因此實際上還是在撰寫 App.js 這個檔案,這是實際工作中常用的小技巧:

// scr\component\App 底下的 App.js import React from "react"; function App() { return <div>Hello!</div>; } export default App;

結果如下,能夠正常 render 出 App:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

完成前置作業之後,接著就可以開始實作留言板了!

第一步:切版

同樣引入 styled-components 來寫 CSS:

import React from "react"; import styled from "styled-components";

思考 Component 架構

先思考頁面上需要哪些功能,切出相對應的 Component 架構:

// 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 來修改樣式:

// 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; `;

結果如下:

刻好介面以後,再來就可以串接 API 讀取資料啦!

第二步:串接 API

測試用的 API 可參考:Lidemy 學生專用 API Server,留言板要串接的是 Comments API,資料結構如下:

URL:https://student-json-api.lidemy.me/comments

useState 初始 component 狀態

引入 useState 使用,設定 component 初始狀態:

import React, {useState} from "react"; function APP() { // 設定初始狀態 const [messages, setMessages] = useState([]); // ... }

根據處理選擇 eventHandler 或 useEffect

當我們要對 Component 做一些事情時,通常會有兩種方式:

  • eventHandler 事件機制
  • useEffect:在 render 之後要做的事情

而我們的目標是「在 render 之後拿取資料」,因此在這裡選用 useEffect:

import React, {useState, useEffect} from "react";

useEffect:在 render 之後拿取資料

接著寫 useEffect 在 component mount 之後要做的事情,透過 fetch 來拿取 API 的資料,轉成 JSON 格式後,再以 setMessages 改變狀態,也可用 catch 進行錯誤處理:

// 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:

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,每則留言會跟上一則有距離,有兩種寫法:

const MessageContainer = styled.div` // ... // 第一種 $:not(:first-child) { margin-top: 8px; } // 第二種(較容易記) & + & { margin-top: 8px; } `;

串接 API 結果如下:

錯誤處理:messageApiError

實作一個 ErrorMessage Component 來顯示錯誤訊息:

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 網址,多加其他參數:

const API_ENDPOINT = "https://1student-json-api.lidemy.me/comments?_sort=createdAt&_order=desc";

畫面就會顯示錯誤訊息:

錯誤處理:No Message

當沒有留言,也就是 messages.length === 0 時才會出現:

{messageMessageApiError && ( <ErrorMessage> {/* 直接 render object 會出錯,因此需轉成 string */} Something went wrong. {messageMessageApiError.toString()} </ErrorMessage> )} // 當沒有留言時才會出現 {messages.length === 0 && <div>No Message</div>}

結果如下:

但這種寫法其實會有個問題,也就是重新 render 的一瞬間會先看到 No Message,然後才是讀取到的 API 資料。

可在判斷加上 messages &&,必須確認裡面有東西(是一個陣列)才會執行:

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 進行型別檢查

根據終端機顯示的警告訊息,幫 Component 加上 PropTypes:

引入 prop-types 套件:

import PropTypes from "prop-types"

Message Component:

Message.propTypes = { author: PropTypes.string, time: PropTypes.string, // 可 render 的參數型別是 node children: PropTypes.node, };

會發現 time 格式有誤:

可透過 JavaScript 的 new Date 轉成一個 Object,再用 toLocaleString() 這個方法,根據瀏覽器的語言轉換成我們看得懂的格式:

這樣寫的好處就是不需另外引入 Library!

接著修改程式碼,轉換 message.createdAt 的格式:

messages.map((message) => ( <Message key={message.id} author={message.nickname} time={new Date(message.createdAt).toLocaleString()} >

這樣就成功轉換時間格式:

第三步:實作新增留言功能

控制 Controled Component:設定 useState

在表單中,textarea 或 input 就屬於受控組件,而受不受控的組件比較如下:

  • 受控組件(Controlled Component):具有 mutable state 可變屬性,能夠以 setState 的形式去控制資料
  • 非受控組件(Uncontrolled Component):需透過 ref 取得 DOM 元素來更新資料

在表單中的 MessageTextArea 屬於 Controled Component,需透過 useState 來改變狀態,可設定 onChange 綁定事件機制,根據輸入的 value 來 setValue:

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() 阻止預設行為,再執行我們想要的事情:

const handleFormSubmit = (e) => { // 阻止預設的表單發送行為 e.preventDefault(); }; // ... return ( <Page> <Title>React 留言板</Title> <MessageForm onSubmit={handleFormSubmit}>

新增留言功能 POST

使用API 文件 提供的方法來新增資料:

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,會顯示下方錯誤訊息:

因此可在顯示訊息前可進行錯誤處理,根據是否 ok 顯示錯誤 data.message

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 決定是否顯示:

return ( <Page> <Title>React 留言板</Title> <MessageForm onSubmit={handleFormSubmit}> <MessageLable>留言內容</MessageLable> <MessageTextArea value={value} onChange={handleTextareaChange} rows={8} /> <SubmitButton>送出</SubmitButton> {postMessageError && <ErrorMessage>{postMessageError}</ErrorMessage>}

onFocus 事件處理

但其實在送出新的留言之前,也就是當使用者 focus 在 textarea 區塊時,就可以取消錯誤訊息,可透過 onFocus 事件處理:

const handleTextareaFocus = () => { setPostMessageError(null); }; // ... <MessageTextArea value={value} onChange={handleTextareaChange} // onfocus 時就取消顯示錯誤 onFocus={handleTextareaFocus} rows={8} /> <SubmitButton>送出</SubmitButton> {postMessageError && <ErrorMessage>{postMessageError}</ErrorMessage>}

這樣在 focus textarea 時,錯誤訊息就會消失。

但是還有個問題,就是即使表單為空時還是可以不斷送出 request:

可透過判斷「是否能送出留言的 state」解決這個問題:

// 預設為 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 就不會被點擊:

// 會遮住整個畫面 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>

結果如下:

開發者工具:調整網速

可以透過開發者工具 -> 選擇 Slow 3G 降低網速,以觀察 Loading 的狀況:

或是選擇 Add 來自己新增一個 super slow 網速,把下載速度設為 1 kb/s:

刪除功能:filter()

在之前實戰 TodoList 中有提到,刪除功能會用 filter() 處理:

const handleDeleteTodo = id => { // 留下該 id 以外的 todo setTodos(todos.filter(todo => todo.id !== id)) }

在留言板也是相同道理,只是再加上串接 API 的步驟!

若要在 component 加上事件機制,可將 function 作為參數從父層傳到子層,步驟如下:

1. 把要做的 function 寫在 Parent

並傳入參數給 Children,這裡指的就是 handleDeleteMessage

// 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:

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 狀態:

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

結果如下:

參考資料:


部署到 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:

部署前預覽:在本地端架 server

輸入下方指令,會在本地端建立一個 HTTP server,可在 http://localhost:5000/ 預覽畫面:

$ npm install -g serve
$ serve -s build

並且會在 React 專案建立 build 資料夾,存放 HTML、JS 等檔案:

部署到 GitHub Pages

可參考官方文件:https://create-react-app.dev/docs/deployment/#github-pages

1. 在部署之前,記得要先在自己的 GitHub 頁面新增專案:

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

結果如下:

3. 接著在 package.json 檔案新增 homepage,value 是 GitHub 的專案網址:

把下方內容改成自己的專案網址:

"homepage": "https://myusername.github.io/my-app",

4. 安裝 gh-pages

$ npm install --save gh-pages

5. 在 package.json 新增下方內容,用來發布到 GitHub 的指令:

"scripts": { "predeploy": "npm run build", "deploy": "gh-pages -d build", "start": "react-scripts start", "build": "react-scripts build",

6. 部署到 GitHub

輸入下方指令即可完成部署:

$ npm run deploy

點選專案的 Setting 頁面:

往下拉可以查看 GitHub Page 的網址:

結語

其實在實作留言板之後,會發現思考方式和 Todolist 很類似,只是多了串接 API 的步驟來拿取資料,此外像是資料結構、建立 Component、透過事件機制或改變狀態來 render 畫面等等。

或許是因為,之前是一邊實作一邊聽講解的關係,觀念會比較零散一點,但這次又再從頭實做一個小專案,瞭解到該如何整理專案結構,根據想要的頁面功能似去建立 Component,熟悉 styled-components 的寫法。

Huli 在這章節最後說可以挑戰看看分頁和刪除功能,自己就另外去找資料來挑戰看看,刪除功能的話,感覺之前在其他工具也有講到類似觀念,實作起來應該不會太難…?(馬上立 Flag),雖然花了一點時間去 debug,但也總算被自己試成功了!獲得小小的成就感,之後也想找時間來試試看分頁功能,總之先繼續前進部落格實作吧!