###### 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,但也總算被自己試成功了!獲得小小的成就感,之後也想找時間來試試看分頁功能,總之先繼續前進部落格實作吧!