感謝 iT 邦幫忙與博碩文化,本系列文章已出版成書「從 Hooks 開始,讓你的網頁 React 起來」,首刷版稅將全額贊助 iT 邦幫忙鐵人賽,歡迎前往購書,鼓勵筆者撰寫更多優質文章。
useState
, css-in-js
, Emotion.js
昨天下班前,公司的設計師看了終於表示滿意:「沒錯,白天就是要是用太陽,晚上就是要用月亮。」於是我也心滿意足的離開了。
沒料到今天一早到公司的時候,設計又跑來跟我說,她覺得用起來還是不太對勁,作為一個著重使用者體驗的設計師,果然就是有異於常人敏銳的觀察力和感受力。她說:「這個不行!點了右下角的更新按鈕後,為什麼畫面一點動靜都沒有!這樣誰知道是在更新呢?」,我想了一想,好像真的有道理,決定就把這個功能做給她。
由於現今網站許多都是透過 AJAX 去向後端伺服器拉取資料回來呈現,拉取資料的過程中必然需要消耗一些時間,因此處理「載入中」的狀態算是現在每個網站都需要考慮到的。
以 Instagram 為例,一開始進入網頁的時候,會先看到一個「空畫面」:
接著會出現一個「空殼」,可以注意到右下角有一個載入中的圖示:
回到我們的即時天氣 App 中可以怎麼樣做呢?
在這裡因為我們有向兩道不同的 API 發送請求,因此需要等到兩個 API 的資料都拿回來之後,才算是「資料載入」完畢。現在先讓我們在 WeatherApp 的組件中添加一個名為 isLoading
的資料狀態。
一樣可以先打開並複製一個昨天在 CodeSandbox 上完成的程式碼 - Weather APP - dynamic weather icon with sunrise and sunset data。在 WeatherApp.js
中,在原本 useState
的地方,預設值內加入一個 isLoading
的資料狀態:
isLoading
的預設值設為 true
,表示一進來的時候就正在拉取資料這裡也可以把
isLoading
拆成另一個state
,但這裡考量到載入完成指的就是天氣資料(weatherElement
)是否已經載入完成,因此多數時候是isLoading
和weatherElement
是會一起變動的。
接著在 fetchData
的地方,當兩道 API 的資料都載入完成後,就透過 setWeatherElement
把 isLoading
的狀態改成 false
,表示載入完成:
現在,當使用者初次進來網站時,一開始的 isLoading
會是 true
,也就是資料正在載入中;待 fetchData
的資料都回來之後,isLoading
會變成 false
。
但還有一個情況是當使用者點擊右下角的重新整理時,也需要把 isLoading
的狀態改成 true
,這時候我們可以在 fetchData
實際開始向 API 拉取資料(fetchingData
)前,先把 isLoading
的狀態設成 true
。
但要注意的是,像下圖這種寫法是會產生錯誤的:
我們需要在 fetchingData()
之前去將 isLoading
改成 true
這部分的邏輯是沒有錯的,但這麼做之所以會導致錯誤是因為在「Day 16 - 定義並請求組件會使用到的資料 - useState 的更多使用」時,我們曾提到「每次 setSomething 時都是用新的資料覆蓋舊的」,所以這裡如果直接用:
setWeatherElement({ isLoading: true })
那麼整個 weatherElement
的資料狀態都會被覆蓋掉,變成只剩下 { isLoading: true }
。好在,透過 useState
產生的 setSomething
這個方法中,參數不只可以帶入物件,還可以帶入函式,透過這個函式就可以取得上一次的資料狀態,慣例上我們會把前一次的資料狀態取名為 prevState
:
// 在 setState 中如果是帶入函式的話,可以取得前一次的資料狀態
setState(prevState => {
// Object.assign would also work
return {...prevState, ...updatedValues};
});
因此,這裡的 setWeatherElement
中,只需要帶入函式就可以取得原本的資料狀態,再透過物件的解構賦值把原有資料帶進去,更新 isLoading
的狀態改成 true
就可以了,程式碼會修改成下面這樣:
// ./src/WeatherApp.js
// ...
const WeatherApp = () => {
// ...
const fetchData = useCallback(() => {
// const fetchingData = () => {...}
setWeatherElement((prevState) => ({
...prevState,
isLoading: true,
}));
fetchingData();
}, []);
// ...
};
到這一步後,一開始畫面載入或使用者點選「更新按鈕」時 isLoading
會是 true
,資料載入完畢後 isLoading
會變成 false
。如果你不確定是不是有正確修改的話,可以在 return
JSX 的地方,使用 console.log(weatherElement.isLoading)
看一下:
從瀏覽器的開發者工具中可以看到:
isLoading
的狀態分別是 true
(預設值)-> true
(拉資料前)-> false
(拉完資料後)isLoading
的狀態分別是 true
(拉資料前) -> false
(拉完資料後)現在透過 isLoading
的狀態,我們已經可以清楚知道什麼時候是正在拉取資料,什麼時候已經取得資料,因此就可以來撰寫載入中要顯示的樣式了。
這裡載入中的提示很簡單,當資料在載入中的時候,右下角的「更新按鈕」就改成顯示「載入中」的圖示,並搭配旋轉,這個「載入中」的圖示已經放在之前從 Dropbox 下載過的天氣圖示中,因此如果你先前已經載入並上傳這些圖示到 CodeSandbox 的話,就不用在重新下載與上傳一次。
現在就可以根據 isLoading
的狀態來切換要顯示的是「更新圖示」或「載入中圖示」,切換圖示的方式就和「Day 07 - 幫計數器設個最大最小值吧 - JSX 中條件渲染的使用」一樣:
./src/images
資料夾中載入 loading 圖示,並取名為 LoadingIcon
isLoading
為 true
時顯示 LoadingIcon
否則顯示 RedoIcon
// ./src/WeatherApp.js
// ...
// STEP 1:從 `images` 資料夾中載入 loading 圖示,並取名為 `LoadingIcon`
import { ReactComponent as LoadingIcon } from './images/loading.svg';
// ...
const WeatherApp = () => {
// ...
return (
<Container>
<WeatherCard>
{/* ... */}
<Redo onClick={fetchData}>
最後觀測時間:
{new Intl.DateTimeFormat('zh-TW', {
hour: 'numeric',
minute: 'numeric',
}).format(new Date(weatherElement.observationTime))}{' '}
{/* STEP 2:當 isLoading 的時候顯示 LoadingIcon 否則顯示 RedoIcon */}
{weatherElement.isLoading ? <LoadingIcon /> : <RedoIcon />}
</Redo>
</WeatherCard>
</Container>
);
};
export default WeatherApp;
現在,只要資料在載入中,右下角的圖示就會變成 LoadingIcon,載入完畢後就會變回原本的 RedoIcon:
如果你的網路速度很快的話,可能會發現你幾乎看不到 Loading 圖示,這時候你可以如上圖所示,在
Network
面板把自己的網速暫時調慢試試看。
很明顯這種效果設計師是不會滿意的,一點都沒有「載入中」的感覺,放個「C」還以為是想測試使用者的視力勒,因此我們得要加上一點旋轉的效果來讓它看起來更像是在「載入中」。
只需要透過 CSS 的 animation
就可以讓圖示旋轉,在最上面透過 Emotion 定義 styled components 的地放,撰寫 Redo
CSS 樣式的地方,只需要在這裡面加上 animation
的效果就可以了:
@keyframes
定義旋轉的動畫效果,並取名為 rotate
svg
圖示透過 animation
屬性套用 rotate
動畫效果const Redo = styled.div`
position: absolute;
right: 15px;
bottom: 15px;
font-size: 12px;
display: inline-flex;
align-items: flex-end;
color: #828282;
svg {
margin-left: 10px;
width: 15px;
height: 15px;
cursor: pointer;
/* STEP 2:使用 rotate 動畫效果在 svg 圖示上 */
animation: rotate infinite 1.5s linear;
}
/* STEP 1:定義旋轉的動畫效果,並取名為 rotate */
@keyframes rotate {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
`;
這時候畫面會像這樣:
看起來稍微好一點,但是不對啊!哪有更新的圖示也會變成跟著一起轉的...。
還記得曾經在「Day 15 - 就是這個畫面 - 使用 Emotion 為組件增添 CSS 樣式」提過,透過 CSS-in-JS 的這種寫法,我們可以在 CSS 中使用 JavaScript,並且可以把資料透過 props 傳到 Styled Component 內,讓 CSS 可以根據這個資料來調整套用的樣式。
因此這裡可以這樣做:
isLoading
資料狀態透過 props 帶入 <Redo>
中,也就是 <Redo isLoading={...} >
Redo
這個 Styled Component 定義的地方把傳入的 props 取出,直接透過物件的解構賦值取出 isLoading
,並以此判斷是否要執行動畫,0s
的話表示載入完畢,則不旋轉// ...
const Redo = styled.div`
/* ... */
svg {
margin-left: 10px;
width: 15px;
height: 15px;
cursor: pointer;
animation: rotate infinite 1.5s linear;
/* STEP 2:取得傳入的 props 並根據它來決定動畫要不要執行 */
animation-duration: ${({ isLoading }) => (isLoading ? '1.5s' : '0s')};
}
@keyframes rotate {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
`;
// ...
const WeatherApp = () => {
// ...
return (
<Container>
<WeatherCard>
{/* ... */}
{/* STEP 1:把 isLoading 的資料狀態透過 props 傳入 Styled Component */}
<Redo onClick={fetchData} isLoading={weatherElement.isLoading}>
最後觀測時間:
{new Intl.DateTimeFormat('zh-TW', {
hour: 'numeric',
minute: 'numeric',
}).format(new Date(weatherElement.observationTime))}{' '}
{weatherElement.isLoading ? <LoadingIcon /> : <RedoIcon />}
</Redo>
</WeatherCard>
</Container>
);
};
export default WeatherApp;
到目前為止,畫面就差不多完成了!
現在稍微來整理一下專案的程式碼。
在程式開發的過程中,語意化是非常重要的一件事,它可以幫助接手的開發者(甚至是自己)都更容易了解自己在寫些什麼。這裡一開始我們把右下角重新整理的按鈕取名為「Redo」有些不太精確,Redo 比較像是回到上一步的感覺,因此改成叫做「Refresh」應該會比較容易理解。
更動的部分如下圖所示:
在 WeatherApp 組件最後回傳 JSX 的地方,使用了非常多 weatherElement.observationTime
、weatherElement.locationName
、weatherElement.temperature
、...,這樣寫起來非常重複,因為一直要多寫 weatherElement.ooo
:
因此在 React 中對於物件類型的資料,經常會使用物件的解構賦值先把要使用到的資料取出來,像是這樣:
const {
observationTime,
locationName,
temperature,
windSpeed,
description,
weatherCode,
rainPossibility,
comfortability,
isLoading,
} = weatherElement;
如此,在 return
的地方就可以直接使用這些變數,而不需要在前面多加上 weatherElement.ooo
,修改後的程式碼會變得更加精簡:
// ./src/WeatherApp.js
// ...
const WeatherApp = () => {
const [weatherElement, setWeatherElement] = useState({
// ...
});
const {
observationTime,
locationName,
temperature,
windSpeed,
description,
weatherCode,
rainPossibility,
comfortability,
isLoading,
} = weatherElement;
// ...
const moment = useMemo(() => getMoment(locationName), [locationName]);
// ...
return (
<Container>
<WeatherCard>
<Location>{locationName}</Location>
<Description>
{description} {comfortability}
</Description>
<CurrentWeather>
<Temperature>
{Math.round(temperature)} <Celsius>°C</Celsius>
</Temperature>
<WeatherIcon
currentWeatherCode={weatherCode}
moment={moment || 'day'}
/>
</CurrentWeather>
<AirFlow>
<AirFlowIcon />
{windSpeed} m/h
</AirFlow>
<Rain>
<RainIcon />
{Math.round(rainPossibility)} %
</Rain>
<Refresh onClick={fetchData} isLoading={isLoading}>
最後觀測時間:
{new Intl.DateTimeFormat('zh-TW', {
hour: 'numeric',
minute: 'numeric',
}).format(new Date(observationTime))}{' '}
{isLoading ? <LoadingIcon /> : <RefreshIcon />}
</Refresh>
</WeatherCard>
</Container>
);
};
今天完整的程式碼一樣會放在 CodeSandbox 上 Weather APP - add loading state。
Weather APP - add loading state
請問以下這些程式是否不需要加在WeatherApps.js裏?
setState(prevState => {
// Object.assign would also work
return {...prevState, ...updatedValues};
});
thank you very much.