# React 筆記 (react-router, context, reducer) 此筆記是基於 zerotomastery.io 推出 React 課程 [Complete React Developer in 2022](https://www.udemy.com/course/complete-react-developer-zero-to-mastery/) 所撰寫。 # 第 2 節: React Key Concepts 本節重點: 1. Don’t touch the DOM. 2. Build websites like lego blocks. 3. Unidirectional data flow ⇒ 容易 debug,源頭的 state 容易除錯 4. Cross platfoms: - React Native (mobile apps) - React 360 (VR apps) - React Desktop - React blessed (teminal) ## 7. Declarative ****宣告式**** vs Imperative ****命令式**** - Imperative paradigm: You directly change indivisual parts of your app in response to various user events. - Declarative paradigm: You declare a state and REACT automatically does it for us. ## 8. Component Architecture 元件結構 - React is designed around the concept of reusable components. ## 11. How To Be A Great React Developer ### The job of a REACT developer 1. Decide on Components. 2. Decide the State and where it lives. 3. What changes when state changes. # 第 3 節: React Basics ## 20. Create React App - NPX ### 什麼是 NPX? NPM 版本大於等於 5.2 版就會自動附加 NPX。 **NPX 指定用來安裝並且安裝完後會執行,執行完後會刪除,所以不會佔容量。** ## 22. Create React App - React-Scripts 2 ```json // npx create-react-app project 之後的 package.json { "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, } ``` - `react-scripts build` or `yarn build` 會打包好寫好的 App.js 並在根目錄生成一個 build 資料夾 - **BABEL** 會將程式碼轉換成很基礎的 JS code 讓不同家瀏覽器都看懂。 - **Webpack** 會將程式碼進行模組化,大檔案拆分成 `.chunk.js`,提升效能。 - `react-scripts eject` 99.9% 的機率不會用到,這用來更改打包的方式。 ## 27. Monsters Rolodex - Class Components 類別型元件寫法 ```jsx // 1. import Component import { Component } from 'react'; // 2. 宣告 class extends Component class App extends Component { // 3. 新增 render(),裡面 return JSX render(){ return ( <div className="App"> <header className="App-header"> </header> </div> ); } } export default App; ``` ## 28. Monsters Rolodex - Component State ```jsx import { Component } from 'react' class App extends Component { // 1. 要先有 constructor 方法 constructor() { // 2. 一定要先呼叫 super();super() 用來呼叫父類別的 constructor super() // 3. 才可以用 this this.state = { name: 'Robin', } } render() { return ( <div className="App"> <header className="App-header"> <p>{this.state.name}</p> <button>change name</button> </header> </div> ) } } export default App ``` ## 29. Monsters Rolodex - setState & 30. Monsters Rolodex - States and Shallow Merge **本兩小節重點:** - React 只有在 state 物件的記憶體位址改變時才會重新更新元件。 - `this.setState()` 會創造一個新的物件並且用 shallow merge 的方式去更新值,如本專案 state 有 name、company 兩種屬性,在 setState 的時候只有更新 name 的值 (`this.setState({ name: 'Kate' })`),state 會變成 `{name: ‘Kate’, company: ‘Google’}`,**沒有被更新到的屬性還是會被保留!** 承上上述範例,若在按鈕上新增點擊事件,點擊後將名字從 Robin 改為 Kate,直接寫 `this.state.name = 'Kate';` 是不行的,儘管值確實被改變了,但是 state 物件的記憶體位址是一樣的,所以 React 不會重新渲染畫面。 必須使用 `this.setState` 的方式並傳入屬性值去覆蓋原本的屬性: ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); this.state = { name: 'Robin', company: 'Google' }; } render() { return ( <div className="App"> <header className="App-header"> <p>{this.state.name}</p> <button onClick={() => { // this.state.name = 'Kate'; this.setState({ name: 'Kate' }) }} > change name </button> </header> </div> ); } } export default App; ``` ## 31. Monsters Rolodex - setState and Secondary Callback **本小節重點:** `this.setState()` 的參數也可接受一個 callback 函式,並且還可以再接收一個 optional 的 callback 函式,第一個 callback 用來 shallow merge `this.state` 的值,第二個 callback 會在 state 值更新完後執行(可處理 setState 是非同步的問題) ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); this.state = { name: 'Robin', company: 'Google', }; } render() { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p>{this.state.name}</p> <button onClick={() => { this.setState( () => { return { name: 'Kate' }; }, // 第二個 callback 為 optional 可不加 () => { console.log(this.state.name); } ); }} > change name </button> </header> </div> ); } } export default App; ``` ## 36. Monsters Rolodex - Lifecycle Method: componentDidMount 實作開始 ⇒ 拿資料 - `componentDidMount`:component 初次被放入 DOM 的時候,一個元件的生命週期中只會發生一次。 ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); // 1. 已知 monsters 要拿來 map(),初始值給空陣列 this.state = { monsters: [], }; } // 2. 生命週期拿資料 componentDidMount() { fetch('https://jsonplaceholder.typicode.com/users') .then((response) => response.json()) .then((users) => { // 3. 拿到資料後 setState 回去 this.setState( () => { return { monsters: users }; }, () => { console.log(this.state); } ); }); } render() { return ( <div className="App"> // 略... </div> ); } } export default App; ``` ## 38. Monsters Rolodex - Renders & Re-renders in React (類別型元件渲染順序) **本小節重點:** 以下程式碼 console.log 的執行順序為 ⇒ 1 2 3 2 1. `cosntructor()` 會先執行,初始化 state 的值。 2. 再來執行 `render()`。 3. 接著 `componentDidMount()` 這裡拿取資料並更新 state 的值,一旦呼叫 `setState()` 就會再 `render()` 一次,所以又印出一個 2。 ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); this.state = { monsters: [], }; console.log(1); } componentDidMount() { console.log(3); fetch('https://jsonplaceholder.typicode.com/users') .then((response) => response.json()) .then((users) => { this.setState( () => { return { monsters: users }; }, () => { // console.log(this.state); } ); }); } render() { console.log(2); return ( <div className="App"> // 略... </div> ); } } export default App; ``` ## 類別型元件生命週期補充 > 下列圖片引用自張至寧老師 ![](https://i.imgur.com/HQTmRZo.png) ![](https://i.imgur.com/65ngQVZ.png) ## 40. Monsters Rolodex - Searching & Filtering(searchbox 字串篩選) **本小節重點:** 1. `<input>` 掛 onChange 事件取得 `e.target.value`,用這個值去 filter array。 2. filtered array 用 setState 去更新 state 中的 array 的狀態。 3. 這邊使用 `String.includes()` 去比對字串,要注意的是 `.includes()` 會判別大小寫,所以此項目會一律先 `toLowerCase()` 再去篩選。 - 程式碼: ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); this.state = { monsters: [], }; } componentDidMount() { fetch('https://jsonplaceholder.typicode.com/users') .then((response) => response.json()) .then((users) => { // console.log(users); this.setState( () => { return { monsters: users }; } // , // () => { // console.log(this.state); // } ); }); } render() { return ( <div className="App"> <input type="search" className="search-box" placeholder="monster's name" onChange={(e) => { // console.log(e.target.value); const inputString = e.target.value.toLowerCase(); const filteredMonsters = this.state.monsters.filter((monster) => { return monster.name.toLowerCase().includes(inputString); }); this.setState(() => { return { monsters: filteredMonsters }; }); }} /> {this.state.monsters.map((monster) => { return <h1 key={monster.id}>{monster.name}</h1>; })} </div> ); } } export default App; ``` ## 42. Monsters Rolodex - Storing Original Data (searchbox 字串篩選優化) **本小節重點:** 本來做法是在 onChange 事件中直接 setState array 的值,但正確的情況是每次進行篩選時,array 都要初始值,所以這邊寫法改成 setState searchString,不要去 setState array。 - 程式碼: ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); this.state = { monsters: [], searchString: '', }; } componentDidMount() { fetch('https://jsonplaceholder.typicode.com/users') .then((response) => response.json()) .then((users) => { // console.log(users); this.setState( () => { return { monsters: users }; } // , // () => { // console.log(this.state); // } ); }); } render() { const filteredMonsters = this.state.monsters.filter((monster) => { return monster.name.toLowerCase().includes(this.state.searchString); }); return ( <div className="App"> <input type="search" className="search-box" placeholder="monster's name" onChange={(e) => { this.setState(() => { const searchString = e.target.value.toLowerCase(); return { searchString }; }); }} /> {filteredMonsters.map((monster) => { return <h1 key={monster.id}>{monster.name}</h1>; })} </div> ); } } export default App; ``` ## 43. Monsters Rolodex - Optimizations(效能優化) **本小節重點:** - 事件 Handler 不要寫在 `render()` 裡面,應該要盡可能在 class 內宣告成一個方法。 在本次專案範例中原本是將 handler 以函式表達式的形式寫在 JSX 中,如 `onChange={(e)⇒{…}}`,但這樣做在每次事件觸發時都會重新建造這個 handler。在 class 內宣告成一個方法,只需要建造一次 handler,來達到效能提升。 - 解構 ⇒ 將 `this.state.monsters` 和 `this.onSearchChange` 用解構提取變數出來,程式碼看起來較整潔。 - 程式碼: ```jsx import { Component } from 'react'; class App extends Component { constructor() { super(); this.state = { monsters: [], searchString: '', }; } onSearchChange = (e) => { const searchString = e.target.value.toLowerCase(); this.setState(() => { return { searchString }; }); }; componentDidMount() { // 略... } render() { const { monsters, searchString } = this.state; const { onSearchChange } = this; const filteredMonsters = monsters.filter((monster) => { return monster.name.toLowerCase().includes(searchString); }); return ( <div className="App"> <input type="search" className="search-box" placeholder="monster's name" onChange={onSearchChange} /> {filteredMonsters.map((monster) => { return <h1 key={monster.id}>{monster.name}</h1>; })} </div> ); } } export default App; ``` ### 補充(很重要) 上述範例中 class App 的 onSearchChange 方法是以箭頭函式作宣告,要注意的是這邊的 `this.setState(…)` 中的 `this`,是來自於外部環境,因為箭頭函式沒有自己的 `this`: ```jsx onSearchChange = (e) => { const searchString = e.target.value.toLowerCase(); this.setState(() => { return { searchString }; }); }; ``` 若用非箭頭函式來宣告此 handler 的話,運行這個專案會拋出 **Uncaught TypeError: Cannot read properties of undefined (reading 'setState')。**因為實際上這邊的 `this` 會指向**全域物件**: ```jsx onSearchChange(e) { const searchString = e.target.value.toLowerCase(); // this 為 undefined,請看以下說明 this.setState(() => { return { searchString }; }); } ``` 以下內容節錄並改寫自 [React 與 bind this 的一些心得](https://medium.com/reactmaker/react-%E8%88%87-bind-this-%E7%9A%84%E4%B8%80%E4%BA%9B%E5%BF%83%E5%BE%97-323c8d3d395d): > 當使用 `extend React.Component` 的方式去宣告元件的時候,React 確實會綁定 `this` 到元件內,但是卻有以下特定的地方才會被綁進去 > > 1. 生命周期函式,例如:`constructor()`、 `componentDidMount()` 等等 > 2. `render()` 內 > > 其他自己定義的 property 就不會被綁入 `this` ,而且 `this` 會被指到 `windows` 這個全域上。 > 但又因為在嚴謹模式下,this 指向全域物件這件事是無效的,所以本範例中的 `this` 為 `undefined`! **解決方法:** 我們可以在 `constructor()` 中利用 `bind()` 作 self-binding 就可將 this 重新指向,不會再報錯: ```jsx constructor() { super(); this.state = { monsters: [], searchString: '', }; this.onSearchChange = this.onSearchChange.bind(this); } ``` - 參考資料: - **[In handleIncrement method, "this" is referring to undefined](https://stackoverflow.com/questions/69035073/in-handleincrement-method-this-is-referring-to-undefined)** - **[why this resolves to undefined not window/global-env in react component method if it not bind this in constructor](https://stackoverflow.com/questions/55989840/why-this-resolves-to-undefined-not-window-global-env-in-react-component-method-i)** - [**Dealing with Undefined 'this' in React Event Handlers in a Performant Way**](https://www.voitanos.io/blog/deal-with-undefined-this-react-event-handler-performant-way/) ## 45. Monsters Rolodex - CardList Component **本小節重點:** 1. 將 CardList 分解成一個 component。 2. 講師建議元件名稱可取為 `xxx.component.jsx`。 ## 46. Monsters Rolodex - Component Props **本小節重點:** 在 `render(){}` 中取用並解構 `this.props`。 ## 47. Monsters Rolodex - Rendering and Re-rendering part 2 類別型元件重新渲染(重要) **本小節結論:** 在兩種況下元件會重新渲染 (re-render): 1. `this.setState()` 被呼叫時 ⇒ this.state 更新 2. `this.props` 的值更新時 ## 48. Monsters Rolodex - SearchBox Component **本小節重點:** 將 SearchBox 分解成一個 component。 ## 49. Monsters Rolodex - CSS in React 在 components 底下的子資料夾中直接新增該元件的 CSS 檔,再 import 到元件裡面 ![](https://i.imgur.com/29x5ndM.png) **要注意的是這寫的寫法 CSS 會在整個 App.js 生效,CSS 有可能發生覆寫的情況。** ## 55. Monsters Rolodex - Functional Component Intro 函式型元件 - 函式型元件沒有生命週期 ## 56. Pure & Impure Functions 純粹函式 - 以下資料節錄自 **[什麼是 Pure Function?在 React 當中的重要性是什麼?](https://dev.to/simonecheng/shi-mo-shi-pure-functionzai-react-dang-zhong-de-zhong-yao-xing-shi-shi-mo--1kjg)** > 簡單來說,一個 function 只要符合以下兩個條件: > > 1. 相同的 input,永遠都輸出相同的 output。 > 2. 沒有產生 side effect。跟其他function不會互相干擾,不會修改/引用/存取或是依賴到到外部變數,但是當作參數傳入是可以的。 > 雖然 side effect 聽起來很像是負面的名詞,但不表示 side effect 就是不好的,在程式當中,side effect 單純就只是描述在寫 function 時有可能會出現的情況或是現象而已。 > > > **side effect 有哪些?** > > 以下介紹一些常見的 side effect,但不限於此: > > 1. Making a HTTP request > 2. Mutating data > 3. Printing to a screen or console > 4. DOM Query/Manipulation > 5. Math.random() > 6. Getting the current time **本小節結論:** - **Hooks creates impure functions.** - Generally speaking, we want to write functions purely, But when you write functional components, you will create impure functions. **補充** 以下內容節錄並改寫自 **[Day 12: ES6篇: Side Effects(副作用)與Pure Functions(純粹函式)](https://ithelp.ithome.com.tw/articles/10185780)** > 在 React 中其實處處可見到純粹函式的設計,但可能沒那麼顯眼,我們在其中都有使用到,只是身在其中有可能不自知。你可能沒注意到像React官網的這個網頁 [Components and Props](https://facebook.github.io/react/docs/components-and-props.html) 中就有這一句粗體的文字,這是在講解元件對於自己本身props的嚴格規則: > > > > All React components must act like pure functions with respect to their props. > 所有 React 元件對於它們的 props 都必須運作地像純粹函式。 > > ## 57. Monsters Rolodex - Hooks: useState - 在 Functional component 中直接呼叫 `useState()`,並且傳入一個參數作為初始值。 - `useState()` 會回傳包含兩個值的陣列: - 第一個值是當前 State 的狀態。(若都沒改變就是一開始傳入的初始值) - 第二個值是一個函式,藉由傳入參數到這個函式並呼叫來改變初始值的值。 **補充:** A side effect is when a function creates some kind of effect outside of its scope. ## 58. Monsters Rolodex - Functional Component Re-rendering 函式型元件重新渲染時機 函式型元件跟類別型元件重新渲染的條件是一樣的,如下面兩項: - Props 值更新的時候 - State 值改變的時候 ### 重要 1. 函式型元件呼叫 `useState` 的 `setXXX()` 去更新 state 值的時候,若值跟上一次一樣(物件型態的值就是看記憶體位址有無改變)就不會再次渲染,如有一 state 初始值為空字串 `''`,再用 `setXXX('')` 就不會重新渲染元件。而類別型元件則是一呼叫 `this.setState()` 就會重新渲染 `render()` 的內容! 2. 函式型元件會重新跑整個 function 內的內容,不像類別型元件只會渲染 `render()`! ## 59. Monsters Rolodex - Infinite Re-rendering 無窮渲染 **本小節重點:** 在寫函式型元件的時候,直接呼叫 `useState` 的 `setXXX()` 會造成無窮渲染 infinite loop,**這邊就算 `setXXX()` 的值與初始值相同也一樣會無窮渲染**,如下程式碼: ```jsx import { useState } from 'react'; const App = () => { const [searchString, setSearchString] = useState(''); const [monsters, setMonsters] = useState([]); // 無窮渲染報錯 => 這邊就算 set 的值與初始值相同為空字串也一樣會 setSearchString('') fetch('https://jsonplaceholder.typicode.com/users') .then((response) => response.json()) .then((users) => { // 無窮渲染報錯 setMonsters(users); }); return ( <div className="App"> // 略... </div> ); }; export default App; ``` ## 60. Monsters Rolodex - Hooks: useEffect ![](https://i.imgur.com/Hn0T1mC.png) This picture is from 張至寧老師。 # 第 4 節: Capstone Project: Intro + Setup 本節只有首頁的 JSX 及一些基礎設置而已。 ## 77. Adding Sass 在 React 中使用 .scss 檔案,安裝 Sass 套件 - `yarn add sass` # 第 5 節: Routing + React-Router 本課程教的是 React-Router Version 6。 - `yarn add react-router-dom@6` ## 83. Setting Up Our Homepage ### 啟用 React-Router 6 1. 在 `index.js` `import { BrowserRouter } from 'react-router-dom';` 2. 用 `<BrowerRouter>` 把 `<App>` 包起來 ```jsx // 略... import { BrowserRouter } from 'react-router-dom'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <BrowserRouter> <App /> </BrowserRouter> </React.StrictMode> ); ``` ### 根目錄/src 下建置 routers 資料夾 這邊用來放置顯示層級是 top-level 的 component。 ### 建置路由 1. 在 `App.js` `import { Routes, Route } from 'react-router-dom';` 2. `<Routes>` 裡面包 `<Route>` - 本小節用到的 `<Route>` 的屬性如下,path 指到該路由就渲染 element 指定的 top-level component: - `path=""` - `element={}` ```jsx import Home from './routes/home.component'; import { Routes, Route } from 'react-router-dom'; const App = () => { return ( <Routes> <Route path="/" element={<Home />} /> </Routes> ); }; export default App; ``` ## 84. React Router Outlet 巢狀路由 **本小節重點:** React-Router 6 使用巢狀路由時,**當路由已符合父元件和子元件**,React 還是只會渲染父元件。要讓子元件也一併渲染的話,要在父元件中使用 `<Outlet />` 。 範例: 1. 當路由為 `[http://localhost:3000/home/shop](http://localhost:3000/home/shop)` ⇒ 符合父、子元件路由,目前只會顯示父元件: ```jsx import Home from './routes/home.component'; import { Routes, Route } from 'react-router-dom'; const App = () => { const Shop = () => { return <h1>i am the shop page</h1>; }; return ( <Routes> <Route path="/home" element={<Home />}> <Route path="shop" element={<Shop />} /> // 這邊的 path 不能寫 /shop 會報錯 </Route> </Routes> ); }; export default App; ``` 1. 要在父元件 `<Home />` 中引用 `<Outlet />`,子元件就會渲染在 `<Outlet />` 的位置(也可以放在 `<Directory />` 上面)。 ```jsx import Directory from '../components/directory/directory.component'; import { Outlet } from 'react-router-dom'; const Home = () => { return ( <div> <Directory categories={categories} />; <Outlet /> </div> ); }; export default Home; ``` ## 85. Navigation Bar Component 巢狀路由 **本小節重點:** 在巢狀路由下,當子元件想要跟隨父元件指定的路由一起渲染時(子元件自己不寫 path 屬性),可使用 index 屬性(即 `index={true}`)。 - 記得還是要用 Outlet 去告訴父元件要將子元件渲染在哪裡! 範例: ```jsx import Home from './routes/home.component'; import { Routes, Route, Outlet } from 'react-router-dom'; const App = () => { const Naigation = () => { return ( <div> <h1>i am the navigation page</h1> <Outlet /> </div> ); }; const Shop = () => { return <h1>i am the shop page</h1>; }; return ( <Routes> <Route path="/" element={<Naigation />}> <Route index element={<Home />} /> <Route path="shop" element={<Shop />} /> </Route> </Routes> ); }; export default App; ``` ## 86. React Router Link 巢狀路由 **本小節重點:** - `<Fragment>` 可以用來取代父級的 `<div>`,且不會渲染在畫面上。(PS 因元件只能 return 一個 tag ) - `<Link>` 同 `<a>` 標籤,用 `to="..."` 屬性指定連到哪個路由。 ## 87. Styling for Navigation + Logo **本小節重點:** - 引用 SVG 圖片 ⇒ 用 import component 的方式 ```jsx import { ReactComponent as CrwnLogo } from '../../assets/crown.svg'; const Naigation = () => { return ( <Fragment> <div className="navigation"> <Link className="logo-container" to="/"> <CrwnLogo className="logo" /> </Link> </Fragment> ); }; export default Naigation; ``` # 第 7 節: React Context For State Management ### 105. User Context、106. Re-rendering From Context、107. Signing Out Context 介紹 以下內容節錄自改寫 **[I Want To Know React - Context 語法](https://ithelp.ithome.com.tw/articles/10252519)、[初探 Context](https://ithelp.ithome.com.tw/articles/10252123)** > Context 是一種利用向下廣播來傳遞資料的方式,此方法可以解決 props 必須要一層層向下傳遞的缺點。你可以將 context 視為一個用來儲存狀態的 component! > > > ### **何時使用 Context** > > 然而並不是所有情境都適合使用 context。React 官方推薦只有在以下情境時,才使用 context。 > > - 要把**全域性資料**傳遞給很多 components 時。所謂的全域性資料代表整個 App 都需要的資料,例如使用者資訊、時區設定、語系、UI 主題。 > > ### **Context 角色** > > React context 的使用會環繞三個角色在運作: > > - Context Object > - Provider > - Consumer > > 一個 React app 中可以有多個 React context。每個 React context 的本體都是一個物件(在這邊把它稱為 context object)。其中 context object 中又會有兩個很重要的屬性:Provider(提供者)與 Consumer(消費者)。 > > - Provider(提供者)的功用就是用來**提供** context 值。 > - Consumer(消費者)的功用則是用來**使用** context 值。 > > 使用 Provider 的 component 與使用 Consumer 的 component 之間不需要是直接的父子層關係。Provider 只要在 Consumer 的上層即可讓 Consumer 接收到 context 值,而處於 Provider 與 Consumer 之間的中間層 component 則不須做任何的改動。 > > ### **Context 使用步驟** > > 根據以上的資訊,我們可以把使用 Context 歸納成以下幾個步驟: > > 1. 創建 React context object > 2. 在 Provider 中的 value 屬性放入值(即放值到 context object 中但不能直接取用),以將該值廣播給自己以下的 Consumer component 使用 > 3. Consumer 或者使用 useContext Hook 接收值,可根據接收到的值 component 可顯示對應的內容或執行對應的動作 - 關於 `createContext(defaultValue)` > `defaultValue`:代表這個 context 的預設值,與 props 一樣,可為任意的值因為代表預設值,所以只有在 Consumer 以上的 component 中都沒有 Provider 時才會使用到 `defaultValue` 的內容。需要注意的是,如果 Consumer 上面有 Provider,但此 Provider 的值為 `undefined` 的話,則 Consumer 依然不會使用 `defaultValue`,拿到的值會是 `undefined`。 > - 當 Provider 的 value 改變,則所有使用此 Provider 的 context object 的 Component 都會 re-run 但不一定會 re-redner!若 JSX 的部分都沒有更新就不會再 render。 ### 本小節實作: 1. `src/components` 下新增 contexts 資料夾,底下再新增 `user.context.jsx` ```jsx // 1. 引入 createContext import { createContext, useState } from 'react'; // 2. 創建 Context 物件並填入預設值。 PS: Context 物件可以是純值 export const UserContext = createContext({ currentUser: null, setCurrentUser: () => null, }); // PS: 本課程講師將 <UserContext.Provider> 又再包裝成一個元件 export const UserProvider = ({ children }) => { const [currentUser, setCurrentUser] = useState(null); const value = { currentUser, setCurrentUser }; // 3. 將想要傳遞的值透過 .Provider 標籤的 value 屬性傳過去! return <UserContext.Provider value={value}>{children}</UserContext.Provider>; }; ``` 1. 用 `<UserContext.Provider>` 將 `<App>` 包覆 ⇒ Provider 的子元件可以取得 Provider 的 value(即 Context 物件) ```jsx // in index.js import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { BrowserRouter } from 'react-router-dom'; import { UserProvider } from './components/contexts/user.context'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <BrowserRouter> <UserProvider> <App /> </UserProvider> </BrowserRouter> </React.StrictMode> ); ``` 1. 成功登入要把回傳的 user 資訊放到 Context 裡面,所以在 `sign-in-form.component.jsx` 操作 ```jsx // 有略過其他非相關程式碼... // 1. 要引入 useContext 才能取用 Context import { useState, useContext } from 'react'; // 2. 也要引用 Context 物件 import { UserContext } from '../contexts/user.context'; const SignInForm = () => { // 3. 解構提取 setCurrentUser const { setCurrentUser } = useContext(UserContext); const handleSubmit = async (event) => { event.preventDefault(); try { const { user } = await signInAuthUserWithEmailAndPassword( email, password ); // 4. 存取 user 資料 setCurrentUser(user); setFormFields(defaultFormFields); } catch (error) { console.log(error); } } }; return ( <div className="sign-up-container"> // 略... </div> ); }; export default SignInForm; ``` 1. 第一次註冊成功後也要是登入狀態,所以 `sign-up-form.component.jsx` 也要做上述動作。 2. 取得登入資料後的 header 的 Sign In 按鈕要改成 Sign Out 並加入登出功能,以 UserContext 中有無 user 資訊作判別 - 在 `firebase.utils.js` ```jsx import { signOut } from 'firebase/auth'; export const signOutUser = async () => await signOut(auth); ``` - 在 `navigation.component.jsx` ```jsx import { Fragment } from 'react'; import { Outlet, Link } from 'react-router-dom'; import { ReactComponent as CrwnLogo } from '../../assets/crown.svg'; import { UserContext } from '../../components/contexts/user.context'; import { useContext } from 'react'; import { signOutUser } from '../../utils/firebase/firebase.utils'; const Naigation = () => { const { currentUser, setCurrentUser } = useContext(UserContext); const signOutHandler = async () => { await signOutUser(); // 登出後 Context user 要清除才會顯示 Sign in setCurrentUser(null); }; return ( <Fragment> <div className="navigation"> <Link className="logo-container" to="/"> <CrwnLogo className="logo" /> </Link> <div className="nav-links-container"> <Link className="nav-link" to="/shop"> Shop </Link> {currentUser ? ( <span className="nav-link" onClick={signOutHandler}> Sign Out </span> ) : ( <Link className="nav-link" to="/auth"> Sign in </Link> )} </div> </div> <Outlet /> </Fragment> ); }; export default Naigation; ``` # 第 9 節: React Context Continued ## 112. New Shop Page 建立商品頁面 - `src/routes/shop/shop.component.jsx` ```jsx import SHOP_DATA from '../../shop-data.json'; const Shop = () => { return ( <div> {SHOP_DATA.map(({ id, name }) => ( <div key={id}> <h1>{name}</h1> </div> ))} </div> ); }; export default Shop; ``` ## 113. Products Context **本小節重點:** 將假商品資料 array 放在 Context 中。 1. `src/contexts/` 新增 `products.context.jsx` ```jsx // 1. 引入 createContext import { createContext, useState } from 'react'; import PRODUCTS from '../shop-data.json'; // 2. 建立 Context 物件並給初始值 export const ProductsContext = createContext({ products: [], }); export const ProductsProvider = ({ children }) => { const [products, setProducts] = useState(PRODUCTS); const value = { products }; return ( <ProductsContext.Provider value={value}> {children} </ProductsContext.Provider> ); }; ``` 1. 用 `ProductsContext.Provider>` 包覆 `<App>`, `<UserProvider>` 和 `<ProductsProvider>` 放置的內外層位置取決於你要怎麼設計的程式碼,這邊課程講師是將 `<ProductsProvider>` 放在內層 ```jsx // in index.js import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import reportWebVitals from './reportWebVitals'; import { BrowserRouter } from 'react-router-dom'; import { UserProvider } from './contexts/user.context'; import { ProductsProvider } from './contexts/products.context'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <BrowserRouter> <UserProvider> <ProductsProvider> <App /> </ProductsProvider> </UserProvider> </BrowserRouter> </React.StrictMode> ); ``` ## 116. Toggle Cart Open 購物車彈窗功能 **本小節重點:** 點選 cart-icon 要讓購物車彈窗彈出,isCartOpen 放置在 Context 中。 1. `src/contexts` 新增 `cart.context.jsx` ```jsx import { createContext, useState } from 'react'; export const CartContext = createContext({ isCartOpen: false, setIsCartOpen: () => {}, }); export const CartProvider = ({ children }) => { const [isCartOpen, setIsCartOpen] = useState(false); const value = { isCartOpen, setIsCartOpen }; return <CartContext.Provider value={value}>{children}</CartContext.Provider>; }; ``` 1. `<CartContext.Provider>` 包覆 `<App>` ```jsx // 略... const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <BrowserRouter> <UserProvider> <ProductsProvider> <CartProvider> <App /> </CartProvider> </ProductsProvider> </UserProvider> </BrowserRouter> </React.StrictMode> ); ``` 1. `navigation.component.jsx` 要取用 CartContext 中 isCartOpen 的值來決定要不要讓 cart-dropdown 出現: ```jsx import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; import { UserContext } from '../../contexts/user.context'; const Naigation = () => { const { currentUser } = useContext(UserContext); const { isCartOpen } = useContext(CartContext); return ( <Fragment> <div className="navigation"> {currentUser ? ( <span className="nav-link" onClick={signOutUser}> Sign Out </span> ) : ( <Link className="nav-link" to="/auth"> Sign in </Link> )} <CartIcon /> </div> {isCartOpen && <CartDropdown />} </div> <Outlet /> </Fragment> ); }; export default Naigation; ``` 1. `cart-icon.component.jsx` 點擊購物車 Icon 要觸發 setIsCartOpen ```jsx import { ReactComponent as ShoppingIcon } from '../../assets/shopping-bag.svg'; import './cart-icon.styles.scss'; import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; const CartIcon = () => { const { isCartOpen, setIsCartOpen } = useContext(CartContext); const toggleIsCartOpen = () => setIsCartOpen(!isCartOpen); return ( <div className="cart-icon-container" onClick={toggleIsCartOpen}> <ShoppingIcon className="shopping-icon" /> <span className="item-count">0</span> </div> ); }; export default CartIcon; ``` ## 117.&118. Add To Cart Pt.1、Pt.2 **本小節重點:** 點選商品頁之加入購物車按鈕要顯示在 cart-dropdown 上**且將購物車中的 items 要放入 CartContext** 。 1. 先製作 cart-item 的 component:`src/components/cart-item/cart-item.component.jsx` ```jsx // 簡易骨架尚未 styling import './cart-item.styles.scss'; const CartItem = ({ cartItem }) => { const { name, quantity } = cartItem; return ( <div> <h2>{name}</h2> <span>{quantity}</span> </div> ); }; export default CartItem; ``` 1. `cart.context.jsx` ```jsx import { createContext, useState } from 'react'; // 3. 建立處理 cartItems array 的 function => 回傳一個處理好的 array const cartItemsHandler = (cartItems, productToAdd) => { const itemIsExisted = cartItems.find( (cartItem) => cartItem.id === productToAdd.id ); // 如果點擊加入購物車的商品已存在於購物車中 if (itemIsExisted) { return cartItems.map((cartItem) => cartItem.id === productToAdd.id // 已存在就數量加一,未存在直接回傳該商品 ? { ...cartItem, quantity: cartItem.quantity + 1 } : cartItem ); } // 如果點擊加入購物車的商品不存在於購物車中就加入並且新增 quantity 屬性 return [...cartItems, { ...productToAdd, quantity: 1 }]; }; export const CartContext = createContext({ isCartOpen: false, setIsCartOpen: () => {}, // 1. CartContenxt 新增 cartItems、addItemToCart 並給預設值 cartItems: [], addItemToCart: () => {}, }); export const CartProvider = ({ children }) => { const [isCartOpen, setIsCartOpen] = useState(false); // 2. 新增 cartItems 的 state const [cartItems, setCartItems] = useState([]); // 4. 建立 addItemToCart function const addItemToCart = (productToAdd) => { // 新增 item => 調整 cartItems 的 state setCartItems(cartItemsHandler(cartItems, productToAdd)); }; // 5. addItemToCart, cartItems 傳入 value 供子元件取用 const value = { isCartOpen, setIsCartOpen, addItemToCart, cartItems }; return <CartContext.Provider value={value}>{children}</CartContext.Provider>; }; ``` 1. `product-card.component.jsx` 商品頁按鈕要 import 剛在 CartContext 寫好的 addItemToCart function: ```jsx import './product-card.styles.scss'; import Button from '../button/button.component'; // 1. 前置 import import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; const ProductCard = ({ product }) => { // props.product const { name, price, imageUrl } = product; const { addItemToCart } = useContext(CartContext); const addProductToCart = () => addItemToCart(product); return ( <div className="product-card-container"> <img src={imageUrl} alt={name} /> <div className="footer"> <span className="name">{name}</span> <span className="price">{price}</span> </div> // Button 綁上 onClick <Button buttonType="inverted" onClick={addProductToCart}> Add to card </Button> </div> ); }; export default ProductCard; ``` 1. `cart-dropdown.component.jsx` 裡頭的 cartItems 陣列是要從 CartContext 取出 ```jsx import Button from '../button/button.component'; import './cart-dropdown.styles.scss'; import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; import CartItem from '../cart-item/cart-item.component'; const CartDropdown = () => { const { cartItems } = useContext(CartContext); return ( <div className="cart-dropdown-container"> <div className="cart-items"> {cartItems.map((item) => ( <CartItem key={item.id} cartItem={item} /> ))} </div> <Button>GO TO CHECKOUT</Button> </div> ); }; export default CartDropdown; ``` ## 120. Cart Item Designs **本小節重點:** - 更新 `cart-item.component.jsx` - Navigation 中 `cart-icon.component.jsx` 的購物包包要隨著購物車內容數量增減 ⇒ 新增 cartCount 至 CartContext。 ```jsx import { createContext, useState, useEffect } from 'react'; export const CartContext = createContext({ isCartOpen: false, setIsCartOpen: () => {}, cartItems: [], addItemToCart: () => {}, // 1. 新增 cartCount 到 Context 的預設值 cartCount: 0, }); export const CartProvider = ({ children }) => { const [isCartOpen, setIsCartOpen] = useState(false); const [cartItems, setCartItems] = useState([]); // 2. 新增 cartCount 的 useState const [cartCount, setCartCount] = useState(0); // 3. 每當 cartItems 變動時就重新計算 cartCounts useEffect(() => { const newCartCounts = cartItems.reduce( (total, cartItem) => total + cartItem.quantity, 0 ); setCartCount(newCartCounts); }, [cartItems]); const value = { isCartOpen, setIsCartOpen, addItemToCart, cartItems, cartCount, // 4. 傳入 value }; return <CartContext.Provider value={value}>{children}</CartContext.Provider>; }; ``` ```jsx // cart-icon.component.jsx 顯示 cartCount 的數量 import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; const CartIcon = () => { // 1. 從 CartContext 提取 cartCount const { isCartOpen, setIsCartOpen, cartCount } = useContext(CartContext); const toggleIsCartOpen = () => setIsCartOpen(!isCartOpen); return ( <div className="cart-icon-container" onClick={toggleIsCartOpen}> <ShoppingIcon className="shopping-icon" /> // 2. 顯示 cartCount <span className="item-count">{cartCount}</span> </div> ); }; export default CartIcon; ``` ## 121. Creating Checkout Page 結帳頁面製作 **本小節重點:** - 結帳頁面製作、增加購物車 item 數量按鈕功能 - useNavigate 1. `src/routes/checkout/checkout.component.jsx` ```jsx // 結帳頁面要從 CartContext 拿資料 import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; const Checkout = () => { const { cartItems, addItemToCart } = useContext(CartContext); return ( <div> <h1>I am the Checkout page</h1> {cartItems.map((cartItem) => { const { id, name, quantity } = cartItem; return ( <div key={id}> <h2>{name}</h2> <span>{quantity}</span> <br /> <span>decrement</span> <br /> <span onClick={() => addItemToCart(cartItem)}>increment</span> // PS: 不可在 onClick 直接呼叫 addItemToCart(cartItem) 這會並造成無窮渲染 </div> ); })} </div> ); }; export default Checkout; ``` 1. import 至 `App.js` ```jsx const App = () => { return ( <Routes> <Route path="/" element={<Naigation />}> <Route index element={<Home />} /> <Route path="shop" element={<Shop />} /> <Route path="auth" element={<Authentication />} /> <Route path="checkout" element={<Checkout />} /> </Route> </Routes> ); }; ``` 1. cart-dropdown 的 checkout 按鈕點選之後要連到 checkout 頁面 ⇒ useNavigate ```jsx import { useNavigate } from 'react-router-dom'; const CartDropdown = () => { const goToCheckoutPage = () => { navigate('/checkout'); }; return ( <div className="cart-dropdown-container"> <div className="cart-items"> {cartItems.map((item) => ( <CartItem key={item.id} cartItem={item} /> ))} </div> <Button onClick={goToCheckoutPage}>GO TO CHECKOUT</Button> </div> ); }; export default CartDropdown; ``` ## 122. Checkout Item Pt. 1 購物車 item減少數量按鈕功能 - `cart.context.jsx` 新增 `removeItemFromCartHelper` 並在 `checkout.component.jsx` 使用 ```jsx import { createContext, useState, useEffect } from 'react'; const addItemToCartHelper = (cartItems, productToAdd) => { // 在 cartItems 找找看要加入的商品是否在陣列中 const foundItem = cartItems.find( (cartItem) => cartItem.id === productToAdd.id ); // 若找到該 item quantity + 1,其餘 item 不變 (直接 return) if (foundItem) { return cartItems.map((cartItem) => cartItem.id === productToAdd.id ? { ...cartItem, quantity: cartItem.quantity + 1 } : cartItem ); } // 沒找到就新增 => 複製原 cartItems 再合併 productToAdd 跟新增 quantity 屬性 & 值為 1 return [...cartItems, { ...productToAdd, quantity: 1 }]; }; const removeItemFromCartHelper = (cartItems, productToRemove) => { // 找到要移除的 product const foundItem = cartItems.find( (cartItem) => cartItem.id === productToRemove.id ); // foundItem quantity 等於 1 的話就要整個從 cartItems 陣列中刪除 if (foundItem.quantity === 1) { return cartItems.filter((cartItem) => cartItem.id !== productToRemove.id); } // foundItem quantity 不等於 1 該 item quantity - 1,其餘 item 不變 (直接 return) return cartItems.map((cartItem) => cartItem.id === productToRemove.id ? { ...cartItem, quantity: cartItem.quantity - 1 } : cartItem ); }; export const CartContext = createContext({ isCartOpen: false, setIsCartOpen: () => {}, cartItems: [], addItemToCart: () => {}, removeItemFromCart: () => {}, cartCount: 0, }); export const CartProvider = ({ children }) => { const [isCartOpen, setIsCartOpen] = useState(false); const [cartItems, setCartItems] = useState([]); const [cartCount, setCartCount] = useState(0); useEffect(() => { const newCartCounts = cartItems.reduce( (total, cartItem) => total + cartItem.quantity, 0 ); setCartCount(newCartCounts); }, [cartItems]); const addItemToCart = (productToAdd) => { setCartItems(addItemToCartHelper(cartItems, productToAdd)); }; const removeItemFromCart = (productToRemove) => { setCartItems(removeItemFromCartHelper(cartItems, productToRemove)); }; const value = { isCartOpen, setIsCartOpen, addItemToCart, removeItemFromCart, cartItems, cartCount, }; return <CartContext.Provider value={value}>{children}</CartContext.Provider>; }; ``` ## 123. Checkout Item Pt.2 & 124. Checkout Item Pt.3 **本兩節重點:** - 結帳頁面結構微調整 - `cart.context.jsx` 新增 `clearItemFromCart` method ⇒ 點選叉叉從購物車移除商品。此動作與 `addItemToCart` 及 `removeItemFromCart` 動作相似,不再多說明。 - 新增 `checkout-item.component.jsx` 並加上 `addItemToCart`、`removeItemFromCart` 和 `clearItemFromCart` onClick 事件 ```jsx import { useContext } from 'react'; import { CartContext } from '../../contexts/cart.context'; const CheckoutItem = ({ cartItem }) => { const { name, imageUrl, price, quantity } = cartItem; const { clearItemFromCart, addItemToCart, removeItemFromCart } = useContext(CartContext); const clearItemFromCartHandler = () => clearItemFromCart(cartItem); const addItemToCartHandler = () => addItemToCart(cartItem); const removeItemFromCartHandler = () => removeItemFromCart(cartItem); return ( <div className="checkout-item-container"> <div className="image-container"> <img src={imageUrl} alt={`${name}`} /> </div> <span className="name">{name}</span> <span className="quantity"> <div className="arrow" onClick={removeItemFromCartHandler}> &#10094; </div> <span className="value">{quantity}</span> <div className="arrow" onClick={addItemToCartHandler}> &#10095; </div> </span> <span className="price">{price}</span> <span className="remove-button" onClick={clearItemFromCartHandler}> &#10005; </span> </div> ); }; export default CheckoutItem; ``` ## 125. Cart Total `cart.context.jsx` 新增 cartTotal,做法與 cartCount 一樣,不再多做說明。 # 第 13 節: Reducers ## useReducer 怎麼用? 1. 宣告一個 reducer function,它要回傳一個新的 state 物件,並接收兩個參數: 1. `state` 目前的 state 物件 2. `action` 也是一個物件,其屬性包含: 1. `type` 字串,具體說明此 action 的動作 2. `payload` 任意直,optional 2. 使用 `useReducer`,語法如下: ```jsx const [state, dispatch] = useReducer(reducerFunction, state 初始值) ``` useReducer 接收兩個參數: 1. 我們宣告的 reducer function 2. state 的初始值(自己宣告) useReducer 回傳兩個值: 1. `state` 物件 2. dispatch function 想要讓 reducer function 接收到一個 action,就要呼叫 `dispatch()` 並傳入 action 物件作為參數。 呼叫 `dispatch()` 就會 run reducer function,reducer function 會回傳一個新的 state 物件,整個函式型元件就會 re-rerun。 ### useReducer 使用場合 當一個事件發生需要更新數個 state 時就很適合使用 useReducer。 ## 145. User Reducer **本小節重點:** 將 `user.context.jsx` 中的 useState 改為 useReducer。 ```jsx import { createContext, useReducer, useEffect } from 'react'; import { onAuthStateChangedListener, createUserDocumentFromAuth, } from '../utils/firebase/firebase.utils'; export const UserContext = createContext({ currentUser: null, setCurrentUser: () => null, }); export const USER_ACTION_TYPE = { SET_CURRENT_USER: 'SET_CURRENT_USER', }; // 建立 reducer function const userReducer = (state, action) => { const { type, payload } = action; switch (type) { case USER_ACTION_TYPE.SET_CURRENT_USER: return { ...state, currentUser: payload, }; // 出現非預期 type 則拋出錯誤 default: throw new Error(`unhandled type ${type} in userReducer`); } }; const INITIAL_STATE = { currentUser: null, }; export const UserProvider = ({ children }) => { useEffect(() => { const unscribe = onAuthStateChangedListener((user) => { if (user) { createUserDocumentFromAuth(user); } setCurrentUser(user); }); return unscribe; }, []); const [{ currentUser }, dispatch] = useReducer(userReducer, INITIAL_STATE); // const { currentUser } = state const setCurrentUser = (user) => { dispatch({ type: USER_ACTION_TYPE.SET_CURRENT_USER, payload: user }); }; const value = { currentUser, setCurrentUser }; return <UserContext.Provider value={value}>{children}</UserContext.Provider>; }; ``` ## 146.、147.、148. Cart Reducer **本小節重點:** 將 `cart.context.jsx` 中的 useState 改為 useReducer。 ### useReducer 實際演練 1. 宣告 reducer 的 state 初始值 ```jsx const INITIAL_STATE = { isCartOpen: false, cartItems: [], cartCount: 0, cartTotal: 0, }; ``` 1. 建立 reducer function,通常 reducer function 內會用到 switch 且 defaut case 為拋出錯誤,用來提示遇到非預期的 action type。 ```jsx // reducer function 起手式大致如下 const cartReducer = (state, action) => { const { type, payload } = action; switch (type) { case 'SET_CART_ITEMS': return { ...state, ...payload, }; case 'SET_IS_CART_OPEN': return { ...state, isCartOpen: payload, }; default: throw new Error(`Unhadled typed of ${type} in cartReducer`); } }; ``` 1. 使用 `useReducer()` 並在需求處呼叫 `dispatch()` 傳入 action 物件 ```jsx const [state, dispatch] = useReducer(cartReducer, INITIAL_STATE); const { cartItems, cartCount, cartTotal, isCartOpen } = state; const updateCartItemsForReducer = (newCartItems) => { const newCartCounts = newCartItems.reduce( (total, cartItem) => total + cartItem.quantity, 0 ); const newCartTotal = newCartItems.reduce( (total, cartItem) => total + cartItem.quantity * cartItem.price, 0 ); dispatch({ type: 'SET_CART_ITEMS', payload: { cartItems: newCartItems, cartTotal: newCartTotal, cartCount: newCartCounts, }, }); }; const addItemToCart = (productToAdd) => { const newCartItems = addItemToCartHelper(cartItems, productToAdd); updateCartItemsForReducer(newCartItems); }; const removeItemFromCart = (productToRemove) => { const newCartItems = removeItemFromCartHelper(cartItems, productToRemove); updateCartItemsForReducer(newCartItems); }; const clearItemFromCart = (productToClear) => { const newCartItems = clearItemFromCartHelper(cartItems, productToClear); updateCartItemsForReducer(newCartItems); }; const setIsCartOpen = (bool) => { dispatch({ type: 'SET_IS_CART_OPEN', payload: bool }); }; ``` 1. 程式碼優化 - action types 應宣告成一個常數物件並利用「.」運算子取值,避免人為打字錯誤。 ```jsx const CART_ACTION_TYPES = { SET_CART_ITEMS: 'SET_CART_ITEMS', SET_IS_CART_OPEN: 'SET_IS_CART_OPEN', }; ``` - 每次呼叫 dispatch() 傳入的 action 物件,可另打造 createAction helper function 來避免人為打字疏失。 ```jsx // in src/utils/reducer/reducer.utils.js export const createAction = (type, payload) => ({ type, payload }); ``` ```jsx const updateCartItemsForReducer = (newCartItems) => { const newCartCounts = newCartItems.reduce( (total, cartItem) => total + cartItem.quantity, 0 ); const newCartTotal = newCartItems.reduce( (total, cartItem) => total + cartItem.quantity * cartItem.price, 0 ); dispatch( createAction(CART_ACTION_TYPES.SET_CART_ITEMS, { cartItems: newCartItems, cartTotal: newCartTotal, cartCount: newCartCounts, }) ); }; const setIsCartOpen = (bool) => { dispatch(createAction(CART_ACTION_TYPES.SET_IS_CART_OPEN, bool)); }; ```