在進入駐點集訓的重頭戲-個人專案前,有一週名為「Dive into topics」的技術鑽研時間,練習 React 的進階觀念如 styled-components、React Router、useReducer + Context 等,同時一邊發想個人專案主題,可說是整個培訓下半場的暖身活動。
進度安排
為期一週的技術補強時間,生活跟 STYLiSH 時期有些相似,一樣要寫 School 安排好的作業、一樣會在 GitHub 上發 PR 給導師審閱、一樣會在通過後合併再同步到本機,每天早上也都有晨會(recap),除討論作業進度外,也針對個人專案即將使用的 create-react-app 開發環境做了初步簡介。
因為後面有更重要的個人專案,本週一共只安排兩個階段的進度,比 STYLiSH 每天都有新進度輕鬆許多,導師也強調要以調整好自己的步調、完整構思個人專案為優先。當然,如果兩階段的進度提早完成,也可以私下去找導師,挑戰隱藏版的第三、甚至第四階段任務。
經過十二週的學習(含前四週遠距階段),School 在本週週六安排期中考,讓大家檢視自己的學習狀況。期中考以遠距方式進行,可以用各種方式找答案,但不應與人討論。如果前面學得夠紮實,題目本身並不算太難,但如果寫得不順或成果不理想也不必太擔心,考試結果不會影響畢業或後續進度,再找時間把不熟的觀念補起來就行。
CSS in JS 重點(以 styled-components 為例)
- 簡介:把 CSS 寫在 React 元件裡,有別於從其他檔案引入 CSS 的方式。
- 好處:讓每個 HTML 元素 CSS 的 class 都變得獨一無二,避免全域汙染。
- 範例:
// 最上面先引入styled-components套件
import styled from 'styled-components';
// 建立Button的styled元件,名稱第一個字母應大寫
// 把相關的CSS設定寫在裡面
const Button = styled.button`
width:150px;
height:80px;
color:white;
border-radius:1rem;
${'' /* 動態props寫法
可讓單一元件可因props不同套用不同屬性 */}
background-color: ${(props) =>
props.align === "left" ? "red" : "blue"};
${'' /* 偽類(pseudo-class)寫法 */}
&:hover{
cursor:pointer;
background-color:black;
}
${'' /* 做RWD套用media query寫法 */}
@media (max-width: 1000px) {
width:100px;
height:53px;
font-size:0.6rem;
}
`
function App() {
return (
<>
{/* 在JSX裡把建好的styled元件當一般元件使用 */}
<Button align={"left"}>Primary Button</Button>
<Button>Secondary Button</Button>
</>
);
}
export default App;
上述程式碼呈現畫面如下:
若在檢測器中的 DOM 觀察這兩顆按鈕,可發現兩者皆被創造一個獨一無二的 class:
補充:模組化 CSS(CSS modules)亦將每個 CSS 的 class 都加上一個雜湊(hash),也可避免全域汙染。
React Router 重點
- 功能:實現單面應用(single-page application, SPA),僅載入一個 .html 頁面,避免更改網址就重新發送請求、接收回應,提升使用者體驗。
基本範例:
//App()
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Main from "./Main";
import Info from "./Info";
function App() {
return (
<>
<BrowserRouter>
<Routes>
{/* 設定'/'路徑到<Main />頁、'/info'路徑到<Info />頁 */}
<Route path='/' element={<Main />} />
<Route path='/info' element={<Info />} />
</Routes>
</BrowserRouter>
</>
);
}
export default App;
另建立 Main 頁面如下:
//Main()
import React from 'react';
const Main = () => {
return (
<>
<h1>Main</h1>
</>
)
}
export default Main;
Info 頁面如下:
//Info()
import React from 'react';
const Info = () => {
return (
<>
<h1>
Info
</h1>
</>
)
}
export default Info;
瀏覽器顯示結果如下:
搭配 Navigate
//App()
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import Main from "./Main";
import Info from "./Info";
function App() {
return (
<>
<BrowserRouter>
<Routes>
{/* 所有非設定路徑都透過Navigate導引到首頁 */}
<Route path="*" element={<Navigate to="/" />} />
<Route path='/' element={<Main />} />
<Route path='/info' element={<Info />} />
</Routes>
</BrowserRouter>
</>
);
}
export default App;
瀏覽器顯示結果如下,可發現如果輸入非設定好的網址都會被導引到首頁:
動態路由 & useParams
//App()
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Item from "./Item";
function App() {
return (
<>
<BrowserRouter>
<Routes>
{/* 設定動態路由
// 可另設定路徑為為item/123 */}
<Route path='/item/:itemId' element={<Item />} />
</Routes>
</BrowserRouter>
</>
);
}
export default App;
另建立 Item 頁面如下:
//Item()
import { useParams } from "react-router-dom";
const Item = () => {
// 取得網址的params供下方JSX使用
const params = useParams();
const itemId = params.itemId;
return (
<>
<h1>
Item ID is {itemId}
</h1>
</>
)
}
export default Item;
瀏覽器顯示結果如下:
透過 Link 或 useNavaigate 建立超連結
App() 不變,但將 Item() 改成如下:
//Item()
import { useParams, Link, useNavigate } from "react-router-dom";
const Item = () => {
const params = useParams();
const itemId = params.itemId;
// 取用useNavigate功能
const navigate = useNavigate();
return (
<>
<h1>
Item ID is {itemId}
</h1>
{/* 點擊此Link可抵達Info頁面 */}
<Link to="/info">Go to Info</Link>
{/* 透過navigate,點擊此div可抵達首頁 */}
<div onClick={() => {navigate(`/`);}}>
Go back to Homepage
</div>
</>
)
}
export default Item;
在瀏覽器可以發現,不論點擊 Link 或綁定 navigate 的 <div>
,都可以有超連結效果,且都可以在換頁後點擊「上一頁」回到原頁面:
巢狀路由 & Outlet
與動態路由相似,都可以在 params 後面加上下一層的 params,Outlet 可標示該頁專屬的內容顯示區。
現將 App() 改成如下:
//App()
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import Main from "./Main";
import Profile from "./Profile";
function App() {
return (
<>
<BrowserRouter>
<Routes>
<Route path="*" element={<Navigate to="/" />} />
<Route path='/' element={<Main />} />
{/* 設定巢狀路由*/}
<Route path='/profile/*' element={<Profile />} >
{/* 路徑為'/profile/categoryA'時,
Profile頁面另顯示<p>Category A</p> */}
<Route path="categoryA" element={<p>Category A</p>} />
{/* 路徑為'/profile/categoryA'時,
Profile頁面另顯示<p>Category B</p> */}
<Route path="categoryB" element={<p>Category B</p>} />
</Route>
</Routes>
</BrowserRouter>
</>
);
}
export default App;
在 Profile() 中的程式碼中,可透過 Outlet
標示在 App() 中,element
的顯示位置:
//Profile()
import React from 'react';
import { Outlet } from "react-router-dom";
const Profile = () => {
return (
<>
<h1>Profile</h1>
<Outlet />
</>
)
}
export default Profile;
瀏覽器顯示結果如下,可發現如果 params 只有 profile 時,頁面也僅有寫在 Profile() 的內容,但若有下一層的 params 且該層有設定 element
,則 element
內容會顯示在 <Outlet />
位置:
useReducer + Context 重點
- 功能:在跨元件同時管理多個狀態(state)時,將兩者結合可將所有狀態綜合管理,並確保與狀態無關的元件不需要作為中介者傳遞特定狀態。
useReducer(簡單狀態,取自 Codevolution 教學影片)
import React, { useReducer } from 'react';
const initialState = 0;
const reducer = (state, action) => {
switch(action){
case 'increment':
return state + 1
case 'decrement':
return state - 1
case 'reset':
return initialState
default:
return state
}
};
function App() {
const [count, dispatch] = useReducer(reducer, initialState)
return (
<>
<div>Count is {count}</div>
<button onClick={() => dispatch('increment')}>
Increment
</button>
<button onClick={() => dispatch('decrement')}>
Decrement
</button>
<button onClick={() => dispatch('reset')}>
Reset
</button>
</>
);
}
export default App;
在上述範例中,可分析 useReducer 運作步驟如下:
- useReducer 取得
reducer
函式與原始狀態initialState
:useReducer(reducer, initialState)
- 設定
reducer
函式與原始狀態initialState
// 原始狀態為0
const initialState = 0;
// reducer函式取state與action兩參數
// 若action === 'increment'則state+1
// 若action === 'decrement'則state-1
// 若action === 'reset'則變回initialState
// 若無action則為既有的state
const reducer = (state, action) => {
switch(action){
case 'increment':
return state + 1
case 'decrement':
return state - 1
case 'reset':
return initialState
default:
return state
}
};
- useReducer 以
count
表示當前狀態,可顯示在 JSX 中、dispatch
代表對應不同情況時,會指派何種 action:const [count, dispatch] = useReducer(reducer, initialState)
JSX 中可顯示當下
count
狀態、dispatch
告訴reducer
函式當下對應的是哪一種 case{/* 這裡顯示當下的count狀態 */} <div>Count is {count}</div> {/* 點擊Increment按鈕表示對應reducer函式中increment的case */} <button onClick={() => dispatch('increment')}> Increment </button> {/* 點擊Decrement按鈕表示對應reducer函式中decrement的case */} <button onClick={() => dispatch('decrement')}> Decrement </button> {/* 點擊Reset按鈕表示對應reducer函式中reset的case */} <button onClick={() => dispatch('reset')}> Reset </button>
瀏覽器顯示結果如下:
useReducer(複雜狀態,修改自 Codevolution 教學影片)
在上述範例中,useReducer 做到的事也可以用 useState 完成,尚無法彰顯 useReducer「處理複雜狀態」的優勢,因此,在下述範例中,我們透過兩種方式讓狀態變得複雜:
count
狀態變為一物件,原本的狀態取名為firstCounter
,另新增count
中第二個名為secondCounter
的狀態。- 新增按鈕專門改變
secondCounter
的狀態。 - 不管是對
firstCounter
或secondCounter
,都有新的按鈕,點擊時會對該狀態加上/減去不同數字。
import React, { useReducer } from 'react';
// 原始狀態變為一物件
// 內有firstCounter與secondCounter
const initialState = {
firstCounter:0,
secondCounter:10
};
// action變為物件,取當中的type表示不同case
const reducer = (state, action) => {
switch(action.type){
//未含2的兩種case僅改變count狀態的firstCounter
case 'increment':
return {...state, firstCounter:state.firstCounter + action.value}
case 'decrement':
return {...state, firstCounter:state.firstCounter - action.value}
//包含2的兩種case僅改變count狀態的secondCounter
case 'increment2':
return {...state, secondCounter:state.secondCounter + action.value}
case 'decrement2':
return {...state, secondCounter:state.secondCounter - action.value}
case 'reset':
return initialState
default:
return state
}
};
function App() {
const [count, dispatch] = useReducer(reducer, initialState)
return (
<>
{/* count變為物件,再各取當中要顯示的值 */}
<div>First Count is {count.firstCounter}</div>
<div>Second Count is {count.secondCounter}</div>
{/* dispatch的action都變為物件,以便同時設定兩種狀態 */}
<button onClick={() => dispatch({type:'increment', value:1})}>
Increment first counter by 1
</button>
<button onClick={() => dispatch({type:'decrement', value:2})}>
Decrement first counter by 2
</button>
<button onClick={() => dispatch({type:'increment', value:3})}>
Increment first counter by 3
</button>
<button onClick={() => dispatch({type:'decrement', value:4})}>
Decrement first counter by 4
</button>
<br />
{/* 針對狀態中的secondCounter,再寫不同的dispatch */}
<button onClick={() => dispatch({type:'increment2', value:5})}>
Increment second counter by 5
</button>
<button onClick={() => dispatch({type:'decrement2', value:6})}>
Decrement second counter by 6
</button>
<button onClick={() => dispatch({type:'increment2', value:7})}>
Increment second counter by 7
</button>
<button onClick={() => dispatch({type:'decrement2', value:8})}>
Decrement second counter by 8
</button>
<br />
<button onClick={() => dispatch({type:'reset'})}>
Reset
</button>
</>
);
}
export default App;
瀏覽器畫面如下:
Context
Context / Context API 的主要用途就是讓資料可以跨元件傳輸,避免 prop drilling 問題。
以下述範例來說,資料在 App() 提供、ChildTwo() 取用,如果只用 props 傳這些資料,中間不會用到該筆資料、僅用來傳遞訊息的 ChildOne() 就成了工具人,這就是 prop drilling 現象;使用 Context 可讓資料直接從 App() 傳到 ChildTwo(),讓介在中間的 ChildOne() 可保持乾淨、不接觸到該筆資料。
左方未使用 Context 的情況即為 prop drilling,有元件僅為傳遞資料的工具人
使用 Context 主要有三大步驟:
- 製造一個 Context:
//Example()
import React from 'react';
// 創造一個Context
const ExampleContext = React.createContext();
// 輸出Context
export default ExampleContext;
- 在所有會使用到該 Context 的地方最上層包覆 Provider,並寫好要傳出的值:
//App()
import ChildOne from "./ChildOne";
import ExampleContext from "./ExampleContext";
const App = () => {
return (
<>
{/* 將會用到Context的地方包覆Provider,並寫好傳出的值 */}
<ExampleContext.Provider value={{
name:'Andy',
age:50
}}>
<ChildOne />
</ExampleContext.Provider>
</>
)
}
export default App;
- 在要取用該 Context 的地方包覆 Consumer,並用一回呼函式取得傳入的值:
//ChildTwo()
import ExampleContext from "./ExampleContext";
const ChildTwo = () => {
return (
<div style={{backgroundColor:"yellow"}}>
<h1>Child Two</h1>
{/* 要接收值的地方包Consumer */}
<ExampleContext.Consumer>
{/* 用回呼函式取得傳入的值 */}
{(ctx) => {
return (
<>
<div>My name is {ctx.name}.</div>
<div>I am {ctx.age} years old.</div>
</>
)
}}
</ExampleContext.Consumer>
</div>
)
}
export default ChildTwo;
在 Consumer 的地方,也可以用 useContext 這個 hook,就可以避免複雜的回呼函式:
//ChildTwo()
import { useContext } from "react";
import ExampleContext from "./ExampleContext";
const ChildTwo = () => {
// 用useContext取得傳入的值,供下方JSX使用
const ctx = useContext(ExampleContext);
return (
<div style={{backgroundColor:"yellow"}}>
<h1>Child Two</h1>
{/* 不需再有回呼函式 */}
<div>My name is {ctx.name}.</div>
<div>I am {ctx.age} years old.</div>
</div>
)
}
export default ChildTwo;
若觀察夾在 App() 與 ChildTwo() 中間的 ChildOne() 元件,可發現當中完全不會被 Context 影響:
//ChildOne();
import ChildTwo from "./ChildTwo";
const ChildOne = () => {
return (
<div style={{backgroundColor:"pink"}}>
<h1>Child One</h1>
<ChildTwo />
</div>
)
}
export default ChildOne;
不論在接收處是用 Consumer 或 useContext 接住傳入內容,瀏覽器看到的畫面皆如下,粉紅色區域為與 Context 無關的 ChildOne() 元件,黃色區域為接收 Context 資訊的 ChildTwo() 元件:
useReducer + Context(修改自 Codevolution 教學影片)
現在我們將 useReducer 與 Context 結合,目標為在不同元件中管理同一個狀態。
現有一最上層元件 App(),我們欲在 ChildTwo() 與 ChildThree() 元件同時管理一個名為 count
的狀態,但 ChildTwo() 與 App() 之間隔有一層 ChildOne() 元件,該元件與 count
狀態無關。
首先,先在App() 元件寫好 count
狀態與 dispatch
,再透過 Provider 往其他地方傳:
//App()
import React, { useReducer } from 'react';
import ChildOne from "./ChildOne";
import ChildThree from "./ChildThree";
// 在App()元件建立好CountContext
export const CountContext = React.createContext();
// initialState與reducer皆先制定,再透過CountContext往其他元件傳
const initialState = 0;
const reducer = (state,action) => {
switch(action){
case 'increment':
return state + 1
case 'decrement':
return state - 1
case 'reset':
return initialState
default:
return state
}
}
const App = () => {
// 用useReducer建立好count狀態與dispatch
const [count, dispatch] = useReducer(reducer, initialState)
return (
<>
{/* count狀態與dispatch透過Provider往下傳 */}
<CountContext.Provider value={{
countState: count,
countDispatch: dispatch
}}>
<h1>Count in App is {count}</h1>
<ChildOne />
<ChildThree />
</CountContext.Provider>
</>
)
}
export default App;
ChildOne() 元件不受影響,因此仍為如下程式碼:
//ChildOne();
import ChildTwo from "./ChildTwo";
const ChildOne = () => {
return (
<div style={{backgroundColor:"pink"}}>
<h1>Child One</h1>
<ChildTwo />
</div>
)
}
export default ChildOne;
ChildTwo() 與 ChildThree() 元件皆會取用 count
狀態與 dispatch
,兩者的程式碼相似。
ChildTwo() 程式碼如下:
//ChildTwo
import { useContext } from "react";
import { CountContext } from "./App";
const ChildTwo = () => {
// 用useContext取得傳入的值,賦值於countContext變數供下方JSX使用
const countContext = useContext(CountContext);
return (
<div style={{backgroundColor:"yellow"}}>
{/* 透過countContext取得當中的countState狀態 */}
<h1>Child Two: Count is {countContext.countState}</h1>
{/* 透過countContext取得當中的countDispatch方法 */}
<button onClick={() => countContext.countDispatch('increment')}>
Increment
</button>
<button onClick={() => countContext.countDispatch('decrement')}>
Decrement
</button>
<button onClick={() => countContext.countDispatch('reset')}>
Reset
</button>
</div>
)
}
export default ChildTwo;
ChildThree() 程式碼如下:
//ChildThree()
import { useContext } from "react";
import { CountContext } from "./App";
const ChildThree = () => {
// 用useContext取得傳入的值,賦值於countContext變數供下方JSX使用
const countContext = useContext(CountContext);
return (
<div style={{backgroundColor:"aqua"}}>
{/* 透過countContext取得當中的countState狀態 */}
<h1>Child Three: Count is {countContext.countState}</h1>
{/* 透過countContext取得當中的countDispatch方法 */}
<button onClick={() => countContext.countDispatch('increment')}>
Increment
</button>
<button onClick={() => countContext.countDispatch('decrement')}>
Decrement
</button>
<button onClick={() => countContext.countDispatch('reset')}>
Reset
</button>
</div>
)
}
export default ChildThree;
如此一來,就可以在 ChildTwo() 與 ChildThree() 元件同時透過 dispatch
管理 count
狀態:
Redux 重點
- 官方定義:JavaScript 應用程式中,可預測的狀態容器。(A Predictable State Container for JS Apps)。
- 功能:與 useReducer + Context 類似,可跨元件同時管理多個狀態。
- 原理:資料流為單向,流程說明取官網圖示如下:
Redux 原理示意圖(取自 Redux 官網)
初始設定
- 以 reducer 函式建立一個 store,reducer 函式初次回傳的值即為初始的
state
。 - 使用者介面(UI)元件訂閱(subscribe)並渲染當下 store 中的
state
,上圖範例為 $0。
發生異動(如上圖中點擊 Deposit $10 按鈕後)
- Event Handler 指派(dispatch)一個
action
物件到 store,以上圖而言,action
物件為{type:"deposit", payload:10}
。 - Reducer 函式取原 $0 的
state
與新傳入的action
進行運算,運算結果即為新的state
,上圖範例為 $10。 - Store 通知所有 UI 元件說 store 已被更新。
- 每個 UI 元件檢查自己的
state
是否因該次 store 更新而受影響,若有則會依新的state
重新渲染畫面,如上圖中的 UI 變為 $10。
範例(以原生 JavaScript 撰寫,修改自 React — The Complete Guide (incl Hooks, React Router, Redux) 線上課程提供之範例):
//redux-demo.js
//先引入redux套件
const redux = require('redux');
//建立一個store中的reducer函式
// 函式要取兩個參數:
// 1.初始狀態{counter:0}
// 2.發生事件後由dispatch()傳入的action物件
const counterReducer = (state = {counter:0}, action) => {
// action物件中的type為'increment'時,讓state中的counter加上1
if(action.type === 'increment'){
return {
counter:state.counter + 1
};
}
// action物件中的type為'decrement'時,讓state中的counter減去1
if(action.type === 'decrement'){
return {
counter:state.counter - 1
};
}
// 經action對應的type更新state後,reducer回傳新的state值
return state;
};
// 建立一個store,store裡面存一個reducer函式
const store = redux.legacy_createStore(counterReducer);
// 以store中的getState方法拿到最新的state
const counterSubscriber = () => {
const latestState = store.getState();
console.log(latestState)
}
// 取store的subscribe方法,以counterSubscriber函式為引數
// state改變時就執行subscribe()
store.subscribe(counterSubscriber);
// 設定store兩種dispatch()方法
// dispatch()傳到counterReducer的action物件
// 會透過type決定要如何改變state
store.dispatch({type:'increment'});
store.dispatch({type:'decrement'});
在命令提示字元輸入 node redux-demo.js 即可看到執行結果:
理解方式如下:
//原始的state為{counter:0}
//第一次dispatch傳入的type為increment
//帶回reducer函式後,會對應讓state中的counter加上1的狀況
{ counter: 1 }
//第二次dispatch傳入的type為decrement
//帶回reducer函式後,會對應讓state中的counter減去1的狀況
{ counter: 0 }
由於撰寫 Redux 程式碼有一定複雜度且容易出錯,一般會改用 Redux Toolkit (RTK) 處理,詳細可參考 Codevolution 製作的系列教學影片。
為了便於操作,在用 React 搭配 Redux 或 RTK 進行開發時,多會搭配 React-Redux 套件,但值得注意的是,不論是 Redux 或 RTK,都非 React 限定工具,可搭配其他框架如 AngularJS、Vue.js、甚至是原生 JavaScript 一起使用(如上述範例即使用原生 JavaScript 實現)。
補充:zustand
說明:與 Redux 同為可供 React 使用的狀態管理函式庫,寫起來比 Redux 更為清爽,詳細使用方式可見官方 GitHub 說明。
zustand 官方圖示(取自 GitHub)
心得
在上個週末端午連假前,校長 Shirney 跟負責輔導就業的 Tiffany 各出了一份作業給我們,前者讓我們開始構思個人專案主題、後者則讓我們上網看職缺,思考自己的職涯方向。
原來個人專案不是我當初想的那樣
在遠距學習時期,就曾在「AppWorks School 三月女性校友線上分享會|成為妳心目中的軟體工程師」線上直播活動中,聽到任職於 LINE TV 的學姐 Aimee 強調個人專案的重要性,我也在遠距第一週作業就開始試著用 HTML+CSS 刻出當時想像的個人專案模樣,但經過數週駐點集訓的洗禮後,卻赫然發現當初想的跟我現階段實際要呈現的有非常大的不同。
首先是就開發工具而言,我們不再用純 HTML+CSS+JavaScript 寫網頁,而是用前端函式庫 React,畢竟現在業界也不太有人會只用前端三劍客進行開發;再者,以謀職為導向的個人專案作品著重「技術」的呈現,思考網頁功能時,除了從使用者體驗的角度來看之外,還多少摻雜一些為了使用到特定技術的設計,要特別留意的是,倒也不用為了秀技術而做跟本身專案主題扯不上邊的功能。
學習、學習、還是學習
以 React 的各種 hook 而言,最常用的莫過於 useState 跟 useEffect,前者多用來處理事件、後者處理跟畫面呈現不直接相關的副作用(side effect),目前碰到的專案多半只需要使用到這兩者,偶爾操作 DOM 元素會另外用到 useRef。然而,React 還有不少的 hook,只是以目前的專案複雜度而言,都還派不上用場,因此,我特別珍惜本週學習 useReducer + Context 的機會,並同步認識與之觀念相仿的 Redux。
這幾週以來陸續看到第 15 屆的學長姐開始面試,看著他們的生活,也不免想到兩個月後的自己。在完成 Tiffany 所派作業的過程中,我們可以看到各家公司對於求職者能力的要求,這才發現過去數週完成專案時所學的技術有多麼重要,還有更多業界普遍使用、但目前還不會的技能,例如 TypeScript、Next.js、Jest 等,想學的還有很多,但要在接下來這五週就把所有不會的技能補齊顯得有些不切實際,還是以「把個人專案做好」為第一要務,待專案告一段落後,也別忘了要持續學習,畢竟軟體疊代速率很快,而 School 最重視的,就是我們的學習能力。